feat: add support for OIDC logouts

Closes HP-407.
This commit is contained in:
Aarnav Tale
2026-04-26 20:28:36 -04:00
parent ac6f9e4f7e
commit b961b339bb
13 changed files with 512 additions and 15 deletions
+23 -3
View File
@@ -7,15 +7,35 @@ export async function loader() {
} }
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) { export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
try { try {
await context.auth.require(request); principal = await context.auth.require(request);
} catch { } catch {
redirect("/login"); return redirect("/login");
} }
// When API key is disabled, we need to explicitly redirect // When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again. // 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, { return redirect(url, {
headers: { headers: {
+13 -5
View File
@@ -66,13 +66,21 @@ export async function loader({ request, context }: Route.LoaderArgs) {
log.warn("auth", "Failed to link Headscale user: %s", String(error)); 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("/", { return redirect("/", {
headers: { headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, { "Set-Cookie": await context.auth.createOidcSession(
name: identity.name, userId,
email: identity.email, {
username: identity.username, name: identity.name,
}), email: identity.email,
username: identity.username,
},
{ idToken },
),
}, },
}); });
} }
+6
View File
@@ -123,6 +123,9 @@ const oidcConfig = type({
authorization_endpoint: "string.url?", authorization_endpoint: "string.url?",
token_endpoint: "string.url?", token_endpoint: "string.url?",
userinfo_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"?', token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options // Old/deprecated options
@@ -147,6 +150,9 @@ const partialOidcConfig = type({
authorization_endpoint: "string.url?", authorization_endpoint: "string.url?",
token_endpoint: "string.url?", token_endpoint: "string.url?",
userinfo_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"?', token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options // Old/deprecated options
+1
View File
@@ -36,6 +36,7 @@ export const authSessions = sqliteTable("auth_sessions", {
user_id: text("user_id"), user_id: text("user_id"),
api_key_hash: text("api_key_hash"), api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"), api_key_display: text("api_key_display"),
oidc_id_token: text("oidc_id_token"),
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(), expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()), created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
}); });
+3
View File
@@ -109,6 +109,7 @@ const appLoadContext = {
authorizationEndpoint: config.oidc.authorization_endpoint, authorizationEndpoint: config.oidc.authorization_endpoint,
tokenEndpoint: config.oidc.token_endpoint, tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint, userinfoEndpoint: config.oidc.userinfo_endpoint,
endSessionEndpoint: config.oidc.end_session_endpoint,
tokenEndpointAuthMethod: tokenEndpointAuthMethod:
config.oidc.token_endpoint_auth_method === "client_secret_jwt" config.oidc.token_endpoint_auth_method === "client_secret_jwt"
? undefined ? undefined
@@ -119,8 +120,10 @@ const appLoadContext = {
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params, extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source, profilePictureSource: config.oidc.profile_picture_source,
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
}), }),
disableApiKeyLogin: config.oidc.disable_api_key_login, disableApiKeyLogin: config.oidc.disable_api_key_login,
useEndSession: config.oidc.use_end_session,
} }
: undefined, : undefined,
db, db,
+39 -4
View File
@@ -15,6 +15,7 @@ export interface OidcConfig {
authorizationEndpoint?: string; authorizationEndpoint?: string;
tokenEndpoint?: string; tokenEndpoint?: string;
userinfoEndpoint?: string; userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string; jwksUri?: string;
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post"; tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
@@ -25,6 +26,7 @@ export interface OidcConfig {
allowWeakRsaKeys?: boolean; allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>; extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar"; profilePictureSource?: "oidc" | "gravatar";
postLogoutRedirectUri?: string;
} }
export interface ResolvedEndpoints { export interface ResolvedEndpoints {
@@ -49,6 +51,7 @@ export interface OidcIdentity {
username: string; username: string;
email?: string; email?: string;
picture?: string; picture?: string;
idToken?: string;
} }
export type OidcErrorCode = export type OidcErrorCode =
@@ -89,6 +92,8 @@ export interface OidcService {
flowState: OidcFlowState, flowState: OidcFlowState,
): Promise<Result<OidcIdentity, OidcError>>; ): Promise<Result<OidcIdentity, OidcError>>;
buildEndSessionUrl(idToken?: string): string | undefined;
invalidate(): void; invalidate(): void;
reload(config: OidcConfig): void; reload(config: OidcConfig): void;
} }
@@ -173,6 +178,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
tokenEndpoint: config.tokenEndpoint!, tokenEndpoint: config.tokenEndpoint!,
jwksUri: config.jwksUri!, jwksUri: config.jwksUri!,
userinfoEndpoint: config.userinfoEndpoint, userinfoEndpoint: config.userinfoEndpoint,
endSessionEndpoint: config.endSessionEndpoint,
}; };
lastError = undefined; lastError = undefined;
@@ -247,7 +253,8 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined); const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined);
const userinfoEndpoint = const userinfoEndpoint =
config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined); 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) { if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
const missing: string[] = []; 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( 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); const subject = resolveSubject(claims);
if (!subject) { if (!subject) {
throw new Error("OIDC subject was not resolved before identity construction"); throw new Error("OIDC subject was not resolved before identity construction");
@@ -769,9 +776,29 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
username, username,
email: claims.email, email: claims.email,
picture, 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 { function invalidate(): void {
endpoints = undefined; endpoints = undefined;
lastError = undefined; lastError = undefined;
@@ -803,7 +830,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return undefined; return undefined;
} }
return { status, discover, startFlow, handleCallback, invalidate, reload }; return {
status,
discover,
startFlow,
handleCallback,
buildEndSessionUrl,
invalidate,
reload,
};
function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void { function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void {
if (!currentConfig.allowWeakRsaKeys) { if (!currentConfig.allowWeakRsaKeys) {
+6 -2
View File
@@ -20,6 +20,7 @@ export type Principal =
| { | {
kind: "oidc"; kind: "oidc";
sessionId: string; sessionId: string;
idToken?: string;
user: { user: {
id: string; id: string;
subject: string; subject: string;
@@ -64,7 +65,7 @@ export interface AuthService {
createOidcSession( createOidcSession(
userId: string, userId: string,
profile: NonNullable<CookiePayload["profile"]>, profile: NonNullable<CookiePayload["profile"]>,
maxAge?: number, options?: { idToken?: string; maxAge?: number },
): Promise<string>; ): Promise<string>;
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>; createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
@@ -185,6 +186,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return { return {
kind: "oidc", kind: "oidc",
sessionId: session.id, sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
user: { user: {
id: user.id, id: user.id,
subject: user.sub, subject: user.sub,
@@ -249,13 +251,15 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
async function createOidcSession( async function createOidcSession(
userId: string, userId: string,
profile: NonNullable<CookiePayload["profile"]>, profile: NonNullable<CookiePayload["profile"]>,
maxAge = opts.cookie.maxAge, options?: { idToken?: string; maxAge?: number },
): Promise<string> { ): Promise<string> {
const maxAge = options?.maxAge ?? opts.cookie.maxAge;
const sid = ulid(); const sid = ulid();
await opts.db.insert(authSessions).values({ await opts.db.insert(authSessions).values({
id: sid, id: sid,
kind: "oidc", kind: "oidc",
user_id: userId, user_id: userId,
oidc_id_token: options?.idToken,
expires_at: new Date(Date.now() + maxAge * 1000), expires_at: new Date(Date.now() + maxAge * 1000),
}); });
+19
View File
@@ -185,6 +185,25 @@ integration:
# token_endpoint: "" # token_endpoint: ""
# userinfo_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
# `<server.base_url>/admin/login?s=logout` page.
# post_logout_redirect_uri: ""
# The authentication method to use when communicating with the token endpoint. # The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best # This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure. # method and fall back to `client_secret_basic` if unsure.
+46
View File
@@ -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 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. 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 `<server.base_url>/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 ## Troubleshooting
### Common Issues ### Common Issues
@@ -0,0 +1 @@
ALTER TABLE `auth_sessions` ADD `oidc_id_token` text;
@@ -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": []
}
+1 -1
View File
@@ -194,7 +194,7 @@ describe("session round-trip", () => {
test("expired session throws", async () => { test("expired session throws", async () => {
const userId = await auth.findOrCreateUser("sub-1", { name: "Alice" }); 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 cookieValue = cookieHeader.split(";")[0];
const request = new Request("http://localhost/test", { const request = new Request("http://localhost/test", {
+78
View File
@@ -418,6 +418,7 @@ describe("handleCallback", () => {
expect(result.value.name).toBe("Test User"); expect(result.value.name).toBe("Test User");
expect(result.value.email).toBe("test@example.com"); expect(result.value.email).toBe("test@example.com");
expect(result.value.username).toBe("testuser"); expect(result.value.username).toBe("testuser");
expect(result.value.idToken).toBe(idToken);
}); });
test("state mismatch returns error", async () => { test("state mismatch returns error", async () => {
@@ -1126,3 +1127,80 @@ describe("path-based issuers", () => {
pathServer.close(); 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);
});
});