brain-university-api / api /identity.py
jang0294's picture
Upload folder using huggingface_hub
e280d04 verified
Raw
History Blame Contribute Delete
16.8 kB
"""
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
@router.post("/auth/login")
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)}
@router.get("/auth/oidc/login")
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,
)
@router.get("/auth/oidc/callback")
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)}