From edc64f159aad57e27dac504b3156865e8532ec88 Mon Sep 17 00:00:00 2001 From: Kerim Atilgan Date: Mon, 26 Oct 2026 09:55:42 +0100 Subject: [PATCH] =?UTF-8?q?Abmelden=20beendet=20auch=20die=20Authentik-Sit?= =?UTF-8?q?zung=20und=20springt=20zur=C3=BCck=20ins=20Tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kopfleiste meldet immer über das Portal ab, auch ohne Portal-Sitzung, und gibt die aktuelle Seite als returnTo mit. - Portal merkt sich den Rücksprung über den Umweg zu Authentik. - Setup-Skript setzt den Abmelde-Ablauf des Providers 'Portal' auf default-invalidation-flow (der Provider-Ablauf meldet nur bei der App ab). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 11 ++++++++++- scripts/authentik-setup.py | 12 +++++++++++- server/src/index.ts | 16 +++++++++++++++- server/src/routes/auth.ts | 18 ++++++++++++++++++ topbar/src/topbar.ts | 10 +++++----- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1a08386..cd14045 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,16 @@ Ist man im Tool angemeldet, im Portal aber nicht, zeigt das Menü „Jetzt anmel ## Abmelden -„Abmelden“ im Portal oder in der Kopfleiste beendet die Portal-Sitzung **und die Authentik-Sitzung**. Die eigenen Sitzungen der Tools laufen bis zu ihrem Ablauf weiter. Für ein echtes Single-Logout braucht jedes Tool einen Backchannel-Logout-Endpunkt; das ist als nächster Ausbauschritt vorgesehen. +„Abmelden“ in der Kopfleiste läuft so ab: + +1. Die Leiste feuert `hi-abmelden`, das Tool beendet seine eigene Sitzung (siehe oben). +2. Weiter zu `portal/auth/logout?returnTo=`: Die Portal-Sitzung endet, der Rücksprung wird für 5 Minuten in einem Cookie gemerkt (nur erlaubte Domains). +3. Weiter zum End-Session-Endpunkt von Authentik. Der Provider „Portal“ nutzt den Abmelde-Ablauf **`default-invalidation-flow`**, der auch die Authentik-Sitzung beendet. Der übliche `default-provider-invalidation-flow` würde nur beim Portal abmelden, man wäre beim nächsten Klick sofort wieder drin. +4. Authentik schickt zurück auf `portal/`, das Portal leitet weiter zur gemerkten Seite. Dort zeigt das Tool seine Anmeldeseite. + +Das funktioniert auch, wenn die Portal-Sitzung schon abgelaufen ist. „Abmelden“ im Portal selbst macht Schritt 2 bis 4 und landet auf der Authentik-Anmeldung. + +Tools, die nicht auf `hi-abmelden` hören, behalten ihre eigene Sitzung bis zu deren Ablauf. Mit beendeter Authentik-Sitzung kann man sich dort aber nicht neu per SSO anmelden. ## Betrieb diff --git a/scripts/authentik-setup.py b/scripts/authentik-setup.py index cbeae55..4812c0e 100644 --- a/scripts/authentik-setup.py +++ b/scripts/authentik-setup.py @@ -50,6 +50,8 @@ LAUNCH_URLS = { "datenuebertragungen": "https://data.handy-insel.de/api/auth/oidc/start", } +LOGOUT_FLOW = "default-invalidation-flow" + SERVICE_ACCOUNT = "hi-portal" SERVICE_GROUP = "hi-portal-service" SERVICE_ROLE = "Portal: Apps pro Benutzer lesen" @@ -133,13 +135,21 @@ def ensure_portal_app(apps): step("1. OIDC-Provider und App 'Portal'") template = next(p for p in results("/providers/oauth2/") if p["name"] == "Preisliste") provider = next((p for p in results("/providers/oauth2/") if p["name"] == "Portal"), None) + # "Abmelden" in Portal und Kopfleiste soll auch die Authentik-Sitzung beenden. Der übliche + # default-provider-invalidation-flow meldet nur bei der App ab, dieser Ablauf auch bei Authentik. + logout_flow = next(f for f in results("/flows/instances/?designation=invalidation") if f["slug"] == LOGOUT_FLOW) if provider: print(" = Provider 'Portal' vorhanden") + if provider.get("invalidation_flow") != logout_flow["pk"]: + call("PATCH", f"/providers/oauth2/{provider['pk']}/", {"invalidation_flow": logout_flow["pk"]}) + print(f" + Abmelde-Ablauf: {LOGOUT_FLOW} (beendet auch die Authentik-Sitzung)") + else: + print(f" = Abmelde-Ablauf: {LOGOUT_FLOW}") else: provider = call("POST", "/providers/oauth2/", { "name": "Portal", "authorization_flow": template["authorization_flow"], - "invalidation_flow": template["invalidation_flow"], + "invalidation_flow": logout_flow["pk"], "client_type": "confidential", # Neuere Authentik-Versionen setzen per API keine Standard-Grant-Types; # ohne diese Liste lehnt Authentik jede Anmeldung als "malformed" ab. diff --git a/server/src/index.ts b/server/src/index.ts index a02a63d..5eb3e6b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,7 +7,7 @@ import helmet from 'helmet'; import { assertConfig, config } from './config.js'; import { initOidc } from './oidc.js'; import { SqliteStore } from './sessionStore.js'; -import { authRouter } from './routes/auth.js'; +import { AFTER_LOGOUT_COOKIE, authRouter, safeAfterLogout } from './routes/auth.js'; import { apiRouter } from './routes/api.js'; assertConfig(); @@ -68,8 +68,22 @@ app.get('/topbar.js', (_req, res) => { }); app.use(express.static(publicDir, { index: false, maxAge: '1h' })); +function readCookie(req: Request, name: string): string | undefined { + for (const part of (req.headers.cookie ?? '').split(';')) { + const [key, ...value] = part.trim().split('='); + if (key === name) return decodeURIComponent(value.join('=')); + } + return undefined; +} + // Portal-Seite: nur für angemeldete Benutzer, sonst direkt zu Authentik. app.get('*', (req, res) => { + // Authentik schickt nach dem Abmelden auf "/" zurück; von dort zurück ins Tool, in dem abgemeldet wurde. + const afterLogout = safeAfterLogout(readCookie(req, AFTER_LOGOUT_COOKIE)); + if (afterLogout && req.path === '/' && !req.session.user) { + res.clearCookie(AFTER_LOGOUT_COOKIE); + return res.redirect(afterLogout); + } if (!req.session.user) return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`); res.setHeader('Cache-Control', 'no-store'); res.sendFile(path.join(publicDir, 'index.html')); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index ee97698..e81b439 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -53,11 +53,29 @@ authRouter.get('/callback', async (req, res) => { } }); +/** Merkt sich über den Umweg zu Authentik, wohin es nach dem Abmelden zurückgeht (siehe index.ts). */ +export const AFTER_LOGOUT_COOKIE = 'hi.portal.nach_abmelden'; + +export function safeAfterLogout(raw: unknown): string | null { + const target = safeReturnTo(raw); + return target === '/' ? null : target; +} + +// Beendet Portal- und Authentik-Sitzung, auch wenn die Portal-Sitzung schon abgelaufen ist. authRouter.get('/logout', async (req, res, next) => { try { const url = await buildLogoutUrl(req.session.idToken); + const returnTo = safeAfterLogout(req.query.returnTo); req.session.destroy(() => { res.clearCookie('hi.portal.sid'); + if (returnTo) { + res.cookie(AFTER_LOGOUT_COOKIE, returnTo, { + httpOnly: true, + sameSite: 'lax', + secure: config.isProduction, + maxAge: 5 * 60 * 1000, + }); + } res.redirect(url); }); } catch (err) { diff --git a/topbar/src/topbar.ts b/topbar/src/topbar.ts index 452ca96..9dc82a0 100644 --- a/topbar/src/topbar.ts +++ b/topbar/src/topbar.ts @@ -213,7 +213,7 @@ class HiTopbar extends HTMLElement { } /** Erst das Tool abmelden lassen (Ereignis "hi-abmelden"), dann Portal und Authentik. */ - private async logout(href: string | null): Promise { + private async logout(href: string): Promise { const pending: Promise[] = []; const event = new CustomEvent('hi-abmelden', { bubbles: true, @@ -226,9 +226,9 @@ class HiTopbar extends HTMLElement { await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, LOGOUT_WAIT_MS))]); } if (event.defaultPrevented) return; - // Ohne Portal-Sitzung gibt es dort nichts abzumelden; neu laden zeigt die Anmeldung des Tools. - if (href) location.href = href; - else location.reload(); + // Immer über das Portal, auch ohne Portal-Sitzung: nur so endet auch die Authentik-Sitzung. + // Danach geht es zurück auf diese Seite, die dann die Anmeldung des Tools zeigt. + location.href = `${href}?returnTo=${encodeURIComponent(location.href)}`; } private render(): void { @@ -291,7 +291,7 @@ class HiTopbar extends HTMLElement { ); this.root.querySelector('[data-logout]')?.addEventListener('click', (e) => { e.preventDefault(); - void this.logout(user ? links.logout : null); + void this.logout(links.logout); }); } }