test(oauth-provider): add black-box DPoP interop smoke tests (#11791)

This commit is contained in:
Gustavo Valverde
2026-10-15 08:35:47 +00:00
committed by GitHub
parent cdd491b9d0
commit af4b7965e8
3 changed files with 229 additions and 1 deletions
+2 -1
View File
@@ -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"
+219
View File
@@ -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<BetterAuthOptions, "basePath" | "advanced">;
/** 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");
});
}
});
+8
View File
@@ -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