mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
test(oauth-provider): add black-box DPoP interop smoke tests (#11791)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
});
|
||||
Generated
+8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user