"""steward/auth/oidc.py OIDC Authorization Code flow helpers using httpx. No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.). JWT signature verification is intentionally skipped for homelab use; we trust the HTTPS connection to the discovery-documented token/userinfo endpoints. """ from __future__ import annotations import base64 import hashlib import json import logging import os import time import httpx logger = logging.getLogger(__name__) # Module-level cache: {discovery_url: (fetched_at, doc)} _discovery_cache: dict[str, tuple[float, dict]] = {} _DISCOVERY_TTL = 300 # seconds async def get_discovery(discovery_url: str) -> dict: """Fetch and cache the OIDC provider discovery document.""" now = time.monotonic() if discovery_url in _discovery_cache: fetched_at, doc = _discovery_cache[discovery_url] if now - fetched_at < _DISCOVERY_TTL: return doc async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(discovery_url) resp.raise_for_status() doc = resp.json() _discovery_cache[discovery_url] = (now, doc) return doc def build_authorize_url(doc: dict, client_id: str, redirect_uri: str, scopes: str, state: str, nonce: str) -> str: """Build the IdP authorization redirect URL.""" import urllib.parse params = { "response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "scope": scopes, "state": state, "nonce": nonce, } return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params) async def exchange_code(doc: dict, client_id: str, client_secret: str, code: str, redirect_uri: str) -> dict: """Exchange authorization code for tokens. Returns token response dict.""" async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( doc["token_endpoint"], data={ "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": client_id, "client_secret": client_secret, }, ) resp.raise_for_status() return resp.json() async def get_userinfo(doc: dict, access_token: str) -> dict: """Fetch user info from the IdP's userinfo endpoint.""" async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( doc["userinfo_endpoint"], headers={"Authorization": f"Bearer {access_token}"}, ) resp.raise_for_status() return resp.json() def decode_id_token_payload(id_token: str) -> dict: """Decode ID token payload without verifying signature (homelab only).""" try: parts = id_token.split(".") if len(parts) < 2: return {} payload_b64 = parts[1] # Add padding payload_b64 += "=" * (4 - len(payload_b64) % 4) return json.loads(base64.urlsafe_b64decode(payload_b64)) except Exception: return {} def map_role(groups: list[str], admin_group: str, operator_group: str) -> str: """Map IdP groups to a local role string.""" if admin_group and admin_group in groups: return "admin" if operator_group and operator_group in groups: return "operator" return "viewer" def generate_state() -> str: return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=")