"""Authorization-code login with PKCE, nonce and browser-bound single-use state.""" from __future__ import annotations import base64 import hashlib import hmac import re import secrets from time import time from urllib.parse import urlencode, urlsplit from uuid import UUID import httpx import jwt from fastapi import HTTPException from sqlalchemy import delete from sqlmodel import Session from ..core.config import settings from ..db.models import LoginFlow from .user_access import digest FLOW_COOKIE = "appops_login_flow" SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"] def _https_url(value: str) -> str: try: parts = urlsplit(value) _ = parts.port except ValueError: raise HTTPException(503, "OIDC URL configuration is invalid") from None if parts.scheme != "https" or not parts.hostname or parts.username or parts.password or parts.fragment or parts.query: raise HTTPException(503, "OIDC requires a configured HTTPS URL") return value def provider_config(provider: str) -> dict: if provider == "authentik": issuer, client_id, secret = settings.authentik_issuer, settings.authentik_client_id, settings.authentik_client_secret elif provider == "entra": try: tenant = str(UUID(settings.entra_tenant_id)) except ValueError: raise HTTPException(503, "Configure a specific Microsoft Entra tenant") from None issuer = f"https://login.microsoftonline.com/{tenant}/v2.0" client_id, secret = settings.entra_client_id, settings.entra_client_secret else: raise HTTPException(404, "Unknown identity provider") if not issuer or not client_id or not secret or not settings.auth_public_url: raise HTTPException(503, "Identity provider is not fully configured") _https_url(issuer) _https_url(settings.auth_public_url) return {"issuer": issuer, "client_id": client_id, "secret": secret, "redirect_uri": f"{settings.auth_public_url}/api/auth/callback/{provider}"} def login_provider(provider: str) -> str: """Use the configured Entra federation without weakening native validation.""" try: provider_config(provider) return provider except HTTPException as exc: if provider != "entra" or exc.status_code != 503: raise if not re.fullmatch(r"[a-zA-Z0-9_-]{1,100}", settings.authentik_entra_source): raise HTTPException(503, "Microsoft Entra login is not configured") provider_config("authentik") return "authentik" def provider_status() -> list[dict]: result = [] for provider, label in [("authentik", "Authentik"), ("entra", "Microsoft Entra")]: try: effective_provider = login_provider(provider) if provider == "entra" and effective_provider == "authentik": label = "Microsoft Entra via Authentik" configured = True except HTTPException: configured = False result.append({"id": provider, "label": label, "configured": configured}) return result def discovery(config: dict) -> dict: with httpx.Client(timeout=10, follow_redirects=False) as client: response = client.get(f"{config['issuer'].rstrip('/')}/.well-known/openid-configuration") response.raise_for_status() metadata = response.json() if metadata.get("issuer") != config["issuer"]: raise HTTPException(503, "Identity provider issuer mismatch") for key in ["authorization_endpoint", "token_endpoint", "jwks_uri"]: _https_url(str(metadata.get(key) or "")) return metadata def start_login(db: Session, provider: str) -> tuple[str, str]: config = provider_config(provider) metadata = discovery(config) state, browser, nonce, verifier = (secrets.token_urlsafe(48) for _ in range(4)) db.exec(delete(LoginFlow).where(LoginFlow.expires_at <= int(time()))) db.add(LoginFlow(id=digest(state), provider=provider, browser_hash=digest(browser), nonce=nonce, verifier=verifier, expires_at=int(time()) + 600)) db.commit() challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() params = {"client_id": config["client_id"], "redirect_uri": config["redirect_uri"], "response_type": "code", "scope": "openid profile email", "state": state, "nonce": nonce, "code_challenge": challenge, "code_challenge_method": "S256"} return f"{metadata['authorization_endpoint']}?{urlencode(params)}", browser def consume_flow(db: Session, provider: str, state: str, browser: str) -> LoginFlow: flow = db.get(LoginFlow, digest(state)) if not flow or flow.provider != provider or flow.expires_at <= time() or not browser or not hmac.compare_digest(flow.browser_hash, digest(browser)): raise HTTPException(401, "Login expired or browser validation failed") # Atomic consumption prevents concurrent callbacks replaying the same flow. result = db.exec(delete(LoginFlow).where(LoginFlow.id == flow.id)) db.commit() if result.rowcount != 1: raise HTTPException(401, "Login has already been used") return flow def verify_id_token(encoded: str, jwks: dict, config: dict, nonce: str, provider: str) -> dict: header = jwt.get_unverified_header(encoded) algorithm = header.get("alg") if algorithm not in SIGNING_ALGORITHMS: raise HTTPException(401, "Unsupported identity token signature") keys = jwt.PyJWKSet.from_dict(jwks) matches = [key for key in keys.keys if key.key_id == header.get("kid") and key.algorithm_name == algorithm] if len(matches) != 1: raise HTTPException(401, "Identity provider signing key unavailable") claims = jwt.decode(encoded, matches[0].key, algorithms=SIGNING_ALGORITHMS, audience=config["client_id"], issuer=config["issuer"], leeway=30, options={"require": ["iss", "sub", "aud", "exp", "iat", "nonce"]}) if not isinstance(claims["nonce"], str) or not hmac.compare_digest(claims["nonce"], nonce): raise HTTPException(401, "Identity token nonce mismatch") if claims.get("azp", config["client_id"]) != config["client_id"]: raise HTTPException(401, "Identity token client mismatch") if isinstance(claims["aud"], list) and len(claims["aud"]) > 1 and claims.get("azp") != config["client_id"]: raise HTTPException(401, "Identity token authorized party missing") if provider == "entra" and (not isinstance(claims.get("tid"), str) or claims["tid"].casefold() != settings.entra_tenant_id.casefold() or not isinstance(claims.get("oid"), str) or not claims["oid"]): raise HTTPException(401, "Microsoft Entra tenant or account mismatch") return claims def finish_login(db: Session, provider: str, state: str, browser: str, code: str) -> tuple[str, dict]: flow = consume_flow(db, provider, state, browser) config = provider_config(provider) metadata = discovery(config) with httpx.Client(timeout=10, follow_redirects=False) as client: response = client.post(metadata["token_endpoint"], data={ "grant_type": "authorization_code", "code": code, "redirect_uri": config["redirect_uri"], "client_id": config["client_id"], "client_secret": config["secret"], "code_verifier": flow.verifier, }) response.raise_for_status() tokens = response.json() keys_response = client.get(metadata["jwks_uri"]) keys_response.raise_for_status() claims = verify_id_token(tokens.get("id_token", ""), keys_response.json(), config, flow.nonce, provider) # Access/refresh/ID tokens are deliberately discarded after validation. return config["issuer"], claims