Spaces:
Sleeping
Sleeping
| """ | |
| Org-scoped identity for Brain University β Phase 2 (docs/TENANCY.md). | |
| Three ways to obtain a session: | |
| 1. POST /auth/login {email, password} β local accounts (argon2id) | |
| 2. GET /auth/oidc/login β GET /auth/oidc/callback β OIDC authorization-code | |
| flow (issuer/client from env; endpoints 404 when OIDC_ISSUER is unset) | |
| 3. Legacy POST /login (api/server.py) β single-admin HMAC token; | |
| api.auth.verify_token() maps it to org 'org-demo' with role 'admin', so | |
| the deployed demo keeps working with zero new env. | |
| Session = JWT (HS256 over BU_AUTH_SECRET), claims {sub, org_id, role, exp}. | |
| `sub` is users.user_id (the identity spec's `id` β see migrations/004). | |
| Env (all optional; partial sets fail loudly, P0 style): | |
| BU_BOOTSTRAP_ADMIN_EMAIL β with _PASSWORD: provision this local admin in | |
| BU_BOOTSTRAP_ADMIN_PASSWORD org 'org-demo' on first use if absent. | |
| One without the other = RuntimeError. | |
| OIDC_ISSUER β enables /auth/oidc/*; discovery at | |
| {OIDC_ISSUER}/.well-known/openid-configuration | |
| OIDC_CLIENT_ID β REQUIRED once OIDC_ISSUER is set | |
| OIDC_CLIENT_SECRET β REQUIRED once OIDC_ISSUER is set | |
| OIDC_REDIRECT_URL β REQUIRED once OIDC_ISSUER is set; must match | |
| the IdP-registered callback | |
| (https://host/auth/oidc/callback) | |
| OIDC org resolution: user linked by (iss, sub); on first login the verified | |
| email's domain must match an orgs.domain row, else 403 'no org for domain'. | |
| OIDC users default to role 'viewer'. | |
| Mounted by api/server.py: | |
| from api.identity import router as identity_router | |
| app.include_router(identity_router) | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import secrets | |
| import threading | |
| import time | |
| from urllib.parse import urlencode | |
| import httpx | |
| import jwt | |
| from argon2 import PasswordHasher | |
| from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError | |
| from fastapi import APIRouter, HTTPException | |
| from fastapi.responses import RedirectResponse | |
| from pydantic import BaseModel | |
| from api import auth as _auth # BU_AUTH_SECRET / TTL; auth does NOT import us at module level | |
| BOOTSTRAP_ORG_ID = "org-demo" # seeded by migrations/004 | |
| ROLES = ("admin", "sme", "viewer") | |
| SESSION_ALGO = "HS256" | |
| STATE_TTL_S = 600 # OIDC state/nonce validity: 10 minutes | |
| _STATE_AUD = "bu-oidc-state" # aud claim keeps state JWTs unusable as sessions | |
| _ID_TOKEN_ALGOS = ["RS256", "RS384", "RS512", "ES256", "ES384", "PS256"] | |
| # ββ Env (validated at import β P0 fail-loud style) ββββββββββββββββββββββββββ | |
| OIDC_ISSUER = os.environ.get("OIDC_ISSUER", "").strip() | |
| OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "").strip() | |
| OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "") | |
| OIDC_REDIRECT_URL = os.environ.get("OIDC_REDIRECT_URL", "").strip() | |
| if OIDC_ISSUER: | |
| _missing = [name for name, val in ( | |
| ("OIDC_CLIENT_ID", OIDC_CLIENT_ID), | |
| ("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET), | |
| ("OIDC_REDIRECT_URL", OIDC_REDIRECT_URL), | |
| ) if not val] | |
| if _missing: | |
| raise RuntimeError( | |
| "Refusing to start: OIDC_ISSUER is set but required OIDC env " | |
| "var(s) unset: " + ", ".join(_missing) | |
| + ". Set them all, or unset OIDC_ISSUER to disable OIDC " | |
| "(local + legacy logins keep working without it)." | |
| ) | |
| def _bootstrap_env() -> tuple[str, str] | None: | |
| """(email, password) when both set, None when both unset; partial raises.""" | |
| email = os.environ.get("BU_BOOTSTRAP_ADMIN_EMAIL", "").strip() | |
| password = os.environ.get("BU_BOOTSTRAP_ADMIN_PASSWORD", "") | |
| if not email and not password: | |
| return None | |
| if not (email and password): | |
| missing = "BU_BOOTSTRAP_ADMIN_PASSWORD" if email else "BU_BOOTSTRAP_ADMIN_EMAIL" | |
| raise RuntimeError( | |
| f"Refusing to start: {missing} unset while its counterpart is " | |
| "set. Set both BU_BOOTSTRAP_ADMIN_EMAIL and " | |
| "BU_BOOTSTRAP_ADMIN_PASSWORD, or neither." | |
| ) | |
| return email.lower(), password | |
| _bootstrap_env() # fail loudly at import on partial env | |
| # ββ Password hashing (argon2id) βββββββββββββββββββββββββββββββββββββββββββββ | |
| _hasher = PasswordHasher() # argon2id, library-default parameters | |
| def hash_password(password: str) -> str: | |
| return _hasher.hash(password) | |
| def verify_password(pw_hash: str, password: str) -> bool: | |
| try: | |
| return _hasher.verify(pw_hash, password) | |
| except (VerifyMismatchError, VerificationError, InvalidHashError): | |
| return False | |
| # ββ Session JWTs β claims {sub, org_id, role, exp} ββββββββββββββββββββββββββ | |
| def mint_session(user: dict, ttl_s: int | None = None) -> str: | |
| """Issue a session JWT for a users row (dict with id/org_id/role).""" | |
| exp = int(time.time()) + (ttl_s or _auth.AUTH_TTL_S) | |
| claims = { | |
| "sub": user["id"], | |
| "org_id": user["org_id"], | |
| "role": user["role"], | |
| "exp": exp, | |
| } | |
| return jwt.encode(claims, _auth.AUTH_SECRET, algorithm=SESSION_ALGO) | |
| def verify_session(token: str) -> dict | None: | |
| """Return the session claims when `token` is a valid, unexpired org JWT. | |
| None for anything else (legacy HMAC tokens, state tokens, garbage) β | |
| api.auth.verify_token() then falls back to the legacy verifier. | |
| """ | |
| if not token or token.count(".") != 2: # fast path: not a JWT at all | |
| return None | |
| try: | |
| claims = jwt.decode(token, _auth.AUTH_SECRET, algorithms=[SESSION_ALGO]) | |
| except jwt.InvalidTokenError: | |
| return None | |
| if not all(claims.get(k) for k in ("sub", "org_id", "role")): | |
| return None | |
| if claims["role"] not in ROLES: | |
| return None | |
| return claims | |
| # ββ DB helpers (users/orgs β migrations/004; users.user_id is the id) βββββββ | |
| _USER_COLS = ("user_id, display_name, created_at, org_id, email, role, " | |
| "oidc_iss, oidc_sub, pw_hash") | |
| def _db(): | |
| from atp import db | |
| return db | |
| _MIGRATED = False | |
| def _ensure_db() -> None: | |
| """Idempotent lazy migration (same pattern as atp/store.py).""" | |
| global _MIGRATED | |
| if not _MIGRATED: | |
| _db().run_migrations() | |
| _MIGRATED = True | |
| def _to_user(row: dict) -> dict: | |
| user = dict(row) | |
| user["id"] = user.pop("user_id") | |
| return user | |
| def get_user_by_email(email: str) -> dict | None: | |
| rows = _db().query( | |
| f"SELECT {_USER_COLS} FROM users WHERE email = :email", | |
| {"email": email.strip().lower()}, | |
| ) | |
| return _to_user(rows[0]) if rows else None | |
| def get_user_by_oidc(iss: str, sub: str) -> dict | None: | |
| rows = _db().query( | |
| f"SELECT {_USER_COLS} FROM users" | |
| " WHERE oidc_iss = :iss AND oidc_sub = :sub", | |
| {"iss": iss, "sub": sub}, | |
| ) | |
| return _to_user(rows[0]) if rows else None | |
| def get_org_by_domain(domain: str) -> dict | None: | |
| rows = _db().query( | |
| "SELECT id, name, domain, created_at FROM orgs" | |
| " WHERE domain IS NOT NULL AND lower(domain) = lower(:domain)", | |
| {"domain": domain}, | |
| ) | |
| return dict(rows[0]) if rows else None | |
| def create_user(*, email: str, org_id: str, role: str, | |
| oidc_iss: str | None = None, oidc_sub: str | None = None, | |
| pw_hash: str | None = None, | |
| display_name: str | None = None) -> dict: | |
| """Insert an identity user; returns the created (or racing-winner) row.""" | |
| if role not in ROLES: | |
| raise ValueError(f"role must be one of {ROLES}, got {role!r}") | |
| email = email.strip().lower() | |
| try: | |
| _db().execute( | |
| "INSERT INTO users (user_id, display_name, created_at, org_id," | |
| " email, role, oidc_iss, oidc_sub, pw_hash)" | |
| " VALUES (:id, :name, :ts, :org, :email, :role, :iss, :sub, :pw)", | |
| { | |
| "id": "u-" + secrets.token_hex(8), | |
| "name": display_name or email, | |
| "ts": time.time(), # users.created_at is REAL (001) | |
| "org": org_id, | |
| "email": email, | |
| "role": role, | |
| "iss": oidc_iss, | |
| "sub": oidc_sub, | |
| "pw": pw_hash, | |
| }, | |
| ) | |
| except Exception: | |
| # Unique-email race: another worker inserted first β use theirs. | |
| existing = get_user_by_email(email) | |
| if existing is None: | |
| raise | |
| return existing | |
| user = get_user_by_email(email) | |
| assert user is not None | |
| return user | |
| def link_user_oidc(user_id: str, iss: str, sub: str) -> None: | |
| _db().execute( | |
| "UPDATE users SET oidc_iss = :iss, oidc_sub = :sub" | |
| " WHERE user_id = :id", | |
| {"iss": iss, "sub": sub, "id": user_id}, | |
| ) | |
| # ββ Bootstrap admin (local fallback account, org-demo) ββββββββββββββββββββββ | |
| def provision_bootstrap_admin() -> dict | None: | |
| """Create the BU_BOOTSTRAP_ADMIN_* local admin in org-demo if absent. | |
| Returns the user row (existing or created), or None when the env pair is | |
| unset. Partial env = RuntimeError (P0 fail-loud style). Idempotent. | |
| """ | |
| pair = _bootstrap_env() | |
| if pair is None: | |
| return None | |
| email, password = pair | |
| _ensure_db() | |
| existing = get_user_by_email(email) | |
| if existing is not None: | |
| return existing | |
| return create_user( | |
| email=email, | |
| org_id=BOOTSTRAP_ORG_ID, | |
| role="admin", | |
| pw_hash=hash_password(password), | |
| display_name="Bootstrap admin", | |
| ) | |
| _READY = False | |
| _READY_LOCK = threading.Lock() | |
| def _ensure_ready() -> None: | |
| """Migrations + bootstrap provisioning, once per process, thread-safe.""" | |
| global _READY | |
| if _READY: | |
| return | |
| with _READY_LOCK: | |
| if _READY: | |
| return | |
| _ensure_db() | |
| provision_bootstrap_admin() | |
| _READY = True | |
| # ββ OIDC β discovery, JWKS, signed state ββββββββββββββββββββββββββββββββββββ | |
| _DISCOVERY_TTL_S = 3600 | |
| _discovery_cache: dict = {} | |
| _jwks_clients: dict[str, jwt.PyJWKClient] = {} | |
| def oidc_enabled() -> bool: | |
| return bool(OIDC_ISSUER) | |
| def _discovery() -> dict: | |
| """Fetch (and cache) the issuer's openid-configuration.""" | |
| now = time.time() | |
| if _discovery_cache.get("cfg") and now - _discovery_cache["fetched"] < _DISCOVERY_TTL_S: | |
| return _discovery_cache["cfg"] | |
| url = OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration" | |
| try: | |
| resp = httpx.get(url, timeout=10.0) | |
| resp.raise_for_status() | |
| cfg = resp.json() | |
| except httpx.HTTPError as e: | |
| raise HTTPException(502, f"OIDC discovery failed: {e.__class__.__name__}") from e | |
| for key in ("authorization_endpoint", "token_endpoint", "jwks_uri", "issuer"): | |
| if key not in cfg: | |
| raise HTTPException(502, f"OIDC discovery document missing {key!r}") | |
| _discovery_cache.update(cfg=cfg, fetched=now) | |
| return cfg | |
| def _jwks_client(jwks_uri: str) -> jwt.PyJWKClient: | |
| client = _jwks_clients.get(jwks_uri) | |
| if client is None: | |
| client = jwt.PyJWKClient(jwks_uri, cache_keys=True) | |
| _jwks_clients[jwks_uri] = client | |
| return client | |
| def _mint_state(nonce: str) -> str: | |
| """Signed CSRF state, 10 min TTL. aud keeps it unusable as a session.""" | |
| claims = {"aud": _STATE_AUD, "nonce": nonce, | |
| "exp": int(time.time()) + STATE_TTL_S} | |
| return jwt.encode(claims, _auth.AUTH_SECRET, algorithm=SESSION_ALGO) | |
| def _verify_state(state: str) -> dict | None: | |
| if not state: | |
| return None | |
| try: | |
| return jwt.decode(state, _auth.AUTH_SECRET, | |
| algorithms=[SESSION_ALGO], audience=_STATE_AUD) | |
| except jwt.InvalidTokenError: | |
| return None | |
| # ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| router = APIRouter() | |
| def _public_user(user: dict) -> dict: | |
| return {"id": user["id"], "email": user["email"], | |
| "org_id": user["org_id"], "role": user["role"]} | |
| class LoginBody(BaseModel): | |
| email: str | |
| password: str | |
| def local_login(body: LoginBody): | |
| """Local-account login β session JWT with {sub, org_id, role, exp}.""" | |
| _ensure_ready() | |
| user = get_user_by_email(body.email) | |
| ok = (user is not None and user.get("pw_hash") | |
| and verify_password(user["pw_hash"], body.password)) | |
| if not ok: | |
| time.sleep(0.25) # match legacy /login: slow credential stuffing | |
| raise HTTPException(401, "invalid credentials") | |
| return {"token": mint_session(user), "user": _public_user(user)} | |
| def oidc_login(): | |
| """Redirect to the IdP's authorization endpoint (code flow).""" | |
| if not oidc_enabled(): | |
| raise HTTPException(404, "Not Found") | |
| cfg = _discovery() | |
| nonce = secrets.token_urlsafe(16) | |
| params = { | |
| "response_type": "code", | |
| "client_id": OIDC_CLIENT_ID, | |
| "redirect_uri": OIDC_REDIRECT_URL, | |
| "scope": "openid email profile", | |
| "state": _mint_state(nonce), | |
| "nonce": nonce, | |
| } | |
| return RedirectResponse( | |
| cfg["authorization_endpoint"] + "?" + urlencode(params), | |
| status_code=302, | |
| ) | |
| def oidc_callback(code: str = "", state: str = "", error: str = "", | |
| error_description: str = ""): | |
| """Exchange the code, verify the id_token, provision/link, mint session.""" | |
| if not oidc_enabled(): | |
| raise HTTPException(404, "Not Found") | |
| _ensure_ready() | |
| if error: | |
| raise HTTPException(400, f"OIDC error: {error} {error_description}".strip()) | |
| st = _verify_state(state) | |
| if st is None: | |
| raise HTTPException(400, "invalid or expired state") | |
| if not code: | |
| raise HTTPException(400, "missing code") | |
| cfg = _discovery() | |
| try: | |
| resp = httpx.post( | |
| cfg["token_endpoint"], | |
| data={ | |
| "grant_type": "authorization_code", | |
| "code": code, | |
| "redirect_uri": OIDC_REDIRECT_URL, | |
| "client_id": OIDC_CLIENT_ID, | |
| "client_secret": OIDC_CLIENT_SECRET, | |
| }, | |
| timeout=10.0, | |
| ) | |
| resp.raise_for_status() | |
| id_token = resp.json().get("id_token", "") | |
| except httpx.HTTPError as e: | |
| raise HTTPException(502, f"OIDC token exchange failed: {e.__class__.__name__}") from e | |
| if not id_token: | |
| raise HTTPException(502, "OIDC token response had no id_token") | |
| try: | |
| signing_key = _jwks_client(cfg["jwks_uri"]).get_signing_key_from_jwt(id_token) | |
| claims = jwt.decode( | |
| id_token, | |
| signing_key.key, | |
| algorithms=_ID_TOKEN_ALGOS, | |
| audience=OIDC_CLIENT_ID, | |
| issuer=cfg["issuer"], | |
| leeway=30, | |
| ) | |
| except jwt.PyJWKClientError as e: | |
| raise HTTPException(502, f"OIDC JWKS fetch failed: {e.__class__.__name__}") from e | |
| except jwt.InvalidTokenError: | |
| raise HTTPException(401, "invalid id_token") | |
| if claims.get("nonce") != st["nonce"]: | |
| raise HTTPException(401, "nonce mismatch") | |
| iss, sub = claims["iss"], claims["sub"] | |
| user = get_user_by_oidc(iss, sub) | |
| if user is None: | |
| email = (claims.get("email") or "").strip().lower() | |
| if not email: | |
| raise HTTPException(403, "id_token has no email claim") | |
| if claims.get("email_verified") is False: | |
| raise HTTPException(403, "email not verified by identity provider") | |
| existing = get_user_by_email(email) | |
| if existing is not None: | |
| # Same verified email as an existing account β link identities. | |
| link_user_oidc(existing["id"], iss, sub) | |
| user = get_user_by_email(email) | |
| else: | |
| domain = email.split("@", 1)[1] | |
| org = get_org_by_domain(domain) | |
| if org is None: | |
| raise HTTPException(403, "no org for domain") | |
| user = create_user( | |
| email=email, | |
| org_id=org["id"], | |
| role="viewer", # OIDC default; org admins promote later | |
| oidc_iss=iss, | |
| oidc_sub=sub, | |
| display_name=claims.get("name") or email, | |
| ) | |
| return {"token": mint_session(user), "user": _public_user(user)} | |