feat(oauth-provider): add OIDC front-channel logout (#9923)

Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
shiminshen
2026-10-14 18:54:59 +01:00
committed by GitHub
co-authored by shiminshen Gustavo Valverde
parent 1c8b98e8d1
commit c750fc5ad2
23 changed files with 1789 additions and 87 deletions
@@ -0,0 +1,5 @@
---
"@better-auth/oauth-provider": patch
---
OAuth client updates can now remove a logout URI. Sending `backchannel_logout_uri: null` or `frontchannel_logout_uri: null` to `/oauth2/update-client` or `adminUpdateOAuthClient` clears the URI and its `*_session_required` flag, and the client stops receiving those logout notifications. Before, `null` was rejected and a registered logout URI could only be replaced.
@@ -0,0 +1,9 @@
---
"@better-auth/oauth-provider": minor
---
OAuth providers can now notify relying parties through the browser with OpenID Connect Front-Channel Logout 1.0. Register a `frontchannel_logout_uri` on a client, using the scheme, host, and port of one of its `redirect_uris`. When a user logs out through `/oauth2/end-session` in the browser, the provider loads that URI in a hidden iframe with `iss` and `sid`, then continues to the verified `post_logout_redirect_uri`. Clients with this URI also receive `sid` in their ID tokens.
Front-channel logout is reliable only when the provider and its relying parties share a registrable domain, because browsers block or partition third-party cookies. Use back-channel logout for relying parties on other sites. Providers with no front-channel clients see no change.
Run `npx auth@latest migrate`, or `npx auth@latest generate` if you manage the schema yourself, to add the `frontchannelLogoutUri` and `frontchannelLogoutSessionRequired` columns to `oauthClient`.
+21
View File
@@ -105,8 +105,27 @@ jobs:
- name: Install Next.js demo
run: pnpm --dir demo/nextjs install --frozen-lockfile
# Firefox and WebKit run the front-channel logout spec only when the code
# it depends on changes; Chromium runs it on every change.
- name: Detect front-channel logout changes
id: frontchannel
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
run: |
if git diff --quiet "$BASE_SHA"...HEAD -- \
packages/oauth-provider/src/logout.ts \
packages/better-auth/src/cookies \
e2e/integration/vanilla-node/e2e/frontchannel-logout.spec.ts \
e2e/integration/playwright.config.ts; then
echo "cross-browser=" >> "$GITHUB_OUTPUT"
else
echo "cross-browser=1" >> "$GITHUB_OUTPUT"
fi
- name: Install Playwright Browsers
uses: ./.github/actions/setup-playwright
with:
browsers: ${{ steps.frontchannel.outputs.cross-browser && 'chromium firefox webkit' || 'chromium' }}
- name: Reset Docker Containers
run: docker compose down --volumes --remove-orphans
@@ -117,6 +136,8 @@ jobs:
docker compose up -d --wait --wait-timeout 60 "${compose_services[@]}"
- name: Integration
env:
PLAYWRIGHT_CROSS_BROWSER: ${{ steps.frontchannel.outputs.cross-browser }}
run: pnpm e2e:integration --filter=./e2e/integration
- name: Build Next.js demo
+77 -3
View File
@@ -264,13 +264,24 @@ To update an oauth client tied to a specific user or organization, use the `/oau
*/
client_id: string,
/**
* The fields to update
* The client fields to update, as described in the create section. Omitted fields keep their current values.
*/
update: OAuthClient,
update: {
/**
* Back-channel logout URI. `null` removes it and `backchannel_logout_session_required`.
*/
backchannel_logout_uri?: string | null
/**
* Front-channel logout URI. `null` removes it and `frontchannel_logout_session_required`.
*/
frontchannel_logout_uri?: string | null
},
}
```
</APIMethod>
Omitted fields keep their current values. To remove a logout URI, send `null` for `backchannel_logout_uri` or `frontchannel_logout_uri`; the matching `*_session_required` flag is cleared too, and the client stops receiving those logout notifications.
Restrictions on this endpoint:
* You cannot change `token_endpoint_auth_method` after creation. The method selected at creation determines whether the client has credential capability.
@@ -777,7 +788,7 @@ await auth.api.adminCreateOAuthClient({
});
```
Better Auth protects confirmation with origin and CSRF checks plus a short-lived, signed cookie. When a current session exists, the cookie is tied to it. Successful browser logout returns a logged-out page. Session deletion continues through the normal hooks, including Back-Channel Logout delivery to registered relying parties.
Better Auth protects confirmation with origin and CSRF checks plus a short-lived, signed cookie. When a current session exists, the cookie is tied to it. Successful browser logout returns a logged-out page, or the [Front-Channel Logout](#front-channel-logout) page when registered relying parties must be notified in the browser. Session deletion continues through the normal hooks, including Back-Channel Logout delivery to registered relying parties.
### Back-Channel Logout
@@ -820,6 +831,55 @@ The Logout Token carries the §2.4 claims (`iss`, `aud`, `iat`, `exp`, `jti`, `e
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>
### Front-Channel Logout
[Front-Channel Logout](https://openid.net/specs/openid-connect-frontchannel-1_0.html) notifies Relying Parties through the user's browser. When a user logs out at `/oauth2/end-session`, the OP renders a page with one hidden iframe per Relying Party that holds tokens on the session. Each iframe loads that RP's `frontchannel_logout_uri`, so the RP can clear its own session cookies.
<Callout type="warn">
The iframes are third-party content. Safari blocks third-party cookies, Firefox partitions them by default, and Chrome blocks them in Incognito and for users who turn them off, so an RP on a different site often cannot see its session cookie inside the iframe. Front-channel logout is reliable only when the OP and its Relying Parties share a registrable domain, such as `auth.example.com` and `app.example.com`. Use [Back-Channel Logout](#back-channel-logout) when RPs run on other sites or when logout must not depend on the browser.
</Callout>
To opt a client in, register a `frontchannel_logout_uri` on the same scheme, host, and port as one of its `redirect_uris`:
```ts title="admin-create-oauth.ts"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: ["https://app.example.com/api/auth/callback"],
enable_end_session: true,
frontchannel_logout_uri: "https://app.example.com/logout/frontchannel", // [!code highlight]
}
});
```
The OP loads the URI with `iss` and `sid` query parameters added to any query it already has, replacing a registered `iss` or `sid`. `sid` matches the `sid` claim in the ID Tokens issued to that client, so the RP can ignore requests that don't match its current session. The RP endpoint should clear its session state and respond with `Cache-Control: no-store`. Setting `frontchannel_logout_session_required: true` is accepted and stored, but changes nothing, because the OP always sends both parameters.
The `frontchannel_logout_uri` is validated at registration and on every client update:
* It must be an absolute `http` or `https` URL with no fragment and no credentials.
* Its scheme, host, and port must match one of the client's `redirect_uris`, as the specification requires. A URI that matches a loopback or private-network redirect URI is accepted, because the browser loads it, not the OP.
* It does not require the `jwt` plugin, because nothing is signed.
A URI that fails validation is rejected with `invalid_client_metadata`.
#### When the iframes are rendered
The page is rendered only when the session actually ends during a browser navigation to `/oauth2/end-session`, either with a verified `id_token_hint` or after the user confirms logout. If a `session.delete.before` hook vetoes the deletion, no Relying Party is notified. When no front-channel client holds tokens on the session, the endpoint redirects or shows the logged-out page as before.
Front-channel logout does not run for:
* `/sign-out` or admin session revocation, because no logout page is shown in the user's browser.
* Fetch-style calls such as `authClient.oauth2.endSession()`, which keep the JSON response.
* Session expiry, which does not delete the session when it happens.
Back-Channel Logout still fires for the first two, because they delete the session. A client that registers both URIs receives both notifications, so make RP logout handlers idempotent.
After the iframes load, or after 3 seconds if an RP doesn't respond, the page redirects to the verified `post_logout_redirect_uri`, or shows the logged-out message when there is none. Without JavaScript, the iframes still load and a meta refresh performs the redirect. The page keeps the same Content Security Policy as the other logout pages, extended only with `frame-src` for the RP origins and its own script, and sends `Referrer-Policy: no-referrer` so the `id_token_hint` in the URL never reaches an RP.
<Callout type="info">
The OP advertises `frontchannel_logout_supported: true` and `frontchannel_logout_session_supported: true` on both `.well-known/openid-configuration` and `.well-known/oauth-authorization-server`, whether or not the `jwt` plugin is enabled.
</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`. An access token that is expired, revoked, or bound to a session that has ended is rejected with `invalid_token` (401), per [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1).
@@ -2197,6 +2257,20 @@ export const oauthClientTableFields = [
"When true, the RP requires a `sid` claim in every Logout Token and user-scoped logouts are skipped",
isOptional: true,
},
{
name: "frontchannelLogoutUri",
type: "string",
description:
"RP URL rendered in a hidden iframe on the OP's logout page when the user's session ends (OIDC Front-Channel Logout 1.0)",
isOptional: true,
},
{
name: "frontchannelLogoutSessionRequired",
type: "boolean",
description:
"Whether the RP requires `iss` and `sid` on its front-channel logout URI. The OP always sends both, so this is stored for registration round-trips.",
isOptional: true,
},
{
name: "tokenEndpointAuthMethod",
type: "string",
+16
View File
@@ -16,5 +16,21 @@ export default defineConfig({
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
// Front-channel logout depends on each browser's third-party cookie
// rules. CI sets PLAYWRIGHT_CROSS_BROWSER only when that code changes.
...(process.env.PLAYWRIGHT_CROSS_BROWSER
? [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
testMatch: "**/e2e/frontchannel-logout.spec.ts",
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
testMatch: "**/e2e/frontchannel-logout.spec.ts",
},
]
: []),
],
});
@@ -0,0 +1,438 @@
import type { IncomingMessage, Server, ServerResponse } from "node:http";
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { oauthProvider } from "@better-auth/oauth-provider";
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { betterAuth } from "better-auth";
import { getMigrations } from "better-auth/db/migration";
import { fromNodeHeaders, toNodeHandler } from "better-auth/node";
import { jwt } from "better-auth/plugins";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { decodeJwt } from "jose";
/**
* Front-channel logout depends on what the browser does with cookies inside
* cross-origin iframes, so these tests run the provider and each relying party
* as separate Better Auth servers with their own databases and cookies. A site
* ignores the port, so apps on `localhost` are same-site with each other and an
* app on `127.0.0.1` is on a different site. Each app uses its own cookie
* prefix because cookies on one host are shared across ports.
*
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html
*/
const SECRET = "frontchannel-logout-e2e-secret-0123456789";
const USER = {
name: "Test User",
email: "test@test.com",
password: "password123",
};
const SCOPES = ["openid", "profile", "email"];
type Handler = (req: IncomingMessage, res: ServerResponse) => unknown;
async function listen(): Promise<{
port: number;
setHandler: (handler: Handler) => void;
stop: () => Promise<void>;
}> {
let handler: Handler = (_req, res) => {
res.statusCode = 503;
res.end();
};
const server: Server = createServer((req, res) => handler(req, res));
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
// Dual-stack, so both `localhost` (often ::1) and 127.0.0.1 connect.
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") resolve(address.port);
else reject(new Error("Failed to get server port"));
});
});
return {
port,
setHandler: (next) => {
handler = next;
},
stop: () =>
new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
function html(res: ServerResponse, body: string) {
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(`<!doctype html><html><body>${body}</body></html>`);
}
async function startProvider() {
const http = await listen();
const origin = `http://localhost:${http.port}`;
const auth = betterAuth({
database: new DatabaseSync(":memory:"),
baseURL: origin,
secret: SECRET,
emailAndPassword: { enabled: true },
trustedOrigins: [origin],
plugins: [
jwt(),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
scopes: SCOPES,
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
}),
],
});
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
await auth.api.signUpEmail({ body: USER });
const signIn = await auth.api.signInEmail({
body: { email: USER.email, password: USER.password },
returnHeaders: true,
});
const adminHeaders = new Headers({
cookie: signIn.headers
.getSetCookie()
.map((cookie) => cookie.split(";")[0])
.join("; "),
});
const authHandler = toNodeHandler(auth);
http.setHandler((req, res) => {
if (req.url?.startsWith("/api/auth")) return authHandler(req, res);
html(res, "provider");
});
return {
auth,
origin,
/** Loopback `http` redirect URIs are accepted for native clients. */
async registerClient(rpOrigin: string) {
const created = await auth.api.adminCreateOAuthClient({
headers: adminHeaders,
body: {
application_type: "native",
redirect_uris: [`${rpOrigin}/api/auth/callback/provider`],
post_logout_redirect_uris: [`${rpOrigin}/signed-out`],
frontchannel_logout_uri: `${rpOrigin}/logout/frontchannel`,
token_endpoint_auth_method: "client_secret_post",
skip_consent: true,
enable_end_session: true,
},
});
return {
clientId: created.client_id,
clientSecret: created.client_secret!,
};
},
stop: http.stop,
};
}
type Provider = Awaited<ReturnType<typeof startProvider>>;
interface FrontchannelRequest {
iss: string | null;
sid: string | null;
/** Whether the RP session cookie reached the RP inside the iframe. */
hadSession: boolean;
loggedOut: boolean;
}
/**
* A relying party that signs in through the provider with `genericOAuth` and
* logs out with core `/sign-out`, which returns the provider's end-session URL.
* Its `/logout/frontchannel` route is a minimal Front-Channel Logout receiver:
* it ends the local session only when `iss` and `sid` match the stored ID
* Token.
*/
async function startRelyingParty(
provider: Provider,
name: string,
options: {
host?: "localhost" | "127.0.0.1";
hangOnFrontchannel?: boolean;
} = {},
) {
const http = await listen();
const origin = `http://${options.host ?? "localhost"}:${http.port}`;
const { clientId, clientSecret } = await provider.registerClient(origin);
const auth = betterAuth({
database: new DatabaseSync(":memory:"),
baseURL: origin,
secret: SECRET,
trustedOrigins: [origin],
advanced: { cookiePrefix: name },
plugins: [
genericOAuth({
config: [
{
providerId: "provider",
clientId,
clientSecret,
discoveryUrl: `${provider.origin}/api/auth/.well-known/openid-configuration`,
authentication: "post",
scopes: SCOPES,
pkce: true,
},
],
}),
],
});
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
const frontchannelRequests: FrontchannelRequest[] = [];
const authHandler = toNodeHandler(auth);
http.setHandler(async (req, res) => {
const url = new URL(req.url ?? "/", origin);
if (url.pathname.startsWith("/api/auth")) return authHandler(req, res);
if (url.pathname === "/signed-out") return html(res, "signed out");
if (url.pathname !== "/logout/frontchannel") return html(res, name);
const iss = url.searchParams.get("iss");
const sid = url.searchParams.get("sid");
const headers = fromNodeHeaders(req.headers);
const session = await auth.api.getSession({ headers });
const request: FrontchannelRequest = {
iss,
sid,
hadSession: Boolean(session),
loggedOut: false,
};
frontchannelRequests.push(request);
if (options.hangOnFrontchannel) return;
if (session) {
const ctx = await auth.$context;
const account = (
await ctx.internalAdapter.findAccounts(session.user.id)
).find((candidate) => candidate.providerId === "provider");
const claims = account?.idToken ? decodeJwt(account.idToken) : null;
if (claims && claims.iss === iss && claims.sid === sid) {
const signOut = await auth.api.signOut({
headers,
body: { disableRedirect: true },
asResponse: true,
});
for (const cookie of signOut.headers.getSetCookie()) {
res.appendHeader("set-cookie", cookie);
}
request.loggedOut = true;
}
}
res.setHeader("cache-control", "no-store");
res.end();
});
return {
origin,
frontchannelRequests,
async activeSessions() {
const ctx = await auth.$context;
return ctx.adapter.count({ model: "session" });
},
stop: http.stop,
};
}
type RelyingParty = Awaited<ReturnType<typeof startRelyingParty>>;
async function postJson<T>(page: Page, path: string, body: unknown) {
return page.evaluate(
async ({ path, body }) => {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) throw new Error(`${path} ${response.status}: ${text}`);
return JSON.parse(text) as T;
},
{ path, body },
);
}
async function signInToProvider(page: Page, provider: Provider) {
await page.goto(`${provider.origin}/`);
await postJson(page, "/api/auth/sign-in/email", {
email: USER.email,
password: USER.password,
});
}
async function signInToRelyingParty(page: Page, rp: RelyingParty) {
await page.goto(`${rp.origin}/`);
const { url } = await postJson<{ url: string }>(
page,
"/api/auth/sign-in/social",
{ provider: "provider", callbackURL: "/" },
);
await page.goto(url);
await page.waitForURL(`${rp.origin}/`);
expect(await rp.activeSessions()).toBe(1);
}
/** Signs out of the RP and returns the provider end-session URL it hands back. */
async function signOutOfRelyingParty(page: Page, rp: RelyingParty) {
await page.goto(`${rp.origin}/`);
const { url } = await postJson<{ url: string }>(page, "/api/auth/sign-out", {
callbackURL: "/signed-out",
disableRedirect: true,
});
expect(url).toContain("/oauth2/end-session");
return url;
}
/** The session the browser holds at `origin`, as that app reports it. */
async function browserSession(page: Page, origin: string) {
await page.goto(`${origin}/`);
return page.evaluate(() =>
fetch("/api/auth/get-session").then((response) => response.json()),
);
}
function collectCspViolations(page: Page) {
const violations: string[] = [];
page.on("console", (message) => {
const text = message.text();
// Firefox's own favicon loader is blocked by `default-src 'none'`.
if (/content[- ]security[- ]policy/i.test(text) && !/favicon/i.test(text)) {
violations.push(text);
}
});
return violations;
}
test.describe("front-channel logout across separate apps", () => {
const servers: Array<{ stop: () => Promise<void> }> = [];
test.afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.stop()));
});
async function start<T extends { stop: () => Promise<void> }>(server: T) {
servers.push(server);
return server;
}
test("logs out same-site apps through the browser, but not an app on another site", async ({
page,
}) => {
const provider = await start(await startProvider());
const appA = await start(await startRelyingParty(provider, "app-a"));
const appB = await start(await startRelyingParty(provider, "app-b"));
const otherSite = await start(
await startRelyingParty(provider, "other-site", { host: "127.0.0.1" }),
);
const cspViolations = collectCspViolations(page);
await signInToProvider(page, provider);
for (const rp of [appA, appB, otherSite]) {
await signInToRelyingParty(page, rp);
}
const endSessionUrl = await signOutOfRelyingParty(page, appA);
await page.goto(endSessionUrl);
await page.waitForURL(`${appA.origin}/signed-out`);
expect(cspViolations).toEqual([]);
expect(await browserSession(page, provider.origin)).toBeNull();
// App B shares the provider's site, so its cookie reached the iframe.
expect(appB.frontchannelRequests).toEqual([
expect.objectContaining({ hadSession: true, loggedOut: true }),
]);
expect(appB.frontchannelRequests[0]!.iss).toBe(
`${provider.origin}/api/auth`,
);
expect(await appB.activeSessions()).toBe(0);
// The other site received the request, but its SameSite=Lax cookie was
// not sent to a cross-site iframe, so it stays signed in. Back-channel
// logout is the documented option for this case.
expect(otherSite.frontchannelRequests).toEqual([
expect.objectContaining({ hadSession: false, loggedOut: false }),
]);
expect(await otherSite.activeSessions()).toBe(1);
});
test("notifies apps after the user confirms logout at the provider", async ({
page,
}) => {
const provider = await start(await startProvider());
const app = await start(await startRelyingParty(provider, "app-a"));
await signInToProvider(page, provider);
await signInToRelyingParty(page, app);
await page.goto(`${provider.origin}/api/auth/oauth2/end-session`);
await page.getByRole("button", { name: "Confirm logout" }).click();
await expect(
page.locator('[data-oidc-logout-state="logged-out"]'),
).toHaveText("Logged out.");
expect(app.frontchannelRequests).toEqual([
expect.objectContaining({ hadSession: true, loggedOut: true }),
]);
expect(await app.activeSessions()).toBe(0);
expect(await browserSession(page, provider.origin)).toBeNull();
});
test("still notifies apps and redirects with JavaScript disabled", async ({
browser,
page,
}) => {
const provider = await start(await startProvider());
const appA = await start(await startRelyingParty(provider, "app-a"));
const appB = await start(await startRelyingParty(provider, "app-b"));
await signInToProvider(page, provider);
await signInToRelyingParty(page, appA);
await signInToRelyingParty(page, appB);
const endSessionUrl = await signOutOfRelyingParty(page, appA);
const noScript = await browser.newContext({
javaScriptEnabled: false,
storageState: await page.context().storageState(),
});
try {
const noScriptPage = await noScript.newPage();
await noScriptPage.goto(endSessionUrl);
// The meta refresh inside <noscript> performs the redirect.
await noScriptPage.waitForURL(`${appA.origin}/signed-out`);
} finally {
await noScript.close();
}
expect(appB.frontchannelRequests).toEqual([
expect.objectContaining({ hadSession: true, loggedOut: true }),
]);
expect(await appB.activeSessions()).toBe(0);
});
test("redirects after the timeout when an app never responds", async ({
page,
}) => {
const provider = await start(await startProvider());
const appA = await start(await startRelyingParty(provider, "app-a"));
const hungApp = await start(
await startRelyingParty(provider, "app-b", {
hangOnFrontchannel: true,
}),
);
await signInToProvider(page, provider);
await signInToRelyingParty(page, appA);
await signInToRelyingParty(page, hungApp);
const endSessionUrl = await signOutOfRelyingParty(page, appA);
await page.goto(endSessionUrl);
await page.waitForURL(`${appA.origin}/signed-out`, { timeout: 10_000 });
expect(hungApp.frontchannelRequests).toHaveLength(1);
});
});
@@ -30,6 +30,8 @@ const OAUTH_CLIENT_RECORD_FIELDS = {
postLogoutRedirectUris: true,
backchannelLogoutUri: true,
backchannelLogoutSessionRequired: true,
frontchannelLogoutUri: true,
frontchannelLogoutSessionRequired: true,
tokenEndpointAuthMethod: true,
grantTypes: true,
responseTypes: true,
@@ -75,6 +77,8 @@ const OAUTH_CLIENT_WIRE_FIELD_NAMES = [
"post_logout_redirect_uris",
"backchannel_logout_uri",
"backchannel_logout_session_required",
"frontchannel_logout_uri",
"frontchannel_logout_session_required",
"token_endpoint_auth_method",
"grant_types",
"response_types",
@@ -0,0 +1,587 @@
import { createHash } from "node:crypto";
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 { decodeJwt } from "jose";
import type { Listener } from "listhen";
import { listen } from "listhen";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { oauthProviderClient } from "./client";
import { oauthProvider } from "./oauth";
type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
/**
* Extracts iframe `src` attribute values from the rendered logout page, with
* HTML attribute entities decoded back to the raw URL.
*/
function extractIframeSources(html: string): string[] {
return [...html.matchAll(/<iframe[^>]*\ssrc="([^"]*)"/g)].map((m) =>
m[1]!.replaceAll("&amp;", "&"),
);
}
function extractInlineScript(html: string): string {
const match = html.match(/<script>([\s\S]*?)<\/script>/);
if (!match) throw new Error("logout page has no inline script");
return match[1]!;
}
describe("oauth front-channel logout", async () => {
const port = 3011;
const baseUrl = `http://localhost:${port}`;
const issuer = `${baseUrl}/api/auth`;
const rpBaseUrl = "http://localhost:5001";
const state = "123";
const scopes = ["openid", "email", "profile"];
let vetoSessionDelete = false;
const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
await getTestInstance({
baseURL: baseUrl,
databaseHooks: {
session: {
delete: {
async before() {
if (vetoSessionDelete) return false;
},
},
},
},
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;
beforeAll(async () => {
server = await listen(toNodeHandler(auth.handler), { port });
});
afterAll(async () => {
if (server) await server.close();
});
beforeEach(async () => {
vetoSessionDelete = false;
const signed = await signInWithTestUser();
headers = signed.headers;
});
async function registerClient(
overrides: Partial<{
enable_end_session: boolean;
frontchannel_logout_uri: string | undefined;
frontchannel_logout_session_required: boolean;
post_logout_redirect_uris: string[];
}> = {},
) {
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [`${rpBaseUrl}/callback`],
// http loopback redirect URIs are only valid for native clients;
// web clients require https on a non-loopback host.
application_type: "native",
token_endpoint_auth_method: "client_secret_post",
skip_consent: true,
enable_end_session: true,
frontchannel_logout_uri: `${rpBaseUrl}/logout/frontchannel`,
...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>>;
}) {
const { client: oauthClient } = params;
const redirectUri = `${rpBaseUrl}/callback`;
const codeVerifier = generateRandomString(32);
const 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,
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;
}>("/oauth2/token", { method: "POST", body, headers: tokenHeaders });
return tokens.data!;
}
/**
* Hits the end-session endpoint the way a top-level browser navigation does.
* `isBrowserNavigation` requires `sec-fetch-mode: navigate` or an HTML
* `accept`, and that is the only request shape that renders the iframe
* fan-out page — anything else keeps the redirect/JSON contract.
*/
async function endSessionNavigation(
query: Record<string, string>,
init?: RequestInit,
) {
const params = new URLSearchParams(query);
return auth.handler(
new Request(`${baseUrl}/api/auth/oauth2/end-session?${params}`, {
...init,
headers: {
accept: "text/html,application/xhtml+xml",
"sec-fetch-mode": "navigate",
...(init?.headers as Record<string, string> | undefined),
},
}),
);
}
it("renders one hidden iframe per front-channel client after ending the session", async () => {
const fcClient = await registerClient();
const plainClient = await registerClient({
frontchannel_logout_uri: undefined,
});
const tokens = await issueTokens({ client: fcClient });
await issueTokens({ client: plainClient });
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
const html = await response.text();
const sources = extractIframeSources(html).map((src) => new URL(src));
expect(sources).toHaveLength(1);
expect(sources[0]!.origin + sources[0]!.pathname).toBe(
`${rpBaseUrl}/logout/frontchannel`,
);
// Spec §3: the OP terminates the session before rendering the iframes
const session = await client.getSession({ fetchOptions: { headers } });
expect(session.data).toBeNull();
});
it("keeps the logout page CSP and allows only the RP frames and the page script", async () => {
const fcClient = await registerClient();
const tokens = await issueTokens({ client: fcClient });
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
});
const html = await response.text();
const scriptHash = createHash("sha256")
.update(extractInlineScript(html))
.digest("base64");
expect(response.headers.get("content-security-policy")).toBe(
`default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; frame-src ${rpBaseUrl}; script-src 'sha256-${scriptHash}'`,
);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
/**
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html#OPLogout
*/
it("sends iss and sid to every front-channel client, matching each ID token sid", async () => {
const initiator = await registerClient({
frontchannel_logout_uri: `${rpBaseUrl}/logout/fc-required`,
frontchannel_logout_session_required: true,
});
// Registers only front-channel logout: no end-session, no back-channel.
const participant = await registerClient({
enable_end_session: false,
frontchannel_logout_uri: `${rpBaseUrl}/logout/fc-optional`,
});
const initiatorTokens = await issueTokens({ client: initiator });
const participantTokens = await issueTokens({ client: participant });
const sid = decodeJwt(initiatorTokens.id_token).sid;
expect(sid).toBeDefined();
expect(decodeJwt(participantTokens.id_token).sid).toBe(sid);
const response = await endSessionNavigation({
id_token_hint: initiatorTokens.id_token,
});
const sources = extractIframeSources(await response.text()).map(
(src) => new URL(src),
);
expect(sources.map((u) => u.pathname).sort()).toEqual([
"/logout/fc-optional",
"/logout/fc-required",
]);
for (const source of sources) {
expect(source.searchParams.get("iss")).toBe(issuer);
expect(source.searchParams.get("sid")).toBe(sid);
}
});
it("redirects to the verified post_logout_redirect_uri after the iframes", async () => {
const fcClient = await registerClient({
post_logout_redirect_uris: [`${rpBaseUrl}/logout/callback`],
});
const tokens = await issueTokens({ client: fcClient });
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
post_logout_redirect_uri: `${rpBaseUrl}/logout/callback`,
state,
});
expect(response.status).toBe(200);
const html = await response.text();
const redirect = `${rpBaseUrl}/logout/callback?state=${state}`;
expect(html).toContain(`data-post-logout-redirect-uri="${redirect}"`);
// Without JavaScript, a meta refresh performs the same redirect.
expect(html).toContain(
`<meta http-equiv="refresh" content="3;url=${redirect}">`,
);
});
it("shows the logged-out state, not a redirect, for an unregistered post_logout_redirect_uri", async () => {
const fcClient = await registerClient();
const tokens = await issueTokens({ client: fcClient });
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
post_logout_redirect_uri: `${rpBaseUrl}/evil`,
});
expect(response.status).toBe(200);
const html = await response.text();
expect(html).not.toContain(`${rpBaseUrl}/evil`);
expect(html).not.toContain("data-post-logout-redirect-uri=");
expect(html).toContain(
'data-logged-out-message="Logged out. The requested post-logout redirect was not registered."',
);
});
it("keeps the immediate redirect when no front-channel client holds tokens on the session", async () => {
const plainClient = await registerClient({
frontchannel_logout_uri: undefined,
post_logout_redirect_uris: [`${rpBaseUrl}/logout/callback`],
});
const tokens = await issueTokens({ client: plainClient });
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
post_logout_redirect_uri: `${rpBaseUrl}/logout/callback`,
state,
});
expect(response.status).toBe(302);
const location = response.headers.get("location")!;
expect(location).toContain(`${rpBaseUrl}/logout/callback`);
expect(location).toContain(`state=${state}`);
});
it("keeps the registered query, overwrites a registered sid, and escapes the iframe source", async () => {
const fcClient = await registerClient({
frontchannel_logout_uri: `${rpBaseUrl}/logout/frontchannel?a=1&b=2&sid=stale`,
});
const tokens = await issueTokens({ client: fcClient });
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
});
const html = await response.text();
// Raw `&` must be entity-encoded inside the attribute value
expect(html).toContain("a=1&amp;b=2&amp;sid=");
const [source] = extractIframeSources(html).map((src) => new URL(src));
expect(source!.searchParams.get("a")).toBe("1");
expect(source!.searchParams.get("b")).toBe("2");
expect(source!.searchParams.getAll("sid")).toEqual([
decodeJwt(tokens.id_token).sid,
]);
expect(source!.searchParams.getAll("iss")).toEqual([issuer]);
});
it("skips a client whose stored frontchannel_logout_uri is not a URL without dropping the others", async () => {
const validClient = await registerClient();
const corruptClient = await registerClient({
frontchannel_logout_uri: `${rpBaseUrl}/logout/corrupt`,
});
const tokens = await issueTokens({ client: validClient });
await issueTokens({ client: corruptClient });
const ctx = await auth.$context;
await ctx.adapter.update({
model: "oauthClient",
where: [{ field: "clientId", value: corruptClient.client_id }],
update: { frontchannelLogoutUri: "not a url" },
});
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
});
const sources = extractIframeSources(await response.text()).map(
(src) => new URL(src).pathname,
);
expect(sources).toEqual(["/logout/frontchannel"]);
});
it("stops notifying a client after an update clears its frontchannel_logout_uri", async () => {
const fcClient = await registerClient({
post_logout_redirect_uris: [`${rpBaseUrl}/logout/callback`],
});
const tokens = await issueTokens({ client: fcClient });
await auth.api.adminUpdateOAuthClient({
headers,
body: {
client_id: fcClient.client_id,
update: { frontchannel_logout_uri: null },
},
});
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
post_logout_redirect_uri: `${rpBaseUrl}/logout/callback`,
});
expect(response.status).toBe(302);
});
it("does not notify relying parties when a hook vetoes the session deletion", async () => {
const fcClient = await registerClient();
const tokens = await issueTokens({ client: fcClient });
vetoSessionDelete = true;
const response = await endSessionNavigation({
id_token_hint: tokens.id_token,
});
const html = await response.text();
expect(html).not.toContain("<iframe");
const session = await auth.api.getSession({ headers });
expect(session).not.toBeNull();
});
it("renders the front-channel page after the user confirms logout", async () => {
const fcClient = await registerClient();
await issueTokens({ client: fcClient });
// No id_token_hint, so the OP asks the user to confirm first.
const confirmation = await endSessionNavigation({});
expect(await confirmation.text()).toContain(
"data-oidc-logout-confirmation",
);
cookieSetter(headers)({ response: confirmation } as never);
const completed = await auth.handler(
new Request(`${baseUrl}/api/auth/oauth2/end-session/confirm`, {
method: "POST",
headers: {
accept: "text/html",
"content-type": "application/x-www-form-urlencoded",
cookie: headers.get("cookie") ?? "",
origin: baseUrl,
"sec-fetch-mode": "navigate",
},
body: new URLSearchParams({ action: "confirm" }),
}),
);
expect(completed.status).toBe(200);
const sources = extractIframeSources(await completed.text()).map(
(src) => new URL(src).pathname,
);
expect(sources).toEqual(["/logout/frontchannel"]);
});
it("preserves the JSON contract for fetch-style requests", async () => {
const fcClient = await registerClient({
post_logout_redirect_uris: [`${rpBaseUrl}/logout/callback`],
});
const tokens = await issueTokens({ client: fcClient });
// Browser `fetch()` calls cannot render iframes, so the response must
// stay on the pre-existing JSON shape even when front-channel clients
// hold tokens on the session.
const response = await auth.handler(
new Request(
`${baseUrl}/api/auth/oauth2/end-session?${new URLSearchParams({
id_token_hint: tokens.id_token,
post_logout_redirect_uri: `${rpBaseUrl}/logout/callback`,
})}`,
{
headers: {
accept: "application/json",
"sec-fetch-mode": "cors",
"sec-fetch-dest": "empty",
"sec-fetch-site": "same-origin",
},
},
),
);
expect(response.headers.get("content-type")).toContain("application/json");
const body = (await response.json()) as { redirect: boolean; url: string };
expect(body.redirect).toBe(true);
expect(body.url).toContain(`${rpBaseUrl}/logout/callback`);
});
});
describe("oauth front-channel logout (jwt plugin disabled)", async () => {
const port = 3022;
const baseUrl = `http://localhost:${port}`;
const rpBaseUrl = "http://localhost:5001";
const state = "123";
const scopes = ["openid", "email", "profile"];
// Front-channel logout never signs anything — the iframe URLs carry plain
// `iss`/`sid` query parameters — so it must keep working with HS256 ID
// tokens when the jwt plugin is disabled.
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: baseUrl,
plugins: [
oauthProvider({
disableJwtPlugin: true,
loginPage: "/login",
consentPage: "/consent",
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
scopes,
}),
],
});
const { headers } = await signInWithTestUser();
const client = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: baseUrl,
fetchOptions: { customFetchImpl },
});
it("renders the front-channel logout page when the jwt plugin is disabled", async () => {
const redirectUri = `${rpBaseUrl}/callback`;
const oauthClient = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
application_type: "native",
token_endpoint_auth_method: "client_secret_post",
skip_consent: true,
enable_end_session: true,
frontchannel_logout_uri: `${rpBaseUrl}/logout/frontchannel`,
},
});
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
throw new Error("client registration failed");
}
const codeVerifier = generateRandomString(32);
const 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,
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<{ id_token: string }>("/oauth2/token", {
method: "POST",
body,
headers: tokenHeaders,
});
const response = await auth.handler(
new Request(
`${baseUrl}/api/auth/oauth2/end-session?${new URLSearchParams({
id_token_hint: tokens.data!.id_token,
})}`,
{
headers: {
accept: "text/html,application/xhtml+xml",
"sec-fetch-mode": "navigate",
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(
extractIframeSources(await response.text()).map(
(src) => new URL(src).pathname,
),
).toEqual(["/logout/frontchannel"]);
});
});
+208 -41
View File
@@ -1,6 +1,11 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { getCurrentAdapter } from "@better-auth/core/context";
import {
defineRequestState,
getCurrentAdapter,
hasRequestState,
} from "@better-auth/core/context";
import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
import { createHash } from "@better-auth/utils/hash";
import { deleteSessionCookie } from "better-auth/cookies";
import { generateRandomString } from "better-auth/crypto";
import { getJwks } from "better-auth/oauth2";
@@ -32,6 +37,11 @@ const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
// window is enough.
const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5_000;
// OIDC Front-Channel Logout 1.0 gives no normative timeout for the iframe
// fan-out; 3 seconds keeps a hung RP iframe from stalling the user-facing
// redirect while still letting typical logout endpoints complete.
const FRONTCHANNEL_LOGOUT_TIMEOUT_MS = 3_000;
interface TokenRow {
id: string;
clientId: string;
@@ -50,16 +60,23 @@ interface BackchannelLogoutTarget {
}
/**
* Plan produced by the synchronous revocation phase. The delivery phase
* consumes this plan and POSTs one Logout Token per target. `sessionId` is
* Work captured before a session is deleted, for every client that holds
* tokens on it. After the deletion commits, the token ids are revoked, one
* Logout Token is POSTed per back-channel target, and the front-channel URIs
* are handed to the logout page rendered for this request. `sessionId` is
* always present because every session-end path that reaches here carries the
* id of the session being terminated.
*/
export interface BackchannelLogoutPlan {
export interface SessionLogoutPlan {
accessTokenIds: string[];
refreshTokenIds: string[];
sessionId: string;
targets: BackchannelLogoutTarget[];
backchannelTargets: BackchannelLogoutTarget[];
/**
* Each client's `frontchannel_logout_uri` with `iss` and `sid` appended,
* ready to load in an iframe.
*/
frontchannelLogoutUris: string[];
}
/**
@@ -119,15 +136,16 @@ async function signLogoutToken(
* Token revocation runs regardless of the JWT plugin (refresh-token revocation
* has no dependency on signing). Only Logout Token delivery needs the JWT
* plugin, so a plan may contain token identifiers with no delivery targets.
* Front-channel URIs sign nothing and are planned in either mode.
*
* Returns `null` when there is nothing to do, so the caller can skip the
* background handoff entirely.
*/
async function prepareBackchannelLogoutPlan(
async function prepareSessionLogoutPlan(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
input: { sessionId: string; userId: string },
): Promise<BackchannelLogoutPlan | null> {
): Promise<SessionLogoutPlan | null> {
const { sessionId, userId } = input;
if (!userId) return null;
@@ -180,7 +198,7 @@ async function prepareBackchannelLogoutPlan(
? []
: clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
const targets = (
const backchannelTargets = (
await Promise.all(
eligibleClients.map(async (client) => {
try {
@@ -199,14 +217,40 @@ async function prepareBackchannelLogoutPlan(
)
).filter((target): target is BackchannelLogoutTarget => target !== null);
// Front-Channel Logout 1.0 §2: `iss` and `sid` are optional unless the
// client registered `frontchannel_logout_session_required`, but if either
// is sent both must be. Sending them to every client lets each RP match
// the request to its session, the same way every Logout Token carries
// `sid`. They overwrite any registered `iss` or `sid` so the RP never
// reads a stale value first.
const issuer = getIssuer(ctx, opts);
const frontchannelLogoutUris = clients.flatMap((client) => {
if (!client.frontchannelLogoutUri || client.disabled) return [];
// A stored URI that bypassed registration validation must not cost
// every other client its revocation and back-channel delivery.
try {
const uri = new URL(client.frontchannelLogoutUri);
uri.searchParams.set("iss", issuer);
uri.searchParams.set("sid", sessionId);
return [uri.toString()];
} catch (error) {
logger.warn(
`front-channel logout: invalid frontchannel_logout_uri for client ${client.clientId}`,
error,
);
return [];
}
});
return {
accessTokenIds: accessToRevokeIds,
refreshTokenIds: refreshToRevokeIds,
sessionId,
targets,
backchannelTargets,
frontchannelLogoutUris,
};
} catch (error) {
logger.error("back-channel logout planning failed", error);
logger.error("logout planning failed", error);
return null;
}
}
@@ -216,9 +260,9 @@ async function prepareBackchannelLogoutPlan(
* `session.delete.after` after the row is consumed and, for transactional
* callers, only after the transaction commits.
*/
async function applyBackchannelLogoutPlan(
async function applySessionLogoutPlan(
ctx: GenericEndpointContext,
plan: BackchannelLogoutPlan,
plan: SessionLogoutPlan,
): Promise<void> {
const revokedAt = new Date();
const adapter = await getCurrentAdapter(ctx.context.adapter);
@@ -249,7 +293,7 @@ async function applyBackchannelLogoutPlan(
}
}
if (plan.targets.length > 0) {
if (plan.backchannelTargets.length > 0) {
await deliverBackchannelLogoutTokens(ctx, plan);
}
}
@@ -268,7 +312,7 @@ async function applyBackchannelLogoutPlan(
*/
async function deliverBackchannelLogoutTokens(
ctx: GenericEndpointContext,
plan: BackchannelLogoutPlan,
plan: SessionLogoutPlan,
): Promise<void> {
const logger = ctx.context.logger;
const jwtPluginOptions = getJwtPlugin(ctx.context)?.options;
@@ -282,7 +326,7 @@ async function deliverBackchannelLogoutTokens(
: await resolveSigningKey(ctx, jwtPluginOptions);
await Promise.allSettled(
plan.targets.map(async ({ client, sub }) => {
plan.backchannelTargets.map(async ({ client, sub }) => {
try {
const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
const token = await signLogoutToken(
@@ -326,7 +370,36 @@ async function deliverBackchannelLogoutTokens(
);
}
export { applyBackchannelLogoutPlan, prepareBackchannelLogoutPlan };
export { applySessionLogoutPlan, prepareSessionLogoutPlan };
/**
* Front-channel logout URIs collected from sessions deleted during the current
* request. `session.delete.after` records them only once the deletion has
* committed, so a vetoed or rolled-back delete never reaches the logout page.
*/
const frontchannelLogoutUrisState = defineRequestState<string[]>(() => []);
/**
* Records a committed plan's front-channel URIs for the logout page of the
* current request. Deletions outside a request (no browser to render iframes)
* have nowhere to deliver them, so they are dropped.
*/
export async function recordFrontchannelLogoutUris(
uris: string[],
): Promise<void> {
if (uris.length === 0 || !(await hasRequestState())) return;
const recorded = await frontchannelLogoutUrisState.get();
await frontchannelLogoutUrisState.set([...recorded, ...uris]);
}
/**
* Returns and clears the front-channel URIs recorded during this request.
*/
async function takeFrontchannelLogoutUris(): Promise<string[]> {
const recorded = await frontchannelLogoutUrisState.get();
await frontchannelLogoutUrisState.set([]);
return recorded;
}
const LOGOUT_CONFIRMATION_TTL_SECONDS = 5 * 60;
const LOGOUT_CONFIRMATION_COOKIE_SUFFIX = ".oauth_logout_confirmation";
@@ -386,17 +459,28 @@ function isBrowserNavigation(ctx: GenericEndpointContext): boolean {
);
}
function logoutPage(title: string, body: string, status = 200): Response {
const LOGOUT_PAGE_CSP =
"default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
function logoutPage(
title: string,
body: string,
status = 200,
page: { head?: string; contentSecurityPolicy?: string } = {},
): Response {
return new Response(
`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body>${body}</body></html>`,
`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>${page.head ?? ""}</head><body>${body}</body></html>`,
{
status,
headers: {
"cache-control": "no-store",
"content-security-policy":
"default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
page.contentSecurityPolicy ?? LOGOUT_PAGE_CSP,
"content-type": "text/html; charset=utf-8",
pragma: "no-cache",
// The end-session URL can carry an id_token_hint; never pass it to
// relying parties or other origins through Referer.
"referrer-policy": "no-referrer",
"x-content-type-options": "nosniff",
},
},
@@ -432,6 +516,79 @@ function logoutSuccessPage(note?: string): Response {
);
}
// Static so the page can allow it by hash; per-request values travel in data
// attributes. Redirects once every iframe has loaded, or after the timeout so a
// hung RP cannot stall the user.
const FRONTCHANNEL_LOGOUT_SCRIPT = `(function () {
var status = document.querySelector("[data-oidc-logout-state]");
var redirectUri = status.getAttribute("data-post-logout-redirect-uri");
var frames = document.querySelectorAll("iframe");
var pending = frames.length;
var finished = false;
function finish() {
if (finished) return;
finished = true;
if (redirectUri) {
window.location.replace(redirectUri);
return;
}
status.setAttribute("data-oidc-logout-state", "logged-out");
status.textContent = status.getAttribute("data-logged-out-message");
}
function settle() {
pending -= 1;
if (pending <= 0) finish();
}
for (var i = 0; i < frames.length; i++) {
frames[i].addEventListener("load", settle);
frames[i].addEventListener("error", settle);
}
setTimeout(finish, ${FRONTCHANNEL_LOGOUT_TIMEOUT_MS});
})();`;
let frontchannelLogoutScriptHash: Promise<string> | undefined;
/**
* OIDC Front-Channel Logout 1.0 §3 logout page: one hidden iframe per relying
* party, then a redirect to the verified `post_logout_redirect_uri` or a
* logged-out message. The OP session has already ended when this renders.
* Without JavaScript the iframes still load, and a meta refresh performs the
* redirect.
*/
async function frontchannelLogoutPage(
uris: string[],
redirect: { uri?: string; note?: string },
): Promise<Response> {
frontchannelLogoutScriptHash ??= createHash("SHA-256", "base64").digest(
FRONTCHANNEL_LOGOUT_SCRIPT,
);
const scriptHash = await frontchannelLogoutScriptHash;
const frameOrigins = [...new Set(uris.map((uri) => new URL(uri).origin))];
const loggedOutMessage = redirect.note
? `Logged out. ${redirect.note}`
: "Logged out.";
const iframes = uris
.map((uri) => `<iframe hidden src="${escapeHtml(uri)}"></iframe>`)
.join("");
const redirectAttribute = redirect.uri
? ` data-post-logout-redirect-uri="${escapeHtml(redirect.uri)}"`
: "";
const noscript = redirect.uri
? `<noscript><a href="${escapeHtml(redirect.uri)}">Continue</a></noscript>`
: `<noscript><p>${escapeHtml(loggedOutMessage)}</p></noscript>`;
return logoutPage(
"Signing out",
`<main><p data-oidc-logout-state="pending" data-logged-out-message="${escapeHtml(loggedOutMessage)}"${redirectAttribute}>Signing out of connected applications&hellip;</p>${noscript}</main>${iframes}<script>${FRONTCHANNEL_LOGOUT_SCRIPT}</script>`,
200,
{
head: redirect.uri
? `<noscript><meta http-equiv="refresh" content="${Math.ceil(FRONTCHANNEL_LOGOUT_TIMEOUT_MS / 1000)};url=${escapeHtml(redirect.uri)}"></noscript>`
: undefined,
contentSecurityPolicy: `${LOGOUT_PAGE_CSP}; frame-src ${frameOrigins.join(" ")}; script-src 'sha256-${scriptHash}'`,
},
);
}
function logoutErrorPage(description: string, status: number): Response {
return logoutPage(
"Logout error",
@@ -818,6 +975,30 @@ async function confirmationRequired(
return logoutConfirmationPage(ctx);
}
const UNREGISTERED_REDIRECT_NOTE =
"The requested post-logout redirect was not registered.";
/**
* Response once the session has ended. A browser navigation renders the
* front-channel logout page when relying parties must be notified; otherwise
* the caller gets the verified redirect, the success page, or an empty result.
*/
async function loggedOutResponse(
ctx: GenericEndpointContext,
redirect: { uri?: string; invalid: boolean },
): Promise<OAuthRedirectResult | Response | null> {
const note = redirect.invalid ? UNREGISTERED_REDIRECT_NOTE : undefined;
const frontchannelLogoutUris = await takeFrontchannelLogoutUris();
if (frontchannelLogoutUris.length > 0 && isBrowserNavigation(ctx)) {
return frontchannelLogoutPage(frontchannelLogoutUris, {
uri: redirect.uri,
note,
});
}
if (redirect.uri) return handleRedirect(ctx, redirect.uri);
return isBrowserNavigation(ctx) ? logoutSuccessPage(note) : null;
}
async function completeConfirmedLogout(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
@@ -838,9 +1019,7 @@ async function completeConfirmedLogout(
if (redirect.uri) return handleRedirect(ctx, redirect.uri);
return isBrowserNavigation(ctx)
? logoutSuccessPage(
redirect.invalid
? "The requested post-logout redirect was not registered."
: undefined,
redirect.invalid ? UNREGISTERED_REDIRECT_NOTE : undefined,
)
: logoutProtocolError(
ctx,
@@ -861,14 +1040,7 @@ async function completeConfirmedLogout(
await deleteLogoutSession(ctx, currentSession.session);
deleteSessionCookie(ctx);
clearLogoutConfirmationState(ctx);
if (redirect.uri) return handleRedirect(ctx, redirect.uri);
return isBrowserNavigation(ctx)
? logoutSuccessPage(
redirect.invalid
? "The requested post-logout redirect was not registered."
: undefined,
)
: null;
return loggedOutResponse(ctx, redirect);
}
/**
@@ -880,7 +1052,9 @@ async function completeConfirmedLogout(
*
* Session termination goes through `internalAdapter.deleteSession`, which fires
* `session.delete.after` so the hook drives revocation and back-channel
* notifications to every RP with tokens on the session.
* notifications to every RP with tokens on the session. When the browser
* navigates here, RPs with a `frontchannel_logout_uri` are then notified
* through hidden iframes before the redirect.
*
* @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
*/
@@ -1004,20 +1178,13 @@ export async function rpInitiatedLogoutEndpoint(
hintedSession ?? (matchesCurrentSession ? currentSession?.session : null);
if (sessionToDelete) {
// internalAdapter.deleteSession triggers the normal before/after session
// hooks, including revocation and back-channel dispatch for every RP.
// hooks: revocation, back-channel dispatch, and the front-channel URIs
// the logout page renders.
await deleteLogoutSession(ctx, sessionToDelete);
}
if (matchesCurrentSession) deleteSessionCookie(ctx);
clearLogoutConfirmationState(ctx);
if (redirect.uri) return handleRedirect(ctx, redirect.uri);
return isBrowserNavigation(ctx)
? logoutSuccessPage(
redirect.invalid
? "The requested post-logout redirect was not registered."
: undefined,
)
: null;
return loggedOutResponse(ctx, redirect);
}
/**
@@ -113,6 +113,8 @@ describe("oauth metadata", async () => {
dpop_signing_alg_values_supported: [...DPOP_SIGNING_ALGORITHMS],
backchannel_logout_supported: true,
backchannel_logout_session_supported: true,
frontchannel_logout_supported: true,
frontchannel_logout_session_supported: true,
claims_supported: baseClaims,
claims_parameter_supported: true,
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
@@ -289,6 +291,8 @@ describe("oauth metadata", async () => {
authorization_response_iss_parameter_supported: true,
backchannel_logout_supported: true,
backchannel_logout_session_supported: true,
frontchannel_logout_supported: true,
frontchannel_logout_session_supported: true,
});
});
@@ -303,6 +307,20 @@ describe("oauth metadata", async () => {
expect(metadata.backchannel_logout_session_supported).toBe(false);
});
it("advertises front-channel logout support even when the jwt plugin is disabled", async () => {
// Front-channel logout renders iframes carrying plain `iss`/`sid` query
// parameters — no signed artifact — so support does not depend on the
// jwt plugin.
const { auth } = await createTestInstance({
oauthProviderConfig: {
disableJwtPlugin: true,
},
});
const metadata = await auth.api.getOpenIdConfig();
expect(metadata.frontchannel_logout_supported).toBe(true);
expect(metadata.frontchannel_logout_session_supported).toBe(true);
});
it("should not provide dynamic client registration endpoint when disabled", async () => {
const { auth } = await createTestInstance({
oauthProviderConfig: {
+5
View File
@@ -102,6 +102,11 @@ export function authServerMetadata(
([...DPOP_SIGNING_ALGORITHMS] as JWSAlgorithms[]),
backchannel_logout_supported: backchannelSupported,
backchannel_logout_session_supported: backchannelSupported,
// Front-channel logout renders iframes carrying plain `iss`/`sid` query
// parameters — no signed artifact — so support is independent of the
// JWT plugin.
frontchannel_logout_supported: true,
frontchannel_logout_session_supported: true,
};
return metadata;
}
+40 -22
View File
@@ -22,10 +22,11 @@ import { consentEndpoint } from "./consent";
import { continueEndpoint } from "./continue";
import { validateOAuthProviderExtensions } from "./extensions";
import { introspectEndpoint } from "./introspect";
import type { BackchannelLogoutPlan } from "./logout";
import type { SessionLogoutPlan } from "./logout";
import {
applyBackchannelLogoutPlan,
prepareBackchannelLogoutPlan,
applySessionLogoutPlan,
prepareSessionLogoutPlan,
recordFrontchannelLogoutUris,
rpInitiatedLogoutConfirmationEndpoint,
rpInitiatedLogoutEndpoint,
} from "./logout";
@@ -113,9 +114,9 @@ export const oAuthState = defineRequestState<{
postLoginClearedForSession?: string;
} | null>(() => null);
export const getOAuthProviderState = oAuthState.get;
const backchannelLogoutPlansByContext = new WeakMap<
const sessionLogoutPlansByContext = new WeakMap<
GenericEndpointContext,
Map<string, BackchannelLogoutPlan>
Map<string, SessionLogoutPlan>
>();
const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
@@ -563,20 +564,16 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
delete: {
async before(session, hookCtx) {
if (!hookCtx) return;
const plan = await prepareBackchannelLogoutPlan(
hookCtx,
opts,
{
sessionId: session.id,
userId: session.userId,
},
);
const plan = await prepareSessionLogoutPlan(hookCtx, opts, {
sessionId: session.id,
userId: session.userId,
});
if (!plan) return;
const logoutPlanBySessionId =
backchannelLogoutPlansByContext.get(hookCtx) ??
new Map<string, BackchannelLogoutPlan>();
sessionLogoutPlansByContext.get(hookCtx) ??
new Map<string, SessionLogoutPlan>();
logoutPlanBySessionId.set(session.id, plan);
backchannelLogoutPlansByContext.set(
sessionLogoutPlansByContext.set(
hookCtx,
logoutPlanBySessionId,
);
@@ -584,20 +581,26 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
async after(session, hookCtx) {
if (!hookCtx) return;
const logoutPlanBySessionId =
backchannelLogoutPlansByContext.get(hookCtx);
sessionLogoutPlansByContext.get(hookCtx);
if (!logoutPlanBySessionId) return;
const plan = logoutPlanBySessionId.get(session.id);
logoutPlanBySessionId.delete(session.id);
if (logoutPlanBySessionId.size === 0) {
backchannelLogoutPlansByContext.delete(hookCtx);
sessionLogoutPlansByContext.delete(hookCtx);
}
if (!plan) return;
const logoutTask = applyBackchannelLogoutPlan(
// Recorded synchronously so the logout page of this request
// can render the iframes; delivery below may run after the
// response.
await recordFrontchannelLogoutUris(
plan.frontchannelLogoutUris,
);
const logoutTask = applySessionLogoutPlan(
hookCtx,
plan,
).catch((error) => {
hookCtx.context.logger.error(
"Back-channel logout failed after session deletion",
"Logout notification failed after session deletion",
error,
);
});
@@ -1456,7 +1459,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
responses: {
"200": {
description:
"Logout completed, or an HTML confirmation page was returned for browser navigation. A redirect is used only for an exact registered post_logout_redirect_uri.",
"Logout completed, or an HTML page was returned for browser navigation — a confirmation page, or the OIDC front-channel logout page when clients with a registered frontchannel_logout_uri hold tokens on the session. A redirect is used only for an exact registered post_logout_redirect_uri.",
content: {
"application/json": {
schema: {
@@ -1476,7 +1479,11 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
},
},
"text/html": {
schema: { type: "string" },
schema: {
type: "string",
description:
"Logout confirmation page, or the OIDC Front-Channel Logout page with one hidden iframe per registered RP",
},
},
},
},
@@ -1629,6 +1636,17 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
description:
"Whether the RP requires a `sid` claim in every Logout Token",
},
frontchannel_logout_uri: {
type: "string",
format: "uri",
description:
"RP URL rendered in a hidden iframe on the OP's logout page when the end-user's OP session terminates",
},
frontchannel_logout_session_required: {
type: "boolean",
description:
"Whether the RP requires `iss` and `sid` query parameters on its front-channel logout URI. The OP always sends both.",
},
token_endpoint_auth_method: {
type: "string",
description:
@@ -93,6 +93,50 @@ describe("oauthClient", async () => {
});
});
it("clears a logout URI and its session flag when an update sends null", async () => {
const created = await authClient.oauth2.createClient({
redirect_uris: [redirectUri],
backchannel_logout_uri: `${rpBaseUrl}/logout/backchannel`,
backchannel_logout_session_required: true,
frontchannel_logout_uri: `${rpBaseUrl}/logout/frontchannel`,
frontchannel_logout_session_required: true,
});
expect(created.error).toBeNull();
const clientId = created.data!.client_id;
const clearedFrontchannel = await authClient.oauth2.updateClient({
client_id: clientId,
update: { frontchannel_logout_uri: null },
});
expect(clearedFrontchannel.error).toBeNull();
expect(clearedFrontchannel.data?.frontchannel_logout_uri).toBeUndefined();
expect(
clearedFrontchannel.data?.frontchannel_logout_session_required,
).toBeUndefined();
expect(clearedFrontchannel.data?.backchannel_logout_uri).toBe(
`${rpBaseUrl}/logout/backchannel`,
);
const clearedBackchannel = await auth.api.adminUpdateOAuthClient({
headers,
body: {
client_id: clientId,
update: { backchannel_logout_uri: null },
},
});
expect(clearedBackchannel.backchannel_logout_uri).toBeUndefined();
expect(
clearedBackchannel.backchannel_logout_session_required,
).toBeUndefined();
const stored = await authClient.oauth2.getClient({
query: { client_id: clientId },
});
expect(stored.data?.backchannel_logout_uri).toBeUndefined();
expect(stored.data?.frontchannel_logout_uri).toBeUndefined();
await authClient.oauth2.deleteClient({ client_id: clientId });
});
it("round-trips application_type through admin create and update", async () => {
const created = await auth.api.adminCreateOAuthClient({
headers,
@@ -20,10 +20,18 @@ import { assertClientPrivileges } from "./privileges";
type OAuthClientUpdate = Omit<
OAuthClientRegistrationMetadata,
"application_type" | "client_id" | "resources"
| "application_type"
| "client_id"
| "resources"
| "backchannel_logout_uri"
| "frontchannel_logout_uri"
> & {
application_type?: "web" | "native";
client_credentials_scopes?: string[];
/** `null` removes the URI and `backchannel_logout_session_required`. */
backchannel_logout_uri?: string | null;
/** `null` removes the URI and `frontchannel_logout_session_required`. */
frontchannel_logout_uri?: string | null;
};
export async function getClientEndpoint(
@@ -248,8 +256,27 @@ export async function updateClientEndpoint(
});
}
const { client_credentials_scopes: rawClientCredentialsScopes, ...updates } =
ctx.body.update;
const {
client_credentials_scopes: rawClientCredentialsScopes,
backchannel_logout_uri: backchannelLogoutUri,
frontchannel_logout_uri: frontchannelLogoutUri,
...otherUpdates
} = ctx.body.update;
const clearsBackchannelLogout = backchannelLogoutUri === null;
const clearsFrontchannelLogout = frontchannelLogoutUri === null;
const updates = {
...otherUpdates,
...(backchannelLogoutUri != null
? { backchannel_logout_uri: backchannelLogoutUri }
: {}),
...(frontchannelLogoutUri != null
? { frontchannel_logout_uri: frontchannelLogoutUri }
: {}),
};
const hasUpdates =
Object.keys(updates).length > 0 ||
clearsBackchannelLogout ||
clearsFrontchannelLogout;
const ownsClient = client.userId
? client.userId === session.user.id
: client.referenceId && opts.clientReference
@@ -259,7 +286,7 @@ export async function updateClientEndpoint(
!ownsClient &&
settings?.admin &&
rawClientCredentialsScopes !== undefined &&
Object.keys(updates).length === 0;
!hasUpdates;
if (!ownsClient && !isCrossOwnerScopeConfiguration) {
throw new APIError("UNAUTHORIZED");
}
@@ -273,10 +300,7 @@ export async function updateClientEndpoint(
);
}
if (
Object.keys(updates).length === 0 &&
rawClientCredentialsScopes === undefined
) {
if (!hasUpdates && rawClientCredentialsScopes === undefined) {
// Never return @internal client_secret
const res = schemaToOAuth(client);
res.client_secret = undefined;
@@ -316,21 +340,31 @@ export async function updateClientEndpoint(
}
}
await checkOAuthClient(
{
...schemaToOAuth(client),
...updates,
},
opts,
{
ctx,
},
);
const updatedMetadata = { ...schemaToOAuth(client), ...updates };
if (clearsBackchannelLogout) {
updatedMetadata.backchannel_logout_uri = undefined;
updatedMetadata.backchannel_logout_session_required = undefined;
}
if (clearsFrontchannelLogout) {
updatedMetadata.frontchannel_logout_uri = undefined;
updatedMetadata.frontchannel_logout_session_required = undefined;
}
await checkOAuthClient(updatedMetadata, opts, {
ctx,
});
// Clear obsolete auth material when switching auth methods
const schemaUpdates: Record<string, unknown> = {
...oauthToSchema(updates),
};
if (clearsBackchannelLogout) {
schemaUpdates.backchannelLogoutUri = null;
schemaUpdates.backchannelLogoutSessionRequired = null;
}
if (clearsFrontchannelLogout) {
schemaUpdates.frontchannelLogoutUri = null;
schemaUpdates.frontchannelLogoutSessionRequired = null;
}
if (
!finalGrantTypes.includes("client_credentials") ||
finalTokenEndpointAuthMethod === "none"
@@ -40,6 +40,8 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
frontchannel_logout_uri: SafeUrlSchema.optional(),
frontchannel_logout_session_required: z.boolean().optional(),
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
application_type: z.enum(["web", "native"]).optional(),
jwks: clientJwksSchema.optional(),
@@ -246,6 +248,8 @@ export const createOAuthClient = (opts: OAuthOptions<Scope[]>) =>
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
frontchannel_logout_uri: SafeUrlSchema.optional(),
frontchannel_logout_session_required: z.boolean().optional(),
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
application_type: z.enum(["web", "native"]).optional(),
jwks: clientJwksSchema.optional(),
@@ -508,8 +512,11 @@ 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(),
// `null` removes the URI and its session_required flag.
backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
backchannel_logout_session_required: z.boolean().optional(),
frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
frontchannel_logout_session_required: z.boolean().optional(),
// token_endpoint_auth_method is immutable because changing the
// registered authentication method also changes credential handling.
application_type: z.enum(["web", "native"]).optional(),
@@ -563,8 +570,11 @@ 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(),
// `null` removes the URI and its session_required flag.
backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
backchannel_logout_session_required: z.boolean().optional(),
frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
frontchannel_logout_session_required: z.boolean().optional(),
// token_endpoint_auth_method is immutable because changing the
// registered authentication method also changes credential handling.
application_type: z.enum(["web", "native"]).optional(),
@@ -21,6 +21,7 @@ import type { OAuthClient } from "./types/oauth";
describe("oauth register", async () => {
const baseUrl = "http://localhost:3000";
const rpBaseUrl = "http://localhost:5000";
const fcBaseUrl = "https://rp.example.com";
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: baseUrl,
plugins: [
@@ -697,6 +698,110 @@ describe("oauth register", async () => {
expect(response.error?.status).toBe(400);
}
});
it("round-trips frontchannel_logout_uri and frontchannel_logout_session_required", async () => {
const frontchannelUri = `${fcBaseUrl}/logout/frontchannel`;
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
frontchannel_logout_uri: frontchannelUri,
frontchannel_logout_session_required: true,
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.frontchannel_logout_uri).toBe(frontchannelUri);
expect(response.data?.frontchannel_logout_session_required).toBe(true);
});
it("rejects frontchannel_logout_uri with a fragment", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
frontchannel_logout_uri: `${fcBaseUrl}/logout/frontchannel#section`,
});
expect(response.error?.status).toBe(400);
});
it("rejects frontchannel_logout_uri ending with a bare fragment delimiter", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
frontchannel_logout_uri: `${fcBaseUrl}/logout/frontchannel#`,
});
expect(response.error?.status).toBe(400);
});
it("rejects credentials in frontchannel_logout_uri", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
frontchannel_logout_uri:
"https://user:password@rp.example.com/logout/frontchannel",
});
expect(response.error?.status).toBe(400);
});
/**
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPLogout
*/
it("rejects frontchannel_logout_uri outside every registered redirect_uri origin", async () => {
const targets = [
"https://other.example.com/logout/frontchannel",
"http://rp.example.com/logout/frontchannel",
"https://rp.example.com:8443/logout/frontchannel",
];
for (const frontchannel_logout_uri of targets) {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
frontchannel_logout_uri,
});
expect(response.error?.status).toBe(400);
}
});
it("allows a loopback frontchannel_logout_uri that matches a loopback redirect_uri", async () => {
// The browser loads this URI, not the OP, so the back-channel SSRF guard
// does not apply; local development works like it does for redirects.
const response = await serverClient.oauth2.register({
redirect_uris: [`${rpBaseUrl}/callback`],
application_type: "native",
token_endpoint_auth_method: "none",
frontchannel_logout_uri: `${rpBaseUrl}/logout/frontchannel`,
});
expect(response.data?.frontchannel_logout_uri).toBe(
`${rpBaseUrl}/logout/frontchannel`,
);
});
it("rejects a loopback frontchannel_logout_uri on a port no redirect_uri uses", async () => {
// Both values are fixed at registration, so the runtime port variance
// RFC 8252 §7.3 allows for loopback redirects does not apply here.
const response = await serverClient.oauth2.register({
redirect_uris: [`${rpBaseUrl}/callback`],
application_type: "native",
token_endpoint_auth_method: "none",
frontchannel_logout_uri: "http://localhost:6000/logout/frontchannel",
});
expect(response.error?.status).toBe(400);
});
it("rejects a redirect_uris update that leaves frontchannel_logout_uri without a matching origin", async () => {
const created = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
frontchannel_logout_uri: `${fcBaseUrl}/logout/frontchannel`,
},
});
await expect(
auth.api.adminUpdateOAuthClient({
headers,
body: {
client_id: created.client_id,
update: {
redirect_uris: ["https://other.example.com/callback"],
},
},
}),
).rejects.toMatchObject({
body: { error: "invalid_client_metadata" },
});
});
});
describe("oauth register - disableJwtPlugin", async () => {
@@ -727,6 +832,22 @@ describe("oauth register - disableJwtPlugin", async () => {
});
expect(response.error?.status).toBe(400);
});
it("allows frontchannel_logout_uri when jwt plugin is disabled", async () => {
// Unlike back-channel logout, front-channel logout involves no signed
// Logout Token, so it has no dependency on the jwt plugin.
const response = await serverClient.oauth2.register({
redirect_uris: [`${rpBaseUrl}/callback`],
// http loopback redirects are native-only; web clients need https.
application_type: "native",
token_endpoint_auth_method: "none",
frontchannel_logout_uri: `${rpBaseUrl}/logout/frontchannel`,
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.frontchannel_logout_uri).toBe(
`${rpBaseUrl}/logout/frontchannel`,
);
});
});
describe("oauth register - unauthenticated", async () => {
+59
View File
@@ -706,6 +706,56 @@ export async function checkOAuthClient(
});
}
}
// The browser, not the OP, requests this URI, so the back-channel SSRF
// guard does not apply and private or loopback hosts are allowed. Its trust
// comes from Front-Channel Logout 1.0 §2 instead: the URI must share the
// scheme, host, and port of a registered redirect URI, so it inherits that
// URI's scheme policy. Nothing is signed, so the JWT plugin is not required.
if (clientWithDefaults.frontchannel_logout_uri !== undefined) {
let url: URL;
try {
url = new URL(clientWithDefaults.frontchannel_logout_uri);
} catch {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "frontchannel_logout_uri must be an absolute URL",
});
}
// Spec §2: the URI MUST NOT include a fragment. Check the raw value
// rather than `url.hash`, which is empty for a bare trailing `#`.
if (clientWithDefaults.frontchannel_logout_uri.includes("#")) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"frontchannel_logout_uri must not include a fragment component",
});
}
if (url.username || url.password) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"frontchannel_logout_uri must not contain credentials",
});
}
const isHttpOrHttps = url.protocol === "https:" || url.protocol === "http:";
const sharesRedirectOrigin = (clientWithDefaults.redirect_uris ?? []).some(
(redirectUri) => {
try {
return new URL(redirectUri).origin === url.origin;
} catch {
return false;
}
},
);
if (!isHttpOrHttps || !sharesRedirectOrigin) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"frontchannel_logout_uri must use the scheme, host, and port of a registered redirect_uri",
});
}
}
}
interface CreateOAuthClientRegistrationBaseInput {
@@ -1339,6 +1389,8 @@ export function oauthToSchema(
post_logout_redirect_uris: postLogoutRedirectUris,
backchannel_logout_uri: backchannelLogoutUri,
backchannel_logout_session_required: backchannelLogoutSessionRequired,
frontchannel_logout_uri: frontchannelLogoutUri,
frontchannel_logout_session_required: frontchannelLogoutSessionRequired,
token_endpoint_auth_method: tokenEndpointAuthMethod,
grant_types: grantTypes,
response_types: responseTypes,
@@ -1394,6 +1446,8 @@ export function oauthToSchema(
postLogoutRedirectUris,
backchannelLogoutUri,
backchannelLogoutSessionRequired,
frontchannelLogoutUri,
frontchannelLogoutSessionRequired,
tokenEndpointAuthMethod,
grantTypes,
responseTypes,
@@ -1446,6 +1500,8 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
postLogoutRedirectUris,
backchannelLogoutUri,
backchannelLogoutSessionRequired,
frontchannelLogoutUri,
frontchannelLogoutSessionRequired,
tokenEndpointAuthMethod,
grantTypes,
responseTypes,
@@ -1508,6 +1564,9 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
backchannel_logout_uri: backchannelLogoutUri ?? undefined,
backchannel_logout_session_required:
backchannelLogoutSessionRequired ?? undefined,
frontchannel_logout_uri: frontchannelLogoutUri ?? undefined,
frontchannel_logout_session_required:
frontchannelLogoutSessionRequired ?? undefined,
token_endpoint_auth_method: tokenEndpointAuthMethod ?? undefined,
grant_types: grantTypes ?? undefined,
response_types: responseTypes ?? undefined,
+8
View File
@@ -117,6 +117,14 @@ export const schema = {
type: "boolean",
required: false,
},
frontchannelLogoutUri: {
type: "string",
required: false,
},
frontchannelLogoutSessionRequired: {
type: "boolean",
required: false,
},
tokenEndpointAuthMethod: {
type: "string",
required: false,
+3 -1
View File
@@ -369,7 +369,9 @@ async function createIdToken(
: undefined;
const emitSid = Boolean(
client.enableEndSession || client.backchannelLogoutUri,
client.enableEndSession ||
client.backchannelLogoutUri ||
client.frontchannelLogoutUri,
);
const payload: JWTPayload = {
...ID_TOKEN_SCOPE_CLAIM_GUARDS,
@@ -1833,6 +1833,25 @@ export interface SchemaClient<
* @default false
*/
backchannelLogoutSessionRequired?: boolean;
/**
* RP URL rendered in a hidden iframe on the OP's logout page when the
* end-user's session ends at `/oauth2/end-session`. Registering it is the
* per-client opt-in for front-channel logout. Must be absolute, without a
* fragment, and share the scheme, host, and port of a registered redirect
* URI.
*
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPLogout
*/
frontchannelLogoutUri?: string;
/**
* When true, the RP requires `iss` and `sid` query parameters on its
* front-channel logout URI. The OP includes both whenever it notifies a
* front-channel client, so this flag is stored but does not change
* delivery.
*
* @default false
*/
frontchannelLogoutSessionRequired?: boolean;
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
grantTypes?: GrantType[];
responseTypes?: "code"[];
@@ -239,6 +239,27 @@ export interface AuthServerMetadata {
* @see https://datatracker.ietf.org/doc/html/rfc9449
*/
dpop_signing_alg_values_supported?: JWSAlgorithms[];
/**
* Boolean value specifying whether the OP supports HTTP-based (front-channel)
* logout, with true indicating support.
*
* Registered in the "OAuth Authorization Server Metadata" IANA registry
* under OpenID Connect Front-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-frontchannel-1_0.html#OPLogout
*/
frontchannel_logout_supported?: boolean;
/**
* Boolean value specifying whether the OP can pass `iss` (issuer) and `sid`
* (session ID) query parameters to identify the RP session with the OP when
* the `frontchannel_logout_uri` is used.
*
* @default false
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html#OPLogout
*/
frontchannel_logout_session_supported?: boolean;
}
/**
@@ -383,6 +404,25 @@ export interface OAuthClient {
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#RPMetadata
*/
backchannel_logout_session_required?: boolean;
/**
* RP URL that the OP renders in a hidden iframe on its logout page so the
* RP can log itself out when the end-user's OP session ends at
* `/oauth2/end-session`. Must share the scheme, host, and port of one of
* the client's `redirect_uris`.
*
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPLogout
*/
frontchannel_logout_uri?: string;
/**
* When true, the RP requires `iss` and `sid` query parameters on its
* `frontchannel_logout_uri`. The OP includes both whenever it notifies a
* front-channel client, so this flag is stored but does not change
* delivery.
*
* @default false
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html#RPLogout
*/
frontchannel_logout_session_required?: boolean;
token_endpoint_auth_method?: TokenEndpointAuthMethod;
grant_types?: GrantType[];
response_types?: "code"[];
+2
View File
@@ -206,6 +206,8 @@ export const clientRegistrationRequestSchema = z.object({
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
backchannel_logout_uri: SafeUrlSchema.optional(),
backchannel_logout_session_required: z.boolean().optional(),
frontchannel_logout_uri: SafeUrlSchema.optional(),
frontchannel_logout_session_required: z.boolean().optional(),
token_endpoint_auth_method: z.string().trim().min(1).optional(),
jwks: clientJwksSchema.optional(),
jwks_uri: z.string().optional(),
+1
View File
@@ -133,6 +133,7 @@
"test-results/**",
"playwright-report/**"
],
"passThroughEnv": ["PLAYWRIGHT_CROSS_BROWSER"],
"cache": false
},
"deploy": {