Files
Calendarr/backend/oidc_identity.py
T
ScarriffleandClaude Opus 5 31baf4b18c 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>
2026-09-09 18:00:46 +02:00

240 lines
8.1 KiB
Python

"""Mapping a verified OIDC identity onto a Calendarr user account.
The security-relevant decisions live here:
* ``(provider_key, sub)`` is the only automatic match. ``sub`` is the one claim
an identity provider guarantees to be stable and unique; email and username
are not.
* Matching a verified email onto an *existing* local account is refused by
default. Anyone who can set an arbitrary email on an IdP account would
otherwise inherit the matching Calendarr account — including an admin's.
Linking is an explicit, authenticated action instead.
* Just-in-time provisioning is off unless the provider enables it.
"""
from __future__ import annotations
import hashlib
import logging
import re
import secrets
import unicodedata
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
import models
from auth import get_password_hash
from oidc_config import OIDCProvider
logger = logging.getLogger(__name__)
USERNAME_MAX = 50
_USERNAME_STRIP = re.compile(r"[^a-z0-9._-]")
class OIDCPolicyError(Exception):
"""A refusal that is the user's/admin's to resolve, not a protocol fault.
``slug`` maps to a translated message on the login screen.
"""
def __init__(self, slug: str, detail: str = ""):
super().__init__(detail or slug)
self.slug = slug
self.detail = detail or slug
def claim_email(claims: dict) -> str:
return (claims.get("email") or "").strip().lower()
def _normalise_username(raw: str) -> str:
"""Fold a claim value into the shape `users.username` requires."""
if not raw:
return ""
folded = unicodedata.normalize("NFKD", raw)
folded = folded.encode("ascii", "ignore").decode("ascii").lower()
folded = _USERNAME_STRIP.sub("", folded)
return folded.strip("._-")[:USERNAME_MAX]
def _username_taken(db: Session, name: str) -> bool:
return db.query(models.User).filter(
func.lower(models.User.username) == name.lower()
).first() is not None
def derive_username(db: Session, claims: dict, provider: OIDCProvider) -> str:
"""Pick a free, valid username for a newly provisioned SSO user.
A collision here is a *different human* who happens to share a
preferred_username, so we never reuse an existing name — that would be
instant account takeover.
"""
candidates = [
claims.get(provider.username_claim),
claim_email(claims).split("@")[0] if claim_email(claims) else "",
"u" + hashlib.sha256((claims.get("sub") or "").encode()).hexdigest()[:12],
]
base = ""
for candidate in candidates:
base = _normalise_username(candidate or "")
if base:
break
if not base:
base = "u" + secrets.token_hex(6)
if not _username_taken(db, base):
return base
for suffix in range(2, 51):
tail = f"-{suffix}"
name = base[: USERNAME_MAX - len(tail)] + tail
if not _username_taken(db, name):
return name
# Pathological case: fall back to something effectively unique.
tail = "-" + secrets.token_hex(5)
return base[: USERNAME_MAX - len(tail)] + tail
def _create_user(db: Session, provider: OIDCProvider, claims: dict,
email: str) -> models.User:
"""Provision a new account. Mirrors users_router.create_user."""
# `users.password_hash` is NOT NULL and `auth.verify_password` feeds it
# straight to bcrypt.checkpw, which raises ValueError on anything that is
# not a real hash — that surfaces as HTTP 500 on /api/auth/login and on
# every CalDAV Basic-Auth attempt. So store a real bcrypt hash of a random
# secret that is never written down anywhere: unusable, but well-formed.
user = models.User(
username=derive_username(db, claims, provider),
display_name=(claims.get("name") or "").strip()[:100] or None,
email=email or None,
password_hash=get_password_hash(secrets.token_urlsafe(48)),
is_admin=False,
auth_source="sso",
)
db.add(user)
db.flush()
# Without this row /api/settings returns 500 for the new user.
db.add(models.UserSettings(user_id=user.id))
return user
def _attach_identity(db: Session, user: models.User, provider: OIDCProvider,
claims: dict, email: str) -> models.OIDCIdentity:
identity = models.OIDCIdentity(
user_id=user.id,
provider_key=provider.key,
issuer=claims.get("iss") or provider.issuer,
subject=claims["sub"],
email=email or None,
last_login_at=datetime.utcnow(),
)
db.add(identity)
return identity
def find_identity(db: Session, provider: OIDCProvider,
subject: str) -> Optional[models.OIDCIdentity]:
return db.query(models.OIDCIdentity).filter(
models.OIDCIdentity.provider_key == provider.key,
models.OIDCIdentity.subject == subject,
).first()
def link_identity(db: Session, user: models.User, provider: OIDCProvider,
claims: dict) -> models.OIDCIdentity:
"""Attach an SSO identity to an already-authenticated account."""
subject = claims.get("sub") or ""
if not subject:
raise OIDCPolicyError("oidc_no_subject")
existing = find_identity(db, provider, subject)
if existing is not None:
if existing.user_id != user.id:
raise OIDCPolicyError("oidc_identity_taken")
existing.last_login_at = datetime.utcnow()
db.commit()
return existing
identity = _attach_identity(db, user, provider, claims, claim_email(claims))
db.commit()
return identity
def resolve_user(db: Session, provider: OIDCProvider,
claims: dict) -> Tuple[models.User, str]:
"""Map verified claims onto a user.
Returns ``(user, action)`` where action is ``login``, ``linked`` or
``created``. Raises :class:`OIDCPolicyError` for every refusal.
"""
subject = claims.get("sub") or ""
if not subject:
raise OIDCPolicyError("oidc_no_subject")
email = claim_email(claims)
# 1. Known identity — the only automatic path.
identity = find_identity(db, provider, subject)
if identity is not None:
identity.last_login_at = datetime.utcnow()
if email:
identity.email = email
db.commit()
return identity.user, "login"
# 2. An existing local account with the same email.
existing = None
if email:
existing = db.query(models.User).filter(
func.lower(models.User.email) == email
).first()
if existing is not None:
if not provider.link_by_email:
# The account-takeover guard. Linking must be explicit.
raise OIDCPolicyError("oidc_account_not_linked")
if claims.get("email_verified") is not True:
raise OIDCPolicyError("oidc_email_not_verified")
logger.warning(
"OIDC: linking provider=%s sub=%s to existing user id=%s by email %s",
provider.key, subject, existing.id, email,
)
_attach_identity(db, existing, provider, claims, email)
db.commit()
return existing, "linked"
# 3. Just-in-time provisioning.
if not provider.allow_signup:
raise OIDCPolicyError("oidc_signup_disabled")
if not email:
raise OIDCPolicyError("oidc_no_email")
if provider.allowed_domains:
domain = email.rsplit("@", 1)[-1]
if domain not in provider.allowed_domains:
raise OIDCPolicyError("oidc_domain_not_allowed")
try:
user = _create_user(db, provider, claims, email)
_attach_identity(db, user, provider, claims, email)
db.commit()
except IntegrityError:
# Race between the uniqueness check and the insert — retry once.
db.rollback()
try:
user = _create_user(db, provider, claims, email)
_attach_identity(db, user, provider, claims, email)
db.commit()
except IntegrityError:
db.rollback()
raise OIDCPolicyError("oidc_provisioning_failed")
logger.info("OIDC: provisioned user id=%s username=%s via %s",
user.id, user.username, provider.key)
return user, "created"