feat(oidc-provider): support private_key_jwt#6053
feat(oidc-provider): support private_key_jwt#6053okisdev wants to merge 11 commits intobetter-auth:mainfrom
Conversation
|
@okisdev is attempting to deploy a commit to the better-auth Team on Vercel. A member of the Team first needs to authorize it. |
better-auth
@better-auth/cli
@better-auth/core
@better-auth/expo
@better-auth/passkey
@better-auth/scim
@better-auth/sso
@better-auth/stripe
@better-auth/telemetry
commit: |
There was a problem hiding this comment.
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
verifyClientAssertionfunction to validate JWT-based client authentication with replay protection - Extended schema with
jwks,jwksUri, andtokenEndpointAuthMethodfields for OAuth applications - Integrated
private_key_jwtauthentication 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]))); |
There was a problem hiding this comment.
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.
| 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", | |
| }); | |
| } |
| }); | ||
| } | ||
|
|
||
| const jwks = JSON.parse(client.jwks); |
There was a problem hiding this comment.
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.
| 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", | |
| }); | |
| } |
| const payload = JSON.parse( | ||
| new TextDecoder().decode(base64.decode(parts[1])), | ||
| ); |
There was a problem hiding this comment.
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.
| 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", | |
| }); | |
| } |
| 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", | ||
| }); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| const payload = JSON.parse( | ||
| new TextDecoder().decode(base64.decode(parts[1])), | ||
| ); |
There was a problem hiding this comment.
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.
| 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", | |
| }); | |
| } |
| export interface ClientAssertionPayload { | ||
| iss: string; | ||
| sub: string; | ||
| aud: string; | ||
| jti: string; | ||
| exp: number; | ||
| iat: number; | ||
| } |
There was a problem hiding this comment.
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.
| iss: 'example-app', // Your client_id | ||
| sub: 'example-app', // Your client_id |
There was a problem hiding this comment.
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.
| iss: 'example-app', // Your client_id | |
| sub: 'example-app', // Your client_id | |
| iss: 'financial-app', // Your client_id | |
| sub: 'financial-app', // Your client_id |
| const publicKey = await importJWK(key); | ||
|
|
||
| const { payload } = await jwtVerify(clientAssertion, publicKey, { | ||
| issuer: clientId, | ||
| subject: clientId, | ||
| audience: tokenEndpoint, | ||
| }).catch((err) => { |
There was a problem hiding this comment.
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.
| **Supported Algorithms:** | ||
| - RSA: RS256, RS384, RS512 | ||
| - ECDSA: ES256, ES384, ES512 | ||
| - EdDSA: Ed25519 |
There was a problem hiding this comment.
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.
| - EdDSA: Ed25519 | |
| - EdDSA (Ed25519) |
| const { | ||
| auth: authorizationServer, | ||
| db, | ||
| testUser: testUserCredentials, |
There was a problem hiding this comment.
Unused variable testUserCredentials.
| testUser: testUserCredentials, |
There was a problem hiding this comment.
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 "client jwks not configured"). 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("invalid_client").</violation>
</file>
Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR
2794a2d to
f1e94f2
Compare
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 "private_key_jwt", 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, |
There was a problem hiding this comment.
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 "private_key_jwt", 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) => {
authenticationScheme:
body.token_endpoint_auth_method || "client_secret_basic",
+ jwks: body.jwks ? JSON.stringify(body.jwks) : undefined,
+ jwksUri: body.jwks_uri,
+ tokenEndpointAuthMethod:
+ body.token_endpoint_auth_method || "client_secret_basic",
</file context>
✅ Addressed in 5c033bf
|
|
||
| if (client.type !== "public") { | ||
| if (authenticatedViaAssertion) { | ||
| await verifyClientAssertion({ |
There was a problem hiding this comment.
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) => {
+
+ if (client.type !== "public") {
+ if (authenticatedViaAssertion) {
+ await verifyClientAssertion({
+ clientAssertion: client_assertion.toString(),
+ clientId: client_id.toString(),
</file context>
| key = foundKey; | ||
| } | ||
|
|
||
| const publicKey = await importJWK(key); |
There was a problem hiding this comment.
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) => {
+ key = foundKey;
+ }
+
+ const publicKey = await importJWK(key);
+
+ const { payload } = await jwtVerify(clientAssertion, publicKey, {
</file context>
✅ Addressed in 5c033bf
| }); | ||
| } | ||
|
|
||
| const jwks = JSON.parse(client.jwks); |
There was a problem hiding this comment.
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) => {
+ });
+ }
+
+ const jwks = JSON.parse(client.jwks);
+ const keys = jwks.keys;
+
</file context>
✅ Addressed in 5c033bf
| export interface ClientAssertionPayload { | ||
| iss: string; | ||
| sub: string; | ||
| aud: string; |
There was a problem hiding this comment.
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>
| aud: string; | |
| aud: string | string[]; |
| const payload = JSON.parse( | ||
| new TextDecoder().decode(base64.decode(parts[1])), | ||
| ); |
There was a problem hiding this comment.
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) => {
+ error: "invalid_client",
+ });
+ }
+ const payload = JSON.parse(
+ new TextDecoder().decode(base64.decode(parts[1])),
+ );
</file context>
| 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', |
There was a problem hiding this comment.
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: 'example-app',
+ name: 'Example App',
+ type: 'web',
+ redirectUrls: 'https://example.com/callback',
+ jwks: JSON.stringify({
+ keys: [{
</file context>
| redirectUrls: 'https://example.com/callback', | |
| redirectURLs: 'https://example.com/callback', |
85f5056 to
5de9704
Compare
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
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
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
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
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
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
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
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
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
This PR adds
private_key_jwtsupport 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
Migration
Written for commit 5c033bf. Summary will update automatically on new commits.