diff --git a/src/lib/auth/idpLogout.test.ts b/src/lib/auth/idpLogout.test.ts new file mode 100644 index 0000000..74ac8cc --- /dev/null +++ b/src/lib/auth/idpLogout.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; + +import { buildIdpLogoutUrl } from "./idpLogout"; + +const full = { + authBaseUrl: "https://identity.example.com", + clientId: "client-123", + redirectUri: "https://app.example.com", + idTokenHint: "the-id-token", +}; + +describe("buildIdpLogoutUrl", () => { + it("builds the end-session url with all params when everything is present", () => { + const url = new URL(buildIdpLogoutUrl(full) as string); + expect(url.origin + url.pathname).toBe( + "https://identity.example.com/oauth2/end-session", + ); + expect(url.searchParams.get("client_id")).toBe("client-123"); + expect(url.searchParams.get("post_logout_redirect_uri")).toBe( + "https://app.example.com", + ); + expect(url.searchParams.get("id_token_hint")).toBe("the-id-token"); + }); + + it("returns null without an id token (the endpoint requires it)", () => { + expect(buildIdpLogoutUrl({ ...full, idTokenHint: undefined })).toBeNull(); + expect(buildIdpLogoutUrl({ ...full, idTokenHint: "" })).toBeNull(); + }); + + it("returns null when any IDP config part is missing", () => { + expect(buildIdpLogoutUrl({ ...full, authBaseUrl: undefined })).toBeNull(); + expect(buildIdpLogoutUrl({ ...full, clientId: undefined })).toBeNull(); + expect(buildIdpLogoutUrl({ ...full, redirectUri: undefined })).toBeNull(); + }); + + it("url-encodes the id token and redirect uri", () => { + const url = buildIdpLogoutUrl({ + ...full, + idTokenHint: "a b+c/d=", + redirectUri: "https://app.example.com/back?x=1", + }) as string; + expect(url).toContain("id_token_hint=a+b%2Bc%2Fd%3D"); + expect(url).toContain( + "post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fback%3Fx%3D1", + ); + }); +}); diff --git a/src/lib/auth/idpLogout.ts b/src/lib/auth/idpLogout.ts new file mode 100644 index 0000000..4caf740 --- /dev/null +++ b/src/lib/auth/idpLogout.ts @@ -0,0 +1,35 @@ +/** + * Build the IDP (Gatekeeper) OIDC end-session URL for federated logout. + * + * Pure and config-injected so it is testable without env. Returns null unless + * every part is present, INCLUDING `idTokenHint`: Gatekeeper's end-session + * endpoint requires `id_token_hint` and rejects a request without it, so a + * caller that lacks the id token must fall back to a local-only sign-out rather + * than redirect the browser into a validation error. + */ +export interface IdpLogoutConfig { + /** IDP base URL, e.g. https://identity.omni.dev */ + authBaseUrl?: string; + /** OAuth client id */ + clientId?: string; + /** Post-logout redirect target (the app's own base URL) */ + redirectUri?: string; + /** The user's id token; required by the end-session endpoint */ + idTokenHint?: string; +} + +export const buildIdpLogoutUrl = ({ + authBaseUrl, + clientId, + redirectUri, + idTokenHint, +}: IdpLogoutConfig): string | null => { + if (!authBaseUrl || !clientId || !redirectUri || !idTokenHint) return null; + + const url = new URL(`${authBaseUrl}/oauth2/end-session`); + url.searchParams.set("client_id", clientId); + url.searchParams.set("post_logout_redirect_uri", redirectUri); + url.searchParams.set("id_token_hint", idTokenHint); + + return url.toString(); +}; diff --git a/src/server/functions/auth.ts b/src/server/functions/auth.ts index d4bccac..41de60e 100644 --- a/src/server/functions/auth.ts +++ b/src/server/functions/auth.ts @@ -9,6 +9,7 @@ import { getRequest, getRequestHeaders } from "@tanstack/react-start/server"; import { decodeJwt } from "jose"; import auth from "@/lib/auth/auth"; +import { buildIdpLogoutUrl } from "@/lib/auth/idpLogout"; import { AUTH_BASE_URL, AUTH_CLIENT_ID, @@ -95,16 +96,16 @@ export const signOutAndRedirect = createServerFn({ method: "POST" }).handler( * Build the IDP end_session URL for federated logout */ export function getIdpLogoutUrl(idTokenHint?: string): string | null { - if (!AUTH_BASE_URL || !AUTH_CLIENT_ID || !BASE_URL) return null; - - const endSessionUrl = new URL(`${AUTH_BASE_URL}/oauth2/end-session`); - endSessionUrl.searchParams.set("client_id", AUTH_CLIENT_ID); - endSessionUrl.searchParams.set("post_logout_redirect_uri", BASE_URL); - if (idTokenHint) { - endSessionUrl.searchParams.set("id_token_hint", idTokenHint); - } - - return endSessionUrl.toString(); + // Returns null (falling back to a local-only sign-out) unless every part is + // present, including the id token: Gatekeeper's end-session endpoint requires + // id_token_hint, so redirecting without it 400s and breaks sign-out. The id + // token may be absent after a token refresh, which does not re-issue one. + return buildIdpLogoutUrl({ + authBaseUrl: AUTH_BASE_URL, + clientId: AUTH_CLIENT_ID, + redirectUri: BASE_URL, + idTokenHint, + }); } /**