feat(oauth-provider): add DPoP support (#10039)

This commit is contained in:
Gustavo Valverde
2026-06-14 18:28:21 -07:00
committed by GitHub
parent 0143d69195
commit aedcb974f0
42 changed files with 2885 additions and 313 deletions
+16
View File
@@ -0,0 +1,16 @@
---
"better-auth": minor
"@better-auth/core": minor
"@better-auth/oauth-provider": minor
"@better-auth/mcp": minor
---
feat(oauth-provider)!: DPoP-bound access tokens (RFC 9449)
OAuth provider integrations can issue and verify DPoP sender-constrained tokens. Clients request them with `dpop_bound_access_tokens` at registration, `dpop_jkt` on the authorization request, or by targeting a resource configured with `dpopBoundAccessTokensRequired`. Issued tokens carry `cnf.jkt`, return `token_type: "DPoP"`, and stay bound through refresh-token rotation, introspection, and userinfo.
Resource servers verify DPoP requests with `verifyAccessTokenRequest`, which checks the `Authorization: DPoP` scheme, the proof, the request target, the access-token hash, and proof replay. The MCP package advertises DPoP in protected resource metadata and verifies DPoP-bound requests. Proof replay is rejected through the database-backed verification store, so anti-replay holds across instances. `verifyAccessTokenRequest` and `requireMcpAuth` use that store by default; build one with `createDpopReplayStore(internalAdapter)` or pass a custom `dpop.replayStore`. This needs database-backed verification storage: a secondary-storage-only deployment rejects DPoP requests rather than skipping replay protection.
Breaking: the raw-token verifier `verifyAccessToken` is renamed to `verifyBearerToken`, both in `better-auth/oauth2` and as the `oauthProviderResourceClient` action, and it rejects DPoP-bound tokens. Use `verifyAccessTokenRequest` on any endpoint that may receive them. The resource-request input type is renamed from `AccessTokenRequestInput` to `ResourceRequestInput`, and the DPoP algorithm option is `signingAlgorithms` everywhere.
Run a schema migration for the DPoP token-binding fields: the `confirmation` column on the access-token and refresh-token tables. DPoP-bound clients also gain `dpopBoundAccessTokens` and resources `dpopBoundAccessTokensRequired`. No dedicated replay table is added; proof replay reuses the verification store.
+3 -3
View File
@@ -83,7 +83,7 @@ Discovery follows OAuth 2.0 Authorization Server Metadata ([RFC 8414](https://da
### Protected Resource Metadata
The [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) `/.well-known/oauth-protected-resource` document is served automatically at the well-known root (and the resource-path-inserted alias). It tells MCP clients which authorization server protects the resource, which scopes it supports, and which `resource` identifier their access tokens must be bound to.
The [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) `/.well-known/oauth-protected-resource` document is served automatically at the well-known root (and the resource-path-inserted alias). It tells MCP clients which authorization server protects the resource, which scopes it supports, which `resource` identifier their access tokens must be bound to, and which DPoP proof algorithms are supported.
Set `resource` to the protected resource identifier MCP clients request and access tokens carry as `aud`:
@@ -97,7 +97,7 @@ mcp({
### MCP Session Handling
Use `requireMcpAuth` to protect an MCP route. It reads the `Authorization` header, verifies the bearer access token against the authorization server's JWKS (signature, issuer, audience, and expiry), and forwards the verified JWT claims to your handler. Unauthenticated requests receive a JSON-RPC `401` with the RFC 9728 `WWW-Authenticate` header, so MCP clients can start the authorization flow.
Use `requireMcpAuth` to protect an MCP route. It reads the `Authorization` header, verifies Bearer access tokens against the authorization server's JWKS (signature, issuer, audience, and expiry), and enforces [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) DPoP proofs when the access token is DPoP-bound. Unauthenticated requests receive a JSON-RPC `401` with the RFC 9728 `WWW-Authenticate` header, so MCP clients can start the authorization flow.
```ts title="api/[transport]/route.ts"
import { auth } from "@/lib/auth";
@@ -141,7 +141,7 @@ const handler = requireMcpAuth(auth, (req, claims) => { // [!code highlight]
export { handler as GET, handler as POST, handler as DELETE };
```
The second argument is the verified JWT payload, not a database record. `requireMcpAuth` never exposes a refresh token; the access token is checked locally against the JWKS without a database round trip.
The second argument is the verified JWT payload, not a database record. `requireMcpAuth` never exposes a refresh token; the access token is checked locally against the JWKS without a database round trip. DPoP-bound tokens must be sent as `Authorization: DPoP <access_token>` with a matching `DPoP` proof header. DPoP proof replay is rejected through your auth instance's database adapter by default, so anti-replay holds across multiple server instances; pass `dpop.replayStore` only to use a different store.
By default `requireMcpAuth` reads the server's resolved base URL from the auth context and uses it as both the expected token issuer and protected resource identifier, with the JWKS at that base URL. This matches what the provider issues, so no configuration is needed for the common case. Override any value when the resource differs or `jwt.issuer` is custom:
+81 -29
View File
@@ -502,6 +502,35 @@ Important Notes:
* With the JWT plugin enabled (default), sending `resource` results in a JWT access token with the selected resource in the `aud` claim.
* With `disableJwtPlugin: true`, access tokens remain opaque. Requested resources are still bound to the token and surfaced through `/oauth2/introspect` and `customAccessTokenClaims` (via `resources`).
#### DPoP sender-constrained tokens
Better Auth supports Demonstrating Proof of Possession (DPoP) as defined by [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). A client can ask for DPoP-bound tokens by registering with `dpop_bound_access_tokens: true`, by sending `dpop_jkt` on the authorization request, or by requesting a resource configured with `dpopBoundAccessTokensRequired: true`.
When a token is DPoP-bound:
* The token endpoint requires a valid `DPoP` proof JWT.
* The token response returns `token_type: "DPoP"`.
* JWT access tokens include `cnf.jkt`; opaque access tokens and refresh tokens persist the same JWK thumbprint.
* Refresh token rotation requires a DPoP proof from the same key.
* Resource requests must use `Authorization: DPoP <access_token>` and a `DPoP` proof containing the access-token hash (`ath`).
```ts title="auth.ts"
oauthProvider({
dpop: {
proofMaxAgeSeconds: 300,
signingAlgorithms: ["ES256", "EdDSA"],
},
resources: [
{
identifier: "https://api.example.com",
dpopBoundAccessTokensRequired: true,
},
],
})
```
The authorization-server metadata advertises `dpop_signing_alg_values_supported`. Resource metadata advertises the same proof algorithms and, when required, `dpop_bound_access_tokens_required`.
#### Private Key JWT Authentication
With `private_key_jwt`, clients authenticate by signing a JWT with their private key instead of using a shared secret. The server verifies the signature using the client's registered public key (JWKS).
@@ -802,43 +831,39 @@ This section shows how your API should verify tokens received from your clients.
### Verification
Verification can be performed using `verifyAccessToken` available through the `oauthProviderResourceClient` plugin or `better-auth/oauth2` package.
Verification can be performed using `verifyAccessTokenRequest` available through the `oauthProviderResourceClient` plugin or `better-auth/oauth2` package. This is the recommended resource-server API because it verifies the access token and, when the token is DPoP-bound, also verifies the request method, URL, `Authorization: DPoP` scheme, proof key, replay `jti`, and `ath` claim.
With `better-auth` package:
```ts title="api/[endpoint].ts"
import { verifyAccessToken } from "better-auth/oauth2";
import {
requestToResourceInput,
verifyAccessTokenRequest,
} from "better-auth/oauth2";
export const GET = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await verifyAccessToken(
accessToken, {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
scopes: ["read:post"], // optional
}
);
const payload = await verifyAccessTokenRequest(requestToResourceInput(req), {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
scopes: ["read:post"], // optional
});
// ...continue
}
```
`requestToResourceInput` reads the `Authorization` and `DPoP` headers and the method and URL from a standard `Request`. Pass a plain object with those fields if your framework does not expose a `Request`.
With `oauthProviderResourceClient` plugin:
```ts title="api/[endpoint].ts"
import { serverClient } from "@/lib/server-client";
export const POST = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await serverClient.verifyAccessToken(
accessToken, {
const payload = await serverClient.verifyAccessTokenRequest(
req,
{
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
@@ -850,6 +875,15 @@ export const POST = async (req: Request) => {
}
```
`verifyBearerToken` is still available when you already extracted a raw bearer token and intentionally do not accept DPoP-bound tokens on that path. It rejects DPoP-bound tokens, so prefer `verifyAccessTokenRequest` for any endpoint that may receive them.
<Callout type="warn">
DPoP verification compares the proof's `htu` against the request URL, and rejects replayed proofs through a `jti` store. Two deployment details matter:
* **Behind a proxy:** the proof is checked against `request.url`, so a TLS-terminating or path-rewriting proxy must forward the externally visible scheme, host, and path, or legitimate proofs are rejected.
* **Replay protection:** `verifyAccessTokenRequest` defaults to an in-memory `jti` store that is safe only for a single instance. For multi-instance or serverless resource servers, pass a shared `dpop.replayStore` such as `createDpopReplayStore(ctx.context.internalAdapter)`, which records proofs in the database-backed verification store (the provider's own endpoints and `requireMcpAuth` use it by default). It requires database-backed verification storage; a secondary-storage-only deployment rejects DPoP requests rather than skipping replay protection.
</Callout>
#### JWT Verification
* Verify the token is valid:
@@ -1790,23 +1824,20 @@ You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/s
Set `verifyOptions.audience` to your protected resource identifier. If omitted, the resource client falls back to the authorization server base URL as the expected audience.
* Using the client `verifyAccessToken` function
* Using the client `verifyAccessTokenRequest` function
See [Verification](#verification) for verification examples.
* With auth available, use the client `verifyAccessToken` function to automatically determine endpoints
* With auth available, use the client `verifyAccessTokenRequest` function to automatically determine endpoints
```ts title="api/[endpoint].ts"
import { auth } from "@/lib/auth";
import { serverClient } from "@/lib/server-client";
export const GET = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await serverClient.verifyAccessToken(
accessToken, {
const payload = await serverClient.verifyAccessTokenRequest(
req,
{
verifyOptions: {
audience: "https://api.example.com",
}
@@ -2081,6 +2112,13 @@ export const oauthClientTableFields = [
description: "Whether PKCE is required for this client",
isOptional: true,
},
{
name: "dpopBoundAccessTokens",
type: "boolean",
description:
"Whether this client must receive and use DPoP-bound access tokens",
isOptional: true,
},
{
name: "metadata",
type: "json",
@@ -2164,6 +2202,13 @@ export const oauthRefreshTokenTableFields = [
type: "Date",
description: "Timestamp when the token will expire",
},
{
name: "confirmation",
type: "json",
description:
"RFC 7800 cnf confirmation that sender-constrains this refresh-token family (e.g. DPoP { jkt }), carried forward on rotation",
isOptional: true,
},
];
<DatabaseTable name="oauthRefreshToken" fields={oauthRefreshTokenTableFields} />
@@ -2238,6 +2283,13 @@ export const oauthAccessTokenTableFields = [
type: "Date",
description: "Timestamp when the token will expire",
},
{
name: "confirmation",
type: "json",
description:
"RFC 7800 cnf confirmation that sender-constrains this access token (e.g. DPoP { jkt }), surfaced as cnf at introspection",
isOptional: true,
},
{
name: "revoked",
type: "Date",
+387
View File
@@ -0,0 +1,387 @@
import type { JWK, JWTPayload } from "jose";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDpopReplayStore,
createInMemoryDpopReplayStore,
deriveDpopAth,
deriveDpopJkt,
enforceDpopBinding,
getConfirmationJkt,
verifyDpopProof,
} from "./dpop";
import { verifyAccessTokenRequest, verifyBearerToken } from "./verify";
const method = "GET";
const url = "https://api.example.com/resource";
const accessToken = "access-token-value";
const nowSeconds = 1_700_000_000;
const issuer = "https://issuer.example.com";
async function createProof(
overrides: {
accessToken?: string;
headerJwk?: JWK;
htm?: string;
htu?: string;
iat?: number;
jti?: string;
privateKey?: CryptoKey;
publicJwk?: JWK;
} = {},
) {
const keyPair = overrides.privateKey
? undefined
: await generateKeyPair("ES256", { extractable: true });
const privateKey = overrides.privateKey ?? keyPair?.privateKey;
if (!privateKey) {
throw new Error("missing private key");
}
const publicJwk =
overrides.publicJwk ??
(keyPair ? await exportJWK(keyPair.publicKey) : undefined);
if (!publicJwk) {
throw new Error("missing public jwk");
}
const proofAccessToken = overrides.accessToken;
return new SignJWT({
jti: overrides.jti ?? "proof-jti",
htm: overrides.htm ?? method,
htu: overrides.htu ?? url,
iat: overrides.iat ?? nowSeconds,
...(proofAccessToken ? { ath: await deriveDpopAth(proofAccessToken) } : {}),
})
.setProtectedHeader({
typ: "dpop+jwt",
alg: "ES256",
jwk: overrides.headerJwk ?? publicJwk,
})
.sign(privateKey);
}
function mockIntrospectionResponse(payload: JWTPayload & { active: boolean }) {
return vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
return new Response(JSON.stringify(payload), {
headers: { "Content-Type": "application/json" },
});
});
}
function remoteVerifyOptions() {
return {
verifyOptions: {
issuer,
audience: url,
},
remoteVerify: {
introspectUrl: `${issuer}/oauth2/introspect`,
clientId: "resource-server",
clientSecret: "resource-secret",
force: true,
},
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("verifyDpopProof", () => {
it("verifies a token-endpoint proof and returns the JWK thumbprint", async () => {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const proofJwt = await createProof({ privateKey, publicJwk });
const proof = await verifyDpopProof({
proofJwt,
method,
url,
nowSeconds,
});
expect(proof.jkt).toBe(await deriveDpopJkt(publicJwk));
expect(proof.htm).toBe(method);
expect(proof.htu).toBe(url);
});
it("requires ath when validating a protected resource request", async () => {
const proofJwt = await createProof();
await expect(
verifyDpopProof({
proofJwt,
method,
url,
accessToken,
requireAth: true,
nowSeconds,
}),
).rejects.toMatchObject({
code: "invalid_dpop_proof",
message: "DPoP proof must include an ath claim",
});
});
it("accepts a resource proof bound to the access token hash", async () => {
const proofJwt = await createProof({ accessToken });
const proof = await verifyDpopProof({
proofJwt,
method,
url,
accessToken,
requireAth: true,
nowSeconds,
});
expect(proof.ath).toBe(await deriveDpopAth(accessToken));
});
it("rejects proof reuse within the replay window", async () => {
const replayStore = createInMemoryDpopReplayStore();
const proofJwt = await createProof({ accessToken });
const options = {
proofJwt,
method,
url,
accessToken,
requireAth: true,
nowSeconds,
replayStore,
};
await verifyDpopProof(options);
await expect(verifyDpopProof(options)).rejects.toMatchObject({
code: "invalid_dpop_proof",
message: "DPoP proof jti has already been used",
});
});
it("rejects method mismatch and private JWK material", async () => {
const keyPair = await generateKeyPair("ES256", { extractable: true });
const privateJwk = await exportJWK(keyPair.privateKey);
const publicJwk = await exportJWK(keyPair.publicKey);
const wrongMethodProof = await createProof({
privateKey: keyPair.privateKey,
publicJwk,
htm: "POST",
});
const privateHeaderProof = await createProof({
privateKey: keyPair.privateKey,
publicJwk,
headerJwk: privateJwk,
jti: "private-jwk",
});
await expect(
verifyDpopProof({
proofJwt: wrongMethodProof,
method,
url,
nowSeconds,
}),
).rejects.toMatchObject({
code: "invalid_dpop_proof",
message: "DPoP proof htm does not match the request method",
});
await expect(
verifyDpopProof({
proofJwt: privateHeaderProof,
method,
url,
nowSeconds,
}),
).rejects.toMatchObject({
code: "invalid_dpop_proof",
message: "DPoP proof jwk must not contain private key material",
});
});
it("rejects a header jwk that passes shape checks but fails to import as invalid_dpop_proof", async () => {
// `kty`/`crv` look fine and there is no private material, so it clears the
// shape checks, but the coordinates are not a real P-256 point, so
// `importJWK` throws. That must surface as a protocol error, not a 500.
const proofJwt = await createProof({
headerJwk: { kty: "EC", crv: "P-256", x: "AAAA", y: "AAAA" },
jti: "bad-jwk-coords",
});
await expect(
verifyDpopProof({ proofJwt, method, url, nowSeconds }),
).rejects.toMatchObject({ code: "invalid_dpop_proof" });
});
it("requires request-aware verification for DPoP-bound access tokens", async () => {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const jkt = await deriveDpopJkt(publicJwk);
mockIntrospectionResponse({
active: true,
iss: issuer,
aud: url,
scope: "read",
cnf: { jkt },
});
await expect(
verifyBearerToken(accessToken, remoteVerifyOptions()),
).rejects.toThrow("DPoP-bound access token requires");
const proofJwt = await createProof({
accessToken,
privateKey,
publicJwk,
iat: Math.floor(Date.now() / 1000),
jti: "request-aware-proof",
});
const payload = await verifyAccessTokenRequest(
{
authorizationHeader: `DPoP ${accessToken}`,
dpopProofJwt: proofJwt,
method,
url,
},
remoteVerifyOptions(),
);
expect(payload.cnf).toMatchObject({ jkt });
});
it("rejects an access token presented without the Bearer or DPoP scheme", async () => {
await expect(
verifyAccessTokenRequest(
{ authorizationHeader: "raw-token-without-scheme", method, url },
remoteVerifyOptions(),
),
).rejects.toThrow("authorization scheme must be Bearer or DPoP");
});
});
describe("enforceDpopBinding", () => {
it("passes a bearer token with no DPoP binding", async () => {
await expect(
enforceDpopBinding({
payload: { sub: "user" },
authorization: { scheme: "Bearer", token: accessToken },
proofJwt: undefined,
method,
url,
}),
).resolves.toBeUndefined();
});
it("rejects a non-bound token presented with the DPoP scheme", async () => {
await expect(
enforceDpopBinding({
payload: { sub: "user" },
authorization: { scheme: "DPoP", token: accessToken },
proofJwt: undefined,
method,
url,
}),
).rejects.toMatchObject({ code: "invalid_token" });
});
it("rejects a DPoP-bound token presented with the bearer scheme", async () => {
const { publicKey } = await generateKeyPair("ES256", { extractable: true });
const jkt = await deriveDpopJkt(await exportJWK(publicKey));
await expect(
enforceDpopBinding({
payload: { sub: "user", cnf: { jkt } },
authorization: { scheme: "Bearer", token: accessToken },
proofJwt: undefined,
method,
url,
}),
).rejects.toMatchObject({ code: "invalid_token" });
});
it("requires a proof header for a DPoP-bound token", async () => {
const { publicKey } = await generateKeyPair("ES256", { extractable: true });
const jkt = await deriveDpopJkt(await exportJWK(publicKey));
await expect(
enforceDpopBinding({
payload: { sub: "user", cnf: { jkt } },
authorization: { scheme: "DPoP", token: accessToken },
proofJwt: undefined,
method,
url,
}),
).rejects.toMatchObject({ code: "invalid_dpop_proof" });
});
it("accepts a DPoP-bound token with a matching, ath-bound proof", async () => {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const jkt = await deriveDpopJkt(publicJwk);
const proofJwt = await createProof({
privateKey,
publicJwk,
accessToken,
iat: Math.floor(Date.now() / 1000),
jti: "enforce-accepts",
});
await expect(
enforceDpopBinding({
payload: { sub: "user", cnf: { jkt } },
authorization: { scheme: "DPoP", token: accessToken },
proofJwt,
method,
url,
replayStore: createInMemoryDpopReplayStore(),
}),
).resolves.toBeUndefined();
});
});
describe("createDpopReplayStore", () => {
it("delegates to reserveVerificationValue, namespaces the key, and rejects a replay", async () => {
const seen = new Set<string>();
const identifiers: string[] = [];
const store = createDpopReplayStore({
reserveVerificationValue: async ({ identifier }) => {
identifiers.push(identifier);
if (seen.has(identifier)) return false;
seen.add(identifier);
return true;
},
});
const reservation = {
key: "replay-key",
expiresAt: new Date(Date.now() + 300_000),
now: new Date(),
};
expect(await store.reserve(reservation)).toBe(true);
expect(await store.reserve(reservation)).toBe(false);
expect(identifiers.every((id) => id.startsWith("dpop-proof:"))).toBe(true);
});
});
describe("getConfirmationJkt", () => {
it("returns the jkt only for an object carrying a non-empty string jkt", () => {
expect(getConfirmationJkt({ jkt: "thumbprint" })).toBe("thumbprint");
});
it("returns undefined for confirmations without a usable jkt", () => {
expect(getConfirmationJkt({ "x5t#S256": "cert-hash" })).toBeUndefined();
expect(getConfirmationJkt({ jkt: "" })).toBeUndefined();
expect(getConfirmationJkt({ jkt: 123 })).toBeUndefined();
expect(getConfirmationJkt(undefined)).toBeUndefined();
expect(getConfirmationJkt(null)).toBeUndefined();
});
it("returns undefined for a truthy non-object instead of throwing", () => {
// A corrupted JSON column can deserialize to a primitive or array; the
// `in` operator would throw a TypeError on these, so guard against it.
expect(() => getConfirmationJkt("malformed")).not.toThrow();
expect(getConfirmationJkt("malformed")).toBeUndefined();
expect(getConfirmationJkt(42)).toBeUndefined();
expect(getConfirmationJkt(["jkt"])).toBeUndefined();
});
});
+568
View File
@@ -0,0 +1,568 @@
import type { JWK, JWTPayload } from "jose";
import {
base64url,
calculateJwkThumbprint,
decodeProtectedHeader,
importJWK,
jwtVerify,
} from "jose";
export const DPOP_AUTHORIZATION_SCHEME = "DPoP";
export const BEARER_AUTHORIZATION_SCHEME = "Bearer";
export const DPOP_PROOF_TYPE = "dpop+jwt";
export const DPOP_SIGNING_ALGORITHMS = [
"EdDSA",
"ES256",
"ES512",
"PS256",
"RS256",
] as const;
const DEFAULT_DPOP_PROOF_MAX_AGE_SECONDS = 300;
const MAX_DPOP_JTI_LENGTH = 512;
const JWK_PRIVATE_FIELDS = new Set([
"d",
"p",
"q",
"dp",
"dq",
"qi",
"oth",
"k",
]);
export type DpopSigningAlgorithm = (typeof DPOP_SIGNING_ALGORITHMS)[number];
export type AccessTokenAuthorizationScheme = "Bearer" | "DPoP" | "Unknown";
export interface AccessTokenAuthorization {
scheme: AccessTokenAuthorizationScheme;
token: string;
}
export type DpopProofErrorCode = "invalid_dpop_proof";
export type DpopProofError = Error & {
code: DpopProofErrorCode;
};
export interface DpopReplayReservation {
key: string;
expiresAt: Date;
now: Date;
}
export interface DpopReplayStore {
reserve: (reservation: DpopReplayReservation) => Promise<boolean> | boolean;
}
export function createInMemoryDpopReplayStore(): DpopReplayStore {
const reservations = new Map<string, number>();
return {
reserve({ key, expiresAt, now }) {
const nowMs = now.getTime();
for (const [storedKey, expiresAtMs] of reservations) {
if (expiresAtMs <= nowMs) {
reservations.delete(storedKey);
}
}
if (reservations.has(key)) return false;
reservations.set(key, expiresAt.getTime());
return true;
},
};
}
/**
* The single-use reservation capability a {@link createDpopReplayStore} needs:
* the auth context's `internalAdapter.reserveVerificationValue`. Kept structural
* so core does not depend on the adapter implementation.
*/
export interface DpopReplayReservations {
reserveVerificationValue: (data: {
identifier: string;
value: string;
expiresAt: Date;
}) => Promise<boolean>;
}
/**
* Database-backed DPoP proof replay store built on the auth context's
* verification reservation primitive (`internalAdapter.reserveVerificationValue`),
* the same atomic single-use mechanism that guards SAML assertion ids and other
* one-time tokens. A replayed proof collides on the deterministic reservation id
* so `reserve` returns `false`, giving cross-instance anti-replay. Prefer this
* over {@link createInMemoryDpopReplayStore} for any multi-instance or serverless
* resource server. Requires database-backed verification storage; a
* secondary-storage-only deployment rejects the proof (fails closed).
*/
export function createDpopReplayStore(
reservations: DpopReplayReservations,
): DpopReplayStore {
return {
reserve: ({ key, expiresAt }) =>
reservations.reserveVerificationValue({
identifier: `dpop-proof:${key}`,
value: key,
expiresAt,
}),
};
}
export interface VerifyDpopProofOptions {
proofJwt: string;
method: string;
url: string;
accessToken?: string;
expectedJkt?: string;
requireAth?: boolean;
nowSeconds?: number;
proofMaxAgeSeconds?: number;
signingAlgorithms?: readonly string[];
replayStore?: DpopReplayStore;
}
export interface VerifiedDpopProof {
jwk: JWK;
jkt: string;
jti: string;
htm: string;
htu: string;
iat: number;
ath?: string;
replayKey: string;
expiresAt: Date;
}
export function createDpopProofError(
code: DpopProofErrorCode,
message: string,
): DpopProofError {
return Object.assign(new Error(message), { code });
}
export function isDpopProofError(error: unknown): error is DpopProofError {
return (
error instanceof Error &&
"code" in error &&
error.code === "invalid_dpop_proof"
);
}
export function parseAccessTokenAuthorization(
authorization: string | null | undefined,
): AccessTokenAuthorization | undefined {
if (!authorization) return undefined;
const value = authorization.trim();
if (!value) return undefined;
const match = /^([A-Za-z][A-Za-z0-9!#$%&'*+.^_`|~-]*)\s+(.+)$/.exec(value);
if (!match) {
return { scheme: "Unknown", token: value };
}
const scheme = match[1] ?? "";
const token = match[2]?.trim() ?? "";
if (scheme.toLowerCase() === "bearer") {
return { scheme: "Bearer", token };
}
if (scheme.toLowerCase() === "dpop") {
return { scheme: "DPoP", token };
}
return { scheme: "Unknown", token: value };
}
export function stripAccessTokenAuthorizationScheme(token: string): string {
return parseAccessTokenAuthorization(token)?.token ?? token;
}
export function normalizeDpopHtu(url: string): string {
const parsed = new URL(url);
// RFC 9449 §4.2: `htu` is the target URI without query and fragment parts.
// Reject a fragment rather than silently strip it, so a malformed proof
// fails fast instead of matching a request URL it does not actually name.
if (parsed.hash) {
throw new Error("DPoP proof htu must not contain a fragment");
}
return `${parsed.origin}${parsed.pathname}`;
}
export async function deriveDpopAth(accessToken: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(accessToken),
);
return base64url.encode(new Uint8Array(digest));
}
export async function deriveDpopJkt(jwk: JWK): Promise<string> {
return calculateJwkThumbprint(jwk, "sha256");
}
/**
* Extracts the DPoP key thumbprint from an RFC 7800 `cnf` confirmation. The
* input is untrusted (a JWT claim, a JSON column), so any shape other than an
* object carrying a non-empty string `jkt` (a primitive, an array, a different
* confirmation method such as mTLS `x5t#S256`) yields `undefined` instead of
* throwing.
*/
export function getConfirmationJkt(confirmation: unknown): string | undefined {
if (
!confirmation ||
typeof confirmation !== "object" ||
Array.isArray(confirmation)
) {
return undefined;
}
const jkt = (confirmation as Record<string, unknown>).jkt;
return typeof jkt === "string" && jkt.length > 0 ? jkt : undefined;
}
export function getDpopJktFromPayload(payload: JWTPayload): string | undefined {
return getConfirmationJkt(payload.cnf);
}
function getStringClaim(
payload: JWTPayload,
claim: string,
): string | undefined {
const value = payload[claim];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function getNumberClaim(
payload: JWTPayload,
claim: string,
): number | undefined {
const value = payload[claim];
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function assertSupportedDpopAlgorithm(
alg: string | undefined,
signingAlgorithms: readonly string[],
) {
if (!alg || alg === "none" || alg.startsWith("HS")) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof must use an asymmetric JWS algorithm",
);
}
if (!signingAlgorithms.includes(alg)) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof uses an unsupported JWS algorithm",
);
}
}
function assertPublicJwk(jwk: JWK | undefined): asserts jwk is JWK {
if (!jwk || typeof jwk !== "object" || Array.isArray(jwk)) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof header must include a public jwk",
);
}
if (jwk.kty === "oct") {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof jwk must be asymmetric",
);
}
for (const field of JWK_PRIVATE_FIELDS) {
if (field in jwk) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof jwk must not contain private key material",
);
}
}
}
async function deriveDpopReplayKey(params: {
jkt: string;
htm: string;
htu: string;
jti: string;
}): Promise<string> {
const input = `${params.jkt}\n${params.htm}\n${params.htu}\n${params.jti}`;
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(input),
);
return base64url.encode(new Uint8Array(digest));
}
async function reserveDpopReplay(
replayStore: DpopReplayStore | undefined,
reservation: DpopReplayReservation,
) {
if (!replayStore) return;
const reserved = await replayStore.reserve(reservation);
if (!reserved) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof jti has already been used",
);
}
}
export async function verifyDpopProof({
proofJwt,
method,
url,
accessToken,
expectedJkt,
requireAth = false,
nowSeconds = Math.floor(Date.now() / 1000),
proofMaxAgeSeconds = DEFAULT_DPOP_PROOF_MAX_AGE_SECONDS,
signingAlgorithms = DPOP_SIGNING_ALGORITHMS,
replayStore,
}: VerifyDpopProofOptions): Promise<VerifiedDpopProof> {
if (!proofJwt || proofJwt.split(".").length !== 3) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof must be a compact JWT",
);
}
let protectedHeader: ReturnType<typeof decodeProtectedHeader>;
try {
protectedHeader = decodeProtectedHeader(proofJwt);
} catch (error) {
throw createDpopProofError(
"invalid_dpop_proof",
error instanceof Error ? error.message : "DPoP proof header is invalid",
);
}
if (protectedHeader.typ !== DPOP_PROOF_TYPE) {
throw createDpopProofError(
"invalid_dpop_proof",
'DPoP proof typ must be "dpop+jwt"',
);
}
assertSupportedDpopAlgorithm(protectedHeader.alg, signingAlgorithms);
assertPublicJwk(protectedHeader.jwk);
let payload: JWTPayload;
try {
// `importJWK` is inside the try: a structurally-valid header `jwk` can
// still fail to import (bad curve, malformed `x`/`y`), and that is bad
// client input, not a server error.
const publicKey = await importJWK(protectedHeader.jwk, protectedHeader.alg);
const verified = await jwtVerify(proofJwt, publicKey, {
typ: DPOP_PROOF_TYPE,
});
payload = verified.payload;
} catch (error) {
throw createDpopProofError(
"invalid_dpop_proof",
error instanceof Error
? error.message
: "DPoP proof signature is invalid",
);
}
const htm = getStringClaim(payload, "htm");
const htu = getStringClaim(payload, "htu");
const jti = getStringClaim(payload, "jti");
const iat = getNumberClaim(payload, "iat");
if (!htm || !htu || !jti || iat === undefined) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof must include htm, htu, jti, and iat claims",
);
}
if (jti.length > MAX_DPOP_JTI_LENGTH) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof jti is too large",
);
}
if (htm.toUpperCase() !== method.toUpperCase()) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof htm does not match the request method",
);
}
let normalizedHtu: string;
let proofHtu: string;
try {
normalizedHtu = normalizeDpopHtu(url);
proofHtu = normalizeDpopHtu(htu);
} catch (error) {
throw createDpopProofError(
"invalid_dpop_proof",
error instanceof Error ? error.message : "DPoP proof htu is invalid",
);
}
if (proofHtu !== normalizedHtu) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof htu does not match the request URL",
);
}
if (iat > nowSeconds + 5 || nowSeconds - iat > proofMaxAgeSeconds) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof iat is outside the accepted window",
);
}
const ath = getStringClaim(payload, "ath");
if (requireAth && !ath) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof must include an ath claim",
);
}
if (accessToken !== undefined) {
const expectedAth = await deriveDpopAth(accessToken);
if (ath !== expectedAth) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof ath does not match the access token",
);
}
}
const jkt = await deriveDpopJkt(protectedHeader.jwk);
if (expectedJkt !== undefined && jkt !== expectedJkt) {
throw createDpopProofError(
"invalid_dpop_proof",
"DPoP proof key does not match the bound token",
);
}
// Key the replay record on the canonical method and normalized URL that
// verification actually compared against, not the raw claim values. Otherwise
// the same `jti` could be reused with different `htm` casing or a different
// `htu` query string that normalizes equal, bypassing replay protection.
const replayKey = await deriveDpopReplayKey({
jkt,
htm: htm.toUpperCase(),
htu: normalizedHtu,
jti,
});
const expiresAt = new Date((iat + proofMaxAgeSeconds) * 1000);
await reserveDpopReplay(replayStore, {
key: replayKey,
expiresAt,
now: new Date(nowSeconds * 1000),
});
return {
jwk: protectedHeader.jwk,
jkt,
jti,
htm,
htu: normalizedHtu,
iat,
ath,
replayKey,
expiresAt,
};
}
export type DpopBindingErrorCode = "invalid_token" | "invalid_dpop_proof";
export type DpopBindingError = Error & {
code: DpopBindingErrorCode;
};
export function createDpopBindingError(
code: DpopBindingErrorCode,
message: string,
): DpopBindingError {
return Object.assign(new Error(message), { code });
}
export function isDpopBindingError(error: unknown): error is DpopBindingError {
return (
error instanceof Error &&
"code" in error &&
(error.code === "invalid_token" || error.code === "invalid_dpop_proof")
);
}
export interface EnforceDpopBindingParams {
/** The already-verified access-token payload (from JWKS or introspection). */
payload: JWTPayload;
/** The parsed `Authorization` header (scheme + token). */
authorization: AccessTokenAuthorization;
/** The `DPoP` proof header value, if any. */
proofJwt: string | null | undefined;
method: string;
url: string;
replayStore?: DpopReplayStore;
proofMaxAgeSeconds?: number;
signingAlgorithms?: readonly string[];
}
/**
* Enforces the RFC 9449 §7.1 sender-constraint check for a resource request,
* given an access-token payload that has already been validated (by JWKS or
* introspection). This is the single source of truth for the
* "is the token DPoP-bound? then require the DPoP scheme, a proof, and a
* matching key" decision, shared by every resource-server entry point.
*
* Throws a {@link DpopBindingError} on any mismatch so callers map the
* `invalid_token` / `invalid_dpop_proof` code into their own transport. Returns
* normally for a valid bearer token (no `cnf.jkt`, no DPoP scheme).
*/
export async function enforceDpopBinding({
payload,
authorization,
proofJwt,
method,
url,
replayStore,
proofMaxAgeSeconds,
signingAlgorithms,
}: EnforceDpopBindingParams): Promise<void> {
const dpopJkt = getDpopJktFromPayload(payload);
if (!dpopJkt) {
if (authorization.scheme === "DPoP") {
throw createDpopBindingError(
"invalid_token",
"DPoP authorization requires a DPoP-bound access token",
);
}
return;
}
if (authorization.scheme !== "DPoP") {
throw createDpopBindingError(
"invalid_token",
"DPoP-bound access token requires the DPoP authorization scheme",
);
}
if (!proofJwt) {
throw createDpopBindingError(
"invalid_dpop_proof",
"DPoP proof header is required",
);
}
try {
await verifyDpopProof({
proofJwt,
method,
url,
accessToken: authorization.token,
expectedJkt: dpopJkt,
requireAth: true,
proofMaxAgeSeconds,
signingAlgorithms,
replayStore,
});
} catch (error) {
if (isDpopProofError(error)) {
throw createDpopBindingError("invalid_dpop_proof", error.message);
}
throw error;
}
}
+44 -1
View File
@@ -26,6 +26,42 @@ export {
RESERVED_AUTHORIZATION_PARAMS,
RESERVED_AUTHORIZATION_PARAMS_SET,
} from "./create-authorization-url";
export type {
AccessTokenAuthorization,
AccessTokenAuthorizationScheme,
DpopBindingError,
DpopBindingErrorCode,
DpopProofError,
DpopProofErrorCode,
DpopReplayReservation,
DpopReplayReservations,
DpopReplayStore,
DpopSigningAlgorithm,
EnforceDpopBindingParams,
VerifiedDpopProof,
VerifyDpopProofOptions,
} from "./dpop";
export {
BEARER_AUTHORIZATION_SCHEME,
createDpopBindingError,
createDpopProofError,
createDpopReplayStore,
createInMemoryDpopReplayStore,
DPOP_AUTHORIZATION_SCHEME,
DPOP_PROOF_TYPE,
DPOP_SIGNING_ALGORITHMS,
deriveDpopAth,
deriveDpopJkt,
enforceDpopBinding,
getConfirmationJkt,
getDpopJktFromPayload,
isDpopBindingError,
isDpopProofError,
normalizeDpopHtu,
parseAccessTokenAuthorization,
stripAccessTokenAuthorizationScheme,
verifyDpopProof,
} from "./dpop";
export type {
AuthorizationURLResult,
GrantAuthority,
@@ -64,9 +100,16 @@ export {
validateAuthorizationCode,
validateToken,
} from "./validate-authorization-code";
export type {
ResourceRequestInput,
VerifyAccessTokenOptions,
VerifyAccessTokenRequestOptions,
} from "./verify";
export {
getJwks,
verifyAccessToken,
requestToResourceInput,
verifyAccessTokenRequest,
verifyBearerToken,
verifyJwsAccessToken,
} from "./verify";
export {
+15 -15
View File
@@ -10,7 +10,7 @@ import {
it,
vi,
} from "vitest";
import { verifyAccessToken } from "./verify";
import { verifyBearerToken } from "./verify";
const issuer = "https://auth.example.com";
const audience = "https://api.example.com/v1";
@@ -21,7 +21,7 @@ const mockedFetch = vi.fn() as unknown as typeof fetch &
let keyCounter = 0;
describe("verifyAccessToken", () => {
describe("verifyBearerToken", () => {
const originalFetch = globalThis.fetch;
beforeAll(() => {
@@ -123,7 +123,7 @@ describe("verifyAccessToken", () => {
mockJWKSResponse(publicJWK);
await expectUnauthorized(
verifyAccessToken(token, {
verifyBearerToken(token, {
jwksUrl,
verifyOptions: { issuer, audience },
}),
@@ -147,7 +147,7 @@ describe("verifyAccessToken", () => {
mockJWKSResponse(publicJWKWithMatchingKid);
await expectUnauthorized(
verifyAccessToken(token, {
verifyBearerToken(token, {
jwksUrl,
verifyOptions: { issuer, audience },
}),
@@ -167,7 +167,7 @@ describe("verifyAccessToken", () => {
mockJWKSResponse(unrelatedKey.publicJWK);
await expectUnauthorized(
verifyAccessToken(token, {
verifyBearerToken(token, {
jwksUrl,
verifyOptions: { issuer, audience },
}),
@@ -183,7 +183,7 @@ describe("verifyAccessToken", () => {
mockJWKSResponse(publicJWK);
await expect(
verifyAccessToken(token, {
verifyBearerToken(token, {
jwksUrl,
verifyOptions: { issuer, audience },
}),
@@ -205,7 +205,7 @@ describe("verifyAccessToken", () => {
mockJWKSResponse(publicJWK);
await expectUnauthorized(
verifyAccessToken(token, {
verifyBearerToken(token, {
jwksUrl,
verifyOptions: { issuer, audience },
}),
@@ -215,7 +215,7 @@ describe("verifyAccessToken", () => {
it("should not verify a token against a JWKS cached for a different issuer with a colliding kid", async () => {
vi.resetModules();
const { verifyAccessToken: verify } = await import("./verify");
const { verifyBearerToken: verify } = await import("./verify");
const sharedKid = "shared-kid";
const keyA = await createTestJWKS(sharedKid);
@@ -272,7 +272,7 @@ describe("verifyAccessToken", () => {
it("should refetch a rotated key set once the cache TTL has elapsed", async () => {
vi.resetModules();
const { verifyAccessToken: verify } = await import("./verify");
const { verifyBearerToken: verify } = await import("./verify");
const rotatingKid = "rotating-kid";
const oldKey = await createTestJWKS(rotatingKid);
@@ -564,7 +564,7 @@ describe("verifyAccessToken", () => {
it("should leave JWKS infrastructure failures as jose errors", async () => {
vi.resetModules();
const { errors: isolatedJoseErrors } = await import("jose");
const { verifyAccessToken: verifyAccessTokenWithIsolatedJwksCache } =
const { verifyBearerToken: verifyAccessTokenWithIsolatedJwksCache } =
await import("./verify");
const { privateKey, kid } = await createTestJWKS();
const token = await createSignedToken(privateKey, kid);
@@ -598,7 +598,7 @@ describe("verifyAccessToken", () => {
mockIntrospection({ scope: "read" });
await expect(
verifyAccessToken("opaque-token-for-another-resource", {
verifyBearerToken("opaque-token-for-another-resource", {
verifyOptions: { issuer, audience },
remoteVerify,
}),
@@ -612,7 +612,7 @@ describe("verifyAccessToken", () => {
});
await expect(
verifyAccessToken("token-minted-for-other-api", {
verifyBearerToken("token-minted-for-other-api", {
verifyOptions: { issuer, audience },
remoteVerify,
}),
@@ -623,7 +623,7 @@ describe("verifyAccessToken", () => {
mockIntrospection({ aud: audience, scope: "read" });
await expect(
verifyAccessToken("valid-token", {
verifyBearerToken("valid-token", {
verifyOptions: { issuer, audience },
remoteVerify,
}),
@@ -634,7 +634,7 @@ describe("verifyAccessToken", () => {
mockIntrospection({ scope: "read" });
await expect(
verifyAccessToken("opaque-token", {
verifyBearerToken("opaque-token", {
verifyOptions: { issuer, audience },
remoteVerify: { ...remoteVerify, allowMissingAudience: true },
}),
@@ -648,7 +648,7 @@ describe("verifyAccessToken", () => {
});
await expect(
verifyAccessToken("token-minted-for-other-api", {
verifyBearerToken("token-minted-for-other-api", {
verifyOptions: { issuer, audience },
remoteVerify: { ...remoteVerify, allowMissingAudience: true },
}),
+161 -17
View File
@@ -15,6 +15,14 @@ import {
UnsecuredJWT,
} from "jose";
import { logger } from "../env";
import type { DpopReplayStore } from "./dpop";
import {
createInMemoryDpopReplayStore,
enforceDpopBinding,
getDpopJktFromPayload,
isDpopBindingError,
parseAccessTokenAuthorization,
} from "./dpop";
const joseInfrastructureErrorCodes = new Set([
joseErrors.JWKSTimeout.code,
@@ -152,6 +160,65 @@ export interface VerifyAccessTokenRemote {
allowMissingAudience?: boolean;
}
export interface VerifyAccessTokenOptions {
/** Verify options */
verifyOptions: JWTVerifyOptions &
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
/** Scopes to additionally verify. Token must include all but not exact. */
scopes?: string[];
/** Required to verify access token locally */
jwksUrl?: string;
/** If provided, can verify a token remotely */
remoteVerify?: VerifyAccessTokenRemote;
}
export interface VerifyAccessTokenRequestOptions
extends VerifyAccessTokenOptions {
dpop?: {
proofMaxAgeSeconds?: number;
/**
* Store used to reject replayed DPoP proof `jti` values.
*
* Defaults to a process-local in-memory store, which is only safe for a
* single-instance deployment: it shares no state across instances and
* resets on cold start, so a captured proof can be replayed against
* another instance within the proof's lifetime. Supply a shared,
* persistent store (for example one backed by your database) for any
* multi-instance or serverless resource server.
*/
replayStore?: DpopReplayStore;
signingAlgorithms?: readonly string[];
};
}
export interface ResourceRequestInput {
authorizationHeader: string | null | undefined;
dpopProofJwt?: string | null | undefined;
method: string;
url: string;
}
/**
* Builds a {@link ResourceRequestInput} from a standard `Request`, reading the
* `Authorization` and `DPoP` headers and the request method and URL. Resource
* servers share this so every entry point maps the wire request the same way.
*/
export function requestToResourceInput(request: Request): ResourceRequestInput {
return {
authorizationHeader: request.headers.get("authorization"),
dpopProofJwt: request.headers.get("dpop"),
method: request.method,
url: request.url,
};
}
/**
* Process-local, single-instance replay store. See the warning on
* {@link VerifyAccessTokenRequestOptions.dpop.replayStore}; multi-instance
* resource servers must pass their own shared store.
*/
const defaultDpopReplayStore = createInMemoryDpopReplayStore();
/**
* Performs local verification of an access token for your APIs.
*
@@ -279,24 +346,9 @@ async function getJwksForVerification(
};
}
/**
* Performs local verification of an access token for your API.
*
* Can also be configured for remote verification.
*/
export async function verifyAccessToken(
async function verifyAccessTokenPayload(
token: string,
opts: {
/** Verify options */
verifyOptions: JWTVerifyOptions &
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
/** Scopes to additionally verify. Token must include all but not exact. */
scopes?: string[];
/** Required to verify access token locally */
jwksUrl?: string;
/** If provided, can verify a token remotely */
remoteVerify?: VerifyAccessTokenRemote;
},
opts: VerifyAccessTokenOptions,
) {
let payload: JWTPayload | undefined;
// Locally verify
@@ -405,3 +457,95 @@ export async function verifyAccessToken(
return payload;
}
function throwDpopUnauthorized(
message: string,
error?: "invalid_dpop_proof" | "invalid_token",
): never {
throw new APIError(
"UNAUTHORIZED",
error
? {
message,
error,
error_description: message,
}
: { message },
);
}
/**
* Performs local verification of a bearer access token for your API.
*
* Can also be configured for remote verification. DPoP-bound access tokens
* require {@link verifyAccessTokenRequest}, because sender-constraining cannot
* be verified without the HTTP method, URL, Authorization scheme, DPoP proof,
* and access-token hash. This function rejects DPoP-bound tokens; reach for it
* only when you hold a raw token string and intentionally accept bearer tokens
* alone.
*/
export async function verifyBearerToken(
token: string,
opts: VerifyAccessTokenOptions,
) {
const payload = await verifyAccessTokenPayload(token, opts);
if (getDpopJktFromPayload(payload)) {
throwDpopUnauthorized(
"DPoP-bound access token requires verifyAccessTokenRequest",
"invalid_token",
);
}
return payload;
}
/**
* Verifies an HTTP resource request carrying an OAuth access token. This is the
* recommended resource-server entry point: it handles both bearer and
* DPoP-bound tokens, the bearer case being the request with no DPoP proof.
*
* It performs the same token validation as {@link verifyBearerToken}, then adds
* the RFC 9449 sender-constraint checks that need request context: authorization
* scheme, method, URL, DPoP proof, `ath`, and `cnf.jkt` binding.
*/
export async function verifyAccessTokenRequest(
request: ResourceRequestInput,
opts: VerifyAccessTokenRequestOptions,
) {
const authorization = parseAccessTokenAuthorization(
request.authorizationHeader,
);
if (!authorization?.token) {
throwDpopUnauthorized("missing authorization header");
}
// RFC 6750 §2.1 / RFC 9449 §7.1: an access token is presented with the
// `Bearer` or `DPoP` scheme. Reject a scheme-less or unknown-scheme value
// rather than accept a bare token.
if (authorization.scheme === "Unknown") {
throwDpopUnauthorized(
"authorization scheme must be Bearer or DPoP",
"invalid_token",
);
}
const payload = await verifyAccessTokenPayload(authorization.token, opts);
try {
await enforceDpopBinding({
payload,
authorization,
proofJwt: request.dpopProofJwt,
method: request.method,
url: request.url,
replayStore: opts.dpop?.replayStore ?? defaultDpopReplayStore,
proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
signingAlgorithms: opts.dpop?.signingAlgorithms,
});
} catch (error) {
if (isDpopBindingError(error)) {
throwDpopUnauthorized(error.message, error.code);
}
throw error;
}
return payload;
}
+42 -33
View File
@@ -1,9 +1,14 @@
import {
DPOP_SIGNING_ALGORITHMS,
isDpopBindingError,
parseAccessTokenAuthorization,
} from "better-auth/oauth2";
import type {
McpResourceClient,
McpResourceClientOptions,
McpSession,
} from "./index";
import { createMcpResourceClient } from "./index";
import { createMcpResourceClient, makeDpopWWWAuthenticate } from "./index";
interface HonoContext {
req: { header: (name: string) => string | undefined; raw: Request };
@@ -59,42 +64,46 @@ export function mcpAuthHono(options: McpResourceClientOptions): {
options.resource ?? client.authURL,
);
const dpopChallenge = makeDpopWWWAuthenticate(
options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS,
);
const unauthorized = (c: HonoContext, challenge: string, message: string) => {
c.header("WWW-Authenticate", challenge);
return c.json(
{
jsonrpc: "2.0",
error: { code: -32000, message },
id: null,
},
401,
);
};
const middleware: HonoMiddleware = async (c, next) => {
const authHeader = c.req.header("Authorization");
const token = authHeader?.startsWith("Bearer ")
? authHeader.slice(7)
: undefined;
if (!token) {
c.header(
"WWW-Authenticate",
`Bearer resource_metadata="${resourceMetadata}"`,
);
return c.json(
{
jsonrpc: "2.0",
error: {
code: -32000,
message: "Unauthorized: Authentication required",
},
id: null,
},
401,
);
let session: McpSession | null;
try {
session = await client.verifyRequest(c.req.raw);
} catch (error) {
if (isDpopBindingError(error)) {
return unauthorized(c, dpopChallenge, "Invalid or expired token");
}
throw error;
}
const session = await client.verifyToken(token);
if (!session) {
c.header(
"WWW-Authenticate",
`Bearer resource_metadata="${resourceMetadata}"`,
);
return c.json(
{
jsonrpc: "2.0",
error: { code: -32000, message: "Invalid or expired token" },
id: null,
},
401,
const usedDpop =
parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" ||
!!c.req.header("DPoP");
const challenge = usedDpop
? dpopChallenge
: `Bearer resource_metadata="${resourceMetadata}"`;
return unauthorized(
c,
challenge,
authHeader
? "Invalid or expired token"
: "Unauthorized: Authentication required",
);
}
+54 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { deriveDpopJkt } from "better-auth/oauth2";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mcpAuthHono } from "./adapters";
import { createMcpResourceClient } from "./index";
@@ -132,3 +134,54 @@ describe("mcpAuthHono", () => {
expect(routes).toContain("/.well-known/oauth-protected-resource/api/auth");
});
});
describe("createMcpResourceClient DPoP challenge", () => {
afterEach(() => {
vi.restoreAllMocks();
});
/**
* A DPoP-bound access token presented with the `Bearer` scheme must be
* answered with an RFC 9449 `DPoP` challenge, not a bearer one, so the client
* knows to retry with a proof.
*
* @see https://github.com/better-auth/better-auth/pull/10039
*/
it("answers a DPoP-bound token sent with the bearer scheme with a DPoP challenge", async () => {
const authURL = "https://auth.example.com";
const issuerKeys = await generateKeyPair("ES256", { extractable: true });
const issuerPublicJwk = await exportJWK(issuerKeys.publicKey);
issuerPublicJwk.kid = "test-key";
const dpopKeys = await generateKeyPair("ES256", { extractable: true });
const jkt = await deriveDpopJkt(await exportJWK(dpopKeys.publicKey));
const accessToken = await new SignJWT({ sub: "user-1", cnf: { jkt } })
.setProtectedHeader({ alg: "ES256", kid: "test-key" })
.setIssuer(authURL)
.setAudience(authURL)
.setIssuedAt()
.setExpirationTime("5m")
.sign(issuerKeys.privateKey);
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : input.toString();
if (url.endsWith("/.well-known/oauth-authorization-server")) {
return Response.json({ issuer: authURL, jwks_uri: `${authURL}/jwks` });
}
if (url === `${authURL}/jwks`) {
return Response.json({ keys: [issuerPublicJwk] });
}
throw new Error(`unexpected fetch ${url}`);
});
const client = createMcpResourceClient({ authURL });
const response = await client.handler(() => new Response("unreachable"))(
new Request(`${authURL}/mcp`, {
headers: { Authorization: `Bearer ${accessToken}` },
}),
);
expect(response.status).toBe(401);
expect(response.headers.get("WWW-Authenticate")).toMatch(/^DPoP /);
});
});
+161 -28
View File
@@ -1,3 +1,12 @@
import type { VerifyAccessTokenRequestOptions } from "better-auth/oauth2";
import {
createInMemoryDpopReplayStore,
DPOP_SIGNING_ALGORITHMS,
enforceDpopBinding,
getDpopJktFromPayload,
isDpopBindingError,
parseAccessTokenAuthorization,
} from "better-auth/oauth2";
import type { JWTPayload } from "jose";
import { createRemoteJWKSet, jwtVerify } from "jose";
@@ -6,6 +15,7 @@ export interface McpResourceClientOptions {
resource?: string;
allowedOrigin?: string;
fetch?: typeof globalThis.fetch;
dpop?: VerifyAccessTokenRequestOptions["dpop"];
}
export interface McpSession extends JWTPayload {
@@ -32,8 +42,14 @@ interface NodeLikeRequest {
headers: Record<string, string | string[] | undefined> & {
get?: (name: string) => string | undefined;
authorization?: string;
host?: string;
"x-forwarded-proto"?: string;
};
get?: (name: string) => string | undefined;
method?: string;
originalUrl?: string;
protocol?: string;
url?: string;
mcpSession?: McpSession;
}
@@ -50,6 +66,7 @@ const PROTECTED_RESOURCE_METADATA_PATH =
export interface McpResourceClient {
verifyToken: (token: string) => Promise<McpSession | null>;
verifyRequest: (req: Request) => Promise<McpSession | null>;
handler: (
fn: (req: Request, session: McpSession) => Response | Promise<Response>,
) => (req: Request) => Promise<Response>;
@@ -82,7 +99,8 @@ function buildCorsHeaders(
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Headers": "Content-Type, Authorization, DPoP",
"Access-Control-Expose-Headers": "WWW-Authenticate",
"Access-Control-Max-Age": "86400",
};
}
@@ -106,8 +124,14 @@ function makeWWWAuthenticate(authURL: string, resource?: string): string {
return `Bearer resource_metadata="${resourceMetadataURL}"`;
}
function make401Response(authURL: string, resource?: string): Response {
const wwwAuth = makeWWWAuthenticate(authURL, resource);
export function makeDpopWWWAuthenticate(algorithms: readonly string[]): string {
// Strip CR/LF and quoting characters so configured algorithm names cannot
// inject extra fields into the `WWW-Authenticate` header value.
const safe = algorithms.map((alg) => alg.replace(/[\r\n"\\]/g, ""));
return `DPoP algs="${safe.join(" ")}"`;
}
function make401Response(wwwAuth: string): Response {
return Response.json(
{
jsonrpc: "2.0",
@@ -159,6 +183,25 @@ export function createMcpResourceClient(
const fetchFn = options.fetch ?? globalThis.fetch;
const corsHeaders = buildCorsHeaders(authURL, options.allowedOrigin);
const expectedAudience = options.resource ?? authURL;
const dpopReplayStore =
options.dpop?.replayStore ?? createInMemoryDpopReplayStore();
const dpopSigningAlgorithms =
options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS;
// Picks the `WWW-Authenticate` challenge for an unauthenticated request: a
// DPoP challenge when the client presented a DPoP token or proof, otherwise
// the bearer resource-metadata challenge.
const selectChallenge = (
authHeader: string | null | undefined,
dpopHeaderPresent: boolean,
): string => {
const usedDpop =
parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" ||
dpopHeaderPresent;
return usedDpop
? makeDpopWWWAuthenticate(dpopSigningAlgorithms)
: makeWWWAuthenticate(authURL, options.resource);
};
let discovery: { issuer: string; jwks_uri: string } | null = null;
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
@@ -182,7 +225,7 @@ export function createMcpResourceClient(
return { discovery, jwks };
};
const verifyToken = async (token: string): Promise<McpSession | null> => {
const verifyJwtToken = async (token: string): Promise<McpSession | null> => {
try {
const { discovery: meta, jwks: keySet } = await loadVerifier();
const { payload } = await jwtVerify(token, keySet, {
@@ -195,21 +238,96 @@ export function createMcpResourceClient(
}
};
const verifyToken = async (token: string): Promise<McpSession | null> => {
const session = await verifyJwtToken(token);
if (!session || getDpopJktFromPayload(session)) return null;
return session;
};
/**
* Verifies a request's access token and, when the token is DPoP-bound, its
* RFC 9449 sender-constraint. Returns `null` when there is no usable token
* or the JWT itself is invalid. Throws a `DpopBindingError` when a
* DPoP-bound token fails the binding check, so the caller can answer with a
* `WWW-Authenticate: DPoP` challenge rather than a bearer one.
*/
const verifyRequest = async (req: Request): Promise<McpSession | null> => {
const authorization = parseAccessTokenAuthorization(
req.headers.get("Authorization"),
);
// RFC 6750 / RFC 9449 §7: only Bearer or DPoP transport. A scheme-less or
// unknown-scheme value is treated as unauthenticated.
if (!authorization?.token || authorization.scheme === "Unknown")
return null;
const session = await verifyJwtToken(authorization.token);
if (!session) return null;
await enforceDpopBinding({
payload: session,
authorization,
proofJwt: req.headers.get("DPoP"),
method: req.method,
url: req.url,
proofMaxAgeSeconds: options.dpop?.proofMaxAgeSeconds,
signingAlgorithms: dpopSigningAlgorithms,
replayStore: dpopReplayStore,
});
return session;
};
const getHeader = (
req: NodeLikeRequest,
name: string,
): string | undefined => {
const lower = name.toLowerCase();
const value =
req.headers?.[lower] ??
req.headers?.[name] ??
req.headers?.get?.(name) ??
req.get?.(name);
if (Array.isArray(value)) return value[0];
return value;
};
const getNodeRequestUrl = (req: NodeLikeRequest): string => {
const rawUrl = req.originalUrl ?? req.url ?? "/";
if (URL.canParse(rawUrl)) return rawUrl;
const fallbackUrl = new URL(authURL);
const host = getHeader(req, "host") ?? fallbackUrl.host;
// `x-forwarded-proto` may be a comma-separated chain ("https, http"); the
// client-facing protocol is the first entry.
const forwardedProto = getHeader(req, "x-forwarded-proto")
?.split(",")[0]
?.trim();
const protocol =
forwardedProto ?? req.protocol ?? fallbackUrl.protocol.replace(":", "");
return `${protocol}://${host}${rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`}`;
};
const handler: McpResourceClient["handler"] = (fn) => {
return async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
const authHeader = req.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return make401Response(authURL, options.resource);
let session: McpSession | null;
try {
session = await verifyRequest(req);
} catch (error) {
if (isDpopBindingError(error)) {
return make401Response(
makeDpopWWWAuthenticate(dpopSigningAlgorithms),
);
}
throw error;
}
const token = authHeader.slice(7);
const session = await verifyToken(token);
if (!session) {
return make401Response(authURL, options.resource);
return make401Response(
selectChallenge(
req.headers.get("Authorization"),
req.headers.has("DPoP"),
),
);
}
return fn(req, session);
@@ -256,6 +374,7 @@ export function createMcpResourceClient(
resource,
authorization_servers: [authURL],
bearer_methods_supported: ["header"],
dpop_signing_alg_values_supported: [...dpopSigningAlgorithms],
};
return async (_req: Request) => {
@@ -269,27 +388,40 @@ export function createMcpResourceClient(
res: NodeLikeResponse,
next: () => void,
) => {
const authHeader =
req.headers?.authorization ??
req.headers?.get?.("Authorization") ??
req.get?.("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
send401Node(
res,
makeWWWAuthenticate(authURL, options.resource),
"Unauthorized: Authentication required",
);
return;
const authHeader = getHeader(req, "authorization");
const requestHeaders = new Headers();
if (authHeader) {
requestHeaders.set("Authorization", authHeader);
}
const dpop = getHeader(req, "dpop");
if (dpop) {
requestHeaders.set("DPoP", dpop);
}
const request = new Request(getNodeRequestUrl(req), {
method: req.method ?? "GET",
headers: requestHeaders,
});
let session: McpSession | null;
try {
session = await verifyRequest(request);
} catch (error) {
if (isDpopBindingError(error)) {
send401Node(
res,
makeDpopWWWAuthenticate(dpopSigningAlgorithms),
"Invalid or expired token",
);
return;
}
throw error;
}
const token = authHeader.slice(7);
const session = await verifyToken(token);
if (!session) {
send401Node(
res,
makeWWWAuthenticate(authURL, options.resource),
"Invalid or expired token",
selectChallenge(authHeader, !!getHeader(req, "dpop")),
authHeader
? "Invalid or expired token"
: "Unauthorized: Authentication required",
);
return;
}
@@ -301,6 +433,7 @@ export function createMcpResourceClient(
return {
verifyToken,
verifyRequest,
handler,
discoveryHandler,
protectedResourceHandler,
+13 -13
View File
@@ -1,6 +1,9 @@
import type { Awaitable } from "@better-auth/core";
import { raiseResourceServerChallenge } from "@better-auth/oauth-provider";
import { verifyAccessToken } from "better-auth/oauth2";
import {
requestToResourceInput,
verifyAccessTokenRequest,
} from "better-auth/oauth2";
import { APIError } from "better-call";
import type { JWTPayload } from "jose";
@@ -12,7 +15,7 @@ import type { JWTPayload } from "jose";
*/
export const mcpHandler = (
/** Verifier options. `audience` must match the protected resource identifier. */
verifyOptions: Parameters<typeof verifyAccessToken>[1],
verifyOptions: Parameters<typeof verifyAccessTokenRequest>[1],
handler: (req: Request, jwt: JWTPayload) => Awaitable<Response>,
opts?: {
/** Maps non-url (ie urn, client) resources to resource_metadata */
@@ -20,24 +23,21 @@ export const mcpHandler = (
},
) => {
return async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
try {
if (!accessToken?.length) {
throw new APIError("UNAUTHORIZED", {
message: "missing authorization header",
});
}
const token = await verifyAccessToken(accessToken, verifyOptions);
const token = await verifyAccessTokenRequest(
requestToResourceInput(req),
verifyOptions,
);
return handler(req, token);
} catch (error) {
try {
raiseResourceServerChallenge(
error,
verifyOptions.verifyOptions.audience,
opts,
{
...opts,
dpopSigningAlgorithms: verifyOptions.dpop?.signingAlgorithms,
},
);
} catch (err) {
if (err instanceof APIError) {
+4 -4
View File
@@ -63,7 +63,7 @@ describe("mcp", async () => {
expected,
}) => {
try {
await apiClient.verifyAccessToken("bad_access_token", {
await apiClient.verifyBearerToken("bad_access_token", {
verifyOptions: {
issuer: authServerUrl,
audience: resource,
@@ -226,7 +226,7 @@ describe("mcp - server-client flows", async () => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(config));
} else if (req.url === "/mcp") {
await verifyAccessToken(req, res);
await verifyBearerToken(req, res);
const mcpServer = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
@@ -260,7 +260,7 @@ describe("mcp - server-client flows", async () => {
await authServer.close();
});
async function verifyAccessToken(
async function verifyBearerToken(
req: IncomingMessage & { auth?: AuthInfo },
res: ServerResponse,
) {
@@ -269,7 +269,7 @@ describe("mcp - server-client flows", async () => {
? authorization.replace("Bearer ", "")
: authorization;
try {
const jwtPayload = await authClient.verifyAccessToken(accessToken, {
const jwtPayload = await authClient.verifyBearerToken(accessToken, {
verifyOptions: {
issuer: authServerUrl,
audience: resource,
+5
View File
@@ -4,6 +4,7 @@ import { generateRandomString } from "better-auth/crypto";
import {
authorizationCodeRequest,
createAuthorizationURL,
DPOP_SIGNING_ALGORITHMS,
refreshAccessTokenRequest,
} from "better-auth/oauth2";
import { jwt } from "better-auth/plugins/jwt";
@@ -140,12 +141,14 @@ describe("mcp plugin", async () => {
authorization_servers: string[];
scopes_supported?: string[];
bearer_methods_supported: string[];
dpop_signing_alg_values_supported?: string[];
};
expect(metadata).toMatchObject({
resource: baseURL,
authorization_servers: [baseURL],
bearer_methods_supported: ["header"],
dpop_signing_alg_values_supported: [...DPOP_SIGNING_ALGORITHMS],
});
expect(metadata.scopes_supported).toBeUndefined();
});
@@ -183,6 +186,7 @@ describe("mcp plugin", async () => {
authorization_servers: string[];
scopes_supported?: string[];
bearer_methods_supported: string[];
dpop_signing_alg_values_supported?: string[];
};
expect(metadata).toMatchObject({
@@ -190,6 +194,7 @@ describe("mcp plugin", async () => {
authorization_servers: [resourceBaseURL],
scopes_supported: ["mcp:read"],
bearer_methods_supported: ["header"],
dpop_signing_alg_values_supported: [...DPOP_SIGNING_ALGORITHMS],
});
});
+13
View File
@@ -11,6 +11,7 @@ import {
oauthProvider,
ResourceUriSchema,
} from "@better-auth/oauth-provider";
import { DPOP_SIGNING_ALGORITHMS } from "better-auth/oauth2";
const PROTECTED_RESOURCE_METADATA_PATH =
"/.well-known/oauth-protected-resource";
@@ -74,11 +75,23 @@ const buildResourceServerMetadata = (
const resourceScopes = scopes.filter(
(scope) => !AUTHORIZATION_SERVER_ONLY_SCOPES.has(scope),
);
const configuredResource = providerOptions.resources?.find(
(configuredResource) => resourceIdentifier(configuredResource) === resource,
);
const dpopBoundAccessTokensRequired =
typeof configuredResource === "object" &&
configuredResource.dpopBoundAccessTokensRequired === true;
const metadata: ResourceServerMetadata = {
resource,
authorization_servers: [getIssuer(ctx, providerOptions)],
bearer_methods_supported: ["header"],
dpop_signing_alg_values_supported: [
...(providerOptions.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS),
],
};
if (dpopBoundAccessTokensRequired) {
metadata.dpop_bound_access_tokens_required = true;
}
if (resourceScopes.length) {
metadata.scopes_supported = [...resourceScopes];
}
+101 -11
View File
@@ -1,24 +1,46 @@
import { APIError } from "better-call";
import type { JWTPayload } from "jose";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requireMcpAuth } from "./require-mcp-auth";
const { verifyAccessToken } = vi.hoisted(() => ({
verifyAccessToken: vi.fn(),
const { verifyAccessTokenRequest } = vi.hoisted(() => ({
verifyAccessTokenRequest: vi.fn(),
}));
vi.mock("better-auth/oauth2", () => ({ verifyAccessToken }));
// Partial mock: only `verifyAccessTokenRequest` is stubbed. The real exports
// (e.g. `DPOP_SIGNING_ALGORITHMS`, used by the DPoP challenge builder) stay so
// the resource-server challenge path works.
vi.mock("better-auth/oauth2", async (importOriginal) => {
const actual = await importOriginal<typeof import("better-auth/oauth2")>();
return { ...actual, verifyAccessTokenRequest };
});
// These tests mock `verifyAccessTokenRequest`, so the replay store is never
// exercised; the stub only needs to satisfy the context type.
const internalAdapterStub = {
reserveVerificationValue: async () => true,
};
const authWith = (baseURL: string, resolvedBaseURL: string) => ({
options: { baseURL },
$context: Promise.resolve({ baseURL: resolvedBaseURL }),
$context: Promise.resolve({
baseURL: resolvedBaseURL,
internalAdapter: internalAdapterStub,
}),
});
describe("requireMcpAuth", () => {
beforeEach(() => {
verifyAccessTokenRequest.mockReset();
});
it("verifies against the provider's resolved base URL, not the bare origin", async () => {
// Regression: the access token `iss`/`aud` are the provider's resolved
// base URL (which includes the base path). Verifying against the origin
// rejected every valid token whenever a base path was configured.
verifyAccessToken.mockResolvedValue({ sub: "user-1" } satisfies JWTPayload);
verifyAccessTokenRequest.mockResolvedValue({
sub: "user-1",
} satisfies JWTPayload);
const auth = authWith(
"https://app.example.com",
"https://app.example.com/api/auth",
@@ -36,8 +58,13 @@ describe("requireMcpAuth", () => {
expect(response.status).toBe(200);
expect(verifiedSub).toBe("user-1");
expect(verifyAccessToken).toHaveBeenCalledWith(
"access-token",
expect(verifyAccessTokenRequest).toHaveBeenCalledWith(
expect.objectContaining({
authorizationHeader: "Bearer access-token",
dpopProofJwt: null,
method: "GET",
url: "https://app.example.com/mcp",
}),
expect.objectContaining({
verifyOptions: expect.objectContaining({
issuer: "https://app.example.com/api/auth",
@@ -49,6 +76,11 @@ describe("requireMcpAuth", () => {
});
it("challenges with the served resource_metadata URL when no token is present", async () => {
verifyAccessTokenRequest.mockRejectedValue(
new APIError("UNAUTHORIZED", {
message: "missing authorization header",
}),
);
const response = await requireMcpAuth(
authWith("https://app.example.com", "https://app.example.com/api/auth"),
async () => new Response("unreachable"),
@@ -60,8 +92,54 @@ describe("requireMcpAuth", () => {
);
});
it("answers a DPoP-bound failure with an RFC 9449 DPoP challenge", async () => {
verifyAccessTokenRequest.mockRejectedValue(
new APIError("UNAUTHORIZED", {
message: "DPoP proof header is required",
error: "invalid_dpop_proof",
error_description: "DPoP proof header is required",
}),
);
const response = await requireMcpAuth(
authWith("https://app.example.com", "https://app.example.com/api/auth"),
async () => new Response("unreachable"),
)(
new Request("https://app.example.com/mcp", {
headers: { Authorization: "DPoP access-token" },
}),
);
expect(response.status).toBe(401);
expect(response.headers.get("WWW-Authenticate")).toMatch(/^DPoP /);
});
it("advertises the configured DPoP signing algorithms in the challenge", async () => {
verifyAccessTokenRequest.mockRejectedValue(
new APIError("UNAUTHORIZED", {
message: "DPoP proof header is required",
error: "invalid_dpop_proof",
error_description: "DPoP proof header is required",
}),
);
const response = await requireMcpAuth(
authWith("https://app.example.com", "https://app.example.com/api/auth"),
async () => new Response("unreachable"),
{ dpop: { signingAlgorithms: ["ES256"] } },
)(
new Request("https://app.example.com/mcp", {
headers: { Authorization: "DPoP access-token" },
}),
);
expect(response.status).toBe(401);
// The challenge advertises the configured alg, not the default set.
expect(response.headers.get("WWW-Authenticate")).toContain('algs="ES256"');
});
it("verifies against an explicit resource override", async () => {
verifyAccessToken.mockResolvedValue({ sub: "user-2" } satisfies JWTPayload);
verifyAccessTokenRequest.mockResolvedValue({
sub: "user-2",
} satisfies JWTPayload);
await requireMcpAuth(
authWith("https://app.example.com", "https://app.example.com/api/auth"),
async () => Response.json({ ok: true }),
@@ -72,8 +150,10 @@ describe("requireMcpAuth", () => {
}),
);
expect(verifyAccessToken).toHaveBeenCalledWith(
"access-token",
expect(verifyAccessTokenRequest).toHaveBeenCalledWith(
expect.objectContaining({
authorizationHeader: "Bearer access-token",
}),
expect.objectContaining({
verifyOptions: expect.objectContaining({
audience: "https://mcp.example.com/mcp",
@@ -83,6 +163,11 @@ describe("requireMcpAuth", () => {
});
it("advertises a scope hint in the challenge when configured", async () => {
verifyAccessTokenRequest.mockRejectedValue(
new APIError("UNAUTHORIZED", {
message: "missing authorization header",
}),
);
const response = await requireMcpAuth(
authWith("https://app.example.com", "https://app.example.com/api/auth"),
async () => new Response("unreachable"),
@@ -99,6 +184,11 @@ describe("requireMcpAuth", () => {
* @see https://github.com/better-auth/better-auth/pull/9992
*/
it("preserves a resource query in the metadata URL", async () => {
verifyAccessTokenRequest.mockRejectedValue(
new APIError("UNAUTHORIZED", {
message: "missing authorization header",
}),
);
const response = await requireMcpAuth(
authWith("https://app.example.com", "https://app.example.com/api/auth"),
async () => new Response("unreachable"),
+65 -46
View File
@@ -1,7 +1,19 @@
import type { Awaitable } from "@better-auth/core";
import { ResourceUriSchema } from "@better-auth/oauth-provider";
import { verifyAccessToken } from "better-auth/oauth2";
import {
ResourceUriSchema,
raiseResourceServerChallenge,
} from "@better-auth/oauth-provider";
import type {
DpopReplayReservations,
DpopReplayStore,
} from "better-auth/oauth2";
import {
createDpopReplayStore,
requestToResourceInput,
verifyAccessTokenRequest,
} from "better-auth/oauth2";
import type { BetterAuthOptions } from "better-auth/types";
import { APIError } from "better-call";
import type { JWTPayload } from "jose";
interface RequireMcpAuthOptions {
@@ -25,29 +37,36 @@ interface RequireMcpAuthOptions {
* (RFC 6750), hinting which scopes the client should request.
*/
scope?: string;
/**
* Maps a non-URL `resource` (an RFC 8707 `urn:` identifier or a client id) to
* the URL of its protected resource metadata. Required when `resource` is not
* an origin-based URL, so the `WWW-Authenticate` challenge can point at it.
*/
resourceMetadataMappings?: Record<string, string>;
/**
* DPoP proof validation settings. By default the replay store is backed by
* the auth instance's database adapter, so anti-replay holds across multiple
* server instances. Override `replayStore` only to point at a different store.
*/
dpop?: {
proofMaxAgeSeconds?: number;
signingAlgorithms?: readonly string[];
replayStore?: DpopReplayStore;
};
}
const unauthorized = (
message: string,
resourceMetadata: string,
scope?: string,
): Response => {
let challenge = `Bearer resource_metadata="${resourceMetadata}"`;
if (scope) {
challenge += `, scope="${scope}"`;
}
const unauthorized = (error: APIError): Response => {
const headers = new Headers(error.headers as HeadersInit);
headers.set("Content-Type", "application/json");
return new Response(
JSON.stringify({
jsonrpc: "2.0",
error: { code: -32000, message },
error: { code: -32000, message: error.message },
id: null,
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": challenge,
},
status: error.statusCode,
headers,
},
);
};
@@ -68,7 +87,10 @@ const unauthorized = (
export const requireMcpAuth = <
Auth extends {
options: BetterAuthOptions;
$context: Promise<{ baseURL: string }>;
$context: Promise<{
baseURL: string;
internalAdapter: DpopReplayReservations;
}>;
},
>(
auth: Auth,
@@ -87,7 +109,7 @@ export const requireMcpAuth = <
// the auth context so the verified issuer and audience match what the
// provider issued. Override via `opts` for a custom `jwt.issuer`, a
// distinct resource, or a non-default JWKS location.
const { baseURL } = await auth.$context;
const { baseURL, internalAdapter } = await auth.$context;
if (!baseURL) {
throw new Error(
"requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `mcpHandler` with explicit verify options.",
@@ -96,38 +118,35 @@ export const requireMcpAuth = <
const issuer = opts?.issuer ?? baseURL;
const resource = opts?.resource ?? baseURL;
const jwksUrl = opts?.jwksUrl ?? `${baseURL}/jwks`;
// RFC 9728: insert the resource path after the well-known segment. The
// provider serves the document at this root-mounted location.
const resourceUrl = new URL(resource);
const resourcePath =
resourceUrl.pathname === "/"
? ""
: resourceUrl.pathname.replace(/\/$/, "");
const resourceMetadata = `${resourceUrl.origin}/.well-known/oauth-protected-resource${resourcePath}${resourceUrl.search}`;
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: authorization;
if (!accessToken?.length) {
return unauthorized(
"Unauthorized: Authentication required",
resourceMetadata,
opts?.scope,
);
}
try {
const jwt = await verifyAccessToken(accessToken, {
const jwt = await verifyAccessTokenRequest(requestToResourceInput(req), {
verifyOptions: { issuer, audience: resource },
jwksUrl,
dpop: {
proofMaxAgeSeconds: opts?.dpop?.proofMaxAgeSeconds,
signingAlgorithms: opts?.dpop?.signingAlgorithms,
// Default to the database-backed store so proof replay is
// rejected across instances, not just within one process.
replayStore:
opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter),
},
});
return handler(req, jwt);
} catch {
return unauthorized(
"Unauthorized: invalid token",
resourceMetadata,
opts?.scope,
);
} catch (error) {
try {
raiseResourceServerChallenge(error, resource, {
scope: opts?.scope,
resourceMetadataMappings: opts?.resourceMetadataMappings,
dpopSigningAlgorithms: opts?.dpop?.signingAlgorithms,
});
} catch (challengeError) {
if (challengeError instanceof APIError) {
return unauthorized(challengeError);
}
if (challengeError instanceof Error) throw challengeError;
throw new Error(String(challengeError));
}
throw new Error(String(error));
}
};
};
+135 -45
View File
@@ -1,6 +1,15 @@
import { logger } from "@better-auth/core/env";
import { BetterAuthError } from "@better-auth/core/error";
import { verifyAccessToken } from "better-auth/oauth2";
import type {
ResourceRequestInput,
VerifyAccessTokenRequestOptions,
} from "better-auth/oauth2";
import {
DPOP_SIGNING_ALGORITHMS,
requestToResourceInput,
verifyAccessTokenRequest,
verifyBearerToken,
} from "better-auth/oauth2";
import type {
BetterAuthClientPlugin,
BetterAuthOptions,
@@ -61,6 +70,64 @@ export const oauthProviderResourceClient = <
return jwtPluginOptions?.jwt?.issuer ?? authServerBaseUrl;
};
const authServerBasePath = auth?.options.basePath;
const resolveVerifyAccessTokenOptions = async (
opts:
| (VerifyAccessTokenAuthOpts & {
verifyOptions?: JWTVerifyOptions &
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
})
| VerifyAccessTokenNoAuthOpts
| undefined,
): Promise<VerifyAccessTokenRequestOptions> => {
const jwtPluginOptions = await getJwtPluginOptions();
const audience = opts?.verifyOptions?.audience ?? authServerBaseUrl;
const issuer =
opts?.verifyOptions?.issuer ??
jwtPluginOptions?.jwt?.issuer ??
authServerBaseUrl;
if (!audience) {
throw Error("please define opts.verifyOptions.audience");
}
if (!issuer) {
throw Error("please define opts.verifyOptions.issuer");
}
const jwksUrl =
opts?.jwksUrl ??
jwtPluginOptions?.jwks?.remoteUrl ??
(authServerBaseUrl
? `${authServerBaseUrl + (authServerBasePath ?? "")}${jwtPluginOptions?.jwks?.jwksPath ?? "/jwks"}`
: undefined);
const introspectUrl =
opts?.remoteVerify?.introspectUrl ??
(authServerBaseUrl
? `${authServerBaseUrl}${authServerBasePath ?? ""}/oauth2/introspect`
: undefined);
return {
...opts,
jwksUrl,
verifyOptions: {
...opts?.verifyOptions,
audience,
issuer,
},
remoteVerify:
opts?.remoteVerify && introspectUrl
? {
...opts.remoteVerify,
introspectUrl,
}
: undefined,
};
};
const toResourceRequestInput = (
request: Request | ResourceRequestInput,
): ResourceRequestInput =>
// Duck-type the `Request` by its header accessor instead of `instanceof`,
// so a cross-realm `Request` (mismatched global vs `undici`, Workers) is
// still recognized rather than mistaken for a plain input.
typeof (request as Request).headers?.get === "function"
? requestToResourceInput(request as Request)
: (request as ResourceRequestInput);
return {
id: "oauth-provider-resource-client",
@@ -75,7 +142,7 @@ export const oauthProviderResourceClient = <
*
* The optional auth parameter can fill known values automatically.
*/
verifyAccessToken: (async (
verifyBearerToken: (async (
token: string | undefined,
opts?: {
verifyOptions?: JWTVerifyOptions &
@@ -87,58 +154,62 @@ export const oauthProviderResourceClient = <
resourceMetadataMappings?: Record<string, string>;
},
): Promise<JWTPayload> => {
const jwtPluginOptions = await getJwtPluginOptions();
const audience = opts?.verifyOptions?.audience ?? authServerBaseUrl;
const issuer =
opts?.verifyOptions?.issuer ??
jwtPluginOptions?.jwt?.issuer ??
authServerBaseUrl;
if (!audience) {
throw Error("please define opts.verifyOptions.audience");
}
if (!issuer) {
throw Error("please define opts.verifyOptions.issuer");
}
const jwksUrl =
opts?.jwksUrl ??
jwtPluginOptions?.jwks?.remoteUrl ??
(authServerBaseUrl
? `${authServerBaseUrl + (authServerBasePath ?? "")}${jwtPluginOptions?.jwks?.jwksPath ?? "/jwks"}`
: undefined);
const introspectUrl =
opts?.remoteVerify?.introspectUrl ??
(authServerBaseUrl
? `${authServerBaseUrl}${authServerBasePath ?? ""}/oauth2/introspect`
: undefined);
const verifyOptions = await resolveVerifyAccessTokenOptions(opts);
try {
if (!token?.length) {
throw new APIError("UNAUTHORIZED", {
message: "missing authorization header",
});
}
return await verifyAccessToken(token, {
...opts,
jwksUrl,
verifyOptions: {
...opts?.verifyOptions,
audience,
issuer,
},
remoteVerify:
opts?.remoteVerify && introspectUrl
? {
...opts.remoteVerify,
introspectUrl,
}
: undefined,
});
return await verifyBearerToken(token, verifyOptions);
} catch (error) {
raiseResourceServerChallenge(error, audience, {
resourceMetadataMappings: opts?.resourceMetadataMappings,
});
raiseResourceServerChallenge(
error,
verifyOptions.verifyOptions.audience,
{
resourceMetadataMappings: opts?.resourceMetadataMappings,
dpopSigningAlgorithms: DPOP_SIGNING_ALGORITHMS,
},
);
}
}) as VerifyAccessTokenOutput<T>,
/**
* Performs verification of a protected-resource request. Use this for
* new resource-server integrations so sender-constrained DPoP access
* tokens are enforced with the request method, URL, Authorization
* scheme, DPoP proof, `ath`, and `cnf.jkt` binding.
*/
verifyAccessTokenRequest: (async (
request: Request | ResourceRequestInput,
opts?: {
verifyOptions?: JWTVerifyOptions &
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
scopes?: string[];
jwksUrl?: string;
remoteVerify?: VerifyAccessTokenRemote;
dpop?: VerifyAccessTokenRequestOptions["dpop"];
/** Maps non-url (ie urn, client) resources to resource_metadata */
resourceMetadataMappings?: Record<string, string>;
},
): Promise<JWTPayload> => {
const verifyOptions = await resolveVerifyAccessTokenOptions(opts);
try {
return await verifyAccessTokenRequest(
toResourceRequestInput(request),
verifyOptions,
);
} catch (error) {
raiseResourceServerChallenge(
error,
verifyOptions.verifyOptions.audience,
{
resourceMetadataMappings: opts?.resourceMetadataMappings,
dpopSigningAlgorithms:
opts?.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS,
},
);
}
}) as VerifyAccessTokenRequestOutput<T>,
/**
* An authorization server does not typically publish
* the `/.well-known/oauth-protected-resource` themselves.
@@ -209,6 +280,10 @@ export const oauthProviderResourceClient = <
authorization_servers: authorizationServer
? [authorizationServer]
: undefined,
dpop_signing_alg_values_supported: [
...(oauthProviderOptions?.dpop?.signingAlgorithms ??
DPOP_SIGNING_ALGORITHMS),
],
...overrides,
};
}) as ProtectedResourceMetadataOutput<T>,
@@ -255,6 +330,15 @@ type VerifyAccessTokenOutput<T> = T extends undefined
token: string | undefined,
opts?: VerifyAccessTokenAuthOpts,
) => Promise<JWTPayload>;
type VerifyAccessTokenRequestOutput<T> = T extends undefined
? (
request: Request | ResourceRequestInput,
opts: VerifyAccessTokenRequestNoAuthOpts,
) => Promise<JWTPayload>
: (
request: Request | ResourceRequestInput,
opts?: VerifyAccessTokenRequestAuthOpts,
) => Promise<JWTPayload>;
type VerifyAccessTokenAuthOpts = {
verifyOptions?: JWTVerifyOptions &
Required<Pick<JWTVerifyOptions, "audience">>;
@@ -264,6 +348,9 @@ type VerifyAccessTokenAuthOpts = {
/** Maps non-url (ie urn, client) resources to resource_metadata */
resourceMetadataMappings?: Record<string, string>;
};
type VerifyAccessTokenRequestAuthOpts = VerifyAccessTokenAuthOpts & {
dpop?: VerifyAccessTokenRequestOptions["dpop"];
};
type VerifyAccessTokenNoAuthOpts =
| {
verifyOptions: JWTVerifyOptions &
@@ -283,6 +370,9 @@ type VerifyAccessTokenNoAuthOpts =
/** Maps non-url (ie urn, client) resources to resource_metadata */
resourceMetadataMappings?: Record<string, string>;
};
type VerifyAccessTokenRequestNoAuthOpts = VerifyAccessTokenNoAuthOpts & {
dpop?: VerifyAccessTokenRequestOptions["dpop"];
};
type ProtectedResourceMetadataOutput<T> = T extends undefined
? (
+14
View File
@@ -0,0 +1,14 @@
import type { GenericEndpointContext } from "@better-auth/core";
export function getDpopProofJwt(
ctx: Pick<GenericEndpointContext, "headers">,
): string | undefined {
return ctx.headers?.get("dpop") ?? undefined;
}
export function getEndpointUrl(
ctx: Pick<GenericEndpointContext, "context"> & { request?: Request },
path: string,
): string {
return ctx.request?.url ?? `${ctx.context.baseURL}${path}`;
}
+44 -15
View File
@@ -106,8 +106,9 @@ describe("oauth-provider extensions", async () => {
accessTokenClaims: { opaque_per_issuance: "jwt-only" },
});
},
// Attempts a sender-constraint on an opaque token (no resource).
// The provider must fail closed rather than mint an unbound token.
// Sender-constrains an opaque access token (no resource). The
// confirmation is persisted on the opaque-token row (RFC 7800 `cnf`)
// and surfaced at introspection, not silently dropped.
[extensionOpaqueBoundGrant]: async ({ grantType, provider }) => {
if (!grantUser) {
throw new APIError("BAD_REQUEST", {
@@ -123,7 +124,7 @@ describe("oauth-provider extensions", async () => {
client,
scopes: ["openid", "email", "vc"],
user: grantUser,
confirmation: { jkt: "should-fail" },
confirmation: { jkt: "opaque-bound-jkt" },
});
},
},
@@ -153,8 +154,10 @@ describe("oauth-provider extensions", async () => {
clientId,
client,
// A sender-constraint the strategy proved (wallet
// attestation); the grant forwards it to issueTokens.
confirmation: { jkt: "wallet-attested-jkt" },
// attestation); the grant forwards it to issueTokens. An
// mTLS-style `x5t#S256` keeps the token bearer-presentable,
// unlike a DPoP `jkt` which would require a proof per request.
confirmation: { "x5t#S256": "wallet-attested-cert" },
};
},
},
@@ -491,8 +494,10 @@ describe("oauth-provider extensions", async () => {
// authenticateClient, and it is stamped as `cnf`, sender-constraining the
// token. `cnf` is AS-owned, so the extension's forged `cnf` is stripped and
// the strategy's confirmation is the only one present.
expect(accessTokenPayload.cnf).toEqual({ jkt: "wallet-attested-jkt" });
expect(tokenResponse.data?.token_type).toBe("DPoP");
expect(accessTokenPayload.cnf).toEqual({
"x5t#S256": "wallet-attested-cert",
});
expect(tokenResponse.data?.token_type).toBe("Bearer");
const idTokenPayload = decodeJwt(tokenResponse.data!.id_token);
expect(idTokenPayload.extension_id_claim).toBe("extension-id");
@@ -1041,7 +1046,7 @@ describe("oauth-provider extensions", async () => {
});
});
it("fails closed when confirmation is supplied for an opaque token", async () => {
it("binds an opaque access token to its confirmation and surfaces it at introspection", async () => {
const oauthClient = await auth.api.adminCreateOAuthClient({
headers,
body: {
@@ -1051,19 +1056,43 @@ describe("oauth-provider extensions", async () => {
type: "web",
},
});
const response = await client.$fetch("/oauth2/token", {
const clientAuth = {
client_id: oauthClient!.client_id,
client_assertion_type: extensionAssertionType,
client_assertion: `assertion:${oauthClient!.client_id}`,
};
const response = await client.$fetch<{
access_token: string;
token_type: string;
}>("/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: extensionOpaqueBoundGrant,
client_id: oauthClient!.client_id,
client_assertion_type: extensionAssertionType,
client_assertion: `assertion:${oauthClient!.client_id}`,
...clientAuth,
}),
headers: { "content-type": "application/x-www-form-urlencoded" },
});
// Must not silently mint an unbound (Bearer) token a caller believes is
// sender-constrained: an opaque token cannot carry the constraint yet.
expect(response.error?.status).toBe(500);
// The opaque token carries the RFC 7800 `cnf` confirmation (persisted on
// its row), so it is sender-constrained, not silently minted unbound.
expect(response.error).toBeNull();
expect(response.data?.token_type).toBe("DPoP");
const introspection = await client.$fetch<{
active: boolean;
token_type?: string;
cnf?: unknown;
}>("/oauth2/introspect", {
method: "POST",
body: new URLSearchParams({
token: response.data!.access_token,
token_type_hint: "access_token",
...clientAuth,
}),
headers: { "content-type": "application/x-www-form-urlencoded" },
});
expect(introspection.data?.active).toBe(true);
expect(introspection.data?.token_type).toBe("DPoP");
expect(introspection.data?.cnf).toEqual({ jkt: "opaque-bound-jkt" });
});
it("authenticateClient binds the assertion audience to the served endpoint", async () => {
+15 -4
View File
@@ -1,6 +1,9 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { logger } from "@better-auth/core/env";
import { getJwks } from "better-auth/oauth2";
import {
getJwks,
stripAccessTokenAuthorizationScheme,
} from "better-auth/oauth2";
import type { Session, User } from "better-auth/types";
import { APIError } from "better-call";
import type { JSONWebKeySet, JWTPayload } from "jose";
@@ -11,8 +14,9 @@ import {
isAudienceClaimAllowed,
isClientLinkedToAnyResource,
} from "./resources";
import { decodeRefreshToken } from "./token";
import { confirmationTokenType, decodeRefreshToken } from "./token";
import type {
Confirmation,
OAuthOpaqueAccessToken,
OAuthOptions,
OAuthRefreshToken,
@@ -217,6 +221,9 @@ async function validateJwtAccessToken(
// https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
jwtPayload.client_id = jwtPayload.azp;
jwtPayload.active = true;
jwtPayload.token_type = confirmationTokenType(
jwtPayload.cnf as Confirmation | undefined,
);
return jwtPayload;
}
@@ -391,6 +398,8 @@ async function validateOpaqueAccessToken(
exp: Math.floor(new Date(accessToken.expiresAt).getTime() / 1000),
iat: Math.floor(new Date(accessToken.createdAt).getTime() / 1000),
scope: accessToken.scopes?.join(" "),
token_type: confirmationTokenType(accessToken.confirmation),
...(accessToken.confirmation ? { cnf: accessToken.confirmation } : {}),
} as JWTPayload;
}
@@ -478,6 +487,8 @@ async function validateRefreshToken(
exp: Math.floor(new Date(refreshToken.expiresAt).getTime() / 1000),
iat: Math.floor(new Date(refreshToken.createdAt).getTime() / 1000),
scope: refreshToken.scopes?.join(" "),
token_type: confirmationTokenType(refreshToken.confirmation),
...(refreshToken.confirmation ? { cnf: refreshToken.confirmation } : {}),
} as JWTPayload;
}
@@ -596,8 +607,8 @@ export async function introspectEndpoint(
}
// Check token
if (token && typeof token === "string" && token.startsWith("Bearer ")) {
token = token.replace("Bearer ", "");
if (token && typeof token === "string") {
token = stripAccessTokenAuthorizationScheme(token);
}
if (!token?.length) {
throw new APIError("BAD_REQUEST", {
@@ -1,6 +1,7 @@
import type { BetterAuthOptions } from "@better-auth/core";
import { APIError, BetterAuthError } from "@better-auth/core/error";
import { createAuthClient } from "better-auth/client";
import { DPOP_SIGNING_ALGORITHMS } from "better-auth/oauth2";
import type { JwtOptions } from "better-auth/plugins/jwt";
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
@@ -113,6 +114,7 @@ describe("oauth metadata", async () => {
],
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true,
dpop_signing_alg_values_supported: [...DPOP_SIGNING_ALGORITHMS],
backchannel_logout_supported: true,
backchannel_logout_session_supported: true,
claims_supported: baseClaims,
@@ -523,6 +525,7 @@ describe("oauth resource metadata", async () => {
expect(metadata).toMatchObject({
resource: validResource,
authorization_servers: [authServerBaseUrl],
dpop_signing_alg_values_supported: [...DPOP_SIGNING_ALGORITHMS],
});
});
+9 -1
View File
@@ -1,5 +1,8 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
import {
DPOP_SIGNING_ALGORITHMS,
PRIVATE_KEY_JWT_SIGNING_ALGORITHMS,
} from "@better-auth/core/oauth2";
import type { JWSAlgorithms, JwtOptions } from "better-auth/plugins";
import { validateIssuerUrl } from "./authorize";
import {
@@ -28,6 +31,7 @@ export function authServerMetadata(
token_endpoint_auth_methods_supported?: TokenEndpointAuthMethod[];
endpoint_auth_methods_supported?: TokenEndpointAuthMethod[];
jwt_disabled?: boolean;
dpop_signing_alg_values_supported?: JWSAlgorithms[];
},
) {
const baseURL = ctx.context.baseURL;
@@ -91,6 +95,9 @@ export function authServerMetadata(
],
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true,
dpop_signing_alg_values_supported:
overrides?.dpop_signing_alg_values_supported ??
([...DPOP_SIGNING_ALGORITHMS] as JWSAlgorithms[]),
backchannel_logout_supported: backchannelSupported,
backchannel_logout_session_supported: backchannelSupported,
};
@@ -125,6 +132,7 @@ function buildAuthServerMetadata(
}),
endpoint_auth_methods_supported: getSupportedAuthMethods(opts),
jwt_disabled: opts.disableJwtPlugin,
dpop_signing_alg_values_supported: opts.dpop?.signingAlgorithms,
});
return { jwtPluginOptions, clientDiscoveries, authMetadata };
}
+2 -2
View File
@@ -3359,7 +3359,7 @@ describe("oauth - config", () => {
expect(tokens.data?.accessToken).toBeDefined();
if (publicClient && !(resource && !disableJwtPlugin)) {
await expect(
client.verifyAccessToken(tokens.data?.accessToken!, {
client.verifyBearerToken(tokens.data?.accessToken!, {
verifyOptions: {
audience: validResource,
issuer: authServerUrl,
@@ -3368,7 +3368,7 @@ describe("oauth - config", () => {
}),
).rejects.toThrowError();
} else {
const payload = await client.verifyAccessToken(
const payload = await client.verifyBearerToken(
tokens.data?.accessToken!,
{
verifyOptions: {
+20 -2
View File
@@ -861,6 +861,16 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
allowedMediaTypes: ["application/x-www-form-urlencoded"],
openapi: {
description: "Obtain an OAuth2.1 access token",
parameters: [
{
name: "DPoP",
in: "header",
required: false,
schema: { type: "string" },
description:
"RFC 9449 DPoP proof JWT for issuing DPoP-bound tokens",
},
],
requestBody: {
required: true,
content: {
@@ -943,7 +953,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
token_type: {
type: "string",
description: "The type of the token issued",
enum: ["Bearer"],
enum: ["Bearer", "DPoP"],
},
expires_in: {
type: "number",
@@ -1249,7 +1259,15 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
in: "header",
required: false,
schema: { type: "string" },
description: "Bearer access token",
description: "Bearer or DPoP access token",
},
{
name: "DPoP",
in: "header",
required: false,
schema: { type: "string" },
description:
"RFC 9449 DPoP proof JWT when using a DPoP-bound access token",
},
],
responses: {
@@ -57,6 +57,8 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
skip_consent: z.boolean().optional(),
enable_end_session: z.boolean().optional(),
require_pkce: z.boolean().optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),
subject_type: z.enum(["public", "pairwise"]).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
@@ -248,6 +250,8 @@ export const createOAuthClient = (opts: OAuthOptions<Scope[]>) =>
grant_types: grantTypesSchema.optional(),
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),
}),
metadata: {
openapi: {
@@ -510,6 +514,8 @@ export const adminUpdateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
.optional(),
skip_consent: z.boolean().optional(),
enable_end_session: z.boolean().optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
}),
@@ -113,6 +113,7 @@ function buildResourceRow(input: OAuthResourceInput, now: Date) {
signingKeyId: input.signingKeyId ?? null,
allowedScopes: input.allowedScopes ?? null,
customClaims: input.customClaims ?? null,
dpopBoundAccessTokensRequired: input.dpopBoundAccessTokensRequired ?? false,
disabled: input.disabled ?? false,
policyVersion: 1,
metadata: input.metadata ?? null,
@@ -232,6 +233,7 @@ export async function updateResourceEndpoint(
"signingKeyId",
"allowedScopes",
"customClaims",
"dpopBoundAccessTokensRequired",
"disabled",
"metadata",
];
@@ -26,6 +26,7 @@ const resourceBodySchema = z.object({
signingKeyId: z.string().nullable().optional(),
allowedScopes: z.array(z.string()).nullable().optional(),
customClaims: z.record(z.string(), z.unknown()).nullable().optional(),
dpopBoundAccessTokensRequired: z.boolean().optional(),
disabled: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
});
+13
View File
@@ -220,6 +220,15 @@ export async function checkOAuthClient(
error_description: `unsupported token_endpoint_auth_method ${tokenEndpointAuthMethod}`,
});
}
if (
clientWithDefaults.dpop_bound_access_tokens !== undefined &&
typeof clientWithDefaults.dpop_bound_access_tokens !== "boolean"
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "dpop_bound_access_tokens must be a boolean",
});
}
// Check value of type, if sent, matches isPublic
if (clientWithDefaults.type) {
@@ -709,6 +718,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
skip_consent: skipConsent,
enable_end_session: enableEndSession,
require_pkce: requirePKCE,
dpop_bound_access_tokens: dpopBoundAccessTokens,
subject_type: subjectType,
reference_id: referenceId,
metadata: inputMetadata,
@@ -775,6 +785,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
skipConsent,
enableEndSession,
requirePKCE,
dpopBoundAccessTokens,
subjectType,
referenceId,
metadata,
@@ -828,6 +839,7 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
skipConsent,
enableEndSession,
requirePKCE,
dpopBoundAccessTokens,
subjectType,
referenceId,
metadata, // in JSON format
@@ -887,6 +899,7 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
skip_consent: skipConsent ?? undefined,
enable_end_session: enableEndSession ?? undefined,
require_pkce: requirePKCE ?? undefined,
dpop_bound_access_tokens: dpopBoundAccessTokens ?? undefined,
subject_type: subjectType ?? undefined,
reference_id: referenceId ?? undefined,
};
@@ -36,4 +36,26 @@ describe("resource server challenge", () => {
'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/calendar", Bearer resource_metadata="https://files.example.com/.well-known/oauth-protected-resource"',
);
});
it("emits RFC 9449 DPoP challenges for invalid DPoP proofs", () => {
try {
raiseResourceServerChallenge(
new APIError("UNAUTHORIZED", {
message: "DPoP proof header is required",
error: "invalid_dpop_proof",
error_description: "DPoP proof header is required",
}),
"https://api.example.com/mcp/tools",
{ dpopSigningAlgorithms: ["ES256"] },
);
} catch (error) {
const apiError = error as APIError;
const headers = new Headers(apiError.headers);
expect(headers.get("WWW-Authenticate")).toBe(
'DPoP error="invalid_dpop_proof", error_description="DPoP proof header is required", algs="ES256"',
);
return;
}
throw new Error("expected challenge");
});
});
@@ -1,14 +1,67 @@
import { isAPIError } from "better-auth/api";
import { DPOP_SIGNING_ALGORITHMS } from "better-auth/oauth2";
import { APIError } from "better-call";
const DPOP_CHALLENGE_ERRORS = new Set(["invalid_dpop_proof"]);
function quoteAuthParam(value: string): string {
// Drop CR/LF before quoting so an error message or configured scope cannot
// inject extra header fields into the `WWW-Authenticate` value.
return value
.replace(/[\r\n]+/g, " ")
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
}
function extractDpopError(error: APIError): {
errorCode?: string;
description: string;
} {
const body = error.body as
| { error?: unknown; error_description?: unknown; message?: unknown }
| undefined;
const errorCode = typeof body?.error === "string" ? body.error : undefined;
const description =
typeof body?.error_description === "string"
? body.error_description
: typeof body?.message === "string"
? body.message
: error.message;
return { errorCode, description };
}
function isDpopChallengeError(error: APIError) {
const { errorCode, description } = extractDpopError(error);
return (
!!errorCode &&
(DPOP_CHALLENGE_ERRORS.has(errorCode) ||
(errorCode === "invalid_token" && description.includes("DPoP")))
);
}
function buildDpopChallenge(
error: APIError,
opts?: {
dpopSigningAlgorithms?: readonly string[];
},
) {
const { errorCode, description } = extractDpopError(error);
const algorithms = opts?.dpopSigningAlgorithms ?? DPOP_SIGNING_ALGORITHMS;
return [
`DPoP error="${quoteAuthParam(errorCode ?? "invalid_dpop_proof")}"`,
`error_description="${quoteAuthParam(description)}"`,
`algs="${quoteAuthParam(algorithms.join(" "))}"`,
].join(", ");
}
/**
* Raise the RFC 6750 `WWW-Authenticate: Bearer` challenge for a resource server.
* Raise an OAuth resource-server challenge for a failed access-token request.
*
* Given an error thrown while verifying a bearer token, this rethrows an
* unauthorized error as a `401` carrying the RFC 9728 `resource_metadata`
* pointer for each resource, and rethrows any other error unchanged. Non-URL
* resources (for example a `urn:` or a client id) resolve their metadata URL
* through `resourceMetadataMappings`.
* Missing/invalid bearer credentials are reported with RFC 6750 plus the RFC
* 9728 `resource_metadata` pointer. DPoP-bound-token failures are reported with
* RFC 9449's `DPoP` challenge so clients know which proof algorithms to use.
* Non-URL resources (for example a `urn:` or a client id) resolve their
* metadata URL through `resourceMetadataMappings`.
*
* @internal
*/
@@ -18,9 +71,20 @@ export function raiseResourceServerChallenge(
opts?: {
/** Maps non-URL (urn, client) resources to their resource_metadata URL. */
resourceMetadataMappings?: Record<string, string>;
/** DPoP JWS algorithms to advertise in RFC 9449 challenges. */
dpopSigningAlgorithms?: readonly string[];
/** Space-delimited scopes to advertise in RFC 6750 bearer challenges. */
scope?: string;
},
): never {
if (isAPIError(error) && error.status === "UNAUTHORIZED") {
if (isDpopChallengeError(error)) {
throw new APIError(
"UNAUTHORIZED",
{ message: error.message },
{ "WWW-Authenticate": buildDpopChallenge(error, opts) },
);
}
const resources = Array.isArray(resource) ? resource : [resource];
const wwwAuthenticateValue = resources
.map((value) => {
@@ -33,7 +97,11 @@ export function raiseResourceServerChallenge(
const resourcePath = url.pathname.endsWith("/")
? url.pathname.slice(0, -1)
: url.pathname;
return `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource${resourcePath}${url.search}"`;
let challenge = `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource${resourcePath}${url.search}"`;
if (opts?.scope) {
challenge += `, scope="${quoteAuthParam(opts.scope)}"`;
}
return challenge;
}
const resourceMetadata = opts?.resourceMetadataMappings?.[value];
if (!resourceMetadata) {
@@ -41,7 +109,11 @@ export function raiseResourceServerChallenge(
message: `missing resource_metadata mapping for ${value}`,
});
}
return `Bearer resource_metadata="${resourceMetadata}"`;
let challenge = `Bearer resource_metadata="${resourceMetadata}"`;
if (opts?.scope) {
challenge += `, scope="${quoteAuthParam(opts.scope)}"`;
}
return challenge;
})
.join(", ");
throw new APIError(
+14
View File
@@ -207,6 +207,11 @@ export interface ResolvedResourcePolicy {
* a token directly or a resource could override an AS-owned claim.
*/
rawCustomClaims: Record<string, unknown>;
/**
* True when at least one requested resource requires DPoP-bound access
* tokens.
*/
dpopBoundAccessTokensRequired: boolean;
/**
* The intersection of the caller's `requestedScopes` with each requested
* resource's `allowedScopes`. When no requested resource defines an
@@ -377,6 +382,7 @@ export async function resolveResourcePolicy(
signingAlgorithm: null,
signingKeyId: null,
rawCustomClaims: {},
dpopBoundAccessTokensRequired: false,
effectiveScopes: [...params.requestedScopes],
};
}
@@ -516,6 +522,9 @@ export async function resolveResourcePolicy(
Object.assign(mergedClaims, row.customClaims);
}
}
const dpopBoundAccessTokensRequired = resolved.some(
(row) => row.dpopBoundAccessTokensRequired === true,
);
const audienceIdentifiers = includesOpenid
? [...uniqueRequestedResources, userInfoResourceIdentifier]
@@ -529,6 +538,7 @@ export async function resolveResourcePolicy(
signingAlgorithm,
signingKeyId,
rawCustomClaims: mergedClaims,
dpopBoundAccessTokensRequired,
effectiveScopes,
};
}
@@ -763,6 +773,7 @@ function buildSeedRow(input: OAuthResourceInput, now: Date) {
signingKeyId: input.signingKeyId ?? null,
allowedScopes: input.allowedScopes ?? null,
customClaims: input.customClaims ?? null,
dpopBoundAccessTokensRequired: input.dpopBoundAccessTokensRequired ?? false,
disabled: input.disabled ?? false,
policyVersion: 1,
metadata: input.metadata ?? null,
@@ -805,6 +816,9 @@ function buildSeedUpdate(
update.allowedScopes = input.allowedScopes;
if (input.customClaims !== undefined)
update.customClaims = input.customClaims;
if (input.dpopBoundAccessTokensRequired !== undefined) {
update.dpopBoundAccessTokensRequired = input.dpopBoundAccessTokensRequired;
}
if (input.disabled !== undefined) update.disabled = input.disabled;
if (input.metadata !== undefined) update.metadata = input.metadata;
return update;
+6 -3
View File
@@ -1,6 +1,9 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { logger } from "@better-auth/core/env";
import { getJwks } from "better-auth/oauth2";
import {
getJwks,
stripAccessTokenAuthorizationScheme,
} from "better-auth/oauth2";
import { APIError } from "better-call";
import type { JSONWebKeySet, JWTPayload } from "jose";
import { createLocalJWKSet, jwtVerify } from "jose";
@@ -323,8 +326,8 @@ export async function revokeEndpoint(
}
// Check token
if (typeof token === "string" && token.startsWith("Bearer ")) {
token = token.replace("Bearer ", "");
if (typeof token === "string") {
token = stripAccessTokenAuthorizationScheme(token);
}
if (!token?.length) {
throw new APIError("BAD_REQUEST", {
+23 -7
View File
@@ -78,23 +78,39 @@ describe("oauth provider schema", () => {
expect(resource.fields.metadata).toBeDefined();
});
it("defers DPoP/mTLS/JWE/opaque-token columns to follow-up PRs", () => {
// Reserving columns without enforcement was rejected as security theater
// (see RFC §"Suggested rollout"). Each deferred column lands alongside
// its enforcement code in the relevant follow-up PR. This test guards
// against an accidental re-introduction in this PR's scope.
it("declares DPoP sender-constraint storage with enforcement state", () => {
const oauthSchema = schema as Record<
string,
{ fields: Record<string, unknown> }
{
fields: Record<
string,
{
type?: string;
required?: boolean;
unique?: boolean;
defaultValue?: unknown;
}
>;
}
>;
const clientFields = oauthSchema.oauthClient?.fields ?? {};
const resourceFields = oauthSchema.oauthResource?.fields ?? {};
const refreshFields = oauthSchema.oauthRefreshToken?.fields ?? {};
const accessFields = oauthSchema.oauthAccessToken?.fields ?? {};
expect(clientFields.dpopBoundAccessTokens?.defaultValue).toBe(false);
expect(resourceFields.dpopBoundAccessTokensRequired?.defaultValue).toBe(
false,
);
expect(refreshFields.confirmation).toBeDefined();
expect(accessFields.confirmation).toBeDefined();
// Other sender-constraint/token-format columns are intentionally absent
// from the schema.
const deferredOnResource = [
"tokenFormat",
"encryptionAlgorithm",
"encryptionKeyId",
"requireDpop",
"requireMtls",
];
for (const fieldName of deferredOnResource) {
+22
View File
@@ -141,6 +141,11 @@ export const schema = {
type: "boolean",
required: false,
},
dpopBoundAccessTokens: {
type: "boolean",
required: false,
defaultValue: false,
},
// All other metadata
referenceId: {
type: "string",
@@ -206,6 +211,11 @@ export const schema = {
type: "json",
required: false,
},
dpopBoundAccessTokensRequired: {
type: "boolean",
required: false,
defaultValue: false,
},
// Lifecycle: disabled → no new issuance, existing tokens still verify.
disabled: {
type: "boolean",
@@ -359,6 +369,12 @@ export const schema = {
type: "date",
required: false,
},
// RFC 7800 `cnf` confirmation that sender-constrains this refresh-token
// family (for example DPoP `{ jkt }`). Carried forward on rotation.
confirmation: {
type: "json",
required: false,
},
// Immutable
scopes: {
type: "string[]",
@@ -443,6 +459,12 @@ export const schema = {
type: "date",
required: false,
},
// RFC 7800 `cnf` confirmation that sender-constrains this access token
// (for example DPoP `{ jkt }`). Surfaced as `cnf` at introspection.
confirmation: {
type: "json",
required: false,
},
// Shall be same as refreshId.scopes if using refreshId
scopes: {
type: "string[]",
+396 -2
View File
@@ -5,15 +5,32 @@ import type { ProviderOptions } from "better-auth/oauth2";
import {
authorizationCodeRequest,
createAuthorizationURL,
deriveDpopAth,
deriveDpopJkt,
refreshAccessTokenRequest,
} from "better-auth/oauth2";
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
import { base64url, createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
import type { JWK } from "jose";
import {
base64url,
createLocalJWKSet,
decodeJwt,
exportJWK,
generateKeyPair,
jwtVerify,
SignJWT,
} from "jose";
import { beforeAll, describe, expect, it } from "vitest";
import { oauthProviderClient } from "./client";
import { oauthProvider } from "./oauth";
import type { OAuthOptions, Scope, VerificationValue } from "./types";
import { confirmationTokenType } from "./token";
import type {
Confirmation,
OAuthOptions,
Scope,
VerificationValue,
} from "./types";
import type { OAuthClient } from "./types/oauth";
import { verificationValueSchema } from "./types/zod";
@@ -3425,3 +3442,380 @@ describe("oauth token - per-client grant_type enforcement", async () => {
expect(location).toContain("error=unauthorized_client");
});
});
describe("oauth token - DPoP", async () => {
const authServerBaseUrl = "http://localhost:3000";
const rpBaseUrl = "http://localhost:5000";
const jwtResource = "https://dpop-api.example.com";
const dpopRequiredResource = "https://dpop-required.example.com";
const tokenEndpoint = `${authServerBaseUrl}/api/auth/oauth2/token`;
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: authServerBaseUrl,
plugins: [
jwt({ jwt: { issuer: authServerBaseUrl } }),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
allowDynamicClientRegistration: true,
resources: [
jwtResource,
{
identifier: dpopRequiredResource,
dpopBoundAccessTokensRequired: true,
},
],
enforcePerClientResources: false,
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
const client = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: authServerBaseUrl,
fetchOptions: { customFetchImpl, headers },
});
let oauthClient: OAuthClient | null;
const providerId = "test";
const redirectUri = `${rpBaseUrl}/api/auth/callback/${providerId}`;
async function createDpopKey() {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const jkt = await deriveDpopJkt(publicJwk);
return { privateKey, publicJwk, jkt };
}
async function createDpopProof(params: {
privateKey: CryptoKey;
publicJwk: JWK;
method: string;
url: string;
jti: string;
accessToken?: string;
}) {
return new SignJWT({
jti: params.jti,
htm: params.method,
htu: params.url,
iat: Math.floor(Date.now() / 1000),
...(params.accessToken
? { ath: await deriveDpopAth(params.accessToken) }
: {}),
})
.setProtectedHeader({
typ: "dpop+jwt",
alg: "ES256",
jwk: params.publicJwk,
})
.sign(params.privateKey);
}
async function createAuthUrl(
overrides?: Partial<Parameters<typeof createAuthorizationURL>[0]>,
) {
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
throw Error("beforeAll not run properly");
}
const codeVerifier = generateRandomString(32);
const { url } = await createAuthorizationURL({
id: providerId,
options: {
clientId: oauthClient.client_id,
clientSecret: oauthClient.client_secret,
redirectURI: redirectUri,
},
redirectURI: "",
authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
state: "123",
scopes: ["openid", "offline_access"],
codeVerifier,
...overrides,
});
return { url, codeVerifier };
}
async function exchangeCode(
code: string,
codeVerifier: string,
resource: string,
dpopProof?: string,
) {
const { body, headers: reqHeaders } = await authorizationCodeRequest({
code,
codeVerifier,
redirectURI: redirectUri,
resource,
options: {
clientId: oauthClient!.client_id,
clientSecret: oauthClient!.client_secret!,
redirectURI: redirectUri,
},
});
const tokenHeaders = new Headers(reqHeaders);
if (dpopProof) tokenHeaders.set("DPoP", dpopProof);
return client.$fetch<{
access_token?: string;
refresh_token?: string;
token_type?: string;
error?: string;
}>("/oauth2/token", { method: "POST", body, headers: tokenHeaders });
}
async function authorizeForCode(jkt: string | undefined, resource: string) {
const { url: authUrl, codeVerifier } = await createAuthUrl(
jkt ? { additionalParams: { dpop_jkt: jkt } } : undefined,
);
authUrl.searchParams.set("resource", resource);
let callbackRedirectUrl = "";
await client.$fetch(authUrl.toString(), {
onError(context) {
callbackRedirectUrl = context.response.headers.get("Location") || "";
},
});
const url = new URL(callbackRedirectUrl);
return { code: url.searchParams.get("code")!, codeVerifier };
}
beforeAll(async () => {
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
grant_types: ["authorization_code", "refresh_token"],
redirect_uris: [redirectUri],
skip_consent: true,
},
});
oauthClient = response;
});
it("issues a JWT access token bound to the proof key via cnf.jkt", async () => {
const dpopKey = await createDpopKey();
const { code, codeVerifier } = await authorizeForCode(
dpopKey.jkt,
jwtResource,
);
const proof = await createDpopProof({
privateKey: dpopKey.privateKey,
publicJwk: dpopKey.publicJwk,
method: "POST",
url: tokenEndpoint,
jti: "issue-proof",
});
const tokens = await exchangeCode(code, codeVerifier, jwtResource, proof);
expect(tokens.error).toBeNull();
expect(tokens.data?.token_type).toBe("DPoP");
const payload = decodeJwt(tokens.data!.access_token!);
expect(payload.cnf).toMatchObject({ jkt: dpopKey.jkt });
});
it("rejects a token request without a proof when the resource requires DPoP", async () => {
const { code, codeVerifier } = await authorizeForCode(
undefined,
dpopRequiredResource,
);
const tokens = await exchangeCode(code, codeVerifier, dpopRequiredResource);
expect(tokens.data?.access_token).toBeUndefined();
expect((tokens.error as { error?: string } | null)?.error).toBe(
"invalid_dpop_proof",
);
});
it("preserves the key binding across refresh and rejects a proofless refresh", async () => {
const dpopKey = await createDpopKey();
const { code, codeVerifier } = await authorizeForCode(
dpopKey.jkt,
jwtResource,
);
const issueProof = await createDpopProof({
privateKey: dpopKey.privateKey,
publicJwk: dpopKey.publicJwk,
method: "POST",
url: tokenEndpoint,
jti: "rotate-issue-proof",
});
const tokens = await exchangeCode(
code,
codeVerifier,
jwtResource,
issueProof,
);
expect(tokens.data?.refresh_token).toBeDefined();
async function refresh(dpopProof?: string) {
const { body, headers: reqHeaders } = await refreshAccessTokenRequest({
refreshToken: tokens.data!.refresh_token!,
options: {
clientId: oauthClient!.client_id,
clientSecret: oauthClient!.client_secret!,
redirectURI: redirectUri,
},
});
const refreshHeaders = new Headers(reqHeaders);
if (dpopProof) refreshHeaders.set("DPoP", dpopProof);
return client.$fetch<{
access_token?: string;
token_type?: string;
error?: string;
}>("/oauth2/token", {
method: "POST",
body,
headers: refreshHeaders,
});
}
const proofless = await refresh();
expect((proofless.error as { error?: string } | null)?.error).toBe(
"invalid_dpop_proof",
);
const refreshProof = await createDpopProof({
privateKey: dpopKey.privateKey,
publicJwk: dpopKey.publicJwk,
method: "POST",
url: tokenEndpoint,
jti: "rotate-refresh-proof",
});
const rotated = await refresh(refreshProof);
expect(rotated.error).toBeNull();
expect(rotated.data?.token_type).toBe("DPoP");
const rotatedPayload = decodeJwt(rotated.data!.access_token!);
expect(rotatedPayload.cnf).toMatchObject({ jkt: dpopKey.jkt });
});
it("rejects a replayed DPoP proof at the token endpoint", async () => {
const dpopKey = await createDpopKey();
// One proof (a single jti) reused across two authorization codes. The
// database-backed replay store must reject the second presentation.
const proof = await createDpopProof({
privateKey: dpopKey.privateKey,
publicJwk: dpopKey.publicJwk,
method: "POST",
url: tokenEndpoint,
jti: "replayed-proof",
});
const first = await authorizeForCode(dpopKey.jkt, jwtResource);
const firstTokens = await exchangeCode(
first.code,
first.codeVerifier,
jwtResource,
proof,
);
expect(firstTokens.error).toBeNull();
expect(firstTokens.data?.token_type).toBe("DPoP");
const second = await authorizeForCode(dpopKey.jkt, jwtResource);
const replayed = await exchangeCode(
second.code,
second.codeVerifier,
jwtResource,
proof,
);
expect(replayed.data?.access_token).toBeUndefined();
expect((replayed.error as { error?: string } | null)?.error).toBe(
"invalid_dpop_proof",
);
});
it("persists dpop_bound_access_tokens through dynamic client registration", async () => {
// RFC 9449 §5.2: a conformant client declares the flag top-level at
// registration. The strict zod body schema must not strip it before it
// reaches storage; the response echoes the persisted value.
const registration = await client.$fetch<OAuthClient>("/oauth2/register", {
method: "POST",
body: {
redirect_uris: [redirectUri],
grant_types: ["authorization_code"],
token_endpoint_auth_method: "client_secret_basic",
dpop_bound_access_tokens: true,
},
});
expect(registration.error).toBeNull();
expect(registration.data?.dpop_bound_access_tokens).toBe(true);
});
it("enforces DPoP from the client dpop_bound_access_tokens flag alone", async () => {
// No resource and no dpop_jkt: the client flag is the only thing that can
// require DPoP, so this fails unless the flag actually reached storage.
const flagged = await auth.api.adminCreateOAuthClient({
headers,
body: {
grant_types: ["authorization_code"],
redirect_uris: [redirectUri],
skip_consent: true,
dpop_bound_access_tokens: true,
},
});
const codeVerifier = generateRandomString(32);
const { url } = await createAuthorizationURL({
id: providerId,
options: {
clientId: flagged!.client_id,
clientSecret: flagged!.client_secret!,
redirectURI: redirectUri,
},
redirectURI: "",
authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
state: "123",
scopes: ["openid"],
codeVerifier,
});
let callbackRedirectUrl = "";
await client.$fetch(url.toString(), {
onError(context) {
callbackRedirectUrl = context.response.headers.get("Location") || "";
},
});
const code = new URL(callbackRedirectUrl).searchParams.get("code")!;
const { body, headers: reqHeaders } = await authorizationCodeRequest({
code,
codeVerifier,
redirectURI: redirectUri,
options: {
clientId: flagged!.client_id,
clientSecret: flagged!.client_secret!,
redirectURI: redirectUri,
},
});
const tokens = await client.$fetch<{
access_token?: string;
error?: string;
}>("/oauth2/token", {
method: "POST",
body,
headers: reqHeaders,
});
expect(tokens.data?.access_token).toBeUndefined();
expect((tokens.error as { error?: string } | null)?.error).toBe(
"invalid_dpop_proof",
);
});
});
/**
* @see https://github.com/better-auth/better-auth/pull/10039
*/
describe("confirmationTokenType", () => {
it("falls back to Bearer for a malformed confirmation instead of throwing", () => {
// A corrupted `confirmation` JSON column can deserialize to a primitive or
// array; the token type must degrade to Bearer rather than 500.
for (const malformed of ["garbage", 42, ["jkt"], { jkt: "" }]) {
expect(confirmationTokenType(malformed as unknown as Confirmation)).toBe(
"Bearer",
);
}
expect(confirmationTokenType({ jkt: "thumbprint" })).toBe("DPoP");
expect(confirmationTokenType(undefined)).toBe("Bearer");
});
});
+97 -16
View File
@@ -1,12 +1,19 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { APIError } from "better-auth/api";
import { generateRandomString } from "better-auth/crypto";
import { generateCodeChallenge } from "better-auth/oauth2";
import {
createDpopReplayStore,
generateCodeChallenge,
getConfirmationJkt,
isDpopProofError,
verifyDpopProof,
} from "better-auth/oauth2";
import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
import type { Session, User } from "better-auth/types";
import type { JWTPayload } from "jose";
import { base64url, decodeProtectedHeader, SignJWT } from "jose";
import { resolveAccessTokenClaims } from "./claims";
import { getDpopProofJwt, getEndpointUrl } from "./dpop";
import {
collectExtensionIdTokenClaims,
getExtensionGrantHandler,
@@ -56,8 +63,8 @@ import {
* (`jkt`) yields `"DPoP"`; any other confirmation (including mTLS `x5t#S256`)
* keeps `"Bearer"`, since that constraint lives at the TLS layer.
*/
function confirmationTokenType(confirmation?: Confirmation): TokenType {
return confirmation && "jkt" in confirmation ? "DPoP" : "Bearer";
export function confirmationTokenType(confirmation?: Confirmation): TokenType {
return getConfirmationJkt(confirmation) ? "DPoP" : "Bearer";
}
const JWT_ACCESS_TOKEN_TYPE = "at+jwt";
@@ -460,6 +467,7 @@ async function createOpaqueAccessToken(
resources?: string[],
referenceId?: string,
refreshId?: string,
confirmation?: Confirmation,
) {
const iat = payload.iat ?? Math.floor(Date.now() / 1000);
const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
@@ -476,6 +484,7 @@ async function createOpaqueAccessToken(
referenceId,
resources,
refreshId,
confirmation,
scopes,
createdAt: new Date(iat * 1000),
expiresAt: new Date(exp * 1000),
@@ -545,6 +554,7 @@ async function createRefreshToken(
originalRefresh?: OAuthRefreshToken<Scope[]> & { id: string },
authTime?: Date,
resources?: string[],
confirmation?: Confirmation,
) {
const iat = payload.iat ?? Math.floor(Date.now() / 1000);
const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592000);
@@ -564,6 +574,7 @@ async function createRefreshToken(
userId: user.id,
referenceId,
authTime,
confirmation,
scopes,
resources,
createdAt: new Date(iat * 1000),
@@ -644,6 +655,7 @@ interface ResourceGrantIssuance {
signingAlgorithm: ResolvedResourcePolicy["signingAlgorithm"];
signingKeyId: ResolvedResourcePolicy["signingKeyId"];
resourceCustomClaims: Record<string, unknown>;
dpopBoundAccessTokensRequired: boolean;
}
async function resolveResourceGrantIssuance(
@@ -689,9 +701,74 @@ async function resolveResourceGrantIssuance(
signingAlgorithm: resourcePolicy.signingAlgorithm,
signingKeyId: resourcePolicy.signingKeyId,
resourceCustomClaims: resourcePolicy.rawCustomClaims,
dpopBoundAccessTokensRequired: resourcePolicy.dpopBoundAccessTokensRequired,
};
}
function throwInvalidDpopProof(errorDescription: string): never {
throw new APIError("BAD_REQUEST", {
error: "invalid_dpop_proof",
error_description: errorDescription,
});
}
function clientRequiresDpopBoundAccessTokens(client: SchemaClient<Scope[]>) {
const metadata = (parseClientMetadata(client.metadata) ?? {}) as Record<
string,
unknown
>;
return (
client.dpopBoundAccessTokens === true ||
metadata.dpop_bound_access_tokens === true
);
}
async function resolveDpopTokenBinding(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
params: {
client: SchemaClient<Scope[]>;
grantIssuance: ResourceGrantIssuance;
verificationValue?: VerificationValue;
refreshToken?: OAuthRefreshToken<Scope[]> & { id: string };
},
): Promise<Confirmation | undefined> {
const authCodeDpopJkt = params.verificationValue?.query.dpop_jkt;
const refreshJkt = getConfirmationJkt(params.refreshToken?.confirmation);
const expectedJkt = refreshJkt ?? authCodeDpopJkt;
const dpopProofJwt = getDpopProofJwt(ctx);
const dpopRequired =
clientRequiresDpopBoundAccessTokens(params.client) ||
params.grantIssuance.dpopBoundAccessTokensRequired ||
!!authCodeDpopJkt ||
!!refreshJkt;
if (!dpopProofJwt) {
if (dpopRequired) {
throwInvalidDpopProof("DPoP proof header is required");
}
return undefined;
}
try {
const proof = await verifyDpopProof({
proofJwt: dpopProofJwt,
method: "POST",
url: getEndpointUrl(ctx, "/oauth2/token"),
expectedJkt,
proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
signingAlgorithms: opts.dpop?.signingAlgorithms,
replayStore: createDpopReplayStore(ctx.context.internalAdapter),
});
return { jkt: proof.jkt };
} catch (error) {
if (isDpopProofError(error)) {
throwInvalidDpopProof(error.message);
}
throw error;
}
}
async function createUserTokens(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
@@ -781,6 +858,20 @@ async function createUserTokens(
: undefined;
const refreshResources = grantIssuance.refreshResources;
// A confirmation supplied by the caller (an extension client-authentication
// strategy, for example mTLS `x5t#S256`, or a binding captured out-of-grant
// for CIBA push/ping) takes precedence; a DPoP proof on the token request is
// the fallback. Either way the AS owns the RFC 7800 `cnf`: stamped into a JWT
// access token, persisted on opaque access and refresh tokens, and surfaced
// as `cnf` at introspection.
const confirmation =
params.confirmation ??
(await resolveDpopTokenBinding(ctx, opts, {
client,
grantIssuance,
verificationValue,
refreshToken: existingRefreshToken,
}));
// Refresh token may need to be created beforehand for id field
const earlyRefreshToken =
@@ -800,6 +891,7 @@ async function createUserTokens(
existingRefreshToken,
authTime,
refreshResources,
confirmation,
)
: undefined;
@@ -822,19 +914,6 @@ async function createUserTokens(
})
: undefined;
// A sender-constraint is stamped onto JWT access tokens. Opaque-token binding
// persistence (a stored confirmation column) ships with the binding
// mechanism, so fail closed rather than silently mint an unbound opaque token
// a caller believes is sender-constrained.
if (params.confirmation && !isJwtAccessToken) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error_description:
"Cannot sender-constrain an opaque access token; confirmation is supported only for JWT access tokens.",
error: "server_error",
});
}
const confirmation = params.confirmation;
// Create access token and refresh token in parallel
const [accessToken, refreshToken] = await Promise.all([
isJwtAccessToken
@@ -869,6 +948,7 @@ async function createUserTokens(
params?.resources,
referenceId,
earlyRefreshToken?.id,
confirmation,
),
earlyRefreshToken
? earlyRefreshToken
@@ -888,6 +968,7 @@ async function createUserTokens(
existingRefreshToken,
authTime,
refreshResources,
confirmation,
)
: undefined,
]);
@@ -1237,6 +1237,27 @@ export interface OAuthOptions<
clientId: string;
ctx: GenericEndpointContext;
}) => Promise<Record<string, string> | null>;
/**
* DPoP proof validation settings.
*
* DPoP is enabled by default when a client or resource asks for DPoP-bound
* access tokens. These values tune proof validation without changing that
* contract.
*/
dpop?: {
/**
* Accepted age of a DPoP proof JWT in seconds.
*
* @default 300
*/
proofMaxAgeSeconds?: number;
/**
* Supported JWS algorithms for DPoP proof JWTs.
*
* @default ["EdDSA", "ES256", "ES512", "PS256", "RS256"]
*/
signingAlgorithms?: JWSAlgorithms[];
};
}
export interface OAuthAuthorizationQuery {
@@ -1354,6 +1375,12 @@ export interface OAuthAuthorizationQuery {
* with the Claim Value being the nonce value sent in the Authentication Request.
*/
nonce?: string;
/**
* RFC 9449 authorization request parameter. When present, the authorization
* code is bound to this JWK thumbprint and the token request must present a
* matching DPoP proof.
*/
dpop_jkt?: string;
/**
* Resource parameter as specified by [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)
*/
@@ -1394,6 +1421,10 @@ export interface OAuthResource {
* with a warning log never silently dropped.
*/
customClaims?: Record<string, unknown> | null;
/**
* Require newly issued access tokens for this resource to be DPoP-bound.
*/
dpopBoundAccessTokensRequired?: boolean;
/**
* Disabled no new issuance for this resource; existing tokens still verify
* until natural expiry. Compare to delete, which hard-rejects existing tokens.
@@ -1425,6 +1456,7 @@ export interface OAuthResourceInput {
signingKeyId?: string;
allowedScopes?: string[];
customClaims?: Record<string, unknown>;
dpopBoundAccessTokensRequired?: boolean;
disabled?: boolean;
metadata?: Record<string, unknown>;
}
@@ -1585,6 +1617,11 @@ export interface SchemaClient<
* requesting offline_access scope, regardless of this setting.
*/
requirePKCE?: boolean;
/**
* RFC 9449 dynamic client metadata. When true, every token request from this
* client must include a valid DPoP proof and receive DPoP-bound tokens.
*/
dpopBoundAccessTokens?: boolean;
//---- All other metadata ----//
/** Used to indicate if consent screen can be skipped */
skipConsent?: boolean;
@@ -1658,6 +1695,11 @@ export interface OAuthOpaqueAccessToken<
* Resources allowed for this access token.
*/
resources?: string[];
/**
* RFC 7800 `cnf` confirmation that sender-constrains this access token (for
* example DPoP `{ jkt }`). Surfaced as the `cnf` claim at introspection.
*/
confirmation?: Confirmation;
}
/**
@@ -1692,6 +1734,11 @@ export interface OAuthRefreshToken<
* Resources allowed for this refresh token
*/
resources?: string[];
/**
* RFC 7800 `cnf` confirmation that sender-constrains this refresh-token
* family (for example DPoP `{ jkt }`). Carried forward on rotation.
*/
confirmation?: Confirmation;
}
/**
@@ -233,6 +233,12 @@ export interface AuthServerMetadata {
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#OPMetadata
*/
backchannel_logout_session_supported?: boolean;
/**
* JWS algorithms supported for RFC 9449 DPoP proof JWTs.
*
* @see https://datatracker.ietf.org/doc/html/rfc9449
*/
dpop_signing_alg_values_supported?: JWSAlgorithms[];
}
/**
@@ -376,6 +382,12 @@ export interface OAuthClient {
* requesting offline_access scope, regardless of this setting.
*/
require_pkce?: boolean;
/**
* RFC 9449 dynamic client metadata. When true, token requests from this
* client must include a valid DPoP proof and receive DPoP-bound access
* tokens.
*/
dpop_bound_access_tokens?: boolean;
/**
* Subject identifier type for this client.
*
+10
View File
@@ -94,6 +94,13 @@ const maxAgeSchema = z
return maxAge;
});
const dpopJktSchema = z
.string()
.regex(
/^[A-Za-z0-9_-]{43}$/,
"dpop_jkt must be a base64url-encoded SHA-256 JWK thumbprint",
);
/**
* Runtime schema for OAuthAuthorizationQuery.
* Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
@@ -122,6 +129,7 @@ export const authorizationQuerySchema = z
.pipe(z.enum(["S256"]))
.optional(),
nonce: z.string().optional(),
dpop_jkt: dpopJktSchema.optional(),
resource: z
.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)])
.optional(),
@@ -191,6 +199,8 @@ export const clientRegistrationRequestSchema = z.object({
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
subject_type: z.enum(["public", "pairwise"]).optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),
// RFC 7591 §2 extension: declare the RFC 8707 resource indicators this client
// will request. Each must be a valid resource URI matching an existing
// oauthResource row; the registration handler links them on success.
+112 -1
View File
@@ -3,10 +3,14 @@ import { generateRandomString } from "better-auth/crypto";
import {
authorizationCodeRequest,
createAuthorizationURL,
deriveDpopAth,
deriveDpopJkt,
} from "better-auth/oauth2";
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
import type { APIError } from "better-call";
import type { JWK } from "jose";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import { beforeAll, describe, expect, it } from "vitest";
import { oauthProviderClient } from "./client";
import { oauthProvider } from "./oauth";
@@ -87,6 +91,7 @@ describe("oauth userinfo", async () => {
Partial<Parameters<typeof authorizationCodeRequest>[0]>,
"code"
>,
extraHeaders?: HeadersInit,
) {
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
throw Error("beforeAll not run properly");
@@ -102,6 +107,13 @@ describe("oauth userinfo", async () => {
},
});
const tokenHeaders = new Headers(headers);
if (extraHeaders) {
for (const [key, value] of new Headers(extraHeaders)) {
tokenHeaders.set(key, value);
}
}
const tokens = await client.$fetch<{
access_token?: string;
id_token?: string;
@@ -114,12 +126,46 @@ describe("oauth userinfo", async () => {
}>("/oauth2/token", {
method: "POST",
body: body,
headers: headers,
headers: tokenHeaders,
});
return tokens;
}
async function createDpopKey() {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const jkt = await deriveDpopJkt(publicJwk);
return { privateKey, publicJwk, jkt };
}
async function createDpopProof(params: {
privateKey: CryptoKey;
publicJwk: JWK;
method: string;
url: string;
jti: string;
accessToken?: string;
}) {
return new SignJWT({
jti: params.jti,
htm: params.method,
htu: params.url,
iat: Math.floor(Date.now() / 1000),
...(params.accessToken
? { ath: await deriveDpopAth(params.accessToken) }
: {}),
})
.setProtectedHeader({
typ: "dpop+jwt",
alg: "ES256",
jwk: params.publicJwk,
})
.sign(params.privateKey);
}
async function getTokens(
overrides?: Partial<Parameters<typeof createAuthUrl>[0]>,
resource?: string,
@@ -299,6 +345,71 @@ describe("oauth userinfo", async () => {
});
});
it("enforces DPoP proof for DPoP-bound userinfo access", async () => {
const dpopKey = await createDpopKey();
const { url: authUrl, codeVerifier } = await createAuthUrl({
additionalParams: { dpop_jkt: dpopKey.jkt },
});
let callbackRedirectUrl = "";
await client.$fetch(authUrl.toString(), {
onError(context) {
callbackRedirectUrl = context.response.headers.get("Location") || "";
},
});
const callbackUrl = new URL(callbackRedirectUrl);
const tokenDpopProof = await createDpopProof({
privateKey: dpopKey.privateKey,
publicJwk: dpopKey.publicJwk,
method: "POST",
url: `${authServerBaseUrl}/api/auth/oauth2/token`,
jti: "token-proof",
});
const tokens = await validateAuthCode(
{
code: callbackUrl.searchParams.get("code")!,
codeVerifier,
resource: validResource,
},
{ DPoP: tokenDpopProof },
);
expect(tokens.data?.token_type).toBe("DPoP");
expect(tokens.data?.access_token).toBeDefined();
const bearerUserinfo = await client.$fetch<Record<string, string>>(
"/oauth2/userinfo",
{
headers: {
authorization: `Bearer ${tokens.data?.access_token ?? ""}`,
},
},
);
expect(bearerUserinfo.error?.status).toBe(401);
const userinfoDpopProof = await createDpopProof({
privateKey: dpopKey.privateKey,
publicJwk: dpopKey.publicJwk,
method: "GET",
url: `${authServerBaseUrl}/api/auth/oauth2/userinfo`,
jti: "userinfo-proof",
accessToken: tokens.data?.access_token,
});
const userinfo = await client.$fetch<Record<string, string>>(
"/oauth2/userinfo",
{
headers: {
authorization: `DPoP ${tokens.data?.access_token ?? ""}`,
DPoP: userinfoDpopProof,
},
},
);
expect(userinfo.data).toMatchObject({
sub: user.id,
name: user.name,
email: user.email,
});
});
it("should pass provide scoped user information - sub only", async () => {
const tokens = await getTokens({
scopes: ["openid"],
+47 -6
View File
@@ -1,6 +1,14 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { APIError } from "better-auth/api";
import {
createDpopReplayStore,
enforceDpopBinding,
getDpopJktFromPayload,
isDpopBindingError,
parseAccessTokenAuthorization,
} from "better-auth/oauth2";
import type { User } from "better-auth/types";
import { getDpopProofJwt, getEndpointUrl } from "./dpop";
import {
collectExtensionUserInfoClaims,
hasUserInfoClaimExtension,
@@ -73,17 +81,18 @@ export async function userInfoEndpoint(
// should keep accepting a non-Bearer Authorization value as a bare token; the
// shared parser is strict and would reject that fallback.
const authorization = ctx.headers?.get("authorization");
const token =
typeof authorization === "string" && authorization?.startsWith("Bearer ")
? authorization?.replace("Bearer ", "")
: authorization;
if (!token?.length) {
const accessTokenAuthorization = parseAccessTokenAuthorization(authorization);
if (!accessTokenAuthorization?.token) {
throw new APIError("UNAUTHORIZED", {
error_description: "authorization header not found",
error: "invalid_request",
});
}
const jwt = await validateAccessToken(ctx, opts, token);
const jwt = await validateAccessToken(
ctx,
opts,
accessTokenAuthorization.token,
);
// A token that is expired, revoked, or bound to an ended session resolves to
// `{ active: false }`. RFC 6750 §3.1 wants `invalid_token` (401) for that,
@@ -95,6 +104,38 @@ export async function userInfoEndpoint(
});
}
// The DPoP `htm`/`htu` check needs the real request method and URL. Without a
// `ctx.request` (a programmatic `auth.api` call) the sender-constraint cannot
// be verified, so fail closed for a DPoP-bound token rather than assume "GET".
if (getDpopJktFromPayload(jwt) && !ctx.request) {
throw new APIError("UNAUTHORIZED", {
error_description:
"DPoP-bound access token requires an HTTP request context",
error: "invalid_token",
});
}
try {
await enforceDpopBinding({
payload: jwt,
authorization: accessTokenAuthorization,
proofJwt: getDpopProofJwt(ctx),
method: ctx.request?.method ?? "GET",
url: getEndpointUrl(ctx, "/oauth2/userinfo"),
proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
signingAlgorithms: opts.dpop?.signingAlgorithms,
replayStore: createDpopReplayStore(ctx.context.internalAdapter),
});
} catch (error) {
if (isDpopBindingError(error)) {
throw new APIError("UNAUTHORIZED", {
error_description: error.message,
error: error.code,
});
}
throw error;
}
const scopes = (jwt.scope as string | undefined)?.split(" ");
if (!scopes?.includes("openid")) {
throw new APIError("BAD_REQUEST", {