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:
co-authored by
Claude Opus 5
parent
a30375aa63
commit
31baf4b18c
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user