Helios

ADR-0007 — Web portal via Telegram Login (new OIDC SDK)

**Status.** Accepted.

Status. Accepted. Date. 2026-05-18.

Context

The owner wants a public web portal at a real domain — configured via the PORTAL_URL / PUBLIC_BASE_URL env vars; helios.example.com is only an illustrative placeholder — parallel to the Mini App. Authentication uses the new Telegram Login SDK, which is OpenID Connect compliant — not the legacy HMAC-signed iframe widget. The legacy flow is archived (core.telegram.org/widgets/login-legacy) and stays a fallback only.

Reference (verified May 2026): https://core.telegram.org/bots/telegram-login.

Decision — surface

apps/portal/ is a new entrypoint that serves:

  • / — the same React app from apps/miniapp/ (single codebase, two mount targets).
  • /api/auth/telegram/callback — OIDC redirect handler.
  • /api/auth/jwks — proxied JWKS for offline verification.
  • /.well-known/openid-configuration — provider metadata for any future federation.

The same Vite build emits two HTML entry points: index.html for Mini App (no auth UI — initData is implicit), portal.html for the web portal (with the Login SDK loaded).

Decision — OIDC flow

Frontend uses Telegram.Login JS SDK (loaded from https://telegram.org/js/telegram-widget.js?22):

Telegram.Login.init({
    client_id: BOT_ID,           // numeric id from BotFather
    request_access: ["write"],   // optional
    nonce: cryptoRandomBase64Url(32),
    lang: navigator.language,
}, onReady);
 
Telegram.Login.open((result) => {
    if (result.error) handleError(result.error);
    else postIdToken(result.id_token, nonceUsed);
});

PKCE (code_challenge_method=S256) is enabled where the SDK supports it. The portal generates the nonce and stores {nonce, code_verifier} in sessionStorage (server-issued, signed HttpOnly cookie also acceptable).

Backend verification (infrastructure/auth/telegram_oidc.py):

  1. Fetch JWKS from https://oauth.telegram.org/... (cached for 1 h, refreshed on kid miss).
  2. Verify signature with the JWKS key matching kid (Ed25519 / RS256 depending on Telegram's key).
  3. Verify claims:
    • iss == https://oauth.telegram.org
    • aud == <our bot id>
    • exp within leeway 30 s
    • iat within auth_max_age_seconds (default 5 min, parameter-tuned)
    • nonce matches the one we issued (one-time use, cleared)
  4. Exchange the verified token for our own short-lived JWT (infrastructure/jwt.py) carrying client_id and tier, then set it as an HttpOnly Secure SameSite=Lax cookie.

The verifier conforms to a TelegramLoginVerifier Protocol so we can plug in different verification strategies in tests.

Decision — domain registration

Pre-register the portal URL in BotFather → Bot Settings → Web Login. The domain parameter is enforced server-side by Telegram. Multiple environments (staging.helios.example.com, helios.example.com) need separate BotFather entries.

Decision — HTTP headers

Cross-Origin-Opener-Policy: same-origin-allow-popups (required by the Login popup).

Content-Security-Policy:

  • default-src 'self'
  • script-src 'self' https://telegram.org
  • frame-src https://oauth.telegram.org
  • connect-src 'self' https://oauth.telegram.org
  • style-src 'self' 'unsafe-inline' (Vite-inlined critical CSS)
  • img-src 'self' data: https://t.me

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload after launch.

Decision — fallback to legacy widget

If the OIDC SDK fails to load (third-party domain blocked, ad-blocker), we fall back to the legacy iframe widget for the same domain. The HMAC-SHA256 verification path lives under infrastructure/auth/telegram_legacy_widget.py and is permitted only when feature.allow_legacy_login_widget = true.

Decision — same React app, two builds

apps/miniapp/   ← Vite, two HTML entries, two bundles via rollupOptions.input
                 emits  dist/index.html         (Mini App)
                        dist/portal.html        (web portal)
                        dist/assets/*           shared

The shell components branch on import.meta.env.VITE_SURFACE in {"miniapp","portal"} — auth context, navigation chrome and fullscreen affordances differ; everything below the shell is shared.

Decision — portal-specific UX

  • No Telegram theme inheritance — the portal carries the Helios brand palette (see design tokens, ADR-0008).
  • Login screen displays the new Telegram Login button via the SDK and a single sentence describing what the portal will see.
  • After login: same dashboards as the Mini App (Targets, Chats, Analytics, Routes, Billing, Settings, Stealth, Business).
  • Cookie-based session (refresh on activity), 14-day idle expiry by default (parameter).
  • No password resets, no 2FA prompts of our own — auth identity is Telegram.

Consequences

  • The portal is a thin extension, not a parallel application.
  • Login flow is verified by spec (JWT + JWKS + nonce + PKCE).
  • Adding a partner integration later is mostly a matter of issuing additional audiences from our own JWT, not changing the Telegram-side flow.

Alternatives considered

  • Legacy HMAC widget only. Rejected — the SDK is the documented path now, and we want clean OIDC semantics (auds, scopes, JWKS).
  • Self-hosted OpenID Provider on top of Telegram Login. Rejected for v1 — not enough downstream consumers to justify the complexity.

See also

  • apps/portal/, apps/miniapp/, infrastructure/auth/
  • ADR-0008 (design system shared between Mini App and portal)
  • docs/runbooks/portal-login.md (operator runbook)

On this page