[GH-ISSUE #8656] oauth-provider: userinfo, introspection, revocation, and MCP endpoints reject DPoP Authorization scheme (RFC 9449) #28477

Open
opened 2026-04-17 19:55:29 -05:00 by GiteaMirror · 0 comments
Owner

Originally created by @gustavovalverde on GitHub (Mar 17, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8656

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Configure oauthProvider with tokenBinding that returns { tokenType: "DPoP", cnf: { jkt } } (i.e., any DPoP token binding implementation)
  2. A client exchanges an authorization code at the token endpoint with a DPoP proof header
  3. The token endpoint correctly issues a DPoP-bound access token — the JWT payload contains cnf: { jkt: "<thumbprint>" }
  4. The client sends the DPoP-bound token to the userinfo endpoint using the correct scheme per RFC 9449 §7.1: Authorization: DPoP <token> + DPoP: <proof>
  5. The userinfo endpoint returns 400 invalid_request

Current vs. Expected behavior

Current: The userInfoEndpoint only strips the Bearer prefix from the Authorization header:

// oauth-provider, userInfoEndpoint
const token = typeof authorization === "string" && authorization?.startsWith("Bearer ")
  ? authorization?.replace("Bearer ", "")
  : authorization;

When a client sends Authorization: DPoP eyJ..., the variable token becomes the literal string "DPoP eyJ..." (including the scheme prefix). This is passed to validateAccessToken, which fails JWT parsing and throws a 400.

The same Bearer-only extraction pattern exists in 3 other locations in the oauth-provider:

  • mcpHandler — same Bearer-only extraction
  • Introspection endpoint — same Bearer-only extraction
  • Revocation endpoint — same Bearer-only extraction

Expected: When the tokenBinding option is configured and the token endpoint issues DPoP-bound tokens (cnf.jkt present via the binding result), resource endpoints should accept the DPoP Authorization scheme per RFC 9449 §7.1:

  1. Parse both DPoP <token> and Bearer <token> Authorization schemes
  2. When DPoP scheme: extract the token, validate the DPoP proof from the DPoP header (method, URL, JWK thumbprint matches cnf.jkt)
  3. When Bearer scheme with a DPoP-bound token: behavior depends on policy (reject for strict enforcement, or accept for permissive mode)

The token endpoint already accepts DPoP proofs and correctly binds tokens via the tokenBinding callback — but its sibling endpoints in the same package can't consume those tokens with the correct Authorization scheme.

What version of Better Auth are you using?

1.5.5 (also reproduces on @better-auth/oauth-provider 1.5.1-beta.3)

System info

{
  "system": { "platform": "darwin", "arch": "arm64" },
  "node": { "version": "v24.12.0" },
  "packageManager": { "name": "pnpm", "version": "10.12.1" },
  "frameworks": [{ "name": "next", "version": "16.1.6" }],
  "databases": [{ "name": "drizzle", "version": "0.45.1" }],
  "betterAuth": { "version": "1.5.5" }
}

Which area(s) are affected? (Select all that apply)

Backend, Package

Auth config (if applicable)

import { oauthProvider } from "@better-auth/oauth-provider";
import { betterAuth } from "better-auth";

// Any tokenBinding that returns DPoP-bound tokens triggers this bug
const dpopTokenBinding = async (input) => {
  // ... validate DPoP proof, compute JWK thumbprint ...
  return {
    tokenType: "DPoP",
    cnf: { jkt: thumbprint },
    responseHeaders: { "DPoP-Nonce": nonce },
  };
};

export const auth = betterAuth({
  plugins: [
    oauthProvider({
      tokenBinding: dpopTokenBinding,
      // Token endpoint correctly issues DPoP-bound tokens (cnf.jkt),
      // but userinfo/introspection/revocation can't consume them
      // when the client uses the DPoP Authorization scheme.
    }),
  ],
});

Additional context

Suggested fix: A shared extractAccessToken(request, opts?) utility that handles both schemes:

function extractAccessToken(request: Request): { token: string; scheme: "bearer" | "dpop" } | null {
  const authorization = request.headers.get("authorization");
  if (!authorization) return null;
  if (authorization.startsWith("Bearer ")) return { token: authorization.slice(7), scheme: "bearer" };
  if (authorization.startsWith("DPoP "))   return { token: authorization.slice(5), scheme: "dpop" };
  return null;
}

When scheme === "dpop", the endpoint would validate the DPoP proof (checking htm, htu, JWK thumbprint against cnf.jkt) before proceeding. This could be wired through the existing tokenBinding callback or a new accessTokenValidator option on oauthProvider.

Workaround: We intercept the userinfo request before it reaches better-auth, validate the DPoP proof, and rewrite Authorization: DPoP <token> to Authorization: Bearer <token>:

export async function rewriteDpopForUserinfo(request: Request): Promise<Request> {
  const authorization = request.headers.get("authorization");
  if (!authorization?.startsWith("DPoP ")) return request;

  const token = authorization.slice(5);
  const tokenPayload = decodeJwt(token);
  await validateDpopProof({ request, tokenPayload }); // custom validation

  const headers = new Headers(request.headers);
  headers.set("authorization", `Bearer ${token}`);
  return new Request(request.url, { method: request.method, headers });
}
Originally created by @gustavovalverde on GitHub (Mar 17, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8656 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Configure `oauthProvider` with `tokenBinding` that returns `{ tokenType: "DPoP", cnf: { jkt } }` (i.e., any DPoP token binding implementation) 2. A client exchanges an authorization code at the token endpoint with a `DPoP` proof header 3. The token endpoint correctly issues a DPoP-bound access token — the JWT payload contains `cnf: { jkt: "<thumbprint>" }` 4. The client sends the DPoP-bound token to the userinfo endpoint using the correct scheme per [RFC 9449 §7.1](https://datatracker.ietf.org/doc/html/rfc9449#section-7.1): `Authorization: DPoP <token>` + `DPoP: <proof>` 5. The userinfo endpoint returns **400 invalid_request** ### Current vs. Expected behavior **Current:** The `userInfoEndpoint` only strips the `Bearer ` prefix from the Authorization header: ```js // oauth-provider, userInfoEndpoint const token = typeof authorization === "string" && authorization?.startsWith("Bearer ") ? authorization?.replace("Bearer ", "") : authorization; ``` When a client sends `Authorization: DPoP eyJ...`, the variable `token` becomes the literal string `"DPoP eyJ..."` (including the scheme prefix). This is passed to `validateAccessToken`, which fails JWT parsing and throws a 400. The same `Bearer`-only extraction pattern exists in 3 other locations in the oauth-provider: - **`mcpHandler`** — same Bearer-only extraction - **Introspection endpoint** — same Bearer-only extraction - **Revocation endpoint** — same Bearer-only extraction **Expected:** When the `tokenBinding` option is configured and the token endpoint issues DPoP-bound tokens (`cnf.jkt` present via the binding result), resource endpoints should accept the `DPoP` Authorization scheme per [RFC 9449 §7.1](https://datatracker.ietf.org/doc/html/rfc9449#section-7.1): 1. Parse both `DPoP <token>` and `Bearer <token>` Authorization schemes 2. When DPoP scheme: extract the token, validate the DPoP proof from the `DPoP` header (method, URL, JWK thumbprint matches `cnf.jkt`) 3. When Bearer scheme with a DPoP-bound token: behavior depends on policy (reject for strict enforcement, or accept for permissive mode) The token endpoint already accepts DPoP proofs and correctly binds tokens via the `tokenBinding` callback — but its sibling endpoints in the same package can't consume those tokens with the correct Authorization scheme. ### What version of Better Auth are you using? 1.5.5 (also reproduces on @better-auth/oauth-provider 1.5.1-beta.3) ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64" }, "node": { "version": "v24.12.0" }, "packageManager": { "name": "pnpm", "version": "10.12.1" }, "frameworks": [{ "name": "next", "version": "16.1.6" }], "databases": [{ "name": "drizzle", "version": "0.45.1" }], "betterAuth": { "version": "1.5.5" } } ``` ### Which area(s) are affected? (Select all that apply) Backend, Package ### Auth config (if applicable) ```typescript import { oauthProvider } from "@better-auth/oauth-provider"; import { betterAuth } from "better-auth"; // Any tokenBinding that returns DPoP-bound tokens triggers this bug const dpopTokenBinding = async (input) => { // ... validate DPoP proof, compute JWK thumbprint ... return { tokenType: "DPoP", cnf: { jkt: thumbprint }, responseHeaders: { "DPoP-Nonce": nonce }, }; }; export const auth = betterAuth({ plugins: [ oauthProvider({ tokenBinding: dpopTokenBinding, // Token endpoint correctly issues DPoP-bound tokens (cnf.jkt), // but userinfo/introspection/revocation can't consume them // when the client uses the DPoP Authorization scheme. }), ], }); ``` ### Additional context **Suggested fix:** A shared `extractAccessToken(request, opts?)` utility that handles both schemes: ```typescript function extractAccessToken(request: Request): { token: string; scheme: "bearer" | "dpop" } | null { const authorization = request.headers.get("authorization"); if (!authorization) return null; if (authorization.startsWith("Bearer ")) return { token: authorization.slice(7), scheme: "bearer" }; if (authorization.startsWith("DPoP ")) return { token: authorization.slice(5), scheme: "dpop" }; return null; } ``` When `scheme === "dpop"`, the endpoint would validate the DPoP proof (checking `htm`, `htu`, JWK thumbprint against `cnf.jkt`) before proceeding. This could be wired through the existing `tokenBinding` callback or a new `accessTokenValidator` option on `oauthProvider`. **Workaround:** We intercept the userinfo request before it reaches better-auth, validate the DPoP proof, and rewrite `Authorization: DPoP <token>` to `Authorization: Bearer <token>`: ```typescript export async function rewriteDpopForUserinfo(request: Request): Promise<Request> { const authorization = request.headers.get("authorization"); if (!authorization?.startsWith("DPoP ")) return request; const token = authorization.slice(5); const tokenPayload = decodeJwt(token); await validateDpopProof({ request, tokenPayload }); // custom validation const headers = new Headers(request.headers); headers.set("authorization", `Bearer ${token}`); return new Request(request.url, { method: request.method, headers }); } ```
GiteaMirror added the bugidentity labels 2026-04-17 19:55:29 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28477