diff --git a/.changeset/oauth-provider-clear-logout-uris.md b/.changeset/oauth-provider-clear-logout-uris.md new file mode 100644 index 0000000000..c231898803 --- /dev/null +++ b/.changeset/oauth-provider-clear-logout-uris.md @@ -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. diff --git a/.changeset/oauth-provider-frontchannel-logout.md b/.changeset/oauth-provider-frontchannel-logout.md new file mode 100644 index 0000000000..97f97062eb --- /dev/null +++ b/.changeset/oauth-provider-frontchannel-logout.md @@ -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`. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 46ca823367..840d6d2852 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 7464ac86f1..ea595e4c5f 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -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 + }, } ``` +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`. +### 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. + + + 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. + + +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. + + + 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. + + ### 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", diff --git a/e2e/integration/playwright.config.ts b/e2e/integration/playwright.config.ts index 59a8a34b7b..58d38cf781 100644 --- a/e2e/integration/playwright.config.ts +++ b/e2e/integration/playwright.config.ts @@ -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", + }, + ] + : []), ], }); diff --git a/e2e/integration/vanilla-node/e2e/frontchannel-logout.spec.ts b/e2e/integration/vanilla-node/e2e/frontchannel-logout.spec.ts new file mode 100644 index 0000000000..9d7cb5a70c --- /dev/null +++ b/e2e/integration/vanilla-node/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; +}> { + let handler: Handler = (_req, res) => { + res.statusCode = 503; + res.end(); + }; + const server: Server = createServer((req, res) => handler(req, res)); + const port = await new Promise((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((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(`${body}`); +} + +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>; + +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>; + +async function postJson(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 }> = []; + test.afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())); + }); + + async function start Promise }>(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