From 14f0efd6f5d5955e3e5903991c0d299b100e788a Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sun, 14 Jun 2026 15:53:05 +0100 Subject: [PATCH] feat(client): add endSession() for RP-initiated logout The server advertises an end_session_endpoint (GET /api/logout) but the JS client only had a local-only logout(), leaving the DarkAuth SSO session alive after an app logged out. Add endSession() which clears the local session and redirects the browser to the end_session_endpoint with id_token_hint, post_logout_redirect_uri, client_id and state. The endpoint is resolved from discovery (with an endSessionEndpoint config override and an /api/logout fallback). logout() remains local-only. Includes unit tests, README and rp-initiated-logout doc updates. --- docs/rp-initiated-logout.md | 19 +++ packages/darkauth-client/.gitignore | 1 + packages/darkauth-client/README.md | 37 ++++- packages/darkauth-client/package.json | 2 +- .../darkauth-client/src/endSession.test.ts | 150 ++++++++++++++++++ packages/darkauth-client/src/index.ts | 43 ++++- packages/darkauth-client/tsconfig.json | 2 +- packages/darkauth-client/tsconfig.test.json | 10 ++ 8 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 packages/darkauth-client/.gitignore create mode 100644 packages/darkauth-client/src/endSession.test.ts create mode 100644 packages/darkauth-client/tsconfig.test.json diff --git a/docs/rp-initiated-logout.md b/docs/rp-initiated-logout.md index ec6bd0a..4dc7972 100644 --- a/docs/rp-initiated-logout.md +++ b/docs/rp-initiated-logout.md @@ -44,6 +44,25 @@ Example: /api/logout?id_token_hint=...&post_logout_redirect_uri=https%3A%2F%2Frp.example.com%2Floggedout&state=abc123 ``` +## SDK Integration (`@darkauth/client`) + +The JavaScript client exposes `endSession()`, which performs the redirect for you. It clears the +local session (same as `logout()`) and then sends the browser to the `end_session_endpoint` resolved +from discovery (falling back to `/api/logout`, or the `endSessionEndpoint` config override). + +```ts +import { endSession } from "@darkauth/client"; + +await endSession({ + postLogoutRedirectUri: `${window.location.origin}/login`, + state: "abc123", +}); +``` + +By default `endSession()` uses the current session's ID token as `id_token_hint` and the configured +`clientId`. The `postLogoutRedirectUri` must be registered in the client's allowlist (see below). +Use `logout()` instead when you only want to clear local tokens without ending the SSO session. + ## No-Hint Confirmation Behavior When no valid `id_token_hint` is provided: diff --git a/packages/darkauth-client/.gitignore b/packages/darkauth-client/.gitignore new file mode 100644 index 0000000..7012086 --- /dev/null +++ b/packages/darkauth-client/.gitignore @@ -0,0 +1 @@ +.test-build diff --git a/packages/darkauth-client/README.md b/packages/darkauth-client/README.md index 8736e7c..7705951 100644 --- a/packages/darkauth-client/README.md +++ b/packages/darkauth-client/README.md @@ -72,7 +72,8 @@ setConfig({ tokenStorage: 'memory', // Optional. Default 'memory'. Use 'localStorage' only for legacy flows. drkStorage: 'memory', // Optional. Default 'memory'. Use 'localStorage' only for explicit convenience mode. refreshMode: 'cookie', // Optional. Default 'cookie'. Use 'token' only for legacy refresh-token clients. - credentials: 'include' // Optional. Default 'include' for cookie refresh. + credentials: 'include', // Optional. Default 'include' for cookie refresh. + endSessionEndpoint: 'https://auth.example.com/api/logout' // Optional. Override the RP-initiated logout endpoint (otherwise read from discovery). }); ``` @@ -106,7 +107,28 @@ Behavior: #### `logout(): void` -Clears the in-memory session, callback state, PKCE verifier, ephemeral ZK key, and any explicitly configured legacy storage. +Clears the in-memory session, callback state, PKCE verifier, ephemeral ZK key, and any explicitly configured legacy storage. This is a **local-only** logout — it does not end the DarkAuth SSO session, so a subsequent `initiateLogin()` may silently re-authenticate. Use `endSession()` when you need to end the IdP session too. + +#### `endSession(options?: EndSessionOptions): Promise` + +Performs OIDC [RP-initiated logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). Clears the local session (same as `logout()`) and then redirects the browser to DarkAuth's `end_session_endpoint`, which ends the SSO session and redirects back to your app. + +```typescript +import { endSession } from '@DarkAuth/client'; + +await endSession({ + postLogoutRedirectUri: `${window.location.origin}/login`, + state: 'optional-csrf-value', +}); +``` + +Options (`EndSessionOptions`): +- `postLogoutRedirectUri?`: Where DarkAuth redirects after ending the session. **Must be registered in the client's `post_logout_redirect_uris` allowlist** on the DarkAuth server (exact match), otherwise the request is rejected. When provided, the SDK also sends `client_id` (the configured `clientId` unless overridden). +- `state?`: Opaque value echoed back on the post-logout redirect (use it to protect against CSRF / restore app state). +- `idTokenHint?`: The ID token to send as `id_token_hint`. Defaults to the current session's ID token. With a valid hint DarkAuth ends the session without a confirmation prompt. +- `clientId?`: Override the `client_id` sent alongside `post_logout_redirect_uri` (defaults to the configured `clientId`). + +The `end_session_endpoint` is resolved from the server's `/.well-known/openid-configuration` discovery document, falling back to `/api/logout`, or to the `endSessionEndpoint` config value when set. #### `getStoredSession(): AuthSession | null` @@ -300,6 +322,17 @@ type Config = { drkStorage?: 'memory' | 'localStorage'; refreshMode?: 'cookie' | 'token'; credentials?: RequestCredentials; + endSessionEndpoint?: string; +} +``` + +### `EndSessionOptions` +```typescript +type EndSessionOptions = { + postLogoutRedirectUri?: string; + state?: string; + idTokenHint?: string; + clientId?: string; } ``` diff --git a/packages/darkauth-client/package.json b/packages/darkauth-client/package.json index fa20056..b615bc3 100644 --- a/packages/darkauth-client/package.json +++ b/packages/darkauth-client/package.json @@ -17,7 +17,7 @@ ], "scripts": { "build": "tsc", - "test": "pnpm run build && node --test --test-reporter=dot --experimental-specifier-resolution=node", + "test": "tsc -p tsconfig.test.json && node --test --test-reporter=dot \".test-build/**/*.test.js\"", "prepack": "pnpm run build", "typecheck": "tsc --noEmit", "prepare": "tsc", diff --git a/packages/darkauth-client/src/endSession.test.ts b/packages/darkauth-client/src/endSession.test.ts new file mode 100644 index 0000000..6b15dc5 --- /dev/null +++ b/packages/darkauth-client/src/endSession.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +class MemoryStorage { + private store = new Map(); + getItem(key: string): string | null { + return this.store.has(key) ? (this.store.get(key) as string) : null; + } + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } + removeItem(key: string): void { + this.store.delete(key); + } + clear(): void { + this.store.clear(); + } +} + +type DiscoveryMetadata = Record; + +const assigned: string[] = []; +let discovery: DiscoveryMetadata | null = null; +let discoveryStatusOk = true; + +const fakeLocation = { + origin: "https://app.example.com", + pathname: "/", + href: "https://app.example.com/", + hash: "", + search: "", + assign(url: string): void { + assigned.push(url); + }, +}; + +const globals = globalThis as unknown as Record; +globals.localStorage = new MemoryStorage(); +globals.sessionStorage = new MemoryStorage(); +globals.location = fakeLocation; +globals.window = { location: fakeLocation }; +globals.fetch = async (input: unknown): Promise => { + const url = String(input); + if (url.includes("/.well-known/openid-configuration")) { + return { + ok: discoveryStatusOk, + json: async () => discovery ?? {}, + }; + } + throw new Error(`unexpected fetch: ${url}`); +}; + +const { setConfig, endSession, logout } = await import("./index.js"); + +const ISSUER = "https://auth.example.com"; +const ID_TOKEN = "header.payload.signature"; + +beforeEach(() => { + assigned.length = 0; + discovery = null; + discoveryStatusOk = true; + (globals.localStorage as MemoryStorage).clear(); + (globals.sessionStorage as MemoryStorage).clear(); + logout(); +}); + +function seedStoredIdToken(): void { + setConfig({ issuer: ISSUER, clientId: "atlas", tokenStorage: "localStorage" }); + (globals.localStorage as MemoryStorage).setItem("id_token", ID_TOKEN); +} + +test("endSession redirects to discovery end_session_endpoint with all params and clears local session", async () => { + discovery = { end_session_endpoint: `${ISSUER}/api/logout` }; + seedStoredIdToken(); + + await endSession({ + postLogoutRedirectUri: "https://app.example.com/login", + state: "xyz", + }); + + assert.equal(assigned.length, 1); + const url = new URL(assigned[0] as string); + assert.equal(url.origin + url.pathname, `${ISSUER}/api/logout`); + assert.equal(url.searchParams.get("id_token_hint"), ID_TOKEN); + assert.equal(url.searchParams.get("post_logout_redirect_uri"), "https://app.example.com/login"); + assert.equal(url.searchParams.get("client_id"), "atlas"); + assert.equal(url.searchParams.get("state"), "xyz"); + assert.equal((globals.localStorage as MemoryStorage).getItem("id_token"), null); +}); + +test("endSession falls back to issuer /api/logout when discovery omits end_session_endpoint", async () => { + discovery = { authorization_endpoint: `${ISSUER}/authorize` }; + seedStoredIdToken(); + + await endSession({ postLogoutRedirectUri: "https://app.example.com/login" }); + + const url = new URL(assigned[0] as string); + assert.equal(url.origin + url.pathname, `${ISSUER}/api/logout`); +}); + +test("endSession uses configured endSessionEndpoint override without discovery", async () => { + setConfig({ + issuer: ISSUER, + clientId: "atlas", + tokenStorage: "localStorage", + endSessionEndpoint: "https://auth.example.com/custom/logout", + discovery: false, + }); + (globals.localStorage as MemoryStorage).setItem("id_token", ID_TOKEN); + + await endSession({ idTokenHint: ID_TOKEN }); + + const url = new URL(assigned[0] as string); + assert.equal(url.origin + url.pathname, "https://auth.example.com/custom/logout"); +}); + +test("endSession omits client_id and post_logout_redirect_uri when no redirect uri given", async () => { + discovery = { end_session_endpoint: `${ISSUER}/api/logout` }; + seedStoredIdToken(); + + await endSession({ idTokenHint: ID_TOKEN }); + + const url = new URL(assigned[0] as string); + assert.equal(url.searchParams.get("id_token_hint"), ID_TOKEN); + assert.equal(url.searchParams.get("post_logout_redirect_uri"), null); + assert.equal(url.searchParams.get("client_id"), null); +}); + +test("endSession uses explicit clientId override when provided", async () => { + discovery = { end_session_endpoint: `${ISSUER}/api/logout` }; + seedStoredIdToken(); + + await endSession({ + postLogoutRedirectUri: "https://app.example.com/login", + clientId: "other-client", + idTokenHint: ID_TOKEN, + }); + + const url = new URL(assigned[0] as string); + assert.equal(url.searchParams.get("client_id"), "other-client"); +}); + +test("logout clears local session without redirecting", () => { + seedStoredIdToken(); + + logout(); + + assert.equal(assigned.length, 0); + assert.equal((globals.localStorage as MemoryStorage).getItem("id_token"), null); +}); diff --git a/packages/darkauth-client/src/index.ts b/packages/darkauth-client/src/index.ts index dbf2307..857455a 100644 --- a/packages/darkauth-client/src/index.ts +++ b/packages/darkauth-client/src/index.ts @@ -12,6 +12,7 @@ type Config = { zk?: boolean; authorizationEndpoint?: string; tokenEndpoint?: string; + endSessionEndpoint?: string; discovery?: boolean; firstParty?: boolean; tokenStorage?: "memory" | "localStorage"; @@ -66,6 +67,13 @@ export type RefreshSessionOptions = { force?: boolean; }; +export type EndSessionOptions = { + postLogoutRedirectUri?: string; + state?: string; + idTokenHint?: string; + clientId?: string; +}; + export type DarkAuthSessionInfo = { authenticated: boolean; sub?: string; @@ -164,6 +172,7 @@ const V2_KEY_JWE_MAX_TTL_SECONDS = 600; type ResolvedEndpoints = { authorizationEndpoint: string; tokenEndpoint: string; + endSessionEndpoint: string; }; let memorySession: AuthSession | null = null; @@ -269,6 +278,7 @@ async function resolveEndpoints(): Promise { cfg.scope || "", cfg.authorizationEndpoint || "", cfg.tokenEndpoint || "", + cfg.endSessionEndpoint || "", cfg.discovery === false ? "0" : "1", ].join("|"); if (endpointsInFlight && endpointsCacheKey === cacheKey) return endpointsInFlight; @@ -277,8 +287,9 @@ async function resolveEndpoints(): Promise { const fallback = { authorizationEndpoint: cfg.authorizationEndpoint || rootEndpoint("/authorize"), tokenEndpoint: cfg.tokenEndpoint || rootEndpoint("/token"), + endSessionEndpoint: cfg.endSessionEndpoint || rootEndpoint("/api/logout"), }; - if (cfg.authorizationEndpoint && cfg.tokenEndpoint) return fallback; + if (cfg.authorizationEndpoint && cfg.tokenEndpoint && cfg.endSessionEndpoint) return fallback; if (cfg.discovery === false || typeof fetch !== "function") return fallback; try { const discoveryUrl = new URL("/.well-known/openid-configuration", cfg.issuer); @@ -287,6 +298,7 @@ async function resolveEndpoints(): Promise { const metadata = (await response.json()) as { authorization_endpoint?: unknown; token_endpoint?: unknown; + end_session_endpoint?: unknown; }; return { authorizationEndpoint: @@ -299,6 +311,11 @@ async function resolveEndpoints(): Promise { (typeof metadata.token_endpoint === "string" ? metadata.token_endpoint : fallback.tokenEndpoint), + endSessionEndpoint: + cfg.endSessionEndpoint || + (typeof metadata.end_session_endpoint === "string" + ? metadata.end_session_endpoint + : fallback.endSessionEndpoint), }; } catch { return fallback; @@ -866,7 +883,7 @@ export async function switchOrganization( return null; } -export function logout(): void { +function clearLocalSession(): void { memorySession = null; memoryRefreshToken = null; clearStoredIdToken(); @@ -878,6 +895,28 @@ export function logout(): void { localStorage.removeItem(REFRESH_TOKEN_KEY); } +export function logout(): void { + clearLocalSession(); +} + +export async function endSession(options: EndSessionOptions = {}): Promise { + const idTokenHint = + options.idTokenHint || + memorySession?.idToken || + (tokenStorageMode() === "localStorage" ? getStoredIdToken() : null) || + undefined; + const endpoints = await resolveEndpoints(); + const url = new URL(endpoints.endSessionEndpoint); + if (idTokenHint) url.searchParams.set("id_token_hint", idTokenHint); + if (options.postLogoutRedirectUri) { + url.searchParams.set("post_logout_redirect_uri", options.postLogoutRedirectUri); + url.searchParams.set("client_id", options.clientId || cfg.clientId); + } + if (options.state) url.searchParams.set("state", options.state); + clearLocalSession(); + location.assign(url.toString()); +} + export function getCurrentUser(): JwtClaims | null { const idToken = memorySession?.idToken || (tokenStorageMode() === "localStorage" ? getStoredIdToken() : null); diff --git a/packages/darkauth-client/tsconfig.json b/packages/darkauth-client/tsconfig.json index 823e367..a4abea8 100644 --- a/packages/darkauth-client/tsconfig.json +++ b/packages/darkauth-client/tsconfig.json @@ -22,5 +22,5 @@ "noUncheckedIndexedAccess": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/packages/darkauth-client/tsconfig.test.json b/packages/darkauth-client/tsconfig.test.json new file mode 100644 index 0000000..d2ae7ef --- /dev/null +++ b/packages/darkauth-client/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./.test-build", + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", ".test-build"] +}