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:
1 parent
c674fce196
commit
edc64f159a
5 files changed
+59
-8
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user