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 fromapps/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):
- Fetch JWKS from
https://oauth.telegram.org/...(cached for 1 h, refreshed onkidmiss). - Verify signature with the JWKS key matching
kid(Ed25519 / RS256 depending on Telegram's key). - Verify claims:
iss == https://oauth.telegram.orgaud == <our bot id>expwithin leeway 30 siatwithinauth_max_age_seconds(default 5 min, parameter-tuned)noncematches the one we issued (one-time use, cleared)
- Exchange the verified token for our own short-lived JWT (
infrastructure/jwt.py) carryingclient_idandtier, then set it as anHttpOnly Secure SameSite=Laxcookie.
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.orgframe-src https://oauth.telegram.orgconnect-src 'self' https://oauth.telegram.orgstyle-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)