diff --git a/README.md b/README.md index 4bcf392..7e804d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/routers/oidc_router.py b/backend/routers/oidc_router.py index 292a92a..369dd3c 100644 --- a/backend/routers/oidc_router.py +++ b/backend/routers/oidc_router.py @@ -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): diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py index 551e466..243e3b4 100644 --- a/backend/tests/test_oidc.py +++ b/backend/tests/test_oidc.py @@ -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"): diff --git a/frontend/css/app.css b/frontend/css/app.css index a665fb3..555b8dd 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -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; diff --git a/frontend/index.html b/frontend/index.html index 03f9f54..f0e76c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -77,6 +77,7 @@ +
diff --git a/frontend/js/api.js b/frontend/js/api.js index b23132a..dcae3d1 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -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}`), }; diff --git a/frontend/js/app.js b/frontend/js/app.js index f3e02d0..87fba01 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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. @@ -16,14 +19,16 @@ let ssoErrorSlug = null; */ 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 ok = params.has('sso'); + const err = params.get('sso_error'); + 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') { diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 208bd05..d500c72 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -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', diff --git a/frontend/js/version.js b/frontend/js/version.js index d217faf..c8e2cb0 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v91'; +export const APP_VERSION = 'v92'; diff --git a/frontend/sw.js b/frontend/sw.js index ddf41c8..836c5d7 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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 => {