Skip to content

feat(oidc-provider): support private_key_jwt#6053

Open
okisdev wants to merge 11 commits intobetter-auth:mainfrom
okisdev:feat(oidc)/support-private_key_jwt
Open

feat(oidc-provider): support private_key_jwt#6053
okisdev wants to merge 11 commits intobetter-auth:mainfrom
okisdev:feat(oidc)/support-private_key_jwt

Conversation

@okisdev
Copy link
Copy Markdown
Contributor

@okisdev okisdev commented Nov 18, 2025

This PR adds private_key_jwt support to oidc provider.

This PR close #5935.


Summary by cubic

Adds private_key_jwt client authentication to the OIDC and MCP token endpoints, enabling confidential clients to use signed JWTs instead of shared secrets. Updates discovery metadata, schema, and tests; closes #5935.

  • New Features

    • Support private_key_jwt for token endpoint auth (OIDC and MCP).
    • Verify client assertions (RS256/384/512, ES256/384/512, EdDSA) with JWKS and prevent jti replay.
    • Advertise supported methods and signing algs in discovery.
    • Infer client_id from JWT when omitted; public clients still require PKCE.
  • Migration

    • Configure clients with tokenEndpointAuthMethod = "private_key_jwt" and provide JWKS via jwks or jwks_uri.
    • Clients must send client_assertion_type = jwt-bearer and a short-lived JWT with unique jti and aud set to the token endpoint.
    • No changes needed for existing client_secret_* or public (PKCE) clients.

Written for commit 5c033bf. Summary will update automatically on new commits.

@okisdev okisdev requested a review from Bekacru as a code owner November 18, 2025 02:31
Copilot AI review requested due to automatic review settings November 18, 2025 02:31
@okisdev okisdev requested a review from himself65 as a code owner November 18, 2025 02:31
@vercel
Copy link
Copy Markdown

vercel bot commented Nov 18, 2025

@okisdev is attempting to deploy a commit to the better-auth Team on Vercel.

A member of the Team first needs to authorize it.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Nov 18, 2025

Open in StackBlitz

better-auth

npm i https://pkg.pr.new/better-auth/better-auth@6053

@better-auth/cli

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/cli@6053

@better-auth/core

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/core@6053

@better-auth/expo

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/expo@6053

@better-auth/passkey

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/passkey@6053

@better-auth/scim

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/scim@6053

@better-auth/sso

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/sso@6053

@better-auth/stripe

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/stripe@6053

@better-auth/telemetry

npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/telemetry@6053

commit: 5c033bf

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds support for the private_key_jwt client authentication method to the OIDC provider, enabling stronger security through asymmetric cryptography instead of shared secrets. This enhancement brings the implementation in line with OAuth 2.0 RFC 7521/7523 standards and financial-grade API requirements.

  • Added verifyClientAssertion function to validate JWT-based client authentication with replay protection
  • Extended schema with jwks, jwksUri, and tokenEndpointAuthMethod fields for OAuth applications
  • Integrated private_key_jwt authentication into both OIDC and MCP token endpoints

Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
packages/better-auth/src/plugins/oidc-provider/utils.ts Implements JWT assertion verification and JTI replay protection
packages/better-auth/src/plugins/oidc-provider/types.ts Adds ClientAssertionPayload interface and updates metadata to advertise private_key_jwt support
packages/better-auth/src/plugins/oidc-provider/schema.ts Adds database fields for JWKS storage and authentication method configuration
packages/better-auth/src/plugins/oidc-provider/index.ts Integrates private_key_jwt authentication into token endpoint for both grant types
packages/better-auth/src/plugins/mcp/index.ts Mirrors private_key_jwt support in MCP plugin's token endpoint
packages/better-auth/src/plugins/oidc-provider/oidc.test.ts Comprehensive test suite covering valid/invalid signatures, expired tokens, and replay attacks
docs/content/docs/plugins/oidc-provider.mdx Documents client authentication methods including setup and usage examples

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

});
}

