feat(auth): OpenID-Connect-SSO gegen Authentik (Backend + Web)

Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne
gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet
enabled:false und der Login-Screen bleibt unveraendert.

Backend
- oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist
  reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung.
- oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber
  authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit
  alg:none und Algorithmus-Confusion ausgeschlossen sind).
- oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf
  ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst
  erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto.
  Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning
  nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist.
- routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange,
  /link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in
  einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit
  der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber
  ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment.
- /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als
  Public Client selbst aus und tauscht das ID-Token; kein Secret im Request.
- models.py: neue Tabelle oidc_identities, users.auth_source; Migration in
  main.py im dortigen try/except-Stil.

Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus
bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu
HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer
bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes
Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer.

Web
- SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links,
  weil /start eine Top-Level-Navigation braucht.
- consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und
  raeumt die Query per replaceState ab, bevor es awaited.
- 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener
  Handoff eine Reload-Schleife.
- i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen).
- Service Worker laesst /api/auth/oidc/ unangetastet durch.

37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens,
alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies,
Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 18:00:46 +02:00
co-authored by Claude Opus 5
parent a30375aa63
commit 31baf4b18c
17 changed files with 2008 additions and 5 deletions
+408
View File
@@ -0,0 +1,408 @@
"""OpenID Connect single sign-on — browser flow and native-client exchange.
Two consumers:
* **Web** — ``/start`` redirects to the provider, ``/callback`` receives the
code, ``/complete`` hands the Calendarr JWT to the SPA.
* **Mobile** — the app runs the whole flow itself as a *public* client
(AppAuth, PKCE, ``offline_access``) and trades the resulting ID token for a
Calendarr JWT at ``/exchange``. It never sends a client secret.
Flow state (PKCE verifier, nonce) lives in a short-lived signed JWT in an
HttpOnly cookie rather than in a process-local dict like
``homeassistant_router._pending_oauth`` — so it survives a restart and works
with more than one worker.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
from datetime import datetime, timedelta
from typing import Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
import oidc_client
import oidc_config
from auth import ALGORITHM, SECRET_KEY, create_access_token, get_current_user
from database import get_db
from dav_util import public_base
from oidc_client import OIDCError
from oidc_identity import OIDCPolicyError, link_identity, resolve_user
logger = logging.getLogger(__name__)
router = APIRouter()
FLOW_COOKIE = "clr_oidc_flow"
FLOW_TTL = 600 # 10 minutes to complete a login
HANDOFF_COOKIE = "clr_oidc_token"
HANDOFF_TTL = 60 # the SPA picks the token up immediately
LINK_RETURN = "/?sso_linked=1"
# ── flow-state cookie ────────────────────────────────────────────────────────
def _cookie_kwargs(request: Request, path: str, max_age: int) -> dict:
return {
"httponly": True,
# Lax, NOT Strict: the cookie has to survive the identity provider's
# top-level cross-site redirect back to /callback. With Strict the
# browser drops it and every single login fails with state_mismatch.
"samesite": "lax",
"secure": public_base(request).startswith("https"),
"path": path,
"max_age": max_age,
}
def _encode_flow(payload: dict) -> str:
data = dict(payload)
data["typ"] = "oidc_flow"
data["exp"] = datetime.utcnow() + timedelta(seconds=FLOW_TTL)
return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
def _decode_flow(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
# Guard against another token type being replayed here.
if payload.get("typ") != "oidc_flow":
return None
return payload
def _pkce_pair() -> tuple:
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def _redirect_uri(request: Request, provider) -> str:
"""Always derived server-side — never taken from client input."""
if provider.redirect_uri:
return provider.redirect_uri
return f"{public_base(request)}/api/auth/oidc/{provider.key}/callback"
def _fail(request: Request, slug: str) -> RedirectResponse:
resp = RedirectResponse(url=f"/?sso_error={slug}", status_code=302)
resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc")
return resp
# ── discovery for clients ────────────────────────────────────────────────────
@router.get("/providers")
def list_providers():
"""Public: what the login screen and the mobile apps need to start a flow.
Never exposes the client secret.
"""
out = []
for provider in oidc_config.get_providers().values():
entry = {
"key": provider.key,
"name": provider.name,
"icon": provider.icon,
"issuer": provider.issuer,
"scopes": provider.scopes,
"start_url": f"/api/auth/oidc/{provider.key}/start",
}
if provider.mobile_client_ids:
entry["mobile_client_id"] = provider.mobile_client_ids[0]
try:
doc = oidc_client.discover(provider)
entry["authorization_endpoint"] = doc.get("authorization_endpoint")
entry["token_endpoint"] = doc.get("token_endpoint")
entry["end_session_endpoint"] = doc.get("end_session_endpoint")
except Exception as e:
# A provider whose discovery is down must not break the login page.
logger.warning("OIDC discovery failed for %s: %s", provider.key, e)
entry["degraded"] = True
out.append(entry)
return {"enabled": bool(out), "providers": out}
# ── browser flow ─────────────────────────────────────────────────────────────
@router.get("/{provider_key}/start")
def oidc_start(provider_key: str, request: Request, db: Session = Depends(get_db)):
provider = oidc_config.get_provider(provider_key)
if provider is None:
raise HTTPException(404, "Unbekannter SSO-Anbieter")
# Before the first-run admin exists, an SSO login would create a non-admin
# account and leave the instance unadministrable.
if db.query(models.User).count() == 0:
return _fail(request, "setup_required")
try:
authorize_url = oidc_client.authorization_endpoint(provider)
except OIDCError as e:
logger.warning("OIDC start failed for %s: %s", provider.key, e.detail)
return _fail(request, e.slug)
verifier, challenge = _pkce_pair()
nonce = secrets.token_urlsafe(24)
state = secrets.token_urlsafe(16)
redirect_uri = _redirect_uri(request, provider)
params = {
"response_type": "code",
"client_id": provider.client_id,
"redirect_uri": redirect_uri,
"scope": provider.scopes,
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
resp = RedirectResponse(url=f"{authorize_url}?{urlencode(params)}", status_code=302)
# Only `state` travels over the wire; verifier and nonce stay in the cookie
# and therefore out of the provider's access logs.
resp.set_cookie(
FLOW_COOKIE,
_encode_flow({"p": provider.key, "n": nonce, "cv": verifier, "jti": state}),
**_cookie_kwargs(request, "/api/auth/oidc", FLOW_TTL),
)
return resp
@router.get("/{provider_key}/callback")
def oidc_callback(provider_key: str, request: Request,
code: str = Query(""), state: str = Query(""),
error: str = Query(""), db: Session = Depends(get_db)):
provider = oidc_config.get_provider(provider_key)
if provider is None:
return _fail(request, "unknown_provider")
if error or not code:
return _fail(request, error or "no_code")
raw = request.cookies.get(FLOW_COOKIE)
payload = _decode_flow(raw) if raw else None
# Double-submit: the state in the URL must match the one in the cookie, so
# an attacker cannot feed a victim a callback URL from their own flow.
if not payload or payload.get("p") != provider.key or payload.get("jti") != state:
return _fail(request, "state_mismatch")
try:
tokens = oidc_client.exchange_code(
provider, code, _redirect_uri(request, provider), payload["cv"])
claims = oidc_client.validate_id_token(
provider, tokens["id_token"],
expected_audience=provider.client_id,
nonce=payload.get("n"),
access_token=tokens.get("access_token"),
)
if not claims.get("email"):
claims.update({k: v for k, v in
oidc_client.fetch_userinfo(
provider, tokens.get("access_token") or "").items()
if k not in claims})
except OIDCError as e:
logger.warning("OIDC callback failed for %s: %s", provider.key, e.detail)
return _fail(request, e.slug)
try:
user, action = resolve_user(db, provider, claims)
except OIDCPolicyError as e:
return _fail(request, e.slug)
logger.info("OIDC login (%s) user id=%s via %s", action, user.id, provider.key)
token = create_access_token({"sub": user.username})
resp = RedirectResponse(url="/?sso=1", status_code=302)
resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc")
# The token goes in an HttpOnly cookie, not in the URL: a query parameter
# would land in the reverse-proxy and uvicorn access logs and in Referer,
# and a fragment would persist in browser history.
resp.set_cookie(
HANDOFF_COOKIE, token,
**_cookie_kwargs(request, "/api/auth/oidc", HANDOFF_TTL),
)
return resp
@router.post("/complete")
def oidc_complete(request: Request, response: Response, db: Session = Depends(get_db)):
"""Trade the one-time handoff cookie for the token, same-origin."""
token = request.cookies.get(HANDOFF_COOKIE)
# Clear it either way: one attempt per login, success or not.
response.delete_cookie(HANDOFF_COOKIE, path="/api/auth/oidc")
if not token:
raise HTTPException(401, "Keine SSO-Sitzung")
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
except JWTError:
raise HTTPException(401, "SSO-Sitzung ungültig")
if not username:
raise HTTPException(401, "SSO-Sitzung ungültig")
user = db.query(models.User).filter(models.User.username == username).first()
if not user:
raise HTTPException(401, "SSO-Sitzung ungültig")
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
# ── native (mobile) clients ──────────────────────────────────────────────────
class ExchangeRequest(BaseModel):
"""Deliberately has no client_secret field — public clients must not send one."""
provider: str
client_id: str
id_token: str
access_token: Optional[str] = None
nonce: Optional[str] = None
@router.post("/exchange")
def oidc_exchange(req: ExchangeRequest, db: Session = Depends(get_db)):
"""Trade a provider ID token for a Calendarr JWT (AppAuth iOS/Android).
The app has already completed the authorization-code flow with PKCE
against the provider. Its security rests on PKCE plus the provider's
registered redirect-URI check (RFC 8252), not on a secret.
"""
provider = oidc_config.get_provider(req.provider)
if provider is None:
raise HTTPException(401, "oidc_unknown_provider")
# Restrict to client ids we know: otherwise an ID token minted for an
# unrelated application at the same provider could be replayed here.
if req.client_id not in oidc_config.accepted_audiences(provider):
raise HTTPException(401, "oidc_unknown_client")
try:
claims = oidc_client.validate_id_token(
provider, req.id_token,
expected_audience=req.client_id,
nonce=req.nonce,
access_token=req.access_token,
)
except OIDCError as e:
logger.info("OIDC exchange rejected: %s", e.detail)
raise HTTPException(401, e.slug)
# The nonce is the app's replay protection; if the token carries one, the
# caller has to prove it knows it.
if claims.get("nonce") and not req.nonce:
raise HTTPException(400, "oidc_nonce_required")
if not claims.get("email") and req.access_token:
claims.update({k: v for k, v in
oidc_client.fetch_userinfo(provider, req.access_token).items()
if k not in claims})
try:
user, action = resolve_user(db, provider, claims)
except OIDCPolicyError as e:
raise HTTPException(403, e.slug)
logger.info("OIDC exchange (%s) user id=%s via %s", action, user.id, provider.key)
return {
"access_token": create_access_token({"sub": user.username}),
"token_type": "bearer",
"user": _user_dict(user),
}
# ── linking from an authenticated session ────────────────────────────────────
@router.get("/identities")
def list_identities(current_user: models.User = Depends(get_current_user)):
return {
"auth_source": current_user.auth_source or "local",
"identities": [
{
"id": i.id,
"provider": i.provider_key,
"name": (oidc_config.get_provider(i.provider_key).name
if oidc_config.get_provider(i.provider_key) else i.provider_key),
"email": i.email,
"last_login_at": i.last_login_at.isoformat() if i.last_login_at else None,
}
for i in current_user.oidc_identities
],
}
@router.post("/{provider_key}/link")
def link_current_user(provider_key: str,
id_token: str = Body(..., embed=True),
client_id: str = Body("", embed=True),
nonce: Optional[str] = Body(None, embed=True),
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)):
"""Attach an SSO identity to the signed-in account.
This is the safe alternative to matching on email: the user proves they
control both the Calendarr account (bearer token) and the SSO identity.
"""
provider = oidc_config.get_provider(provider_key)
if provider is None:
raise HTTPException(404, "Unbekannter SSO-Anbieter")
audience = client_id or provider.client_id
if audience not in oidc_config.accepted_audiences(provider):
raise HTTPException(400, "oidc_unknown_client")
try:
claims = oidc_client.validate_id_token(
provider, id_token, expected_audience=audience, nonce=nonce)
except OIDCError as e:
raise HTTPException(400, e.slug)
try:
identity = link_identity(db, current_user, provider, claims)
except OIDCPolicyError as e:
raise HTTPException(409, e.slug)
return {"linked": True, "id": identity.id, "provider": provider.key}
@router.delete("/identities/{identity_id}")
def unlink_identity(identity_id: int,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)):
identity = db.query(models.OIDCIdentity).filter(
models.OIDCIdentity.id == identity_id,
models.OIDCIdentity.user_id == current_user.id,
).first()
if identity is None:
raise HTTPException(404, "Verknüpfung nicht gefunden")
# An SSO-provisioned user has no usable password, so removing their last
# identity would lock them out permanently.
if (current_user.auth_source or "local") == "sso" and len(current_user.oidc_identities) <= 1:
raise HTTPException(400, "oidc_last_identity")
db.delete(identity)
db.commit()
return {"unlinked": True}
def _user_dict(user: models.User) -> dict:
"""Same shape as auth_router's login response, so clients share a path."""
return {
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
"is_admin": user.is_admin,
}