feat(oauth-provider)!: add OIDC back-channel logout (#9304)

This commit is contained in:
Gustavo Valverde
2026-06-06 07:06:53 -07:00
committed by GitHub
parent 7fe0e2b165
commit e0d2b9eb9b
22 changed files with 1431 additions and 139 deletions
@@ -0,0 +1,22 @@
---
"@better-auth/oauth-provider": minor
"better-auth": patch
---
Add OIDC Back-Channel Logout 1.0 support to `@better-auth/oauth-provider`.
When a user's OP session ends (sign-out, `/oauth2/end-session`, admin revoke, etc.), the provider now enumerates OAuth clients with tokens bound to the session, signs a `logout+jwt` Logout Token per client, and POSTs it to each client's registered `backchannel_logout_uri` in parallel with a short per-RP timeout. Clients can opt in by registering `backchannel_logout_uri` (and optionally `backchannel_logout_session_required`) via DCR or the admin client-create endpoint.
Delivery runs through the host's background task handler when one is configured (Vercel `waitUntil`, Cloudflare `ctx.waitUntil`); without a handler it completes inline so notifications are not lost on request teardown. Configure `advanced.backgroundTasks.handler` on serverless runtimes to keep sign-out fast.
Discovery documents at `/.well-known/openid-configuration` and `/.well-known/oauth-authorization-server` now advertise `backchannel_logout_supported: true` and `backchannel_logout_session_supported: true` when the JWT plugin is enabled.
On session end, refresh tokens without `offline_access` are revoked and refresh tokens with `offline_access` are preserved, per OIDC Back-Channel Logout 1.0 §2.7. As additional hardening (beyond §2.7, which only addresses refresh tokens), access tokens bound to the session are revoked too. Introspection and `/oauth2/userinfo` now treat an opaque or JWT access token whose session has ended as inactive: introspection returns `{ active: false }` and userinfo returns `invalid_token`. This is a breaking change vs. prior behavior, where such tokens stayed active until their own TTL.
Schema changes on `@better-auth/oauth-provider`:
- `oauthClient.backchannelLogoutUri: string | null`
- `oauthClient.backchannelLogoutSessionRequired: boolean`
- `oauthAccessToken.revoked: Date | null`
`better-auth`'s `signJWT` gains an optional `header` argument so JWT profiles that require an explicit media type (e.g. `typ: "logout+jwt"`) can be expressed without reaching for the low-level signing primitives.
@@ -661,6 +661,49 @@ await auth.api.adminCreateOAuthClient({
If `disableJwtPlugin: true`, public clients will never be able to logout using this endpoint since no `id_token` is sent.
</Callout>
### Back-Channel Logout
[Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) is the server-to-server counterpart to RP-Initiated Logout: when a user's session ends at the OP (sign-out, `/oauth2/end-session`, admin revoke, etc.), the OP POSTs a signed Logout Token to each registered Relying Party so they can terminate their own session state and revoke bound API access.
To opt a client in, register a `backchannel_logout_uri`:
```ts title="admin-create-oauth.ts"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
enable_end_session: true,
backchannel_logout_uri: "https://rp.example.com/logout/backchannel", // [!code highlight]
backchannel_logout_session_required: true, // [!code highlight]
}
});
```
When `backchannel_logout_session_required` is `true`, the RP requires a `sid` claim in every Logout Token. Every Logout Token the OP sends already includes `sid`, so such clients are always served.
The OP enumerates clients with active tokens bound to the ending session and POSTs one `logout_token` to each in parallel (5s per-RP timeout, no retry per spec §2.5). It then revokes the session's tokens:
* **Refresh tokens** without `offline_access` are revoked; those with `offline_access` are preserved so long-lived API access can survive the browser session (spec §2.7).
* **Access tokens** bound to the session are revoked as additional hardening; §2.7 itself only addresses refresh tokens. Introspection and `/oauth2/userinfo` also treat any token whose session has ended as inactive, so this no longer depends on the stored flag alone.
The Logout Token carries the §2.4 claims (`iss`, `aud`, `iat`, `exp`, `jti`, `events`, plus `sub` and `sid`) with `typ: logout+jwt` in the protected header and no `nonce`. Its lifetime is capped at 120 seconds, following the §4 security guidance to keep replay windows short. It is signed with the same key as ID Tokens, so any RP that validates ID Tokens through your JWKS can validate Logout Tokens without extra configuration.
<Callout type="warn">
Back-channel logout requires the `jwt` plugin. Registering a `backchannel_logout_uri` while `disableJwtPlugin: true` is rejected with `invalid_client_metadata`.
</Callout>
<Callout type="info">
Delivery runs through `advanced.backgroundTasks.handler` when one is configured (Vercel `waitUntil`, Cloudflare `ctx.waitUntil`), so a slow RP cannot delay sign-out. Without a handler it completes inline before the sign-out response returns: reliable on persistent servers, but it can add latency when an RP is slow. Configure a handler on serverless runtimes.
</Callout>
<Callout type="warn">
With `secondaryStorage` and `session.preserveSessionInDatabase: true`, session deletion keeps the database row and skips the session-delete hook, so tokens are not revoked and Logout Tokens are not dispatched on session end. Avoid that combination if you rely on back-channel logout.
</Callout>
<Callout type="info">
The OP advertises `backchannel_logout_supported: true` and `backchannel_logout_session_supported: true` on both `.well-known/openid-configuration` and `.well-known/oauth-authorization-server`. RPs use these fields during dynamic client registration to decide whether to register a `backchannel_logout_uri`.
</Callout>
### UserInfo Endpoint
The UserInfo Endpoint provides [OIDC](https://openid.net/specs/openid-connect-core-1_0.html)-compliant user information. Available at `/oauth2/userinfo`, the endpoint requires a valid access token with at least the scope `openid`.
@@ -1858,6 +1901,20 @@ export const oauthClientTableFields = [
description: "Array of post-logout redirect URIs",
isOptional: true,
},
{
name: "backchannelLogoutUri",
type: "string",
description:
"RP URL that receives signed Logout Tokens when the user's OP session ends (OIDC Back-Channel Logout 1.0)",
isOptional: true,
},
{
name: "backchannelLogoutSessionRequired",
type: "boolean",
description:
"When true, the RP requires a `sid` claim in every Logout Token and user-scoped logouts are skipped",
isOptional: true,
},
{
name: "tokenEndpointAuthMethod",
type: "string",
@@ -2054,6 +2111,13 @@ export const oauthAccessTokenTableFields = [
type: "Date",
description: "Timestamp when the token will expire",
},
{
name: "revoked",
type: "Date",
description:
"When the token was revoked. Populated on session end and by back-channel logout; introspection and token use reject revoked tokens.",
isOptional: true,
},
];
<DatabaseTable name="oauthAccessToken" fields={oauthAccessTokenTableFields} />
+17 -1
View File
@@ -107,6 +107,17 @@ export async function signJWT(
payload: JWTPayloadWithOptional;
/** Pre-resolved key from resolveSigningKey. Skips redundant DB lookup. */
resolvedKey?: ResolvedSigningKey;
/**
* Extra JWS Protected Header parameters to merge with the defaults
* (`alg` and `kid`). Used by token profiles that require an explicit
* media type, such as OIDC Back-Channel Logout's `typ: "logout+jwt"`.
*
* @see https://www.rfc-editor.org/rfc/rfc8725#section-3.11
*/
header?: {
typ?: string;
cty?: string;
};
},
) {
const { options } = config;
@@ -151,7 +162,10 @@ export async function signJWT(
iss: iss ?? defaultIss,
aud: aud ?? defaultAud,
};
return options.jwt.sign(jwtPayload);
// Forward extra protected-header parameters (e.g. `typ: "logout+jwt"`)
// so profiles that require an explicit media type stay conformant even
// with a remote signer. The signer still owns `alg`/`kid`.
return options.jwt.sign(jwtPayload, config.header);
}
// Use pre-resolved key if available, otherwise resolve from DB
@@ -160,6 +174,8 @@ export async function signJWT(
const jwt = new SignJWT(payload)
.setProtectedHeader({
// Spread caller header first so the resolved `alg`/`kid` always win.
...config.header,
alg,
kid,
})
+11 -1
View File
@@ -110,10 +110,20 @@ export interface JwtOptions {
* MUST be defined within this function.
* You can safely define the header `typ: 'JWT'`.
*
* The optional `header` argument carries extra protected-header
* parameters the caller requires (e.g. `typ: "logout+jwt"` for
* OIDC Back-Channel Logout). Merge them into the signed header so
* such profiles stay conformant; `alg`/`kid` remain yours to set.
*
* @requires jwks.remoteUrl
* @invalidates other jwt.* options
*/
sign?: ((payload: JWTPayload) => Awaitable<string>) | undefined;
sign?:
| ((
payload: JWTPayload,
header?: { typ?: string; cty?: string },
) => Awaitable<string>)
| undefined;
}
| undefined;
@@ -0,0 +1,479 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import {
authorizationCodeRequest,
createAuthorizationURL,
} from "@better-auth/core/oauth2";
import { createAuthClient } from "better-auth/client";
import { generateRandomString } from "better-auth/crypto";
import { toNodeHandler } from "better-auth/node";
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
import { createLocalJWKSet, jwtVerify } from "jose";
import type { Listener } from "listhen";
import { listen } from "listhen";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from "vitest";
import { oauthProviderClient } from "./client";
import { oauthProvider } from "./oauth";
interface ReceivedLogoutRequest {
contentType: string | undefined;
logoutToken: string | undefined;
raw: string;
}
/**
* Minimal mock Relying Party that records back-channel logout requests and
* returns a configurable status, with optional artificial latency.
*/
async function startMockRp(
options: { status?: number; delayMs?: number } = {},
) {
const received: ReceivedLogoutRequest[] = [];
const server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", async () => {
const params = new URLSearchParams(body);
received.push({
contentType: req.headers["content-type"],
logoutToken: params.get("logout_token") ?? undefined,
raw: body,
});
if (options.delayMs) {
await new Promise((r) => setTimeout(r, options.delayMs));
}
res.statusCode = options.status ?? 200;
res.end();
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const url = `http://127.0.0.1:${address.port}`;
return {
received,
url,
async close() {
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
},
};
}
type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
describe("oauth back-channel logout", async () => {
const port = 3010;
const baseUrl = `http://localhost:${port}`;
const state = "123";
const scopes = ["openid", "email", "profile", "offline_access"];
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: baseUrl,
plugins: [
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
allowDynamicClientRegistration: true,
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
scopes,
}),
jwt(),
],
});
let { headers } = await signInWithTestUser();
const client = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: baseUrl,
fetchOptions: { customFetchImpl },
});
let server: Listener;
let rp: Awaited<ReturnType<typeof startMockRp>>;
beforeAll(async () => {
server = await listen(toNodeHandler(auth.handler), { port });
});
afterAll(async () => {
if (server) await server.close();
});
beforeEach(async () => {
rp = await startMockRp();
const signed = await signInWithTestUser();
headers = signed.headers;
});
afterEach(async () => {
await rp.close();
});
async function registerClient(
overrides: Partial<{
enable_end_session: boolean;
backchannel_logout_uri: string | undefined;
backchannel_logout_session_required: boolean;
}> = {},
) {
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [`${rp.url}/callback`],
skip_consent: true,
enable_end_session: true,
backchannel_logout_uri: `${rp.url}/logout/backchannel`,
...overrides,
},
});
if (!response?.client_id || !response?.client_secret) {
throw new Error("client registration failed");
}
return response;
}
async function issueTokens(params: {
client: Awaited<ReturnType<typeof registerClient>>;
requestScopes?: string[];
}) {
const { client: oauthClient, requestScopes = scopes } = params;
const redirectUri = `${rp.url}/callback`;
const codeVerifier = generateRandomString(32);
const { url: authUrl } = await createAuthorizationURL({
id: "test",
options: {
clientId: oauthClient.client_id,
clientSecret: oauthClient.client_secret!,
redirectURI: redirectUri,
},
redirectURI: "",
authorizationEndpoint: `${baseUrl}/api/auth/oauth2/authorize`,
state,
scopes: requestScopes,
codeVerifier,
});
let callbackRedirectUrl = "";
await client.$fetch(authUrl.toString(), {
headers,
onError(context) {
callbackRedirectUrl = context.response.headers.get("Location") || "";
},
});
const code = new URL(callbackRedirectUrl).searchParams.get("code");
if (!code) {
throw new Error(`no authorization code in ${callbackRedirectUrl}`);
}
const { body, headers: tokenHeaders } = await authorizationCodeRequest({
code,
codeVerifier,
redirectURI: redirectUri,
options: {
clientId: oauthClient.client_id,
clientSecret: oauthClient.client_secret!,
redirectURI: redirectUri,
},
} satisfies MakeRequired<
Parameters<typeof authorizationCodeRequest>[0],
"code"
>);
const tokens = await client.$fetch<{
access_token: string;
id_token: string;
refresh_token?: string;
}>("/oauth2/token", { method: "POST", body, headers: tokenHeaders });
return tokens.data!;
}
async function waitForDispatches(min = 1, timeoutMs = 2_000) {
const start = Date.now();
while (rp.received.length < min) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`Timed out waiting for ${min} logout dispatches; received ${rp.received.length}`,
);
}
await new Promise((r) => setTimeout(r, 25));
}
}
it("dispatches a conformant logout token when the session is signed out", async () => {
const oauthClient = await registerClient();
const tokens = await issueTokens({ client: oauthClient });
// signOut on the OP triggers session.delete.before → dispatch
await client.signOut({ fetchOptions: { headers } });
await waitForDispatches();
expect(rp.received).toHaveLength(1);
const received = rp.received[0]!;
expect(received.contentType).toContain("application/x-www-form-urlencoded");
expect(received.logoutToken).toBeDefined();
const { keys } = await auth.api.getJwks();
const { payload, protectedHeader } = await jwtVerify(
received.logoutToken!,
createLocalJWKSet({ keys: keys as any }),
);
expect(protectedHeader.typ).toBe("logout+jwt");
expect(protectedHeader.alg).not.toBe("none");
expect(payload.iss).toBe(`${baseUrl}/api/auth`);
expect(payload.aud).toBe(oauthClient.client_id);
expect(payload.iat).toEqual(expect.any(Number));
expect(payload.exp).toEqual(expect.any(Number));
expect(payload.exp! - payload.iat!).toBeLessThanOrEqual(120);
expect(payload.jti).toEqual(expect.any(String));
expect(payload.sub).toEqual(expect.any(String));
expect(payload.sid).toEqual(expect.any(String));
expect(payload.nonce).toBeUndefined();
expect(payload.events).toEqual({
"http://schemas.openid.net/event/backchannel-logout": {},
});
// ID token sid must equal Logout Token sid (same session identifier)
const [, idTokenPayloadB64] = tokens.id_token.split(".");
const idTokenPayload = JSON.parse(
Buffer.from(idTokenPayloadB64!, "base64url").toString("utf8"),
);
expect(payload.sid).toBe(idTokenPayload.sid);
});
it("dispatches when RP-Initiated Logout tears down the session", async () => {
const oauthClient = await registerClient();
const tokens = await issueTokens({ client: oauthClient });
await client.oauth2.endSession({
query: { id_token_hint: tokens.id_token },
});
await waitForDispatches();
expect(rp.received).toHaveLength(1);
});
it("marks non-offline_access access + refresh tokens revoked, preserves offline_access refresh tokens", async () => {
const oauthClient = await registerClient();
await issueTokens({ client: oauthClient });
const ctx = await auth.$context;
const accessBefore = await ctx.adapter.findMany<{ revoked?: Date | null }>({
model: "oauthAccessToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
const refreshBefore = await ctx.adapter.findMany<{
revoked?: Date | null;
scopes: string[];
}>({
model: "oauthRefreshToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
expect(accessBefore.length).toBeGreaterThan(0);
expect(refreshBefore.length).toBeGreaterThan(0);
for (const t of accessBefore) expect(t.revoked ?? null).toBeNull();
for (const t of refreshBefore) expect(t.revoked ?? null).toBeNull();
await client.signOut({ fetchOptions: { headers } });
await waitForDispatches();
const accessAfter = await ctx.adapter.findMany<{ revoked?: Date | null }>({
model: "oauthAccessToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
const refreshAfter = await ctx.adapter.findMany<{
revoked?: Date | null;
scopes: string[];
}>({
model: "oauthRefreshToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
for (const t of accessAfter) expect(t.revoked).toBeInstanceOf(Date);
// Offline access refresh tokens must survive (spec §2.7)
for (const t of refreshAfter) {
if (t.scopes.includes("offline_access")) {
expect(t.revoked ?? null).toBeNull();
} else {
expect(t.revoked).toBeInstanceOf(Date);
}
}
});
it("revokes access tokens on session end even when no refresh token was issued", async () => {
// Without `offline_access` in scope, `handleAuthorizationCodeGrant` never
// mints a refresh token. Access tokens must still be revoked when the
// session ends (spec §2.7).
const oauthClient = await registerClient();
await issueTokens({
client: oauthClient,
requestScopes: ["openid", "email", "profile"],
});
const ctx = await auth.$context;
const refreshBefore = await ctx.adapter.findMany<{
revoked?: Date | null;
scopes: string[];
}>({
model: "oauthRefreshToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
expect(refreshBefore).toHaveLength(0);
const accessBefore = await ctx.adapter.findMany<{ revoked?: Date | null }>({
model: "oauthAccessToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
expect(accessBefore.length).toBeGreaterThan(0);
for (const t of accessBefore) expect(t.revoked ?? null).toBeNull();
await client.signOut({ fetchOptions: { headers } });
await waitForDispatches();
const accessAfter = await ctx.adapter.findMany<{ revoked?: Date | null }>({
model: "oauthAccessToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
for (const t of accessAfter) expect(t.revoked).toBeInstanceOf(Date);
});
it("does not dispatch to clients without a backchannel_logout_uri", async () => {
const oauthClient = await registerClient({
backchannel_logout_uri: undefined,
});
await issueTokens({ client: oauthClient });
await client.signOut({ fetchOptions: { headers } });
await new Promise((r) => setTimeout(r, 200));
expect(rp.received).toHaveLength(0);
});
it("treats RP failures as non-fatal for the user-facing sign-out", async () => {
await rp.close();
rp = await startMockRp({ status: 500 });
const oauthClient = await registerClient({
backchannel_logout_uri: `${rp.url}/logout/backchannel`,
});
await issueTokens({ client: oauthClient });
const result = await client.signOut({ fetchOptions: { headers } });
expect(result.error).toBeNull();
await waitForDispatches();
expect(rp.received).toHaveLength(1);
});
});
describe("oauth back-channel logout (jwt plugin disabled)", async () => {
const baseUrl = "http://localhost:3021";
const redirectUri = "http://localhost:5556/callback";
const state = "123";
// No jwt plugin: Logout Tokens cannot be signed, so delivery never runs, but
// the spec §2.7 token revocation on session end must still happen.
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: baseUrl,
plugins: [
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
disableJwtPlugin: true,
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
const client = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: baseUrl,
fetchOptions: { customFetchImpl },
});
it("revokes session-bound access tokens on sign-out even when the jwt plugin is disabled", async () => {
const oauthClient = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
skip_consent: true,
enable_end_session: true,
},
});
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
throw new Error("client registration failed");
}
const codeVerifier = generateRandomString(32);
const { url: authUrl } = await createAuthorizationURL({
id: "test",
options: {
clientId: oauthClient.client_id,
clientSecret: oauthClient.client_secret,
redirectURI: redirectUri,
},
redirectURI: "",
authorizationEndpoint: `${baseUrl}/api/auth/oauth2/authorize`,
state,
scopes: ["openid", "profile"],
codeVerifier,
});
let callbackRedirectUrl = "";
await client.$fetch(authUrl.toString(), {
headers,
onError(context) {
callbackRedirectUrl = context.response.headers.get("Location") || "";
},
});
const code = new URL(callbackRedirectUrl).searchParams.get("code");
if (!code) {
throw new Error(`no authorization code in ${callbackRedirectUrl}`);
}
const { body, headers: tokenHeaders } = await authorizationCodeRequest({
code,
codeVerifier,
redirectURI: redirectUri,
options: {
clientId: oauthClient.client_id,
clientSecret: oauthClient.client_secret,
redirectURI: redirectUri,
},
} satisfies MakeRequired<
Parameters<typeof authorizationCodeRequest>[0],
"code"
>);
const tokens = await client.$fetch<{ access_token: string }>(
"/oauth2/token",
{ method: "POST", body, headers: tokenHeaders },
);
expect(tokens.data?.access_token).toBeDefined();
const ctx = await auth.$context;
const before = await ctx.adapter.findMany<{ revoked?: Date | null }>({
model: "oauthAccessToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
expect(before.length).toBeGreaterThan(0);
for (const t of before) expect(t.revoked ?? null).toBeNull();
// Revocation runs inline in `session.delete.before`; with no jwt plugin
// there is no async delivery to wait for.
await client.signOut({ fetchOptions: { headers } });
const after = await ctx.adapter.findMany<{ revoked?: Date | null }>({
model: "oauthAccessToken",
where: [{ field: "clientId", value: oauthClient.client_id }],
});
for (const t of after) expect(t.revoked).toBeInstanceOf(Date);
});
});
+82 -22
View File
@@ -380,7 +380,10 @@ describe("oauth introspect", async () => {
});
});
it("should pass opaque access_token introspection with logged out user", async () => {
it("reports opaque access_token as inactive after the user signs out", async () => {
// Per OIDC Back-Channel Logout §2.7: access tokens bound to a session
// are revoked when the session ends, even if they have not yet
// reached their TTL.
const { headers: testHeaders } = await signInWithTestUser();
const tokens = await getTokens(undefined, undefined, testHeaders);
const signOut = await auth.api.signOut({
@@ -402,19 +405,85 @@ describe("oauth introspect", async () => {
},
},
);
expect(introspection.data).toMatchObject({
active: true,
client_id: oauthClient?.client_id,
scope: "openid profile email offline_access",
sub: expect.any(String),
iss: authServerBaseUrl,
exp: expect.any(Number),
iat: expect.any(Number),
});
expect(introspection.data?.sid).toBeUndefined();
expect(introspection.data).toEqual({ active: false });
});
it("should pass jwt access_token introspection with logged out user", async () => {
it("reports opaque access_token as inactive when its bound session has expired, without a revoked flag", async () => {
// Parity with the JWT path: revocation is a function of session state,
// not only the stored `revoked` flag the session-delete hook writes. Here
// the session is expired in place (no delete, no hook), so the token is
// rejected purely on session liveness.
const { headers: testHeaders } = await signInWithTestUser();
const session = await auth.api.getSession({ headers: testHeaders });
const sessionId = session!.session.id;
const tokens = await getTokens(undefined, undefined, testHeaders);
const ctx = await auth.$context;
await ctx.adapter.update({
model: "session",
where: [{ field: "id", value: sessionId }],
update: { expiresAt: new Date(Date.now() - 60_000) },
});
const rows = await ctx.adapter.findMany<{ revoked?: Date | null }>({
model: "oauthAccessToken",
where: [{ field: "sessionId", value: sessionId }],
});
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) expect(r.revoked ?? null).toBeNull();
const introspection = await client.oauth2.introspect(
{
client_id: oauthClient?.client_id,
client_secret: oauthClient?.client_secret,
token: tokens.data?.access_token!,
token_type_hint: "access_token",
},
{
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
},
);
expect(introspection.data).toEqual({ active: false });
});
it("reports opaque access_token as inactive when the revoked flag is set while the session is live", async () => {
// Isolates the stored-flag path: the session is untouched, only `revoked`
// is set, and introspection must still report the token inactive.
const { headers: testHeaders } = await signInWithTestUser();
const session = await auth.api.getSession({ headers: testHeaders });
const sessionId = session!.session.id;
const tokens = await getTokens(undefined, undefined, testHeaders);
const ctx = await auth.$context;
await ctx.adapter.updateMany({
model: "oauthAccessToken",
where: [{ field: "sessionId", value: sessionId }],
update: { revoked: new Date() },
});
const introspection = await client.oauth2.introspect(
{
client_id: oauthClient?.client_id,
client_secret: oauthClient?.client_secret,
token: tokens.data?.access_token!,
token_type_hint: "access_token",
},
{
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
},
);
expect(introspection.data).toEqual({ active: false });
});
it("reports jwt access_token as inactive after the user signs out", async () => {
// JWT access tokens that carry `sid` are bound to the OP session and
// become inactive when the session ends, per OIDC Back-Channel Logout
// §2.7, regardless of their own TTL.
const { headers: testHeaders } = await signInWithTestUser();
const tokens = await getTokens(
undefined,
@@ -442,16 +511,7 @@ describe("oauth introspect", async () => {
},
},
);
expect(introspection.data).toMatchObject({
active: true,
client_id: oauthClient?.client_id,
scope: "openid profile email offline_access",
sub: expect.any(String),
iss: authServerBaseUrl,
exp: expect.any(Number),
iat: expect.any(Number),
});
expect(introspection.data?.sid).toBeUndefined();
expect(introspection.data).toEqual({ active: false });
});
it("should pass refresh_token introspection with logged out user", async () => {
+17 -10
View File
@@ -104,20 +104,18 @@ async function validateJwtAccessToken(
}
}
// Validate JWT against its session if it exists
// A JWT access token carrying `sid` is bound to that OP session; once the
// session has ended (sign-out, admin revoke, back-channel logout...) the
// token is revoked per OIDC Back-Channel Logout §2.7 even though the JWT
// itself is still within its TTL.
const sessionId = jwtPayload.sid;
if (sessionId) {
const session = await ctx.context.adapter.findOne<Session>({
model: "session",
where: [
{
field: "id",
value: sessionId,
},
],
where: [{ field: "id", value: sessionId }],
});
if (!session || session.expiresAt < new Date()) {
jwtPayload.sid = undefined;
return { active: false };
}
}
@@ -179,6 +177,11 @@ async function validateOpaqueAccessToken(
active: false,
};
}
if (accessToken.revoked) {
return {
active: false,
};
}
let client: SchemaClient<Scope[]> | null | undefined;
if (accessToken.clientId) {
@@ -195,7 +198,11 @@ async function validateOpaqueAccessToken(
}
}
let sessionId = accessToken.sessionId ?? undefined;
// An opaque access token bound to a session (every authorization-code token;
// client-credentials tokens have no sessionId) dies with that session. This
// mirrors the JWT path so revocation is a function of session state and does
// not depend solely on the `revoked` flag written by the session-delete hook.
const sessionId = accessToken.sessionId ?? undefined;
if (sessionId) {
const session = await ctx.context.adapter.findOne<Session>({
model: "session",
@@ -207,7 +214,7 @@ async function validateOpaqueAccessToken(
],
});
if (!session || session.expiresAt < new Date()) {
sessionId = undefined;
return { active: false };
}
}
+313 -34
View File
@@ -1,16 +1,305 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { generateRandomString } from "better-auth/crypto";
import { getJwks } from "better-auth/oauth2";
import { resolveSigningKey, signJWT } from "better-auth/plugins";
import type { Session } from "better-auth/types";
import { APIError } from "better-call";
import type { JWTPayload } from "jose";
import { compactVerify, createLocalJWKSet, decodeJwt } from "jose";
import { handleRedirect } from "./authorize";
import type { OAuthOptions, Scope } from "./types";
import { decryptStoredClientSecret, getClient, getJwtPlugin } from "./utils";
import type { OAuthOptions, SchemaClient, Scope } from "./types";
import {
decryptStoredClientSecret,
getClient,
getJwtPlugin,
resolveSubjectIdentifier,
} from "./utils";
const BACKCHANNEL_LOGOUT_EVENT_URI =
"http://schemas.openid.net/event/backchannel-logout";
const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
// Spec §4 recommends at most two minutes into the future to limit replay.
const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
// Short per-RP ceiling so a slow RP cannot extend a user-facing logout.
// Spec §2.5: "the OP SHOULD NOT retransmit", so a single attempt within the
// window is enough.
const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5_000;
interface TokenRow {
id: string;
clientId: string;
scopes: string[];
revoked?: Date | null;
}
/**
* IMPORTANT NOTES:
* Follows OIDC RP-Initiated Logout
* A client with a registered `backchannel_logout_uri` whose session is being
* terminated. Carries everything the async delivery phase needs so the caller
* can fire it into the background without a second DB read.
*/
interface BackchannelLogoutTarget {
client: SchemaClient<Scope[]>;
sub: string;
}
/**
* Plan produced by the synchronous revocation phase. The delivery phase
* consumes this plan and POSTs one Logout Token per target. `sessionId` is
* always present because every session-end path that reaches here carries the
* id of the session being terminated.
*/
interface BackchannelLogoutPlan {
sessionId: string;
targets: BackchannelLogoutTarget[];
}
/**
* Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
*
* The token reuses the ID Token signing key so any RP that validates ID Tokens
* from this OP can validate Logout Tokens without extra configuration. The
* caller resolves that key once and passes it in so a fan-out to many RPs does
* not re-read it per target.
*
* §2.4 mandates `iss`, `aud`, `iat`, `exp`, `jti`, `events`, and at least one
* of `sub` / `sid` (we send both). A `nonce` claim MUST NOT be present, and
* `alg: none` is forbidden (§2.6).
*/
async function signLogoutToken(
ctx: GenericEndpointContext,
options: Parameters<typeof signJWT>[1]["options"],
resolvedKey: Awaited<ReturnType<typeof resolveSigningKey>>,
claims: {
iss: string;
aud: string;
sub: string;
sid: string;
iat: number;
exp: number;
jti: string;
},
): Promise<string> {
return signJWT(ctx, {
options,
payload: {
...claims,
events: {
[BACKCHANNEL_LOGOUT_EVENT_URI]: {},
},
},
header: { typ: LOGOUT_TOKEN_JWT_TYP },
resolvedKey: resolvedKey ?? undefined,
});
}
/**
* Synchronous phase: enumerate tokens for the session being terminated, revoke
* them, and return a plan for the asynchronous delivery phase. Runs inline in
* the `session.delete.before` hook so the DB state is consistent before the
* session row disappears.
*
* Revocation is the stored backstop, not the primary enforcement: introspection
* and `/userinfo` already treat a token whose session has ended as inactive
* (see `validateOpaqueAccessToken` / `validateJwtAccessToken`), so a missed
* `revoked` write cannot keep a session-bound token alive on its own. Access
* tokens bound to the session are revoked as OP hardening. Refresh tokens
* follow OIDC Back-Channel Logout 1.0 §2.7: those without `offline_access` are
* revoked; `offline_access` refresh tokens survive so long-lived API access can
* outlive the browser session.
*
* Revocation runs regardless of the JWT plugin (refresh-token revocation has no
* dependency on signing). Only the Logout Token delivery plan needs the JWT
* plugin, so when it is disabled we still revoke but never build a plan.
*
* Returns `null` when there is nothing to do, so the caller can skip the
* background handoff entirely.
*/
async function revokeAndPlanBackchannelLogout(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
input: { sessionId: string; userId: string },
): Promise<BackchannelLogoutPlan | null> {
const { sessionId, userId } = input;
if (!userId) return null;
const logger = ctx.context.logger;
try {
const where = [{ field: "sessionId", value: sessionId }];
const [accessTokens, refreshTokens] = await Promise.all([
ctx.context.adapter.findMany<TokenRow>({
model: "oauthAccessToken",
where,
}),
ctx.context.adapter.findMany<TokenRow>({
model: "oauthRefreshToken",
where,
}),
]);
const affectedClientIds = new Set<string>();
for (const t of accessTokens) affectedClientIds.add(t.clientId);
for (const t of refreshTokens) affectedClientIds.add(t.clientId);
if (affectedClientIds.size === 0) return null;
const clients = await ctx.context.adapter.findMany<SchemaClient<Scope[]>>({
model: "oauthClient",
where: [
{
field: "clientId",
operator: "in",
value: Array.from(affectedClientIds),
},
],
});
// Access tokens are always revoked (OP hardening). Refresh tokens follow
// §2.7: revoke unless `offline_access` was granted. The non-offline_access
// branch is reachable via refresh-token scope narrowing, so it must stay.
const revokedAt = new Date();
const accessToRevokeIds = accessTokens
.filter((t) => !t.revoked)
.map((t) => t.id);
const refreshToRevokeIds = refreshTokens
.filter((t) => !t.revoked && !t.scopes?.includes("offline_access"))
.map((t) => t.id);
const revocations = await Promise.allSettled([
accessToRevokeIds.length > 0
? ctx.context.adapter.updateMany({
model: "oauthAccessToken",
where: [{ field: "id", operator: "in", value: accessToRevokeIds }],
update: { revoked: revokedAt },
})
: Promise.resolve(),
refreshToRevokeIds.length > 0
? ctx.context.adapter.updateMany({
model: "oauthRefreshToken",
where: [{ field: "id", operator: "in", value: refreshToRevokeIds }],
update: { revoked: revokedAt },
})
: Promise.resolve(),
]);
// Surface a failed revocation write at error level. Dispatch still
// proceeds (RP notification is independent and the session-liveness
// checks in introspection remain authoritative), but operators need a
// signal that the stored `revoked` backstop drifted from session state.
for (const result of revocations) {
if (result.status === "rejected") {
logger.error(
"back-channel logout: token revocation update failed",
result.reason,
);
}
}
// Logout Tokens are signed through the JWT plugin's JWKS, so skip the
// delivery plan when it is disabled. Registration already rejects
// `backchannel_logout_uri` in that mode; this also guards stale clients.
const eligibleClients = opts.disableJwtPlugin
? []
: clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
if (eligibleClients.length === 0) return null;
const targets: BackchannelLogoutTarget[] = await Promise.all(
eligibleClients.map(async (client) => ({
client,
sub: await resolveSubjectIdentifier(userId, client, opts),
})),
);
return { sessionId, targets };
} catch (error) {
logger.error("back-channel logout revocation failed", error);
return null;
}
}
/**
* Asynchronous phase: sign one Logout Token per target client and POST it to
* the registered `backchannel_logout_uri`. The caller hands this to
* `runInBackgroundOrAwait`, so when a background handler is configured (Vercel
* `waitUntil`, Cloudflare `ctx.waitUntil`) it runs after the response; without
* one it completes inline so delivery is not lost on request teardown.
*
* Spec §2.5: "the OP SHOULD NOT retransmit", so each RP gets a single attempt
* within `BACKCHANNEL_DISPATCH_TIMEOUT_MS`. Every per-client failure (fetch
* error, non-2xx response, signing error, subject resolution error) is
* logged; none of them can reject the outer promise.
*/
async function deliverBackchannelLogoutTokens(
ctx: GenericEndpointContext,
plan: BackchannelLogoutPlan,
): Promise<void> {
const logger = ctx.context.logger;
const jwtPluginOptions = getJwtPlugin(ctx.context)?.options;
const iss = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL;
const iat = Math.floor(Date.now() / 1000);
const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
// Resolve the signing key once and reuse it for every RP target. A custom
// remote signer (`jwt.sign`) owns its own key material, so skip resolution.
const resolvedKey = jwtPluginOptions?.jwt?.sign
? null
: await resolveSigningKey(ctx, jwtPluginOptions);
await Promise.allSettled(
plan.targets.map(async ({ client, sub }) => {
try {
const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
const token = await signLogoutToken(
ctx,
jwtPluginOptions,
resolvedKey,
{
iss,
aud: client.clientId,
sub,
sid: plan.sessionId,
iat,
exp,
jti,
},
);
const response = await fetch(client.backchannelLogoutUri!, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({ logout_token: token }),
signal: AbortSignal.timeout(BACKCHANNEL_DISPATCH_TIMEOUT_MS),
redirect: "error",
});
// Spec §2.8: RP MUST return 200; many frameworks normalize empty 200
// bodies to 204, which is commonly accepted.
if (response.status !== 200 && response.status !== 204) {
logger.warn(
`back-channel logout to client ${client.clientId} returned ${response.status}`,
);
}
} catch (error) {
logger.warn(
`back-channel logout to client ${client.clientId} failed`,
error,
);
}
}),
);
}
export { revokeAndPlanBackchannelLogout, deliverBackchannelLogoutTokens };
/**
* RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
* `id_token_hint`; after verification, the OP terminates the matching session
* and optionally redirects to `post_logout_redirect_uri`.
*
* Session termination goes through `internalAdapter.deleteSession`, which fires
* `session.delete.before` so the hook drives revocation and back-channel
* notifications to every RP with tokens on the session.
*
* @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
*/
@@ -24,7 +313,7 @@ export async function rpInitiatedLogoutEndpoint(
post_logout_redirect_uri,
state,
}: {
// Spec says `id_token_hint` recommended but we make it required for DOS
// id_token_hint is RECOMMENDED by spec; required here to prevent DoS
id_token_hint: string;
client_id?: string;
post_logout_redirect_uri?: string;
@@ -60,7 +349,6 @@ export async function rpInitiatedLogoutEndpoint(
}
}
// Only specified trusted clients can logout via the rpInitiated logout
const client = await getClient(ctx, opts, clientId);
if (!client) {
throw new APIError("BAD_REQUEST", {
@@ -81,10 +369,8 @@ export async function rpInitiatedLogoutEndpoint(
});
}
// Obtain idTokenPayload
let idTokenPayload: JWTPayload | undefined;
if (opts.disableJwtPlugin) {
// Get the client's secret to verify the token
const clientSecret = client.clientSecret;
if (!clientSecret) {
throw new APIError("UNAUTHORIZED", {
@@ -93,7 +379,6 @@ export async function rpInitiatedLogoutEndpoint(
});
}
// Convert the client secret to a key
const secret = await decryptStoredClientSecret(
ctx,
opts.storeClientSecret,
@@ -101,22 +386,15 @@ export async function rpInitiatedLogoutEndpoint(
);
const key = new TextEncoder().encode(secret);
// compactVerify only verifies the token, not its claims (perform manually)
const { payload } = await compactVerify(id_token_hint, key);
const idToken = new TextDecoder().decode(payload);
idTokenPayload = JSON.parse(idToken);
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
} else {
const jwks = await getJwks(id_token_hint, {
jwksFetch: jwksUrl,
});
// compactVerify only verifies the token, not its claims (perform manually)
const jwks = await getJwks(id_token_hint, { jwksFetch: jwksUrl });
const { payload } = await compactVerify(
id_token_hint,
createLocalJWKSet(jwks),
);
const idToken = new TextDecoder().decode(payload);
idTokenPayload = JSON.parse(idToken);
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
}
if (!idTokenPayload) {
@@ -150,36 +428,37 @@ export async function rpInitiatedLogoutEndpoint(
error: "invalid_request",
});
}
const sessionId = idTokenPayload.sid as string | undefined;
// Logout using the sid attached to the idToken
const sessionId = idTokenPayload.sid as string | undefined;
if (!sessionId) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error_description: "id token missing session",
error: "invalid_request",
});
}
try {
const session = await ctx.context.adapter.findOne<Session>({
model: "session",
where: [{ field: "id", value: sessionId }],
});
session?.token
? await ctx.context.internalAdapter.deleteSession(session?.token)
: session?.id
? await ctx.context.adapter.delete<Session>({
model: "session",
where: [{ field: "id", value: session.id }],
})
: await ctx.context.adapter.delete<Session>({
model: "session",
where: [{ field: "id", value: sessionId }],
});
if (session?.token) {
// internalAdapter.deleteSession fires `session.delete.before`, which
// runs revocation and back-channel dispatch for every RP on this
// session.
await ctx.context.internalAdapter.deleteSession(session.token);
} else if (session) {
// A persisted session always carries a token; this only guards a
// corrupted row by removing it directly (best effort).
await ctx.context.adapter.delete<Session>({
model: "session",
where: [{ field: "id", value: session.id }],
});
}
} catch {
// continue - session already deleted
// Session already gone; nothing further to do.
}
// Redirect to post_logout_redirect_uri if provided and exact match (no need to fail)
if (post_logout_redirect_uri) {
const registeredUris = client.postLogoutRedirectUris;
if (registeredUris?.includes(post_logout_redirect_uri)) {
@@ -113,6 +113,8 @@ describe("oauth metadata", async () => {
],
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true,
backchannel_logout_supported: true,
backchannel_logout_session_supported: true,
claims_supported: baseClaims,
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
subject_types_supported: ["public"],
@@ -286,9 +288,22 @@ describe("oauth metadata", async () => {
],
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true,
backchannel_logout_supported: true,
backchannel_logout_session_supported: true,
});
});
it("advertises back-channel logout as unsupported when the jwt plugin is disabled", async () => {
const { auth } = await createTestInstance({
oauthProviderConfig: {
disableJwtPlugin: true,
},
});
const metadata = await auth.api.getOpenIdConfig();
expect(metadata.backchannel_logout_supported).toBe(false);
expect(metadata.backchannel_logout_session_supported).toBe(false);
});
it("should not provide dynamic client registration endpoint when disabled", async () => {
const { auth } = await createTestInstance({
oauthProviderConfig: {
+5
View File
@@ -27,6 +27,9 @@ export function authServerMetadata(
},
) {
const baseURL = ctx.context.baseURL;
// Back-channel logout requires a verifiable Logout Token, which depends on
// the JWT plugin's JWKS. Advertise support only when the plugin is enabled.
const backchannelSupported = !overrides?.jwt_disabled;
const metadata: AuthServerMetadata = {
scopes_supported: overrides?.scopes_supported,
issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL),
@@ -81,6 +84,8 @@ export function authServerMetadata(
],
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true,
backchannel_logout_supported: backchannelSupported,
backchannel_logout_session_supported: backchannelSupported,
};
return metadata;
}
+80 -12
View File
@@ -17,7 +17,11 @@ import { authorizeEndpoint, authorizeRedirectOnError } from "./authorize";
import { consentEndpoint } from "./consent";
import { continueEndpoint } from "./continue";
import { introspectEndpoint } from "./introspect";
import { rpInitiatedLogoutEndpoint } from "./logout";
import {
deliverBackchannelLogoutTokens,
revokeAndPlanBackchannelLogout,
rpInitiatedLogoutEndpoint,
} from "./logout";
import {
authServerMetadata,
metadataResponse,
@@ -282,30 +286,43 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
);
}
// Check for jwt plugin registration
// With secondaryStorage + preserveSessionInDatabase, deleteSession
// keeps the DB row and returns before the session-delete hook runs, so
// OAuth token revocation and back-channel logout never fire on session
// end. Surface it so operators don't assume sign-out invalidates tokens.
// TODO: warning-only is a stopgap. A complete fix needs core to fire
// the session-delete hook (or expose a dedicated session-end seam) even
// when the row is preserved, so revocation and back-channel dispatch run
// for this config. Re-evaluate moving off the delete hook then.
if (
ctx.options.secondaryStorage &&
ctx.options.session?.preserveSessionInDatabase
) {
logger.warn(
"OAuth Provider: `session.preserveSessionInDatabase: true` with secondaryStorage skips the session-delete hook, so OAuth access/refresh tokens are not revoked and back-channel logout is not dispatched on session end.",
);
}
// Well-known warnings are best-effort and only make sense with the
// JWT plugin. A dynamic baseURL resolves per-request, so there is
// nothing to emit at init time for that deployment shape either.
if (!opts.disableJwtPlugin) {
const jwtPlugin = getJwtPlugin(ctx);
const jwtPluginOptions = jwtPlugin?.options;
// Issuer and well-known endpoint checks
const issuer = jwtPluginOptions?.jwt?.issuer ?? ctx.baseURL;
const isDynamicBaseURLInit =
jwtPluginOptions?.jwt?.issuer == null &&
typeof ctx.options.baseURL === "object" &&
ctx.options.baseURL !== null &&
"allowedHosts" in ctx.options.baseURL;
let issuerPath: string;
let issuerPath: string | undefined;
try {
issuerPath = new URL(issuer).pathname;
} catch (error) {
// baseURL may not be available during init when using dynamic baseURL config
if (isDynamicBaseURLInit && issuer === "") {
return;
}
throw error;
if (!isDynamicBaseURLInit || issuer !== "") throw error;
}
// oAuth Server Config
if (
issuerPath !== undefined &&
!opts.silenceWarnings?.oauthAuthServerConfig &&
!(ctx.options.basePath === "/" && issuerPath === "/")
) {
@@ -313,8 +330,8 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
`Please ensure '/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}' exists. Upon completion, clear with silenceWarnings.oauthAuthServerConfig.`,
);
}
// OpenId Config
if (
issuerPath !== undefined &&
!opts.silenceWarnings?.openidConfig &&
ctx.options.basePath !== issuerPath &&
opts.scopes?.includes("openid")
@@ -324,6 +341,44 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
);
}
}
// The hook must register for every configuration path (including
// dynamic baseURL). Revocation runs inline because it mutates DB
// state we rely on. The HTTP fan-out goes through
// `runInBackgroundOrAwait`: with a background handler configured
// (Vercel `waitUntil`, CF `ctx.waitUntil`) it runs after the
// response; without one it is awaited inline so delivery is not lost
// on request teardown. Awaiting here keeps both paths reliable.
return {
options: {
databaseHooks: {
session: {
delete: {
async before(session, hookCtx) {
if (!hookCtx) return;
const plan = await revokeAndPlanBackchannelLogout(
hookCtx,
opts,
{
sessionId: session.id,
userId: session.userId,
},
);
if (!plan) return;
// TODO: re-evaluate this await. It makes delivery reliable on
// every runtime, but without an `advanced.backgroundTasks.handler`
// a hung RP can add up to the per-RP timeout to sign-out latency.
// Alternative to weigh: keep delivery non-blocking and instead
// hard-require a background handler when back-channel logout is on.
await hookCtx.context.runInBackgroundOrAwait(
deliverBackchannelLogoutTokens(hookCtx, plan),
);
},
},
},
},
},
};
},
hooks: {
before: [
@@ -1321,6 +1376,8 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
software_version: z.string().optional(),
software_statement: z.string().optional(),
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
token_endpoint_auth_method: z
.enum([
"none",
@@ -1466,6 +1523,17 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
},
description: "List of allowed logout redirect uris",
},
backchannel_logout_uri: {
type: "string",
format: "uri",
description:
"RP URL to receive signed Logout Tokens when the end-user's OP session terminates",
},
backchannel_logout_session_required: {
type: "boolean",
description:
"Whether the RP requires a `sid` claim in every Logout Token",
},
token_endpoint_auth_method: {
type: "string",
description:
@@ -31,6 +31,8 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
software_version: z.string().optional(),
software_statement: z.string().optional(),
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
token_endpoint_auth_method: z
.enum([
"none",
@@ -258,6 +260,8 @@ export const createOAuthClient = (opts: OAuthOptions<Scope[]>) =>
software_version: z.string().optional(),
software_statement: z.string().optional(),
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
token_endpoint_auth_method: z
.enum([
"none",
@@ -550,6 +554,8 @@ export const adminUpdateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
software_version: z.string().optional(),
software_statement: z.string().optional(),
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
// NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition
grant_types: z
.array(
@@ -604,6 +610,8 @@ export const updateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
software_version: z.string().optional(),
software_statement: z.string().optional(),
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
// NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition
grant_types: z
.array(
@@ -664,8 +664,25 @@ describe("isPrivateHostname", () => {
expect(isPrivateHostname("[::ffff:8.8.8.8]")).toBe(false);
});
it("should block IPv4-mapped IPv6 written in hex", () => {
// ::ffff:7f00:1 == 127.0.0.1, ::ffff:a9fe:a9fe == 169.254.169.254 (IMDS)
expect(isPrivateHostname("[::ffff:7f00:1]")).toBe(true);
expect(isPrivateHostname("[::ffff:a9fe:a9fe]")).toBe(true);
});
it("should block NAT64 and 6to4 tunnels to private targets", () => {
expect(isPrivateHostname("[64:ff9b::7f00:1]")).toBe(true); // NAT64 -> 127.0.0.1
expect(isPrivateHostname("[2002:a9fe:a9fe::]")).toBe(true); // 6to4 -> IMDS
});
it("should block shared address space (carrier-grade NAT)", () => {
expect(isPrivateHostname("100.64.0.1")).toBe(true);
});
it("should block cloud metadata endpoints", () => {
expect(isPrivateHostname("metadata.google.internal")).toBe(true);
expect(isPrivateHostname("metadata.goog")).toBe(true);
expect(isPrivateHostname("instance-data")).toBe(true);
});
});
@@ -296,6 +296,111 @@ describe("oauth register", async () => {
expect(response?.fromMetadata).toBe("value1");
expect(response?.customField).toBe(undefined);
});
it("round-trips backchannel_logout_uri and backchannel_logout_session_required", async () => {
const backchannelUri = `${rpBaseUrl}/logout/backchannel`;
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
backchannel_logout_uri: backchannelUri,
backchannel_logout_session_required: true,
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.backchannel_logout_uri).toBe(backchannelUri);
expect(response.data?.backchannel_logout_session_required).toBe(true);
});
it("rejects backchannel_logout_uri with a fragment", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
backchannel_logout_uri: `${rpBaseUrl}/logout/backchannel#section`,
});
expect(response.error?.status).toBe(400);
});
it("rejects backchannel_logout_uri ending with a bare fragment delimiter", async () => {
// `new URL(...).hash` is empty for a trailing `#`, so the raw value must
// be checked to honor spec §2.2 (no fragment component).
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
backchannel_logout_uri: `${rpBaseUrl}/logout/backchannel#`,
});
expect(response.error?.status).toBe(400);
});
it("rejects http backchannel_logout_uri on confidential clients", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
backchannel_logout_uri: "http://rp.example.com/logout/backchannel",
});
expect(response.error?.status).toBe(400);
});
it("allows http backchannel_logout_uri on public clients", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
token_endpoint_auth_method: "none",
type: "native",
backchannel_logout_uri: `${rpBaseUrl}/logout/backchannel`,
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.backchannel_logout_uri).toBe(
`${rpBaseUrl}/logout/backchannel`,
);
});
it("rejects backchannel_logout_uri pointing at private, tunneled, or metadata targets", async () => {
// These all pass a naive https check but are non-public; the guard must
// reject every encoding, not just dotted-decimal private IPs.
const targets = [
"https://10.0.0.1/logout",
"https://169.254.169.254/logout",
"https://[::ffff:169.254.169.254]/logout",
"https://[64:ff9b::a9fe:a9fe]/logout",
"https://100.64.0.1/logout",
"https://metadata.google.internal/logout",
];
for (const backchannel_logout_uri of targets) {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
backchannel_logout_uri,
});
expect(response.error?.status).toBe(400);
}
});
});
describe("oauth register - disableJwtPlugin", async () => {
const baseUrl = "http://localhost:3000";
const rpBaseUrl = "http://localhost:5000";
const { signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: baseUrl,
plugins: [
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
allowDynamicClientRegistration: true,
disableJwtPlugin: true,
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
const serverClient = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: baseUrl,
fetchOptions: { customFetchImpl, headers },
});
it("rejects backchannel_logout_uri when jwt plugin is disabled", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [`${rpBaseUrl}/callback`],
backchannel_logout_uri: `${rpBaseUrl}/logout/backchannel`,
});
expect(response.error?.status).toBe(400);
});
});
describe("oauth register - unauthenticated", async () => {
+71
View File
@@ -1,4 +1,5 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { isLoopbackHost } from "@better-auth/core/utils/host";
import { APIError, getSessionFromCtx } from "better-auth/api";
import { generateRandomString } from "better-auth/crypto";
import { toExpJWT } from "better-auth/plugins";
@@ -261,6 +262,67 @@ export async function checkOAuthClient(
"jwks and jwks_uri are only allowed with private_key_jwt authentication",
});
}
if (client.backchannel_logout_uri !== undefined) {
if (opts.disableJwtPlugin) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)",
});
}
let url: URL;
try {
url = new URL(client.backchannel_logout_uri);
} catch {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "backchannel_logout_uri must be an absolute URL",
});
}
// Only http/https make sense for a POST target and the server will
// refuse anything else at fetch time; reject up front to avoid storing
// unreachable URIs.
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "backchannel_logout_uri must use http or https",
});
}
// Spec §2.2: "The backchannel_logout_uri MUST NOT include a fragment
// component." Check the raw value rather than `url.hash`, which is empty
// for a bare trailing `#` and would let that fragment delimiter through.
if (client.backchannel_logout_uri.includes("#")) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"backchannel_logout_uri must not include a fragment component",
});
}
const loopback = isLoopbackHost(url.hostname);
// Spec §2.2: SHOULD be https for confidential clients. Enforce on
// confidential clients, with a loopback carve-out (RFC 8252 §7.3) so
// local development against http://127.0.0.1:<port> works.
if (!isPublic && url.protocol !== "https:" && !loopback) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"backchannel_logout_uri must use https for confidential clients",
});
}
// SSRF guard: the OP issues an outbound POST to this URI on every
// session end, so reject any host that is not publicly routable.
// Loopback is exempt for local development (e.g.
// http://127.0.0.1:<port> or https://localhost); non-loopback private,
// link-local, tunneled, and cloud-metadata targets are always rejected.
if (isPrivateHostname(url.hostname) && !loopback) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"backchannel_logout_uri must not point to a private or reserved address",
});
}
}
}
export async function createOAuthClientEndpoint(
@@ -395,6 +457,8 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
// Authentication Metadata
redirect_uris: redirectUris,
post_logout_redirect_uris: postLogoutRedirectUris,
backchannel_logout_uri: backchannelLogoutUri,
backchannel_logout_session_required: backchannelLogoutSessionRequired,
token_endpoint_auth_method: tokenEndpointAuthMethod,
grant_types: grantTypes,
response_types: responseTypes,
@@ -451,6 +515,8 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
// Authentication Metadata
redirectUris,
postLogoutRedirectUris,
backchannelLogoutUri,
backchannelLogoutSessionRequired,
tokenEndpointAuthMethod,
grantTypes,
responseTypes,
@@ -508,6 +574,8 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
// Authentication Metadata
redirectUris,
postLogoutRedirectUris,
backchannelLogoutUri,
backchannelLogoutSessionRequired,
tokenEndpointAuthMethod,
grantTypes,
responseTypes,
@@ -566,6 +634,9 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
// Authentication Metadata
redirect_uris: redirectUris ?? [],
post_logout_redirect_uris: postLogoutRedirectUris ?? undefined,
backchannel_logout_uri: backchannelLogoutUri ?? undefined,
backchannel_logout_session_required:
backchannelLogoutSessionRequired ?? undefined,
token_endpoint_auth_method: tokenEndpointAuthMethod ?? undefined,
grant_types: grantTypes ?? undefined,
response_types: responseTypes ?? undefined,
+12
View File
@@ -100,6 +100,14 @@ export const schema = {
type: "string[]",
required: false,
},
backchannelLogoutUri: {
type: "string",
required: false,
},
backchannelLogoutSessionRequired: {
type: "boolean",
required: false,
},
tokenEndpointAuthMethod: {
type: "string",
required: false,
@@ -288,6 +296,10 @@ export const schema = {
createdAt: {
type: "date",
},
revoked: {
type: "date",
required: false,
},
// Shall be same as refreshId.scopes if using refreshId
scopes: {
type: "string[]",
+4 -1
View File
@@ -186,6 +186,9 @@ async function createIdToken(
? await computeOidcHash(accessToken, signingAlg)
: undefined;
const emitSid = Boolean(
client.enableEndSession || client.backchannelLogoutUri,
);
const payload: JWTPayload = {
...userClaims,
auth_time: authTimeSec,
@@ -198,7 +201,7 @@ async function createIdToken(
nonce,
iat,
exp,
sid: client.enableEndSession ? sessionId : undefined,
sid: emitSid ? sessionId : undefined,
};
// Public clients without a client secret cannot receive an idToken as it can't be verified
@@ -1008,6 +1008,22 @@ export interface SchemaClient<
* For example, `https://example.com/logout/callback`
*/
postLogoutRedirectUris?: string[];
/**
* RP URL that will receive a signed Logout Token when the end-user's OP
* session ends. Registering it is the per-client opt-in for back-channel
* logout. Must be absolute, without a fragment, and HTTPS for confidential
* clients.
*
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#RPMetadata
*/
backchannelLogoutUri?: string;
/**
* When true, the RP requires the `sid` claim in every Logout Token.
* User-scoped (sid-less) logouts are not dispatched to such a client.
*
* @default false
*/
backchannelLogoutSessionRequired?: boolean;
tokenEndpointAuthMethod?:
| "none"
| "client_secret_basic"
@@ -1108,6 +1124,12 @@ export interface OAuthOpaqueAccessToken<
expiresAt: Date;
/** The creation date of the access token. */
createdAt: Date;
/**
* When the access token was revoked. Set by session-end dispatch, the
* revoke endpoint, and back-channel logout. Introspection and protected
* endpoints MUST treat a revoked token as inactive.
*/
revoked?: Date | null;
/**
* Scope granted for the access token.
*
@@ -192,6 +192,28 @@ export interface AuthServerMetadata {
* it on its own.
*/
client_id_metadata_document_supported?: boolean;
/**
* Boolean value specifying whether the OP supports back-channel logout,
* with true indicating support.
*
* Registered in the "OAuth Authorization Server Metadata" IANA registry
* under OpenID Connect Back-Channel Logout 1.0, so this may appear at both
* `.well-known/oauth-authorization-server` and `.well-known/openid-configuration`.
*
* @default false
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#OPMetadata
*/
backchannel_logout_supported?: boolean;
/**
* Boolean value specifying whether the OP can pass a `sid` (session ID)
* Claim in the Logout Token to identify the RP session with the OP.
*
* When true, the OP also includes `sid` in ID Tokens it issues.
*
* @default false
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#OPMetadata
*/
backchannel_logout_session_supported?: boolean;
}
/**
@@ -299,6 +321,22 @@ export interface OAuthClient {
//---- Authentication Metadata ----//
redirect_uris: string[];
post_logout_redirect_uris?: string[];
/**
* RP URL that the OP POSTs a signed Logout Token to when a session at the OP
* ends. The RP uses the token to terminate its own session state for that
* user (including any access tokens it has bound to the session).
*
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#RPMetadata
*/
backchannel_logout_uri?: string;
/**
* When true, the RP requires the `sid` Claim in every Logout Token it
* receives; the OP will not dispatch user-scoped (sid-less) logouts to it.
*
* @default false
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#RPMetadata
*/
backchannel_logout_session_required?: boolean;
token_endpoint_auth_method?:
| "none"
| "client_secret_basic"
@@ -177,6 +177,32 @@ describe("oauth userinfo", async () => {
expect(userinfo.error?.status).toBe(400);
});
it("rejects a revoked access token with invalid_token (401), not invalid_scope", async () => {
const tokens = await getTokens();
expect(tokens.data?.access_token).toBeDefined();
const ctx = await auth.$context;
const session = await auth.api.getSession({ headers });
await ctx.adapter.updateMany({
model: "oauthAccessToken",
where: [{ field: "sessionId", value: session!.session.id }],
update: { revoked: new Date() },
});
try {
await auth.api.oauth2UserInfo({
headers: new Headers({
Authorization: `Bearer ${tokens.data!.access_token!}`,
}),
});
expect.unreachable();
} catch (error) {
const err = error as APIError;
expect(err.statusCode).toBe(401);
expect(err.body).toMatchObject({ error: "invalid_token" });
}
});
it("should pass provide all user information - opaque", async () => {
const tokens = await getTokens();
expect(tokens.data?.access_token).toBeDefined();
+10
View File
@@ -50,6 +50,16 @@ export async function userInfoEndpoint(
}
const jwt = await validateAccessToken(ctx, opts, token);
// A token that is expired, revoked, or bound to an ended session resolves to
// `{ active: false }`. RFC 6750 §3.1 wants `invalid_token` (401) for that,
// not the `invalid_scope` (400) the scope check below would otherwise raise.
if (!jwt.active) {
throw new APIError("UNAUTHORIZED", {
error_description: "the access token is invalid or has been revoked",
error: "invalid_token",
});
}
const scopes = (jwt.scope as string | undefined)?.split(" ");
if (!scopes?.includes("openid")) {
throw new APIError("BAD_REQUEST", {
@@ -3,6 +3,7 @@ import {
CLIENT_ASSERTION_TYPE,
PRIVATE_KEY_JWT_SIGNING_ALGORITHMS,
} from "@better-auth/core/oauth2";
import { isPublicRoutableHost } from "@better-auth/core/utils/host";
import { APIError } from "better-call";
import type { JSONWebKeySet } from "jose";
import {
@@ -32,66 +33,20 @@ const ALGORITHMS_LIST: string[] = [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS];
const pendingAssertionIds = new Set<string>();
/**
* Block SSRF: reject jwks_uri pointing at private/reserved IP ranges.
* Only HTTPS with public hostnames is allowed.
* SSRF gate for user-supplied server-side fetch targets (`jwks_uri`,
* `backchannel_logout_uri`): returns true when the host is NOT publicly
* routable. That covers loopback, RFC 1918 private, link-local (including AWS
* IMDS `169.254.169.254`), shared-address-space (carrier-grade NAT),
* IPv4-mapped IPv6, 6to4/NAT64/Teredo tunnels, every other RFC 6890
* special-purpose range, and cloud-metadata FQDNs.
*
* Delegates to the audited single source of truth so this check cannot drift
* into the kind of encoding bypass that bespoke regexes invite. This is a
* syntactic check only: it does not resolve DNS, so a public name that
* resolves to a private address at fetch time is not caught here.
*/
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split(".");
if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p))) {
return false;
}
const octets = parts.map(Number);
const a = octets[0]!;
const b = octets[1]!;
return (
a === 10 ||
a === 0 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
a === 127
);
}
export function isPrivateHostname(hostname: string): boolean {
const lower = hostname.toLowerCase();
// Strip IPv6 brackets for uniform prefix matching
const host =
lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
if (host === "localhost" || host === "::1") {
return true;
}
if (isPrivateIpv4(host)) {
return true;
}
// Only apply IPv6 heuristics when the hostname contains ":" (the IPv6
// separator). Without this gate, DNS names starting with "fc"/"fd"
// would be incorrectly blocked by the unique-local prefix check.
if (host.includes(":")) {
// IPv4-mapped IPv6 (::ffff:a.b.c.d): extract the trailing IPv4 and check it
const v4MappedMatch = host.match(
/^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/,
);
if (v4MappedMatch && isPrivateIpv4(v4MappedMatch[1]!)) {
return true;
}
// Link-local IPv6: fe80::/10 covers fe8*-feb*
const isLinkLocal =
host.startsWith("fe8") ||
host.startsWith("fe9") ||
host.startsWith("fea") ||
host.startsWith("feb");
// Unique-local IPv6: fc00::/7 covers fc* and fd*
const isUniqueLocal = host.startsWith("fc") || host.startsWith("fd");
if (isLinkLocal || isUniqueLocal) {
return true;
}
}
if (host === "metadata.google.internal") {
return true;
}
return false;
return !isPublicRoutableHost(hostname);
}
function validateJwksUri(