const header = JSON.parse(new TextDecoder().decode(base64.decode(parts[0])));
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for base64 decoding and JSON parsing. If the JWT header is malformed or contains invalid base64, this will throw an unhandled exception. Should wrap in try-catch and throw an APIError with appropriate error_description.

Suggested change
const header = JSON.parse(new TextDecoder().decode(base64.decode(parts[0])));
let header;
try {
header = JSON.parse(new TextDecoder().decode(base64.decode(parts[0])));
} catch (err: any) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid jwt header: " + (err && err.message ? err.message : String(err)),
error: "invalid_client",
});
}

Copilot uses AI. Check for mistakes.
});
}

const jwks = JSON.parse(client.jwks);
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for JSON parsing. If client.jwks contains invalid JSON, this will throw an unhandled exception. Should wrap in try-catch and throw an APIError with 'invalid_client' error.

Suggested change
const jwks = JSON.parse(client.jwks);
let jwks;
try {
jwks = JSON.parse(client.jwks);
} catch (err) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid jwks JSON",
error: "invalid_client",
});
}

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +581
const payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for base64 decoding and JSON parsing. This duplicates validation logic that already exists in verifyClientAssertion. If the JWT payload is malformed, this will throw an unhandled exception. Should wrap in try-catch and throw an APIError.

Suggested change
const payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
let payload;
try {
payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
} catch (err) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid jwt payload",
error: "invalid_client",
});
}

Copilot uses AI. Check for mistakes.
Comment on lines +644 to +673
if (client.type !== "public") {
if (authenticatedViaAssertion) {
await verifyClientAssertion({
clientAssertion: client_assertion.toString(),
clientId: client_id.toString(),
client,
tokenEndpoint: `${ctx.context.baseURL}/oauth2/token`,
ctx,
});
} else {
if (!client.clientSecret || !client_secret) {
throw new APIError("UNAUTHORIZED", {
error_description:
"client_secret is required for confidential clients",
error: "invalid_client",
});
}
const isValidSecret = await verifyStoredClientSecret(
ctx,
client.clientSecret,
client_secret.toString(),
);
if (!isValidSecret) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_secret",
error: "invalid_client",
});
}
}
}
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This authentication logic block is duplicated at lines 815-842 in the authorization_code grant handling. Consider extracting this into a reusable function like authenticateConfidentialClient(client, authenticatedViaAssertion, client_assertion, client_id, client_secret, ctx, tokenEndpoint) to reduce code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +342 to +344
const payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for base64 decoding and JSON parsing. If the JWT payload is malformed, this will throw an unhandled exception. Should wrap in try-catch and throw an APIError.

Suggested change
const payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
let payload;
try {
payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
} catch (error) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid jwt format",
error: "invalid_client",
});
}

Copilot uses AI. Check for mistakes.
Comment on lines +379 to +386
export interface ClientAssertionPayload {
iss: string;
sub: string;
aud: string;
jti: string;
exp: number;
iat: number;
}
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ClientAssertionPayload interface is defined but never used in the codebase. The verifyClientAssertion function casts payload claims individually (e.g., payload.jti as string). Consider using this interface to type the JWT payload after verification for better type safety and consistency.

Copilot uses AI. Check for mistakes.
Comment on lines +507 to +508
iss: 'example-app', // Your client_id
sub: 'example-app', // Your client_id
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example uses 'example-app' as the client_id in the JWT assertion, but the setup example at line 482 creates a client with clientId: 'financial-app'. This inconsistency could confuse users. The values should match for clarity.

Suggested change
iss: 'example-app', // Your client_id
sub: 'example-app', // Your client_id
iss: 'financial-app', // Your client_id
sub: 'financial-app', // Your client_id

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +76
const publicKey = await importJWK(key);

