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:
co-authored by
Claude Opus 5
parent
b560ee260c
commit
cc4daf4a84
@@ -171,11 +171,18 @@ OIDC_AUTHENTIK_ALLOW_SIGNUP=false
|
||||
> `profile` and startup fails.
|
||||
|
||||
**Account matching.** An identity is matched on the provider's `sub` claim.
|
||||
A first-time SSO user whose email matches an existing local account is
|
||||
*refused* by default rather than linked — otherwise anyone who can set that
|
||||
email at the identity provider inherits the account. Link deliberately instead:
|
||||
sign in with the password, then link under Settings. `LINK_BY_EMAIL=true`
|
||||
enables automatic linking and additionally requires `email_verified`.
|
||||
|
||||
A first SSO login with no matching account does **not** fail. The verified
|
||||
identity is parked in a short-lived signed cookie and the login screen asks the
|
||||
user to sign in once with their Calendarr password; the identity is then
|
||||
attached to exactly that account. Both halves are proven — the ID token by us,
|
||||
the account by the password — so no trust in the provider'''s email handling is
|
||||
required.
|
||||
|
||||
`LINK_BY_EMAIL=true` links automatically to an existing account with the same
|
||||
address instead (additionally requiring `email_verified`). It saves one step,
|
||||
but means anyone who can set an arbitrary email at the identity provider
|
||||
inherits the matching account, including an admin'''s. Prefer the default.
|
||||
|
||||
With `ALLOW_SIGNUP=true`, unknown users get an account on first login. Such
|
||||
accounts have no usable password; they use **app passwords** for CalDAV, the
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -565,6 +565,16 @@ a { color: var(--primary); text-decoration: none; }
|
||||
#view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
/* ── SSO on the login screen ─────────────────────────────── */
|
||||
/* Informational, not an error: the SSO identity is fine, it just needs to be
|
||||
tied to an account. */
|
||||
.form-hint {
|
||||
font-size: 13px; line-height: 1.4;
|
||||
padding: 10px 12px; margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-1);
|
||||
background: var(--primary-dim);
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
#sso-block { margin-top: 18px; }
|
||||
.auth-divider {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
<label class="toggle-label" style="margin-bottom:14px">
|
||||
<input type="checkbox" id="login-remember" /> Angemeldet bleiben
|
||||
</label>
|
||||
<div id="login-hint" class="form-hint hidden"></div>
|
||||
<div id="login-error" class="form-error hidden"></div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
</form>
|
||||
|
||||
+4
-1
@@ -3,7 +3,8 @@ const BASE = '/api';
|
||||
|
||||
// Paths where a 401 is a normal answer rather than an expired session, so the
|
||||
// auto-logout reload must not fire (it would loop on a failed SSO handoff).
|
||||
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange'];
|
||||
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange',
|
||||
'/auth/oidc/link-pending'];
|
||||
|
||||
async function request(method, path, body = null, formEncoded = false) {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -118,4 +119,6 @@ export const api = {
|
||||
oidcProviders: () => request('GET', '/auth/oidc/providers'),
|
||||
oidcComplete: () => request('POST', '/auth/oidc/complete', {}),
|
||||
oidcIdentities: () => request('GET', '/auth/oidc/identities'),
|
||||
oidcLinkPending: () => request('POST', '/auth/oidc/link-pending', {}),
|
||||
oidcUnlink: (id) => request('DELETE', `/auth/oidc/identities/${id}`),
|
||||
};
|
||||
|
||||
+36
-2
@@ -6,6 +6,9 @@ import { loadInstance } from './instance.js';
|
||||
// ── Single Sign-On ────────────────────────────────────────
|
||||
// Error slug from a failed SSO round-trip, shown once the login screen is up.
|
||||
let ssoErrorSlug = null;
|
||||
// True when an SSO identity is parked server-side, waiting to be linked to
|
||||
// whichever account the user signs into next.
|
||||
let ssoLinkPending = false;
|
||||
|
||||
/**
|
||||
* Handle the return leg of an OIDC login.
|
||||
@@ -18,12 +21,14 @@ async function consumeSsoHandoff() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ok = params.has('sso');
|
||||
const err = params.get('sso_error');
|
||||
if (!ok && !err) return false; // normal load — zero cost
|
||||
const link = params.has('sso_link');
|
||||
if (!ok && !err && !link) return false; // normal load — zero cost
|
||||
|
||||
// Strip the marker before awaiting anything: a reload mid-flight must not
|
||||
// try to redeem a cookie that has already been spent.
|
||||
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
|
||||
|
||||
if (link) { ssoLinkPending = true; return false; }
|
||||
if (err) { ssoErrorSlug = err; return false; }
|
||||
|
||||
try {
|
||||
@@ -44,6 +49,14 @@ async function renderSsoButtons() {
|
||||
const list = document.getElementById('sso-buttons');
|
||||
if (!block || !list) return;
|
||||
|
||||
if (ssoLinkPending) {
|
||||
const hintEl = document.getElementById('login-hint');
|
||||
if (hintEl) {
|
||||
hintEl.textContent = t('sso_link_prompt');
|
||||
hintEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (ssoErrorSlug) {
|
||||
const errEl = document.getElementById('login-error');
|
||||
if (errEl) {
|
||||
@@ -53,7 +66,10 @@ async function renderSsoButtons() {
|
||||
const msg = [`sso_err_${ssoErrorSlug}`, `sso_err_${bare}`]
|
||||
.map(k => [k, t(k)])
|
||||
.find(([k, v]) => v !== k);
|
||||
errEl.textContent = msg ? msg[1] : t('sso_err_generic');
|
||||
// An unmapped slug still has to be diagnosable — showing only the
|
||||
// generic text hides the one piece of information that identifies the
|
||||
// fault.
|
||||
errEl.textContent = msg ? msg[1] : `${t('sso_err_generic')} (${ssoErrorSlug})`;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
ssoErrorSlug = null;
|
||||
@@ -151,6 +167,13 @@ function showScreen(name) {
|
||||
async function launchApp() {
|
||||
showScreen('app');
|
||||
|
||||
// Confirm a linking that happened during this login.
|
||||
const linkedName = sessionStorage.getItem('ssoLinkedName');
|
||||
if (linkedName !== null) {
|
||||
sessionStorage.removeItem('ssoLinkedName');
|
||||
showToast(t('sso_linked_toast', { name: linkedName }));
|
||||
}
|
||||
|
||||
// Set user avatar initials
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
@@ -250,6 +273,17 @@ function bindLoginForm() {
|
||||
const res = await api.login(username, password, totpCode, remember);
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
// A parked SSO identity is attached to exactly the account that just
|
||||
// proved itself with a password — no email guesswork involved.
|
||||
if (ssoLinkPending) {
|
||||
ssoLinkPending = false;
|
||||
try {
|
||||
const linked = await api.oidcLinkPending();
|
||||
if (linked && linked.linked) {
|
||||
sessionStorage.setItem('ssoLinkedName', linked.name || '');
|
||||
}
|
||||
} catch (e) { /* linking is optional; the login itself succeeded */ }
|
||||
}
|
||||
await launchApp();
|
||||
} catch (err) {
|
||||
if (err.message === '2fa_required') {
|
||||
|
||||
@@ -32,6 +32,9 @@ const translations = {
|
||||
sso_err_email_not_verified: 'Die E-Mail-Adresse ist beim Anbieter nicht bestätigt',
|
||||
sso_err_no_email: 'Der Anbieter hat keine E-Mail-Adresse übermittelt',
|
||||
sso_err_state_mismatch: 'Sitzung abgelaufen, bitte erneut versuchen',
|
||||
sso_link_prompt: 'Dieses Authentik-Konto ist noch nicht verknüpft. Melde dich einmalig mit deinem Calendarr-Passwort an — danach genügt der SSO-Knopf.',
|
||||
sso_linked_toast: 'Konto mit {name} verknüpft',
|
||||
sso_err_token_exchange_failed: 'Der Anbieter hat den Anmeldecode abgelehnt — prüfe Client-Typ (Public) und Redirect-URI',
|
||||
sso_err_provider_unreachable: 'Anmeldedienst nicht erreichbar, bitte später erneut versuchen',
|
||||
sso_err_discovery_failed: 'Anmeldedienst antwortet nicht korrekt',
|
||||
sso_err_setup_required: 'Bitte zuerst das Administrator-Konto einrichten',
|
||||
@@ -836,6 +839,9 @@ const translations = {
|
||||
sso_err_email_not_verified: 'The email address is not verified at the provider',
|
||||
sso_err_no_email: 'The provider did not supply an email address',
|
||||
sso_err_state_mismatch: 'Session expired, please try again',
|
||||
sso_link_prompt: 'This Authentik account is not linked yet. Sign in once with your Calendarr password — after that the SSO button is enough.',
|
||||
sso_linked_toast: 'Account linked with {name}',
|
||||
sso_err_token_exchange_failed: 'The provider rejected the sign-in code — check the client type (public) and the redirect URI',
|
||||
sso_err_provider_unreachable: 'Sign-in service unreachable, please try again later',
|
||||
sso_err_discovery_failed: 'Sign-in service returned an invalid response',
|
||||
sso_err_setup_required: 'Please set up the administrator account first',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v91';
|
||||
export const APP_VERSION = 'v92';
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
// the entry HTML / version files). New releases take effect on the next
|
||||
// reload, no manual SW unregister required.
|
||||
|
||||
const CACHE_VERSION = 'calendarr-v37';
|
||||
const CACHE_VERSION = 'calendarr-v38';
|
||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
|
||||
Reference in New Issue
Block a user