"""OIDC authentication routes: login, callback, logout, CLI login. Provides ``/auth/login``, ``/auth/callback``, ``/auth/logout``, ``/auth/cli-login``, and ``/auth/cli-poll`` endpoints that implement the full OIDC authorization code flow with PKCE. The ``cli-login`` / ``cli-poll`` pair supports the ``omnigent login`` CLI command. See ``designs/OIDC_AUTH.md`` for the complete design. These routes are only mounted when ``OMNIGENT_AUTH_PROVIDER=oidc``. """ from __future__ import annotations import logging import secrets import time from dataclasses import dataclass, field from urllib.parse import urlencode import httpx import jwt from fastapi import APIRouter, Request from starlette.responses import RedirectResponse, Response from omnigent.server.accounts_store import SqlAlchemyAccountStore from omnigent.server.admin_list import AdminList, promote_if_listed from omnigent.server.auth import ( _RESERVED_USERS, UnifiedAuthProvider, ) from omnigent.server.oidc import ( _GITHUB_EMAILS_ENDPOINT, derive_code_challenge, generate_code_verifier, mint_session_cookie, ) from omnigent.server.oidc_access import OidcAdmissionPolicy, resolve_allowed_domains_path from omnigent.stores.permission_store import PermissionStore _logger = logging.getLogger(__name__) # Short-lived cookie for PKCE state during the login flow. _AUTH_STATE_COOKIE_SECURE = "__Host-ap_auth_state" _AUTH_STATE_COOKIE_PLAIN = "ap_auth_state" _AUTH_STATE_TTL_SECONDS = 300 # 5 minutes _CLI_TICKET_TTL_SECONDS = 300 # 5 minutes # How long an OIDC invite URL stays redeemable. Matches the accounts # provider's default invite window (72h) — long enough to share # out-of-band, short enough to bound exposure of an unused link. _OIDC_INVITE_TTL_SECONDS = 72 * 3600 @dataclass class _CliTicket: """A pending CLI login ticket. Created by ``POST /auth/cli-login``, fulfilled by the browser callback, polled by ``GET /auth/cli-poll``. :param created_at: Unix timestamp when the ticket was created. :param token: The session JWT, set when the browser callback fulfills the ticket. ``None`` while pending. :param user_id: The authenticated user's email, set when fulfilled. ``None`` while pending. """ created_at: float = field(default_factory=time.time) token: str | None = None user_id: str | None = None def create_auth_router( auth_provider: UnifiedAuthProvider, permission_store: PermissionStore | None, admin_list: AdminList, account_store: SqlAlchemyAccountStore | None = None, allowed_domains: frozenset[str] | None = None, ) -> APIRouter: """Create an :class:`APIRouter` with OIDC login/callback/logout routes. :param auth_provider: The unified auth provider (must have ``_oidc_config`` set). :param permission_store: Permission store for user upsert on first login. ``None`` if permissions are disabled. :param admin_list: File-backed admin roster. Consulted on each callback to promote a listed email to admin (additive — see :mod:`omnigent.server.admin_list`). OIDC's only admin signal. :param account_store: Invite-token persistence, required only when ``OMNIGENT_OIDC_ALLOW_INVITES`` is on. ``None`` disables the invite routes entirely. Reuses the accounts provider's existing ``account_tokens`` table — the single-use invite token is stamped with the redeeming email and doubles as the durable pre-authorization (no OIDC-specific table). :param allowed_domains: Domains from the server config's ``allowed_domains:`` key, union'd with ``OMNIGENT_OIDC_ALLOWED_DOMAINS`` and the runtime-editable file in the admission policy. :returns: A FastAPI router with ``/login``, ``/callback``, ``/logout`` (and ``/invite`` when invites are enabled). """ router = APIRouter() config = auth_provider._oidc_config # Invites are opt-in AND require the token store. Both must hold. _invites_enabled = config.allow_invites and account_store is not None # Admission policy: domain allowlist (env ∪ runtime-editable file) # with admin-list and (when enabled) invite bypasses. One place # decides who may sign in — see omnigent/server/oidc_access.py. admission = OidcAdmissionPolicy( env_allowed_domains=config.allowed_domains, domains_file_path=resolve_allowed_domains_path(), admin_list=admin_list, invited_lookup=account_store if _invites_enabled else None, config_allowed_domains=allowed_domains, ) # Cookie names and secure flag depend on HTTP vs HTTPS (derived # from redirect_uri). The __Host- prefix requires HTTPS — using # it on http://localhost causes browsers to silently drop the # cookie, resulting in an infinite login redirect. _secure = config.secure_cookies _session_cookie = config.session_cookie_name _state_cookie = _AUTH_STATE_COOKIE_SECURE if _secure else _AUTH_STATE_COOKIE_PLAIN # In-memory store for CLI login tickets. Tickets are short-lived # (5 min) and single-use. Keyed by ticket ID. _cli_tickets: dict[str, _CliTicket] = {} @router.get("/login") async def login(request: Request) -> Response: """Redirect to the IdP's authorization endpoint. Generates PKCE ``code_verifier`` / ``code_challenge`` and a ``state`` parameter. Stores them in a short-lived signed cookie so the callback can verify the response. :param request: The incoming FastAPI request. :returns: 302 redirect to the IdP with PKCE and state params. """ state = secrets.token_urlsafe(32) code_verifier = generate_code_verifier() code_challenge = derive_code_challenge(code_verifier) # Sanitize at ingest so only a safe same-origin path is ever # signed into the state cookie — prevents an open redirect on # the post-auth 302 in /callback. return_to = _sanitize_return_to(request.query_params.get("return_to")) # Optional CLI login ticket — threaded through the state # cookie so the callback can fulfill it. ticket = request.query_params.get("ticket") # Optional OIDC invite token — threaded through the signed state # cookie (not a bare query param) so it can't be tampered with # before the callback redeems it. Only meaningful when invites # are enabled; ignored otherwise. invite = request.query_params.get("invite") if _invites_enabled else None # Store state + code_verifier in a short-lived signed cookie. state_payload: dict[str, str | int] = { "state": state, "code_verifier": code_verifier, "return_to": return_to, "exp": _auth_state_exp(), } if ticket: state_payload["ticket"] = ticket if invite: state_payload["invite"] = invite state_jwt = jwt.encode(state_payload, config.cookie_secret, algorithm="HS256") # Build the authorization URL. params = { "response_type": "code", "client_id": config.client_id, "redirect_uri": config.redirect_uri, "scope": config.scopes, "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256", } auth_url = config.authorization_endpoint + "?" + urlencode(params) response = RedirectResponse(url=auth_url, status_code=302) response.set_cookie( key=_state_cookie, value=state_jwt, max_age=_AUTH_STATE_TTL_SECONDS, httponly=True, secure=config.secure_cookies, samesite="lax", path="/", ) return response @router.get("/callback") async def callback(request: Request) -> Response: """Handle the IdP callback after user authentication. Validates the ``state`` parameter, exchanges the authorization code for tokens, extracts the user's email, mints a session cookie, and redirects to the app. :param request: The incoming FastAPI request containing ``code`` and ``state`` query parameters plus the ``__Host-ap_auth_state`` cookie. :returns: 302 redirect to the app with session cookie set, or 400/403 on validation failure. """ from fastapi.responses import JSONResponse code = request.query_params.get("code") state = request.query_params.get("state") if not code or not state: return JSONResponse( status_code=400, content={"error": "Missing code or state parameter"}, ) # Verify state from the cookie. state_cookie = request.cookies.get(_state_cookie) if not state_cookie: return JSONResponse( status_code=400, content={"error": "Missing auth state cookie"}, ) try: state_payload = jwt.decode(state_cookie, config.cookie_secret, algorithms=["HS256"]) except jwt.InvalidTokenError: return JSONResponse( status_code=400, content={"error": "Invalid or expired auth state"}, ) if state != state_payload.get("state"): return JSONResponse( status_code=400, content={"error": "State mismatch (possible CSRF)"}, ) code_verifier = state_payload.get("code_verifier", "") # Re-sanitize on the way out: /login sanitizes at ingest, but a # cookie minted before this fix (or by a tampering attempt that # somehow forged a valid signature) must not yield an open # redirect at the 302 below. return_to = _sanitize_return_to(state_payload.get("return_to")) # Exchange authorization code for tokens. token_data = { "grant_type": "authorization_code", "code": code, "redirect_uri": config.redirect_uri, "client_id": config.client_id, "client_secret": config.client_secret, "code_verifier": code_verifier, } async with httpx.AsyncClient() as client: # GitHub requires Accept: application/json to get JSON # response from the token endpoint. headers = {"Accept": "application/json"} if config.provider_type == "github" else {} token_resp = await client.post( config.token_endpoint, data=token_data, headers=headers, timeout=10.0, ) if token_resp.status_code != 200: _logger.error( "Token exchange failed: %d %s", token_resp.status_code, token_resp.text, ) return JSONResponse( status_code=400, content={"error": "Token exchange failed"}, ) token_json = token_resp.json() # Extract user email. if config.provider_type == "github": email = await _resolve_github_email(client, token_json.get("access_token", "")) else: email = _resolve_oidc_email(token_json, config) if not email: return JSONResponse( status_code=400, content={"error": "Could not determine user email from IdP"}, ) # Normalize email to lowercase. email = email.lower() # Redeem an OIDC invite (if one rode along in the signed state) # BEFORE the admission check, so the just-bound email passes the # domain gate via the invite bypass. Single-use: the token is # consumed here and stamped with this email on the existing # account_tokens row, which doubles as the durable pre-auth that # admits the email on subsequent logins. Reserved-name emails are # rejected below regardless, so binding one here is harmless. if _invites_enabled: invite_token = state_payload.get("invite") if invite_token: account_store.redeem_oidc_invite( str(invite_token), email, now_epoch_seconds=int(time.time()) ) # Admission control: domain allowlist (env ∪ file) plus the # admin-list / invite bypasses. An empty effective allowlist # means "no restriction" (admit any IdP user) — the OSS default. if not admission.is_admitted(email): domain = email.rsplit("@", 1)[-1] if "@" in email else "" return JSONResponse( status_code=403, content={"error": f"Email domain {domain!r} is not permitted on this server"}, ) # Reject reserved user names. if email in _RESERVED_USERS: return JSONResponse( status_code=403, content={"error": f"Reserved user name {email!r}"}, ) # Ensure user exists in the permission store, then apply the # file-backed admin list. Promotion is additive (never demotes) # and is OIDC's only path to admin — the IdP doesn't tell us # who is an operator. ensure_user must run first so the # set_admin UPDATE inside promote_if_listed matches a row. if permission_store is not None: permission_store.ensure_user(email) promote_if_listed(admin_list, permission_store, email) # Mint session cookie. session_jwt = mint_session_cookie( user_id=email, cookie_secret=config.cookie_secret, ttl_hours=config.session_ttl_hours, provider=config.provider_type, ) # Check if this callback fulfills a CLI login ticket. ticket_id = state_payload.get("ticket") if ticket_id and ticket_id in _cli_tickets: ticket = _cli_tickets[ticket_id] ticket.token = session_jwt ticket.user_id = email # Return a simple HTML page — the CLI is polling # /auth/cli-poll and will pick up the token. import html as _html from starlette.responses import HTMLResponse safe_email = _html.escape(email) html = ( "
" "Authenticated as {safe_email}.
" "You can close this tab and return to the terminal.
" "" ) resp = HTMLResponse(content=html) # Still set the session cookie (useful if they also open # the web UI in the same browser). resp.set_cookie( key=_session_cookie, value=session_jwt, max_age=config.session_ttl_hours * 3600, httponly=True, secure=_secure, samesite="lax", path="/", ) resp.delete_cookie( key=_state_cookie, path="/", secure=_secure, httponly=True, samesite="lax", ) return resp # Normal browser login — redirect back to the app. response = RedirectResponse(url=return_to, status_code=302) response.set_cookie( key=_session_cookie, value=session_jwt, max_age=config.session_ttl_hours * 3600, httponly=True, secure=_secure, samesite="lax", path="/", ) # Clear the auth state cookie. response.delete_cookie( key=_state_cookie, path="/", secure=_secure, httponly=True, samesite="lax", ) return response if _invites_enabled: @router.post("/invite") async def oidc_invite(request: Request) -> Response: """Mint a single-use OIDC invite URL (admin only). Pre-authorizes whoever redeems the link: when they complete the OIDC flow via ``/auth/login?invite=