const { payload } = await jwtVerify(clientAssertion, publicKey, {
issuer: clientId,
subject: clientId,
audience: tokenEndpoint,
}).catch((err) => {
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JWT verification does not validate the nbf (not before) claim if present. While jose library may handle this by default, it's a best practice to explicitly document or enforce this check to prevent acceptance of tokens used before their validity period, especially for security-sensitive authentication.

Copilot uses AI. Check for mistakes.
**Supported Algorithms:**
- RSA: RS256, RS384, RS512
- ECDSA: ES256, ES384, ES512
- EdDSA: Ed25519
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation lists 'Ed25519' as a supported algorithm, but the metadata at line 126 in index.ts specifies 'EdDSA'. While Ed25519 is a curve used with EdDSA, the algorithm name in JWT protected headers should be 'EdDSA' per RFC 8037. Consider clarifying this as 'EdDSA (Ed25519)' to avoid confusion.

Suggested change
- EdDSA: Ed25519
- EdDSA (Ed25519)

Copilot uses AI. Check for mistakes.
const {
auth: authorizationServer,
db,
testUser: testUserCredentials,
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable testUserCredentials.

Suggested change
testUser: testUserCredentials,

Copilot uses AI. Check for mistakes.
@okisdev okisdev marked this pull request as draft November 18, 2025 02:39
@okisdev okisdev marked this pull request as ready for review November 18, 2025 02:43
@himself65 himself65 changed the title feat(oidc): support private_key_jwt feat(oidc-provider): support private_key_jwt Nov 18, 2025
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 9 files

Prompt for AI agents (all 3 issues)

Understand the root cause of the following 3 issues and fix them.


<file name="packages/better-auth/src/plugins/oidc-provider/utils.ts">

<violation number="1" location="packages/better-auth/src/plugins/oidc-provider/utils.ts:90">
`payload.exp` is used without ensuring the JWT provided an `exp` claim, so assertions missing `exp` yield invalid replay-cache expirations and can bypass expiry validation.</violation>
</file>

<file name="packages/better-auth/src/plugins/oidc-provider/index.ts">

<violation number="1" location="packages/better-auth/src/plugins/oidc-provider/index.ts:116">
Registration advertises the new private_key_jwt method but never persists the supplied JWKS/auth-method, so dynamically registered clients choosing this option cannot be authenticated (verifyClientAssertion fails with &quot;client jwks not configured&quot;). The registration handler needs to store jwks/jwks_uri and tokenEndpointAuthMethod for these clients.</violation>

<violation number="2" location="packages/better-auth/src/plugins/oidc-provider/index.ts:579">
Parsing client_assertion without guarding JSON.parse allows malformed JWTs to raise uncaught SyntaxError and crash the token endpoint instead of returning invalid_client. Wrap the decode/parse in a try/catch that throws APIError(&quot;invalid_client&quot;).</violation>
</file>

Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR

@himself65 himself65 force-pushed the canary branch 2 times, most recently from 2794a2d to f1e94f2 Compare December 1, 2025 23:37
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 9 files

Prompt for AI agents (all 3 issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/better-auth/src/plugins/oidc-provider/index.ts">

<violation number="1" location="packages/better-auth/src/plugins/oidc-provider/index.ts:1512">
P1: `jwks_uri` registrations are accepted but `verifyClientAssertion` only looks at inline `client.jwks`, so every private_key_jwt request fails for clients that provide only a JWKS URI. Fetch the JWKS from `jwksUri` (or persist it during registration) before verifying instead of throwing immediately.</violation>
</file>

<file name="packages/better-auth/src/plugins/mcp/index.ts">

<violation number="1" location="packages/better-auth/src/plugins/mcp/index.ts:359">
P2: Wrap the JWT payload decode/parse in a try/catch and return an `invalid_client` error instead of letting malformed assertions crash the endpoint.</violation>
</file>

<file name="docs/content/docs/plugins/oidc-provider.mdx">

<violation number="1" location="docs/content/docs/plugins/oidc-provider.mdx:485">
P2: Rule violated: **Enforce Consistent Naming Conventions**

Use the established `redirectURLs` property name when creating an `oauthApplication` entry so it matches the documented naming convention and actual schema.</violation>
</file>

Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 9 files

Prompt for AI agents (all 7 issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/better-auth/src/plugins/oidc-provider/index.ts">

<violation number="1" location="packages/better-auth/src/plugins/oidc-provider/index.ts:678">
P1: `verifyClientAssertion` is invoked without ever loading JWKS from `jwks_uri`, so any private_key_jwt client that registers only a JWKS URI is rejected with “client jwks not configured”. Fetch the keys (or persist them) before calling the verifier so `client.jwks` is populated.</violation>

<violation number="2" location="packages/better-auth/src/plugins/oidc-provider/index.ts:1512">
P2: `private_key_jwt` clients can be registered without supplying JWKS or a JWKS URI, guaranteeing later auth failures. When token_endpoint_auth_method is &quot;private_key_jwt&quot;, require body.jwks or body.jwks_uri during registration before saving the client.</violation>
</file>

<file name="packages/better-auth/src/plugins/oidc-provider/utils.ts">

<violation number="1" location="packages/better-auth/src/plugins/oidc-provider/utils.ts:37">
P2: Wrap the JWKS JSON parsing in a try/catch so malformed client metadata returns `invalid_client` instead of crashing the token endpoint.</violation>

<violation number="2" location="packages/better-auth/src/plugins/oidc-provider/utils.ts:78">
P2: Handle `importJWK` failures and convert them into an `invalid_client` API error so malformed keys cannot crash the endpoint.</violation>
</file>

<file name="packages/better-auth/src/plugins/oidc-provider/types.ts">

<violation number="1" location="packages/better-auth/src/plugins/oidc-provider/types.ts:385">
P2: `ClientAssertionPayload.aud` is typed as only `string`, which rejects valid JWT client assertions whose audience claim is an array (allowed by RFC 7519). Allow both `string` and `string[]` so consumers can create spec-compliant tokens.</violation>
</file>

<file name="packages/better-auth/src/plugins/mcp/index.ts">

<violation number="1" location="packages/better-auth/src/plugins/mcp/index.ts:359">
P1: Missing error handling for JWT payload parsing. If the base64 decoding or JSON parsing fails (malformed JWT), this will throw an unhandled exception. Wrap in try-catch like the similar code in `oidc-provider/utils.ts`.</violation>
</file>

<file name="docs/content/docs/plugins/oidc-provider.mdx">

<violation number="1" location="docs/content/docs/plugins/oidc-provider.mdx:485">
P2: Rule violated: **Enforce Consistent Naming Conventions**

Use the established `redirectURLs` property name to stay consistent with the rest of the API documentation and prevent misconfiguration caused by the newly introduced `redirectUrls`.</violation>
</file>

Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR

authenticationScheme:
body.token_endpoint_auth_method || "client_secret_basic",
jwks: body.jwks ? JSON.stringify(body.jwks) : undefined,
jwksUri: body.jwks_uri,
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: private_key_jwt clients can be registered without supplying JWKS or a JWKS URI, guaranteeing later auth failures. When token_endpoint_auth_method is "private_key_jwt", require body.jwks or body.jwks_uri during registration before saving the client.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/plugins/oidc-provider/index.ts, line 1512:

<comment>`private_key_jwt` clients can be registered without supplying JWKS or a JWKS URI, guaranteeing later auth failures. When token_endpoint_auth_method is &quot;private_key_jwt&quot;, require body.jwks or body.jwks_uri during registration before saving the client.</comment>

<file context>
@@ -1391,12 +1502,16 @@ export const oidcProvider = (options: OIDCOptions) =&gt; {
 							authenticationScheme:
 								body.token_endpoint_auth_method || &quot;client_secret_basic&quot;,
+							jwks: body.jwks ? JSON.stringify(body.jwks) : undefined,
+							jwksUri: body.jwks_uri,
+							tokenEndpointAuthMethod:
+								body.token_endpoint_auth_method || &quot;client_secret_basic&quot;,
</file context>

✅ Addressed in 5c033bf


if (client.type !== "public") {
if (authenticatedViaAssertion) {
await verifyClientAssertion({
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: verifyClientAssertion is invoked without ever loading JWKS from jwks_uri, so any private_key_jwt client that registers only a JWKS URI is rejected with “client jwks not configured”. Fetch the keys (or persist them) before calling the verifier so client.jwks is populated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/plugins/oidc-provider/index.ts, line 678:

<comment>`verifyClientAssertion` is invoked without ever loading JWKS from `jwks_uri`, so any private_key_jwt client that registers only a JWKS URI is rejected with “client jwks not configured”. Fetch the keys (or persist them) before calling the verifier so `client.jwks` is populated.</comment>

<file context>
@@ -606,6 +661,49 @@ export const oidcProvider = (options: OIDCOptions) =&gt; {
+
+						if (client.type !== &quot;public&quot;) {
+							if (authenticatedViaAssertion) {
+								await verifyClientAssertion({
+									clientAssertion: client_assertion.toString(),
+									clientId: client_id.toString(),
</file context>
Fix with Cubic

key = foundKey;
}

const publicKey = await importJWK(key);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Handle importJWK failures and convert them into an invalid_client API error so malformed keys cannot crash the endpoint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/plugins/oidc-provider/utils.ts, line 78:

<comment>Handle `importJWK` failures and convert them into an `invalid_client` API error so malformed keys cannot crash the endpoint.</comment>

<file context>
@@ -13,3 +17,130 @@ export const defaultClientSecretHasher = async (clientSecret: string) =&gt; {
+		key = foundKey;
+	}
+
+	const publicKey = await importJWK(key);
+
+	const { payload } = await jwtVerify(clientAssertion, publicKey, {
</file context>

✅ Addressed in 5c033bf

});
}

const jwks = JSON.parse(client.jwks);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Wrap the JWKS JSON parsing in a try/catch so malformed client metadata returns invalid_client instead of crashing the token endpoint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/plugins/oidc-provider/utils.ts, line 37:

<comment>Wrap the JWKS JSON parsing in a try/catch so malformed client metadata returns `invalid_client` instead of crashing the token endpoint.</comment>

<file context>
@@ -13,3 +17,130 @@ export const defaultClientSecretHasher = async (clientSecret: string) =&gt; {
+		});
+	}
+
+	const jwks = JSON.parse(client.jwks);
+	const keys = jwks.keys;
+
</file context>

✅ Addressed in 5c033bf

export interface ClientAssertionPayload {
iss: string;
sub: string;
aud: string;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ClientAssertionPayload.aud is typed as only string, which rejects valid JWT client assertions whose audience claim is an array (allowed by RFC 7519). Allow both string and string[] so consumers can create spec-compliant tokens.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/plugins/oidc-provider/types.ts, line 385:

<comment>`ClientAssertionPayload.aud` is typed as only `string`, which rejects valid JWT client assertions whose audience claim is an array (allowed by RFC 7519). Allow both `string` and `string[]` so consumers can create spec-compliant tokens.</comment>

<file context>
@@ -379,6 +379,15 @@ export interface CodeVerificationValue {
+export interface ClientAssertionPayload {
+	iss: string;
+	sub: string;
+	aud: string;
+	jti: string;
+	exp: number;
</file context>
Suggested change
aud: string;
aud: string | string[];
Fix with Cubic

Comment on lines +359 to +361
const payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing error handling for JWT payload parsing. If the base64 decoding or JSON parsing fails (malformed JWT), this will throw an unhandled exception. Wrap in try-catch like the similar code in oidc-provider/utils.ts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/plugins/mcp/index.ts, line 359:

<comment>Missing error handling for JWT payload parsing. If the base64 decoding or JSON parsing fails (malformed JWT), this will throw an unhandled exception. Wrap in try-catch like the similar code in `oidc-provider/utils.ts`.</comment>

<file context>
@@ -322,6 +334,37 @@ export const mcp = (options: MCPOptions) =&gt; {
+									error: &quot;invalid_client&quot;,
+								});
+							}
+							const payload = JSON.parse(
+								new TextDecoder().decode(base64.decode(parts[1])),
+							);
</file context>
Suggested change
const payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
let payload: { iss?: string; sub?: string };
try {
payload = JSON.parse(
new TextDecoder().decode(base64.decode(parts[1])),
);
} catch {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid jwt payload",
error: "invalid_client",
});
}

✅ Addressed in 5537d19

clientId: 'example-app',
name: 'Example App',
type: 'web',
redirectUrls: 'https://example.com/callback',
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Rule violated: Enforce Consistent Naming Conventions

Use the established redirectURLs property name to stay consistent with the rest of the API documentation and prevent misconfiguration caused by the newly introduced redirectUrls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/content/docs/plugins/oidc-provider.mdx, line 485:

<comment>Use the established `redirectURLs` property name to stay consistent with the rest of the API documentation and prevent misconfiguration caused by the newly introduced `redirectUrls`.</comment>

<file context>
@@ -415,6 +415,149 @@ const auth = betterAuth({
+    clientId: &#39;example-app&#39;,
+    name: &#39;Example App&#39;,
+    type: &#39;web&#39;,
+    redirectUrls: &#39;https://example.com/callback&#39;,
+    jwks: JSON.stringify({
+      keys: [{
</file context>
Suggested change
redirectUrls: 'https://example.com/callback',
redirectURLs: 'https://example.com/callback',
Fix with Cubic

@himself65 himself65 force-pushed the canary branch 3 times, most recently from 85f5056 to 5de9704 Compare January 21, 2026 01:28
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Feb 26, 2026

CLA assistant check
All committers have signed the CLA.

@gustavovalverde gustavovalverde added identity OAuth/OIDC provider, MCP, device flow docs Documentation, demos enhancement New feature or improvement and removed enhancement New feature or improvement labels Mar 26, 2026
@gustavovalverde gustavovalverde self-assigned this Mar 27, 2026
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
gustavovalverde added a commit that referenced this pull request Mar 30, 2026
Add end-to-end support for private_key_jwt client authentication
across the OAuth provider (server-side verification), SSO plugin
(client-side signing against external IdPs), and generic OAuth plugin.

Server-side (@better-auth/oauth-provider):
- Accept private_key_jwt assertions at token, introspect, and revoke endpoints
- JWKS registration via jwks or jwks_uri (mutually exclusive, HTTPS enforced)
- JTI replay prevention via verification table with tombstones until exp
- Assertion lifetime cap (assertionMaxLifetime, default 5 minutes)
- Auth method enforcement: private_key_jwt clients cannot fall back to secrets
- JWKS URI caching (5-min TTL, 5s timeout, stale fallback)
- SSRF protection for jwks_uri (private IP blocking, redirect rejection)
- Unified extractClientCredentials() replacing 5-way duplication
- Obsolete auth material cleared on auth-method switch

Client-side (@better-auth/core, @better-auth/sso, generic-oauth):
- signClientAssertion() utility for RFC 7523 JWT construction
- resolvePrivateKey callback for HSM/KMS key resolution (no keys in DB)
- defaultSSO inline privateKey support for static configurations
- Discovery auto-selects private_key_jwt when IdP requires it
- clientSecret optional for private_key_jwt SSO providers
- Pre-signed assertion passthrough (skip signing for HSM/KMS workflows)
- Configurable expiresIn on client assertions

Also fixes pre-existing base64Url bug in client-credentials-token.ts.

Closes #5935
Supersedes #6053
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation, demos identity OAuth/OIDC provider, MCP, device flow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for client assertions (private_key_jwt)

6 participants