Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/lib/auth/idpLogout.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
35 changes: 35 additions & 0 deletions src/lib/auth/idpLogout.ts
Original file line number Diff line number Diff line change
@@ -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();
};
21 changes: 11 additions & 10 deletions src/server/functions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}

/**
Expand Down
Loading