From af4b7965e84ed3fa58aea5da14dd44c7b34bb005 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 15 Oct 2026 08:35:47 +0000 Subject: [PATCH] test(oauth-provider): add black-box DPoP interop smoke tests (#11791) --- e2e/smoke/package.json | 3 +- e2e/smoke/test/dpop-interop.spec.ts | 219 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 8 + 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 e2e/smoke/test/dpop-interop.spec.ts diff --git a/e2e/smoke/package.json b/e2e/smoke/package.json index f388d511e2..ad85e07b3b 100644 --- a/e2e/smoke/package.json +++ b/e2e/smoke/package.json @@ -18,7 +18,8 @@ "@types/babel__core": "^7.20.5", "better-auth": "workspace:*", "ioredis": "^5.11.1", - "msw": "^2.14.6" + "msw": "^2.14.6", + "oauth4webapi": "3.8.8" }, "scripts": { "e2e:smoke": "node --test ./test/*.spec.ts" diff --git a/e2e/smoke/test/dpop-interop.spec.ts b/e2e/smoke/test/dpop-interop.spec.ts new file mode 100644 index 0000000000..4da1f63318 --- /dev/null +++ b/e2e/smoke/test/dpop-interop.spec.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { DatabaseSync } from "node:sqlite"; +import { describe, it } from "node:test"; +import { oauthProvider } from "@better-auth/oauth-provider"; +import type { BetterAuthOptions } from "better-auth"; +import { betterAuth } from "better-auth"; +import { getMigrations } from "better-auth/db/migration"; +import { toNodeHandler } from "better-auth/node"; +import { jwt } from "better-auth/plugins/jwt"; +import * as oauth from "oauth4webapi"; + +/** + * An independent client library builds every DPoP proof, deriving `htu` from + * the URL it actually requests, so the server's expected `htu` is checked + * against real client behavior rather than constants written by the test. + * + * @see https://datatracker.ietf.org/doc/html/rfc9449#section-4.3 + */ +type ServerSetup = { + name: string; + /** Public origin clients call; defaults to the local server's origin. */ + publicOrigin?: string; + /** Path appended to the origin to form `baseURL`. */ + baseURLPath?: string; + options?: Pick; + /** Whether the client calls the token and UserInfo endpoints with a trailing slash. */ + trailingSlash?: boolean /** Known failure: the test still runs, but does not fail the suite. */; + todo?: string; +}; + +const setups: ServerSetup[] = [ + { name: "default config" }, + { + name: "baseURL with a trailing slash", + baseURLPath: "/api/auth/", + todo: "discovery metadata advertises `/api/auth//oauth2/...` endpoints, which return 404", + }, + { name: "custom basePath", options: { basePath: "/auth" } }, + { + name: "skipTrailingSlashes with trailing-slash endpoint URLs", + options: { advanced: { skipTrailingSlashes: true } }, + trailingSlash: true, + }, + { + name: "proxy that rewrites the request host", + publicOrigin: "https://auth.example.test", + }, +]; + +const redirectUri = "https://client.example.test/callback"; + +async function startAuthServer(setup: ServerSetup) { + const placeholder = createServer(); + placeholder.listen(0); + await once(placeholder, "listening"); + const port = (placeholder.address() as AddressInfo).port; + placeholder.close(); + + const serverOrigin = `http://localhost:${port}`; + const publicOrigin = setup.publicOrigin ?? serverOrigin; + const auth = betterAuth({ + baseURL: `${publicOrigin}${setup.baseURLPath ?? ""}`, + secret: "smoke-test-secret-that-is-long-enough-for-validation", + database: new DatabaseSync(":memory:"), + emailAndPassword: { enabled: true }, + ...setup.options, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + scopes: ["openid", "profile", "email", "offline_access"], + }), + ], + }); + const { runMigrations } = await getMigrations(auth.options); + await runMigrations(); + + const server = createServer(toNodeHandler(auth.handler)); + server.listen(port); + await once(server, "listening"); + + // The proxy: requests leave for the public URL and reach the local server. + const toServerUrl = (url: string) => + url.startsWith(publicOrigin) + ? `${serverOrigin}${url.slice(publicOrigin.length)}` + : url; + const proxyFetch = (url: string, init: RequestInit) => + fetch(toServerUrl(url), init); + + return { auth, server, publicOrigin, toServerUrl, proxyFetch }; +} + +describe("DPoP interop with an independent OAuth client", () => { + for (const setup of setups) { + it(`binds tokens and UserInfo to the proof key: ${setup.name}`, { + todo: setup.todo, + }, async (t) => { + const { auth, server, publicOrigin, toServerUrl, proxyFetch } = + await startAuthServer(setup); + t.after(() => server.close()); + + const signUp = await auth.api.signUpEmail({ + body: { + email: "alice@example.com", + password: "smoke-test-password-1", + name: "Alice", + }, + returnHeaders: true, + }); + const cookie = signUp.headers.get("set-cookie") ?? ""; + const registered = await auth.api.adminCreateOAuthClient({ + headers: new Headers({ cookie }), + body: { + redirect_uris: [redirectUri], + token_endpoint_auth_method: "client_secret_post", + grant_types: ["authorization_code", "refresh_token"], + skip_consent: true, + }, + }); + + const metadata = await auth.api.getOpenIdConfig(); + const slash = setup.trailingSlash ? "/" : ""; + const as: oauth.AuthorizationServer = { + ...metadata, + token_endpoint: `${metadata.token_endpoint}${slash}`, + userinfo_endpoint: `${metadata.userinfo_endpoint}${slash}`, + }; + assert.ok( + as.token_endpoint?.startsWith(publicOrigin), + `token endpoint should use the public origin: ${as.token_endpoint}`, + ); + const client: oauth.Client = { client_id: registered.client_id }; + const clientAuth = oauth.ClientSecretPost(registered.client_secret!); + const DPoP = oauth.DPoP(client, await oauth.generateKeyPair("ES256")); + const requestOptions = { + DPoP, + [oauth.allowInsecureRequests]: true, + [oauth.customFetch]: proxyFetch, + }; + + const codeVerifier = oauth.generateRandomCodeVerifier(); + const state = oauth.generateRandomState(); + const authorizationUrl = new URL(metadata.authorization_endpoint); + authorizationUrl.search = new URLSearchParams({ + client_id: client.client_id, + response_type: "code", + redirect_uri: redirectUri, + scope: "openid profile email offline_access", + state, + code_challenge: await oauth.calculatePKCECodeChallenge(codeVerifier), + code_challenge_method: "S256", + }).toString(); + const authorizeResponse = await fetch( + toServerUrl(authorizationUrl.href), + { headers: { cookie, accept: "application/json" }, redirect: "manual" }, + ); + const { url: callbackUrl } = (await authorizeResponse.json()) as { + url: string; + }; + const callbackParameters = oauth.validateAuthResponse( + as, + client, + new URL(callbackUrl), + state, + ); + + const tokens = await oauth.processAuthorizationCodeResponse( + as, + client, + await oauth.authorizationCodeGrantRequest( + as, + client, + clientAuth, + callbackParameters, + redirectUri, + codeVerifier, + requestOptions, + ), + ); + assert.equal(tokens.token_type, "dpop"); + const subject = oauth.getValidatedIdTokenClaims(tokens)?.sub; + assert.ok(subject, "the ID token should carry a subject"); + + const userInfo = await oauth.processUserInfoResponse( + as, + client, + subject, + await oauth.userInfoRequest( + as, + client, + tokens.access_token, + requestOptions, + ), + ); + assert.equal(userInfo.email, "alice@example.com"); + + assert.ok( + tokens.refresh_token, + "offline_access should issue a refresh token", + ); + const refreshed = await oauth.processRefreshTokenResponse( + as, + client, + await oauth.refreshTokenGrantRequest( + as, + client, + clientAuth, + tokens.refresh_token, + requestOptions, + ), + ); + assert.equal(refreshed.token_type, "dpop"); + }); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e24fe4675..bede79117e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -884,6 +884,9 @@ importers: msw: specifier: ^2.14.6 version: 2.14.6(@types/node@25.9.4)(typescript@6.0.3) + oauth4webapi: + specifier: 3.8.8 + version: 3.8.8 e2e/smoke/test/fixtures/cloudflare: dependencies: @@ -12537,6 +12540,9 @@ packages: engines: {node: ^22.12 || ^24 || ^26} hasBin: true + oauth4webapi@3.8.8: + resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} + ob1@0.83.3: resolution: {integrity: sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==} engines: {node: '>=20.19.4'} @@ -29924,6 +29930,8 @@ snapshots: is-plain-obj: 4.1.0 jose: 6.2.3 + oauth4webapi@3.8.8: {} + ob1@0.83.3: dependencies: flow-enums-runtime: 0.0.6