Abmelden beendet auch die Authentik-Sitzung und springt zurück ins Tool

- 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) <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-10-26 09:55:42 +01:00
1 parent c674fce196
commit edc64f159a
5 files changed
+59 -8

No files matched your search

+10 -1
View File
@@ -88,7 +88,16 @@ Ist man im Tool angemeldet, im Portal aber nicht, zeigt das Menü „Jetzt anmel
## Abmelden ## 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=<aktuelle Seite>`: 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 ## Betrieb
+11 -1
View File
@@ -50,6 +50,8 @@ LAUNCH_URLS = {
"datenuebertragungen": "https://data.handy-insel.de/api/auth/oidc/start", "datenuebertragungen": "https://data.handy-insel.de/api/auth/oidc/start",
} }
LOGOUT_FLOW = "default-invalidation-flow"
SERVICE_ACCOUNT = "hi-portal" SERVICE_ACCOUNT = "hi-portal"
SERVICE_GROUP = "hi-portal-service" SERVICE_GROUP = "hi-portal-service"
SERVICE_ROLE = "Portal: Apps pro Benutzer lesen" SERVICE_ROLE = "Portal: Apps pro Benutzer lesen"
@@ -133,13 +135,21 @@ def ensure_portal_app(apps):
step("1. OIDC-Provider und App 'Portal'") step("1. OIDC-Provider und App 'Portal'")
template = next(p for p in results("/providers/oauth2/") if p["name"] == "Preisliste") 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) 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: if provider:
print(" = Provider 'Portal' vorhanden") 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: else:
provider = call("POST", "/providers/oauth2/", { provider = call("POST", "/providers/oauth2/", {
"name": "Portal", "name": "Portal",
"authorization_flow": template["authorization_flow"], "authorization_flow": template["authorization_flow"],
"invalidation_flow": template["invalidation_flow"], "invalidation_flow": logout_flow["pk"],
"client_type": "confidential", "client_type": "confidential",
# Neuere Authentik-Versionen setzen per API keine Standard-Grant-Types; # Neuere Authentik-Versionen setzen per API keine Standard-Grant-Types;
# ohne diese Liste lehnt Authentik jede Anmeldung als "malformed" ab. # ohne diese Liste lehnt Authentik jede Anmeldung als "malformed" ab.
+15 -1
View File
@@ -7,7 +7,7 @@ import helmet from 'helmet';
import { assertConfig, config } from './config.js'; import { assertConfig, config } from './config.js';
import { initOidc } from './oidc.js'; import { initOidc } from './oidc.js';
import { SqliteStore } from './sessionStore.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'; import { apiRouter } from './routes/api.js';
assertConfig(); assertConfig();
@@ -68,8 +68,22 @@ app.get('/topbar.js', (_req, res) => {
}); });
app.use(express.static(publicDir, { index: false, maxAge: '1h' })); 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. // Portal-Seite: nur für angemeldete Benutzer, sonst direkt zu Authentik.
app.get('*', (req, res) => { 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)}`); if (!req.session.user) return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.sendFile(path.join(publicDir, 'index.html')); res.sendFile(path.join(publicDir, 'index.html'));
+18
View File
@@ -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) => { authRouter.get('/logout', async (req, res, next) => {
try { try {
const url = await buildLogoutUrl(req.session.idToken); const url = await buildLogoutUrl(req.session.idToken);
const returnTo = safeAfterLogout(req.query.returnTo);
req.session.destroy(() => { req.session.destroy(() => {
res.clearCookie('hi.portal.sid'); 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); res.redirect(url);
}); });
} catch (err) { } catch (err) {
+5 -5
View File
@@ -213,7 +213,7 @@ class HiTopbar extends HTMLElement {
} }
/** Erst das Tool abmelden lassen (Ereignis "hi-abmelden"), dann Portal und Authentik. */ /** Erst das Tool abmelden lassen (Ereignis "hi-abmelden"), dann Portal und Authentik. */
private async logout(href: string | null): Promise<void> { private async logout(href: string): Promise<void> {
const pending: Promise<unknown>[] = []; const pending: Promise<unknown>[] = [];
const event = new CustomEvent('hi-abmelden', { const event = new CustomEvent('hi-abmelden', {
bubbles: true, bubbles: true,
@@ -226,9 +226,9 @@ class HiTopbar extends HTMLElement {
await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, LOGOUT_WAIT_MS))]); await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, LOGOUT_WAIT_MS))]);
} }
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
// Ohne Portal-Sitzung gibt es dort nichts abzumelden; neu laden zeigt die Anmeldung des Tools. // Immer über das Portal, auch ohne Portal-Sitzung: nur so endet auch die Authentik-Sitzung.
if (href) location.href = href; // Danach geht es zurück auf diese Seite, die dann die Anmeldung des Tools zeigt.
else location.reload(); location.href = `${href}?returnTo=${encodeURIComponent(location.href)}`;
} }
private render(): void { private render(): void {
@@ -291,7 +291,7 @@ class HiTopbar extends HTMLElement {
); );
this.root.querySelector<HTMLAnchorElement>('[data-logout]')?.addEventListener('click', (e) => { this.root.querySelector<HTMLAnchorElement>('[data-logout]')?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
void this.logout(user ? links.logout : null); void this.logout(links.logout);
}); });
} }
} }