"""Tenant authentication: passphrase login + HMAC-signed bearer tokens. The token's subject (``sub``) is the tenant_id. Because the token is signed with a server-side secret, a client cannot forge a token for an arbitrary tenant, so ``request.state.tenant_id`` derived from a verified token is trustworthy — this is what enforces user isolation. Stdlib only (``hashlib``, ``hmac``, ``secrets``). """ from __future__ import annotations import asyncio import base64 import hashlib import hmac import json import logging import secrets import time from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.db.database import get_db from app.db.models import Tenant from app.models.schemas import AuthRequest, AuthTokenResponse, AuthWhoAmIResponse logger = logging.getLogger(__name__) router = APIRouter() _PBKDF2_ITERATIONS = 200_000 _DEFAULT_SECRETS = frozenset({"", "dev-secret-change-me", "change-me-in-production"}) # Process-ephemeral fallback secret, generated once if the operator shipped a # sentinel TENANT_SECRET_KEY in production. Tokens signed with it are # unforgeable but do not survive a restart (every restart forces re-login). _EPHEMERAL_SECRET: str | None = None def _signing_key() -> bytes: global _EPHEMERAL_SECRET configured = (settings.tenant_secret_key or "").strip() if configured and configured not in _DEFAULT_SECRETS: return configured.encode("utf-8") if settings.dev_mode: # Stable across the dev session; fine for local use. return (configured or "dev-secret-change-me").encode("utf-8") if _EPHEMERAL_SECRET is None: _EPHEMERAL_SECRET = secrets.token_urlsafe(48) logger.warning( "TENANT_SECRET_KEY is unset/default in production — using an " "ephemeral signing key. Set TENANT_SECRET_KEY so issued tokens " "survive restarts." ) return _EPHEMERAL_SECRET.encode("utf-8") # ── Password hashing ────────────────────────────────────────────────────────── def hash_password(password: str, *, salt: str | None = None, iterations: int = _PBKDF2_ITERATIONS) -> tuple[str, str, int]: """Return ``(hash_hex, salt_hex, iterations)`` for ``password``.""" salt_bytes = bytes.fromhex(salt) if salt else secrets.token_bytes(16) dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, iterations) return dk.hex(), salt_bytes.hex(), iterations def verify_password(password: str, *, hash_hex: str, salt_hex: str, iterations: int) -> bool: candidate, _, _ = hash_password(password, salt=salt_hex, iterations=iterations) return hmac.compare_digest(candidate, hash_hex) # ── Token mint / verify ───────────────────────────────────────────────────────── def _b64u_encode(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def _b64u_decode(data: str) -> bytes: pad = "=" * (-len(data) % 4) return base64.urlsafe_b64decode(data + pad) def mint_token(tenant_id: str, *, ttl_seconds: int | None = None) -> tuple[str, int]: """Create a signed token for ``tenant_id``; returns ``(token, expires_at)``.""" now = int(time.time()) exp = now + int(ttl_seconds if ttl_seconds is not None else settings.auth_token_ttl_seconds) payload = _b64u_encode(json.dumps({"sub": tenant_id, "iat": now, "exp": exp}, separators=(",", ":")).encode("utf-8")) sig = _b64u_encode(hmac.new(_signing_key(), payload.encode("ascii"), hashlib.sha256).digest()) return f"{payload}.{sig}", exp def verify_token(token: str) -> str | None: """Return the tenant_id if ``token`` is valid and unexpired, else ``None``.""" if not token or "." not in token: return None payload_b64, _, sig_b64 = token.partition(".") expected = _b64u_encode(hmac.new(_signing_key(), payload_b64.encode("ascii"), hashlib.sha256).digest()) if not hmac.compare_digest(sig_b64, expected): return None try: payload = json.loads(_b64u_decode(payload_b64)) except (ValueError, json.JSONDecodeError): return None sub = payload.get("sub") exp = payload.get("exp") if not isinstance(sub, str) or not isinstance(exp, int): return None if exp < int(time.time()): return None return sub # ── Validation ────────────────────────────────────────────────────────────────── def _normalise_tenant_id(raw: str) -> str: tid = (raw or "").strip() if not (3 <= len(tid) <= 128): raise HTTPException(status_code=422, detail="Tenant/User ID must be 3–128 characters.") if not all(c.isalnum() or c in "._-" for c in tid): raise HTTPException( status_code=422, detail="Tenant/User ID may only contain letters, digits, '.', '_' and '-'.", ) return tid def _validate_passphrase(pw: str) -> None: if len(pw or "") < 8: raise HTTPException(status_code=422, detail="Passphrase must be at least 8 characters.") # ── Endpoints ───────────────────────────────────────────────────────────────── @router.post("/auth/register", response_model=AuthTokenResponse, status_code=201) async def register(body: AuthRequest, db: AsyncSession = Depends(get_db)) -> AuthTokenResponse: """Create a new tenant and return a signed access token.""" tenant_id = _normalise_tenant_id(body.tenant_id) _validate_passphrase(body.passphrase) existing = await db.get(Tenant, tenant_id) if existing is not None: raise HTTPException(status_code=409, detail="That Tenant/User ID is already taken. Log in instead.") h, salt, iters = await asyncio.to_thread(hash_password, body.passphrase) db.add(Tenant(id=tenant_id, password_hash=h, password_salt=salt, pbkdf2_iterations=iters)) await db.commit() token, exp = mint_token(tenant_id) logger.info("Registered tenant=%s", tenant_id) return AuthTokenResponse(tenant_id=tenant_id, access_token=token, expires_at=exp) @router.post("/auth/login", response_model=AuthTokenResponse) async def login(body: AuthRequest, db: AsyncSession = Depends(get_db)) -> AuthTokenResponse: """Verify a tenant passphrase and return a signed access token.""" tenant_id = (body.tenant_id or "").strip() tenant = await db.get(Tenant, tenant_id) if tenant_id else None # Run a dummy verify even when the tenant is unknown to keep timing uniform. if tenant is None: await asyncio.to_thread(hash_password, body.passphrase or "x") raise HTTPException(status_code=401, detail="Invalid Tenant/User ID or passphrase.") ok = await asyncio.to_thread( verify_password, body.passphrase or "", hash_hex=tenant.password_hash, salt_hex=tenant.password_salt, iterations=tenant.pbkdf2_iterations, ) if not ok: raise HTTPException(status_code=401, detail="Invalid Tenant/User ID or passphrase.") from datetime import UTC, datetime tenant.last_login_at = datetime.now(UTC) await db.commit() token, exp = mint_token(tenant_id) return AuthTokenResponse(tenant_id=tenant_id, access_token=token, expires_at=exp) @router.get("/auth/me", response_model=AuthWhoAmIResponse) async def whoami(request: Request) -> AuthWhoAmIResponse: """Return the tenant resolved from the current request's verified token/header.""" return AuthWhoAmIResponse(tenant_id=request.state.tenant_id)