Die Apps duerfen keine Scopes erfinden: ein Scope, den der Provider nicht kennt, laesst ihn die gesamte Authorization-Anfrage ablehnen. Daher legt jetzt der Server fest, was die nativen Clients anfragen. OIDC_<KEY>_MOBILE_SCOPES ist frei setzbar; ohne Angabe sind es die Web-Scopes plus offline_access, damit Authentik ab 2024.2 ueberhaupt ein Refresh-Token ausstellt. Der Browser-Flow bleibt unveraendert, der braucht kein offline_access. Ausgeliefert wird der Wert als mobile_scopes in /api/auth/oidc/providers, aber nur wenn eine MOBILE_CLIENT_ID konfiguriert ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412 lines
16 KiB
Python
412 lines
16 KiB
Python
"""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]
|
|
# The apps request exactly these — they must not invent scopes of
|
|
# their own, or a provider that lacks one rejects the whole flow.
|
|
entry["mobile_scopes"] = provider.mobile_scopes
|
|
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,
|
|
}
|