feat(auth): OIDC logout-to-IdP — phase 3 (#798) (#865)

* feat(auth): OIDC logout-to-IdP — phase 3 (#798)

RP-initiated logout behind a new oidc_logout_from_idp setting: logging
out of PicPeak also ends the IdP session. The SSO callback stores the
raw ID token in an HttpOnly cookie (also the marker that the session
came in via SSO — local-password sessions never bounce to the IdP);
/logout builds the end_session URL from discovery metadata with
id_token_hint + post_logout_redirect_uri + client_id and returns it as
ssoLogoutUrl for the frontend to navigate to. Any failure (no
end_session_endpoint, IdP unreachable, feature off) degrades to the
plain local logout.

Settings surface exposes the toggle plus the computed post-logout
redirect URI to register at the IdP. Session timeouts deliberately stay
local-only.

6 integration tests over the mock IdP; live-verified against
Keycloak 26 (logout ends the Keycloak session, no confirmation prompt).

* fix(auth): harden the SSO logout marker cookie (#798 phase 3)

Codex review round 1:
- Derive the oidc_id_token cookie options from the shared cookie policy
  (COOKIE_SAMESITE / COOKIE_DOMAIN / secure resolution) — hardcoded Lax
  meant split-origin deployments running on SameSite=None never sent the
  marker to the cross-site /logout XHR, silently disabling logout-to-IdP.
- Oversized ID tokens (>3.9KB) now store a bare 'sso' marker instead of
  no cookie, so the claimed client_id-only end-session fallback actually
  happens; /logout only passes the value as id_token_hint when it is a
  real JWT.
- establishAdminSession clears any stale marker on every fresh login —
  sessions can die without /logout (deactivation, expiry, restore), and
  a surviving marker would bounce a later local-password session to the
  IdP. The SSO callback re-sets the marker for its own session.

Tests: oversized-token marker + hint-less end-session URL, stale-marker
cleared on local login; helper updated for the clear+set cookie pair.

* fix(auth): validate the logout hint against the current OIDC config (#798 phase 3)

Codex review round 2: an ID token stored at login can outlive an
issuer/client config change; sending it to the newly configured IdP as
id_token_hint strands the user on the IdP's error page (providers
validate iss/aud on the hint). buildEndSessionUrl now decodes the hint
(no verification — routing only): different issuer → skip the round-trip
entirely (the session belongs to another IdP); same issuer but changed
client → keep the round-trip, drop the unusable hint. Two tests pin both
paths.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-24 11:42:09 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent c5dc790e28
commit 219d07b04a
11 changed files with 465 additions and 10 deletions
@@ -107,6 +107,7 @@ export const SsoTab: React.FC = () => {
// (rightly) rejects an explicit true while SSO is off, and the promise
// is that disabling SSO restores password login.
oidc_disable_local_login: form.oidc_enabled ? form.oidc_disable_local_login : false,
oidc_logout_from_idp: form.oidc_enabled ? form.oidc_logout_from_idp : false,
};
if (newSecret.trim()) payload.oidc_client_secret = newSecret.trim();
saveMutation.mutate(payload);
@@ -424,6 +425,35 @@ export const SsoTab: React.FC = () => {
{t('settings.sso.policy.breakGlassHint', 'Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately.')}
</div>
)}
{/* Logout-to-IdP (#798 phase 3) */}
<label className="flex items-start gap-3 pt-1 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
checked={form.oidc_logout_from_idp}
onChange={(e) => set('oidc_logout_from_idp', e.target.checked)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
{t('settings.sso.policy.logoutFromIdp', 'Also sign out of the identity provider')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sso.policy.logoutFromIdpHint', 'Logging out of PicPeak also ends the IdP session (RP-initiated logout). Only applies to sessions that signed in via SSO; without this, logging out of PicPeak leaves the IdP session alive and the next SSO click signs straight back in.')}
</span>
</span>
</label>
{form.oidc_logout_from_idp && form.post_logout_redirect_uri && (
<div className="rounded-lg bg-neutral-50 dark:bg-neutral-800/60 p-3">
<p className="text-xs font-medium text-neutral-600 dark:text-neutral-300 mb-1">
{t('settings.sso.policy.postLogoutRedirectUri', 'Post-logout redirect URI (register this on your IdP client, e.g. Keycloak "Valid post logout redirect URIs")')}
</p>
<code className="block text-xs text-neutral-800 dark:text-neutral-200 break-all">
{form.post_logout_redirect_uri}
</code>
</div>
)}
</div>
</Card>
</div>
+4 -1
View File
@@ -2158,7 +2158,10 @@
"title": "Anmelde-Richtlinie",
"disableLocalLogin": "Lokale Passwort-Anmeldung deaktivieren",
"disableLocalLoginHint": "Die Anmeldeseite zeigt nur noch den SSO-Button und die API lehnt Passwort-Anmeldungen ab. Nur möglich, solange SSO aktiviert ist; wird SSO deaktiviert, ist die Passwort-Anmeldung automatisch wieder möglich.",
"breakGlassHint": "Ausgesperrt, weil der IdP down oder falsch konfiguriert ist? Setzen Sie die Umgebungsvariable OIDC_BREAK_GLASS=true am Backend und starten Sie neu — die Passwort-Anmeldung ist sofort wieder möglich."
"breakGlassHint": "Ausgesperrt, weil der IdP down oder falsch konfiguriert ist? Setzen Sie die Umgebungsvariable OIDC_BREAK_GLASS=true am Backend und starten Sie neu — die Passwort-Anmeldung ist sofort wieder möglich.",
"logoutFromIdp": "Auch beim Identity Provider abmelden",
"logoutFromIdpHint": "Die Abmeldung von PicPeak beendet auch die IdP-Sitzung (RP-initiated Logout). Gilt nur für Sitzungen, die per SSO angemeldet wurden; ohne diese Option bleibt die IdP-Sitzung bestehen und der nächste SSO-Klick meldet direkt wieder an.",
"postLogoutRedirectUri": "Post-Logout-Redirect-URI (beim IdP-Client registrieren, z. B. Keycloak „Valid post logout redirect URIs“)"
}
}
},
+4 -1
View File
@@ -1705,7 +1705,10 @@
"title": "Login policy",
"disableLocalLogin": "Disable local password login",
"disableLocalLoginHint": "The login page shows only the SSO button and the API refuses password logins. Only possible while SSO is enabled; turning SSO off restores password login automatically.",
"breakGlassHint": "Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately."
"breakGlassHint": "Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately.",
"logoutFromIdp": "Also sign out of the identity provider",
"logoutFromIdpHint": "Logging out of PicPeak also ends the IdP session (RP-initiated logout). Only applies to sessions that signed in via SSO; without this, logging out of PicPeak leaves the IdP session alive and the next SSO click signs straight back in.",
"postLogoutRedirectUri": "Post-logout redirect URI (register this on your IdP client, e.g. Keycloak \"Valid post logout redirect URIs\")"
}
}
},
+6 -2
View File
@@ -35,10 +35,14 @@ export const authService = {
async adminLogout() {
try {
await api.post('/auth/logout');
const response = await api.post('/auth/logout');
// RP-initiated logout (#798 phase 3): for SSO sessions with
// logout-to-IdP enabled, the backend hands back the IdP's end-session
// URL — navigate there so the IdP session ends too; the IdP returns
// to /admin/login afterwards.
window.location.href = response.data?.ssoLogoutUrl || '/admin/login';
} catch (err) {
// Ignore logout errors; fallback to redirect
} finally {
window.location.href = '/admin/login';
}
},
+4
View File
@@ -21,7 +21,10 @@ export interface SsoSettings {
oidc_role_mappings: Record<string, string>;
oidc_require_mapped_role: boolean;
oidc_disable_local_login: boolean;
oidc_logout_from_idp: boolean;
redirect_uri: string;
/** Register this at the IdP (e.g. Keycloak "Valid post logout redirect URIs"). */
post_logout_redirect_uri: string;
}
export interface UpdateSsoSettings {
@@ -39,6 +42,7 @@ export interface UpdateSsoSettings {
oidc_role_mappings?: Record<string, string>;
oidc_require_mapped_role?: boolean;
oidc_disable_local_login?: boolean;
oidc_logout_from_idp?: boolean;
}
export interface SsoTestResult {