diff --git a/build/auth/idpLogout.d.ts b/build/auth/idpLogout.d.ts new file mode 100644 index 0000000..83a1e07 --- /dev/null +++ b/build/auth/idpLogout.d.ts @@ -0,0 +1,19 @@ +/** + * Build the IDP (Gatekeeper) OIDC end-session URL for federated logout. + * + * 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 declare const buildIdpLogoutUrl: ({ authBaseUrl, clientId, redirectUri, idTokenHint, }: IdpLogoutConfig) => string | null; diff --git a/build/auth/idpLogout.test.d.ts b/build/auth/idpLogout.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/build/auth/idpLogout.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/build/auth/index.d.ts b/build/auth/index.d.ts index 2952863..98de9e9 100644 --- a/build/auth/index.d.ts +++ b/build/auth/index.d.ts @@ -2,6 +2,7 @@ export { createAuthCache } from "./cache"; export { extractOrgClaims } from "./claims"; export { GatekeeperOrgClient, GatekeeperOrgError, formatRelativeTime, getInviteTimeInfo, isInvitationExpired, validateInvitation, } from "./gatekeeperOrg"; export { createGetAuth } from "./getAuth"; +export { buildIdpLogoutUrl } from "./idpLogout"; export { verifyAccessToken } from "./jwt"; export { createOmniOAuthConfig } from "./oauth"; export { createOidcClient } from "./oidc"; @@ -11,6 +12,7 @@ export { OMNI_CLAIMS_NAMESPACE } from "./types"; export type { AuthCache, AuthCacheConfig, CachedAuthData } from "./cache"; export type { GatekeeperInvitation, GatekeeperMember, GatekeeperMemberRole, GatekeeperOrganization, InvitationValidationResult, InviteTimeInfo, ValidateInvitationParams, } from "./gatekeeperOrg"; export type { BetterAuthApi, GetAuthConfig, GetAuthSession, ResolveRowIdFn, ResolveRowIdParams, SetCookieFn, } from "./getAuth"; +export type { IdpLogoutConfig } from "./idpLogout"; export type { VerifyAccessTokenConfig } from "./jwt"; export type { OmniOAuthConfig } from "./oauth"; export type { OidcClient, OidcClientConfig, OidcDiscovery, } from "./oidc"; diff --git a/build/auth/index.js b/build/auth/index.js index f5bfeeb..1eb64bb 100644 --- a/build/auth/index.js +++ b/build/auth/index.js @@ -3400,6 +3400,21 @@ function createGetAuth(config) { } }; } +// src/auth/idpLogout.ts +var buildIdpLogoutUrl = ({ + authBaseUrl, + clientId, + redirectUri, + idTokenHint +}) => { + 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(); +}; // src/auth/index.ts init_jwt(); @@ -3422,6 +3437,7 @@ function createOmniOAuthConfig(config) { ], accessType: "offline", pkce: true, + authorizationUrlParams: (ctx) => ctx.body?.additionalData?.screen_hint === "signup" ? { prompt: "create" } : {}, mapProfileToUser: (profile) => ({ name: profile.name, email: profile.email, @@ -3542,6 +3558,7 @@ export { createOidcClient, createGetAuth, createAuthCache, + buildIdpLogoutUrl, OMNI_CLAIMS_NAMESPACE, GatekeeperOrgError, GatekeeperOrgClient diff --git a/build/auth/oauth.d.ts b/build/auth/oauth.d.ts index 25db707..11001bd 100644 --- a/build/auth/oauth.d.ts +++ b/build/auth/oauth.d.ts @@ -35,6 +35,13 @@ declare function createOmniOAuthConfig(config: OmniOAuthConfig): { scopes: string[]; accessType: "offline"; pkce: boolean; + authorizationUrlParams: (ctx: { + body?: { + additionalData?: { + screen_hint?: string; + }; + }; + }) => Record; mapProfileToUser: (profile: Record) => { name: string; email: string; diff --git a/build/index.js b/build/index.js index caeb51e..dc7bbdb 100644 --- a/build/index.js +++ b/build/index.js @@ -78035,6 +78035,7 @@ function createOmniOAuthConfig(config) { ], accessType: "offline", pkce: true, + authorizationUrlParams: (ctx) => ctx.body?.additionalData?.screen_hint === "signup" ? { prompt: "create" } : {}, mapProfileToUser: (profile) => ({ name: profile.name, email: profile.email, diff --git a/src/auth/idpLogout.test.ts b/src/auth/idpLogout.test.ts new file mode 100644 index 0000000..74ac8cc --- /dev/null +++ b/src/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/auth/idpLogout.ts b/src/auth/idpLogout.ts new file mode 100644 index 0000000..c29d9f6 --- /dev/null +++ b/src/auth/idpLogout.ts @@ -0,0 +1,34 @@ +/** + * Build the IDP (Gatekeeper) OIDC end-session URL for federated logout. + * + * 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/auth/index.ts b/src/auth/index.ts index a6067c7..c272df7 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -9,6 +9,7 @@ export { validateInvitation, } from "./gatekeeperOrg"; export { createGetAuth } from "./getAuth"; +export { buildIdpLogoutUrl } from "./idpLogout"; export { verifyAccessToken } from "./jwt"; export { createOmniOAuthConfig } from "./oauth"; export { createOidcClient } from "./oidc"; @@ -38,6 +39,7 @@ export type { ResolveRowIdParams, SetCookieFn, } from "./getAuth"; +export type { IdpLogoutConfig } from "./idpLogout"; export type { VerifyAccessTokenConfig } from "./jwt"; export type { OmniOAuthConfig } from "./oauth"; export type {