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
19 changes: 19 additions & 0 deletions docs/rp-initiated-logout.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ Example:
<issuer>/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 `<issuer>/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:
Expand Down
1 change: 1 addition & 0 deletions packages/darkauth-client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.test-build
37 changes: 35 additions & 2 deletions packages/darkauth-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
});
```

Expand Down Expand Up @@ -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<void>`

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 `<issuer>/api/logout`, or to the `endSessionEndpoint` config value when set.

#### `getStoredSession(): AuthSession | null`

Expand Down Expand Up @@ -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;
}
```

Expand Down
2 changes: 1 addition & 1 deletion packages/darkauth-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
150 changes: 150 additions & 0 deletions packages/darkauth-client/src/endSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import assert from "node:assert/strict";
import { beforeEach, test } from "node:test";

class MemoryStorage {
private store = new Map<string, string>();
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<string, unknown>;

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<string, unknown>;
globals.localStorage = new MemoryStorage();
globals.sessionStorage = new MemoryStorage();
globals.location = fakeLocation;
globals.window = { location: fakeLocation };
globals.fetch = async (input: unknown): Promise<unknown> => {
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);
});
43 changes: 41 additions & 2 deletions packages/darkauth-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Config = {
zk?: boolean;
authorizationEndpoint?: string;
tokenEndpoint?: string;
endSessionEndpoint?: string;
discovery?: boolean;
firstParty?: boolean;
tokenStorage?: "memory" | "localStorage";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -269,6 +278,7 @@ async function resolveEndpoints(): Promise<ResolvedEndpoints> {
cfg.scope || "",
cfg.authorizationEndpoint || "",
cfg.tokenEndpoint || "",
cfg.endSessionEndpoint || "",
cfg.discovery === false ? "0" : "1",
].join("|");
if (endpointsInFlight && endpointsCacheKey === cacheKey) return endpointsInFlight;
Expand All @@ -277,8 +287,9 @@ async function resolveEndpoints(): Promise<ResolvedEndpoints> {
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);
Expand All @@ -287,6 +298,7 @@ async function resolveEndpoints(): Promise<ResolvedEndpoints> {
const metadata = (await response.json()) as {
authorization_endpoint?: unknown;
token_endpoint?: unknown;
end_session_endpoint?: unknown;
};
return {
authorizationEndpoint:
Expand All @@ -299,6 +311,11 @@ async function resolveEndpoints(): Promise<ResolvedEndpoints> {
(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;
Expand Down Expand Up @@ -866,7 +883,7 @@ export async function switchOrganization(
return null;
}

export function logout(): void {
function clearLocalSession(): void {
memorySession = null;
memoryRefreshToken = null;
clearStoredIdToken();
Expand All @@ -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<void> {
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);
Expand Down
2 changes: 1 addition & 1 deletion packages/darkauth-client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
10 changes: 10 additions & 0 deletions packages/darkauth-client/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./.test-build",
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", ".test-build"]
}
Loading