feat(auth): OIDC login + identity binding + HARBORFORGE_OIDC_ONLY

- Generic OIDC (Authlib discovery) Authorization Code flow; backend
  issues the existing HS256 JWT on success. Unbound identities are
  rejected (no auto-provisioning).
- User.oidc_issuer/oidc_subject (unique together) + startup migration.
- PUT/DELETE /users/{id}/oidc-binding (admin or account-manager;
  JWT or API key; 409 on conflict). Self-link /auth/oidc/link
  (non-OIDC_ONLY only). Public GET /auth/config.
- HARBORFORGE_OIDC_ONLY: /auth/token rejected, create/update ignore
  password (passwordless users; API keys + OIDC still work).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY; authlib+itsdangerous deps;
  SessionMiddleware for OIDC state. Fixed _user_response to expose
  the new binding fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-17 20:22:04 +01:00
parent 90b494f097
commit 94155614f5
9 changed files with 280 additions and 5 deletions

145
app/api/routers/oidc.py Normal file
View File

@@ -0,0 +1,145 @@
"""OIDC (OpenID Connect) login + public auth-config.
Generic OIDC via discovery. The backend performs the Authorization Code
flow, then issues its own existing HS256 JWT (same as password login) so
the rest of the app is unchanged.
Sign-in policy: an OIDC identity must already be bound to an hf user
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected — no
auto-provisioning.
"""
from datetime import timedelta
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.core.config import get_db, settings
from app.models import models
from app.api.deps import create_access_token, get_current_user
router = APIRouter(prefix="/auth", tags=["Auth"])
# Authlib registry — only configured when OIDC is enabled.
_oauth = None
def _get_oidc():
"""Lazily build the Authlib OIDC client; 503 if not configured."""
global _oauth
if not (settings.OIDC_ENABLED and settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID):
raise HTTPException(status_code=503, detail="OIDC is not configured")
if _oauth is None:
from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=settings.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration",
client_id=settings.OIDC_CLIENT_ID,
client_secret=settings.OIDC_CLIENT_SECRET,
client_kwargs={"scope": settings.OIDC_SCOPES},
)
_oauth = oauth
return _oauth.oidc
def _frontend(suffix_qs: dict | None = None, fragment: str | None = None) -> str:
"""Build the post-login frontend redirect (never client-controlled)."""
base = settings.OIDC_POST_LOGIN_REDIRECT or "/"
url = base
if suffix_qs:
url += ("&" if "?" in base else "?") + urlencode(suffix_qs)
if fragment:
url += "#" + fragment
return url
@router.get("/config")
def auth_config():
"""Public: lets the frontend decide which login UI to render."""
return {
"oidc_enabled": bool(settings.OIDC_ENABLED and settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID),
"oidc_only": bool(settings.HARBORFORGE_OIDC_ONLY),
"password_login": not bool(settings.HARBORFORGE_OIDC_ONLY),
"oidc_login_url": "/auth/oidc/login",
}
@router.get("/oidc/login")
async def oidc_login(request: Request):
"""Start the OIDC Authorization Code flow for sign-in."""
oidc = _get_oidc()
request.session.pop("hf_oidc_mode", None)
request.session.pop("hf_oidc_uid", None)
request.session["hf_oidc_mode"] = "login"
return await oidc.authorize_redirect(request, settings.OIDC_REDIRECT_URI)
@router.get("/oidc/link")
async def oidc_link(request: Request, current_user: models.User = Depends(get_current_user)):
"""Self-service: bind the caller's own account to an OIDC identity.
Only available when NOT in OIDC-only mode (admins use the binding API
in that mode)."""
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Self-service linking is disabled in OIDC-only mode")
oidc = _get_oidc()
request.session["hf_oidc_mode"] = "link"
request.session["hf_oidc_uid"] = current_user.id
return await oidc.authorize_redirect(request, settings.OIDC_REDIRECT_URI)
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)):
"""OIDC redirect target. Resolves identity → hf user (must be bound)."""
oidc = _get_oidc()
mode = request.session.pop("hf_oidc_mode", "login")
link_uid = request.session.pop("hf_oidc_uid", None)
try:
token = await oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(_frontend({"oidc_error": "exchange_failed"}))
claims = token.get("userinfo") or {}
if not claims:
try:
claims = await oidc.userinfo(token=token)
except Exception:
claims = {}
subject = claims.get("sub")
issuer = claims.get("iss") or settings.OIDC_ISSUER
if not subject:
return RedirectResponse(_frontend({"oidc_error": "no_subject"}))
if mode == "link":
if settings.HARBORFORGE_OIDC_ONLY or link_uid is None:
return RedirectResponse(_frontend({"oidc_error": "link_not_allowed"}))
user = db.query(models.User).filter(models.User.id == link_uid).first()
if not user:
return RedirectResponse(_frontend({"oidc_error": "user_gone"}))
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
return RedirectResponse(_frontend({"oidc_error": "already_bound"}))
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
return RedirectResponse(_frontend({"oidc_linked": "1"}))
# sign-in: identity must already be bound to an active hf user
user = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"):
return RedirectResponse(_frontend({"oidc_error": "not_linked"}))
access_token = create_access_token(
data={"sub": str(user.id)},
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
return RedirectResponse(_frontend(fragment=urlencode({"token": access_token})))