diff --git a/app/routes/auth/logout.ts b/app/routes/auth/logout.ts index 541e539..f34d8e6 100644 --- a/app/routes/auth/logout.ts +++ b/app/routes/auth/logout.ts @@ -7,15 +7,35 @@ export async function loader() { } export async function action({ request, context }: ActionFunctionArgs) { + let principal: Awaited> | undefined; try { - await context.auth.require(request); + principal = await context.auth.require(request); } catch { - redirect("/login"); + return redirect("/login"); } // When API key is disabled, we need to explicitly redirect // with a logout state to prevent auto login again. - const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login"; + let url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login"; + + // For OIDC sessions, redirect to the provider's RP-initiated logout + // endpoint when explicitly enabled, so the upstream IdP session is also + // ended. Disabled by default because the post_logout_redirect_uri must be + // pre-registered on the IdP — turning this on without registering it would + // strand users on the IdP's error page. + if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) { + const status = context.oidc.service.status(); + if (status.state !== "ready") { + // Trigger discovery if it hasn't happened yet so we can find the + // end_session_endpoint without forcing a re-login. + await context.oidc.service.discover(); + } + + const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken); + if (endSessionUrl) { + url = endSessionUrl; + } + } return redirect(url, { headers: { diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index d27224c..fad9a62 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -66,13 +66,21 @@ export async function loader({ request, context }: Route.LoaderArgs) { log.warn("auth", "Failed to link Headscale user: %s", String(error)); } + // Only persist the id_token when RP-initiated logout is enabled — otherwise + // we'd be storing a credential we never use. + const idToken = context.oidc?.useEndSession ? identity.idToken : undefined; + return redirect("/", { headers: { - "Set-Cookie": await context.auth.createOidcSession(userId, { - name: identity.name, - email: identity.email, - username: identity.username, - }), + "Set-Cookie": await context.auth.createOidcSession( + userId, + { + name: identity.name, + email: identity.email, + username: identity.username, + }, + { idToken }, + ), }, }); } diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 6c1b369..63485e9 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -123,6 +123,9 @@ const oidcConfig = type({ authorization_endpoint: "string.url?", token_endpoint: "string.url?", userinfo_endpoint: "string.url?", + end_session_endpoint: "string.url?", + post_logout_redirect_uri: "string.url?", + use_end_session: "boolean = false", token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', // Old/deprecated options @@ -147,6 +150,9 @@ const partialOidcConfig = type({ authorization_endpoint: "string.url?", token_endpoint: "string.url?", userinfo_endpoint: "string.url?", + end_session_endpoint: "string.url?", + post_logout_redirect_uri: "string.url?", + use_end_session: "boolean?", token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', // Old/deprecated options diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 452baaa..7eb0928 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -36,6 +36,7 @@ export const authSessions = sqliteTable("auth_sessions", { user_id: text("user_id"), api_key_hash: text("api_key_hash"), api_key_display: text("api_key_display"), + oidc_id_token: text("oidc_id_token"), expires_at: integer("expires_at", { mode: "timestamp" }).notNull(), created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()), }); diff --git a/app/server/index.ts b/app/server/index.ts index bfbe78f..f0555a8 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -109,6 +109,7 @@ const appLoadContext = { authorizationEndpoint: config.oidc.authorization_endpoint, tokenEndpoint: config.oidc.token_endpoint, userinfoEndpoint: config.oidc.userinfo_endpoint, + endSessionEndpoint: config.oidc.end_session_endpoint, tokenEndpointAuthMethod: config.oidc.token_endpoint_auth_method === "client_secret_jwt" ? undefined @@ -119,8 +120,10 @@ const appLoadContext = { allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, extraParams: config.oidc.extra_params, profilePictureSource: config.oidc.profile_picture_source, + postLogoutRedirectUri: config.oidc.post_logout_redirect_uri, }), disableApiKeyLogin: config.oidc.disable_api_key_login, + useEndSession: config.oidc.use_end_session, } : undefined, db, diff --git a/app/server/oidc/provider.ts b/app/server/oidc/provider.ts index 162372a..6cf3bee 100644 --- a/app/server/oidc/provider.ts +++ b/app/server/oidc/provider.ts @@ -15,6 +15,7 @@ export interface OidcConfig { authorizationEndpoint?: string; tokenEndpoint?: string; userinfoEndpoint?: string; + endSessionEndpoint?: string; jwksUri?: string; tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post"; @@ -25,6 +26,7 @@ export interface OidcConfig { allowWeakRsaKeys?: boolean; extraParams?: Record; profilePictureSource?: "oidc" | "gravatar"; + postLogoutRedirectUri?: string; } export interface ResolvedEndpoints { @@ -49,6 +51,7 @@ export interface OidcIdentity { username: string; email?: string; picture?: string; + idToken?: string; } export type OidcErrorCode = @@ -89,6 +92,8 @@ export interface OidcService { flowState: OidcFlowState, ): Promise>; + buildEndSessionUrl(idToken?: string): string | undefined; + invalidate(): void; reload(config: OidcConfig): void; } @@ -173,6 +178,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { tokenEndpoint: config.tokenEndpoint!, jwksUri: config.jwksUri!, userinfoEndpoint: config.userinfoEndpoint, + endSessionEndpoint: config.endSessionEndpoint, }; lastError = undefined; @@ -247,7 +253,8 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined); const userinfoEndpoint = config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined); - const endSessionEndpoint = metadata.end_session_endpoint as string | undefined; + const endSessionEndpoint = + config.endSessionEndpoint ?? (metadata.end_session_endpoint as string | undefined); if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) { const missing: string[] = []; @@ -390,7 +397,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { }); } - return ok(buildIdentity(enriched)); + return ok(buildIdentity(enriched, tokens.id_token)); } async function exchangeCode( @@ -738,7 +745,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { } } - function buildIdentity(claims: OidcClaims): OidcIdentity { + function buildIdentity(claims: OidcClaims, idToken?: string): OidcIdentity { const subject = resolveSubject(claims); if (!subject) { throw new Error("OIDC subject was not resolved before identity construction"); @@ -769,9 +776,29 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { username, email: claims.email, picture, + idToken, }; } + function buildEndSessionUrl(idToken?: string): string | undefined { + if (!endpoints?.endSessionEndpoint) { + return undefined; + } + + const params = new URLSearchParams(); + if (idToken) { + params.set("id_token_hint", idToken); + } + + params.set("client_id", config.clientId); + + const postLogoutRedirectUri = + config.postLogoutRedirectUri ?? new URL(`${__PREFIX__}/login?s=logout`, config.baseUrl).href; + params.set("post_logout_redirect_uri", postLogoutRedirectUri); + + return `${endpoints.endSessionEndpoint}?${params.toString()}`; + } + function invalidate(): void { endpoints = undefined; lastError = undefined; @@ -803,7 +830,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { return undefined; } - return { status, discover, startFlow, handleCallback, invalidate, reload }; + return { + status, + discover, + startFlow, + handleCallback, + buildEndSessionUrl, + invalidate, + reload, + }; function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void { if (!currentConfig.allowWeakRsaKeys) { diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index 6a3f27f..2c49c26 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -20,6 +20,7 @@ export type Principal = | { kind: "oidc"; sessionId: string; + idToken?: string; user: { id: string; subject: string; @@ -64,7 +65,7 @@ export interface AuthService { createOidcSession( userId: string, profile: NonNullable, - maxAge?: number, + options?: { idToken?: string; maxAge?: number }, ): Promise; createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise; @@ -185,6 +186,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService { return { kind: "oidc", sessionId: session.id, + idToken: session.oidc_id_token ?? undefined, user: { id: user.id, subject: user.sub, @@ -249,13 +251,15 @@ export function createAuthService(opts: AuthServiceOptions): AuthService { async function createOidcSession( userId: string, profile: NonNullable, - maxAge = opts.cookie.maxAge, + options?: { idToken?: string; maxAge?: number }, ): Promise { + const maxAge = options?.maxAge ?? opts.cookie.maxAge; const sid = ulid(); await opts.db.insert(authSessions).values({ id: sid, kind: "oidc", user_id: userId, + oidc_id_token: options?.idToken, expires_at: new Date(Date.now() + maxAge * 1000), }); diff --git a/config.example.yaml b/config.example.yaml index 714e622..d087bb9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -185,6 +185,25 @@ integration: # token_endpoint: "" # userinfo_endpoint: "" +# RP-initiated logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html). +# When true, /logout redirects the user to the IdP's end_session_endpoint +# (auto-discovered or set manually below) so the upstream session is ended too. +# +# Disabled by default: the `post_logout_redirect_uri` MUST be pre-registered +# in your OIDC client configuration on the IdP. If it isn't, users will land +# on the provider's error page after logout. +# use_end_session: false + +# Optional. Override the auto-discovered end_session_endpoint, or supply one +# if your provider does not advertise it via discovery. +# end_session_endpoint: "" + +# Where the identity provider should redirect after RP-initiated logout. +# Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered +# in the OIDC client configuration. If unset, Headplane defaults to its own +# `/admin/login?s=logout` page. +# post_logout_redirect_uri: "" + # The authentication method to use when communicating with the token endpoint. # This is fully optional and Headplane will attempt to auto-detect the best # method and fall back to `client_secret_basic` if unsure. diff --git a/docs/features/sso.md b/docs/features/sso.md index 188df9e..f5b54f0 100644 --- a/docs/features/sso.md +++ b/docs/features/sso.md @@ -235,6 +235,52 @@ When a new OIDC user signs in for the first time, they go through a brief onboarding flow that helps them connect their first device to the Tailnet. This flow can be skipped. Once completed, users are taken to the main dashboard. +## Single Logout (RP-Initiated Logout) + +Headplane supports +[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). +When enabled, clicking "Log Out" in the UI from an OIDC-backed session will: + +1. Destroy the local Headplane session. +2. Redirect the browser to the identity provider's `end_session_endpoint`. +3. Pass along the original `id_token` as `id_token_hint`, plus a + `post_logout_redirect_uri` so the IdP can return the user to Headplane after + it has cleared its own session. + +### Configuration + +This feature is **disabled by default** because the `post_logout_redirect_uri` +must be pre-registered in your OIDC client on the IdP. Enabling it without that +registration will land users on the provider's error page after logout. + +To enable it, set `oidc.use_end_session: true`: + +```yaml +oidc: + # Required: opt in to RP-initiated logout + use_end_session: true + + # Optional: override the auto-discovered end_session_endpoint, or set it + # manually if your provider does not expose it via discovery. + # end_session_endpoint: "https://idp.example.com/realms/main/protocol/openid-connect/logout" + + # Optional. Defaults to `/admin/login?s=logout`. + # post_logout_redirect_uri: "https://headplane.example.com/admin/login?s=logout" +``` + +If your provider exposes `end_session_endpoint` in its discovery document +(Keycloak, Authentik, Auth0, Azure AD, …) Headplane picks it up automatically +once `use_end_session` is `true`. + +::: tip +Make sure the redirect URI you supply (or the default one Headplane builds) is +listed under the post-logout / valid redirect URIs in your IdP's client +configuration, otherwise the provider will refuse to redirect back. +::: + +When `use_end_session` is `false` (the default), Headplane simply destroys its +own session and returns the user to the login page. + ## Troubleshooting ### Common Issues diff --git a/drizzle/20260426221245_add_oidc_id_token/migration.sql b/drizzle/20260426221245_add_oidc_id_token/migration.sql new file mode 100644 index 0000000..9b23759 --- /dev/null +++ b/drizzle/20260426221245_add_oidc_id_token/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `auth_sessions` ADD `oidc_id_token` text; \ No newline at end of file diff --git a/drizzle/20260426221245_add_oidc_id_token/snapshot.json b/drizzle/20260426221245_add_oidc_id_token/snapshot.json new file mode 100644 index 0000000..6b15297 --- /dev/null +++ b/drizzle/20260426221245_add_oidc_id_token/snapshot.json @@ -0,0 +1,276 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f0bdd789-6848-40b3-a8b6-99b4d1f6ef07", + "prevIds": ["dd3ce0c0-106f-4628-8c36-bdf9747e5f41"], + "ddl": [ + { + "name": "auth_sessions", + "entityType": "tables" + }, + { + "name": "host_info", + "entityType": "tables" + }, + { + "name": "users", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "api_key_hash", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "api_key_display", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "oidc_id_token", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "host_id", + "entityType": "columns", + "table": "host_info" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "host_info" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "host_info" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sub", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "picture", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'member'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "headscale_user_id", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_login_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "caps", + "entityType": "columns", + "table": "users" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pk", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["host_id"], + "nameExplicit": false, + "name": "host_info_pk", + "table": "host_info", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_pk", + "table": "users", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "users_sub_unique", + "entityType": "uniques", + "table": "users" + }, + { + "columns": ["headscale_user_id"], + "nameExplicit": false, + "name": "users_headscale_user_id_unique", + "entityType": "uniques", + "table": "users" + } + ], + "renames": [] +} diff --git a/tests/unit/auth/auth-service.test.ts b/tests/unit/auth/auth-service.test.ts index 6605870..d23671e 100644 --- a/tests/unit/auth/auth-service.test.ts +++ b/tests/unit/auth/auth-service.test.ts @@ -194,7 +194,7 @@ describe("session round-trip", () => { test("expired session throws", async () => { const userId = await auth.findOrCreateUser("sub-1", { name: "Alice" }); - const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, -1); + const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, { maxAge: -1 }); const cookieValue = cookieHeader.split(";")[0]; const request = new Request("http://localhost/test", { diff --git a/tests/unit/oidc/provider.test.ts b/tests/unit/oidc/provider.test.ts index ef889e2..242ec13 100644 --- a/tests/unit/oidc/provider.test.ts +++ b/tests/unit/oidc/provider.test.ts @@ -418,6 +418,7 @@ describe("handleCallback", () => { expect(result.value.name).toBe("Test User"); expect(result.value.email).toBe("test@example.com"); expect(result.value.username).toBe("testuser"); + expect(result.value.idToken).toBe(idToken); }); test("state mismatch returns error", async () => { @@ -1126,3 +1127,80 @@ describe("path-based issuers", () => { pathServer.close(); }); }); + +describe("buildEndSessionUrl", () => { + test("returns undefined before discovery", () => { + const svc = createOidcService(testConfig()); + expect(svc.buildEndSessionUrl("token")).toBeUndefined(); + }); + + test("returns undefined when provider has no end_session_endpoint", async () => { + const svc = createOidcService( + testConfig({ + authorizationEndpoint: `${baseUrl}/authorize`, + tokenEndpoint: `${baseUrl}/token`, + jwksUri: `${baseUrl}/jwks`, + }), + ); + + await svc.discover(); + expect(svc.buildEndSessionUrl("token")).toBeUndefined(); + }); + + test("builds RP-initiated logout URL after discovery", async () => { + const svc = createOidcService(testConfig()); + await svc.discover(); + + const url = svc.buildEndSessionUrl("the-id-token"); + expect(url).toBeDefined(); + + const parsed = new URL(url!); + expect(`${parsed.protocol}//${parsed.host}${parsed.pathname}`).toBe(`${baseUrl}/logout`); + expect(parsed.searchParams.get("id_token_hint")).toBe("the-id-token"); + expect(parsed.searchParams.get("client_id")).toBe(CLIENT_ID); + expect(parsed.searchParams.get("post_logout_redirect_uri")).toBe( + "https://headplane.example.com/admin/login?s=logout", + ); + }); + + test("omits id_token_hint when not provided", async () => { + const svc = createOidcService(testConfig()); + await svc.discover(); + + const url = svc.buildEndSessionUrl(); + expect(url).toBeDefined(); + + const parsed = new URL(url!); + expect(parsed.searchParams.has("id_token_hint")).toBe(false); + expect(parsed.searchParams.get("client_id")).toBe(CLIENT_ID); + }); + + test("uses configured post_logout_redirect_uri override", async () => { + const svc = createOidcService( + testConfig({ postLogoutRedirectUri: "https://example.com/post-logout" }), + ); + await svc.discover(); + + const url = svc.buildEndSessionUrl("the-id-token"); + const parsed = new URL(url!); + expect(parsed.searchParams.get("post_logout_redirect_uri")).toBe( + "https://example.com/post-logout", + ); + }); + + test("honours manually configured end_session_endpoint", async () => { + const svc = createOidcService( + testConfig({ + authorizationEndpoint: `${baseUrl}/authorize`, + tokenEndpoint: `${baseUrl}/token`, + jwksUri: `${baseUrl}/jwks`, + endSessionEndpoint: "https://provider.example.com/manual-logout", + }), + ); + + await svc.discover(); + const url = svc.buildEndSessionUrl("the-id-token"); + expect(url).toBeDefined(); + expect(url!.startsWith("https://provider.example.com/manual-logout?")).toBe(true); + }); +});