feat(oidc): SSO-Identitaet bewusst mit einem Konto verknuepfen

Bisher war eine SSO-Anmeldung ohne passendes Konto eine Sackgasse: rote
Fehlermeldung, kein Weg weiter. Die Verknuepfung ging nur ueber
LINK_BY_EMAIL, das der E-Mail-Verwaltung des Providers vertrauen muss.

Jetzt endet der Callback bei einer besitzerlosen Identitaet nicht im Fehler,
sondern parkt sie in einem kurzlebigen signierten HttpOnly-Cookie und leitet
auf /?sso_link=1. Der Login-Screen bittet daraufhin einmalig um das
Calendarr-Passwort; nach erfolgreichem Login loest der Client
POST /api/auth/oidc/link-pending ein und die Identitaet haengt an genau dem
Konto, das sich gerade ausgewiesen hat. Beide Haelften sind damit bewiesen —
das ID-Token haben wir selbst geprueft, das Konto durch das Passwort — und
die Vertrauensannahme ueber die E-Mail entfaellt.

Das greift fuer alle drei Faelle, in denen eine gueltige Identitaet keinen
Besitzer hat: kein Konto vorhanden, Selbstregistrierung aus, oder E-Mail
bereits vergeben.

Ausserdem: unbekannte Fehler-Slugs werden jetzt mit angezeigt statt hinter
"Anmeldung fehlgeschlagen" zu verschwinden — genau die Information, die zur
Diagnose fehlte. Und oidc_token_exchange_failed hat eine eigene Meldung, die
auf Client-Typ und Redirect-URI hinweist, weil das der haeufigste
Konfigurationsfehler ist.

Sechs Tests dazu; Ablauf zusaetzlich im Browser gegen einen echten
OIDC-Stub-Provider durchgespielt: Hinweis statt Fehler, Verknuepfung nach
Passwort-Login, danach Anmeldung allein per SSO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 20:46:34 +02:00
co-authored by Claude Opus 5
parent b560ee260c
commit cc4daf4a84
10 changed files with 238 additions and 12 deletions
+82
View File
@@ -47,6 +47,18 @@ 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_COOKIE = "clr_oidc_link"
LINK_TTL = 900 # 15 minutes to sign in with a password and link
# Refusals that mean "this identity is fine, it just has no owner yet". In all
# three the honest answer is to ask the user to prove which account is theirs,
# rather than to dead-end them: no account exists, one exists under the same
# address, or automatic creation is switched off.
LINKABLE_REFUSALS = {
"oidc_account_not_linked",
"oidc_signup_disabled",
"oidc_email_conflict",
}
LINK_RETURN = "/?sso_linked=1"
@@ -72,6 +84,30 @@ def _encode_flow(payload: dict) -> str:
return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
def _encode_link(claims: dict, provider_key: str) -> str:
"""A verified-but-unlinked identity, parked until the user proves the
Calendarr account is theirs by signing in with their password."""
data = {
"typ": "oidc_link",
"p": provider_key,
"sub": claims.get("sub"),
"iss": claims.get("iss"),
"email": claims.get("email"),
"exp": datetime.utcnow() + timedelta(seconds=LINK_TTL),
}
return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
def _decode_link(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
if payload.get("typ") != "oidc_link" or not payload.get("sub"):
return None
return payload
def _decode_flow(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
@@ -224,6 +260,18 @@ def oidc_callback(provider_key: str, request: Request,
try:
user, action = resolve_user(db, provider, claims)
except OIDCPolicyError as e:
if e.slug in LINKABLE_REFUSALS:
# The identity is verified but belongs to nobody yet. Park it and
# invite the user to prove the Calendarr account is theirs. That is
# strictly safer than matching on the email address alone, which
# trusts the provider not to hand out someone else's.
resp = RedirectResponse(url="/?sso_link=1", status_code=302)
resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc")
resp.set_cookie(
LINK_COOKIE, _encode_link(claims, provider.key),
**_cookie_kwargs(request, "/api/auth/oidc", LINK_TTL),
)
return resp
return _fail(request, e.slug)
logger.info("OIDC login (%s) user id=%s via %s", action, user.id, provider.key)
@@ -265,6 +313,40 @@ def oidc_complete(request: Request, response: Response, db: Session = Depends(ge
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@router.post("/link-pending")
def oidc_link_pending(request: Request, response: Response,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db)):
"""Attach the parked SSO identity to the account that just signed in.
Both halves are proven: we validated the ID token ourselves before parking
it, and the bearer token proves this password login succeeded.
"""
raw = request.cookies.get(LINK_COOKIE)
response.delete_cookie(LINK_COOKIE, path="/api/auth/oidc")
if not raw:
raise HTTPException(404, "Keine offene SSO-Verknüpfung")
payload = _decode_link(raw)
if not payload:
raise HTTPException(400, "oidc_link_expired")
provider = oidc_config.get_provider(payload.get("p", ""))
if provider is None:
raise HTTPException(400, "oidc_unknown_provider")
claims = {"sub": payload["sub"], "iss": payload.get("iss"),
"email": payload.get("email")}
try:
link_identity(db, current_user, provider, claims)
except OIDCPolicyError as e:
raise HTTPException(409, e.slug)
logger.info("OIDC: linked %s identity to user id=%s after password login",
provider.key, current_user.id)
return {"linked": True, "provider": provider.key, "name": provider.name}
# ── native (mobile) clients ──────────────────────────────────────────────────
class ExchangeRequest(BaseModel):
+83
View File
@@ -293,6 +293,89 @@ def _nonce_from(location: str) -> str:
return parse_qs(urlparse(location).query)["nonce"][0]
def _run_callback(client, monkeypatch, *, sub, email):
"""Drive /start + /callback for an identity, returning the callback response."""
start = client.get("/api/auth/oidc/authentik/start", follow_redirects=False)
state = _state_from(start.headers["location"])
nonce = _nonce_from(start.headers["location"])
monkeypatch.setattr(oidc_client, "exchange_code", lambda *a, **k: {
"id_token": make_id_token(sub=sub, nonce=nonce, email=email),
})
return client.get(f"/api/auth/oidc/authentik/callback?code=good&state={state}",
follow_redirects=False)
def test_unlinked_identity_offers_linking_instead_of_failing(client, authentik, monkeypatch):
"""An unknown SSO identity parks itself rather than dead-ending."""
admin_token = register_admin(client, "admin", "pw")
create_user(client, admin_token, "erin")
cb = _run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert cb.status_code == 302
assert cb.headers["location"] == "/?sso_link=1"
assert "clr_oidc_link=" in cb.headers["set-cookie"]
# Nothing is linked until an account proves itself.
with db_session() as db:
assert db.query(models.OIDCIdentity).count() == 0
def test_password_login_redeems_the_parked_identity(client, authentik, monkeypatch):
admin_token = register_admin(client, "admin", "pw")
uid, _ = create_user(client, admin_token, "erin")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
# The user signs in with their password; the client then redeems the cookie.
login = client.post("/api/auth/login", json={"username": "erin", "password": "pw"})
token = login.json()["access_token"]
linked = client.post("/api/auth/oidc/link-pending", headers=auth(token))
assert linked.status_code == 200, linked.text
assert linked.json()["linked"] is True
with db_session() as db:
identity = db.query(models.OIDCIdentity).one()
assert identity.user_id == uid
assert identity.subject == "ak-erin"
# From now on SSO alone gets them in.
again = _run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert again.headers["location"] == "/?sso=1"
def test_parked_identity_links_to_whoever_signs_in(client, authentik, monkeypatch):
"""The link follows the password login, not the email in the token — that
is the whole point of asking instead of matching."""
admin_token = register_admin(client, "admin", "pw")
other_id, _ = create_user(client, admin_token, "frank")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
login = client.post("/api/auth/login", json={"username": "frank", "password": "pw"})
client.post("/api/auth/oidc/link-pending", headers=auth(login.json()["access_token"]))
with db_session() as db:
assert db.query(models.OIDCIdentity).one().user_id == other_id
def test_link_pending_without_a_cookie_is_a_404(client, authentik):
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "erin")
assert client.post("/api/auth/oidc/link-pending", headers=auth(token)).status_code == 404
def test_link_cookie_is_single_use(client, authentik, monkeypatch):
admin_token = register_admin(client, "admin", "pw")
_, token = create_user(client, admin_token, "erin")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert client.post("/api/auth/oidc/link-pending", headers=auth(token)).status_code == 200
assert client.post("/api/auth/oidc/link-pending", headers=auth(token)).status_code == 404
def test_link_pending_requires_authentication(client, authentik, monkeypatch):
register_admin(client, "admin", "pw")
_run_callback(client, monkeypatch, sub="ak-erin", email="erin@example.com")
assert client.post("/api/auth/oidc/link-pending").status_code == 401
# ── /exchange: token validation (the mobile entry point) ─────────────────────
def _seed_identity(client, sub="ak-sub-1", username="alice"):