[PR #8836] [MERGED] feat(oauth): add private_key_jwt client authentication (RFC 7523) #25143

Closed
opened 2026-04-15 22:44:21 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/8836
Author: @gustavovalverde
Created: 3/30/2026
Status: Merged
Merged: 4/10/2026
Merged by: @gustavovalverde

Base: nextHead: feat/private-key-jwt


📝 Commits (8)

  • 403c997 feat(oauth): add private_key_jwt client authentication (RFC 7523)
  • 7dc7be8 fix(oauth): harden private_key_jwt security, unify algorithm constants, and fix SSRF protection
  • 3bdd90f chore: add changeset and remove unused export flagged by knip
  • f8c569c refactor(sso): remove redundant kid/algorithm from defaultSSO.privateKey
  • e1d4859 refactor(oauth): deduplicate assertion resolution, credential destructuring, and constants
  • 5d8af2c fix: resolve cspell errors in test hostnames and comments
  • 07a2a05 fix(oauth): address review comments from Copilot
  • a9568b0 fix(sso): let signClientAssertion infer algorithm from JWK

📊 Changes

39 files changed (+4749 additions, -1550 deletions)

View changed files

.changeset/private-key-jwt.md (+8 -0)
📝 docs/content/docs/plugins/generic-oauth.mdx (+27 -1)
📝 docs/content/docs/plugins/oauth-provider.mdx (+783 -713)
📝 docs/content/docs/plugins/sso.mdx (+726 -635)
📝 packages/better-auth/src/plugins/generic-oauth/index.ts (+12 -0)
📝 packages/better-auth/src/plugins/generic-oauth/routes.ts (+8 -0)
📝 packages/better-auth/src/plugins/generic-oauth/types.ts (+11 -2)
packages/core/src/oauth2/client-assertion.test.ts (+171 -0)
packages/core/src/oauth2/client-assertion.ts (+136 -0)
📝 packages/core/src/oauth2/client-credentials-token.ts (+53 -16)
📝 packages/core/src/oauth2/index.ts (+10 -0)
packages/core/src/oauth2/private-key-jwt-authentication.test.ts (+149 -0)
📝 packages/core/src/oauth2/refresh-access-token.ts (+37 -13)
📝 packages/core/src/oauth2/validate-authorization-code.ts (+36 -12)
📝 packages/oauth-provider/src/introspect.ts (+18 -18)
📝 packages/oauth-provider/src/metadata.test.ts (+6 -0)
📝 packages/oauth-provider/src/metadata.ts (+13 -0)
📝 packages/oauth-provider/src/oauth.ts (+22 -1)
📝 packages/oauth-provider/src/oauthClient/endpoints.test.ts (+153 -1)
📝 packages/oauth-provider/src/oauthClient/endpoints.ts (+20 -2)

...and 19 more files

📄 Description

Summary

End-to-end private_key_jwt client authentication per RFC 7523, covering both sides of the OAuth exchange: server-side assertion verification in @better-auth/oauth-provider, and client-side assertion signing in the core OAuth2 primitives, SSO plugin, and generic OAuth plugin.

Closes #5935
Closes #6053

What changed

Server-side verification (@better-auth/oauth-provider): the token, introspect, and revoke endpoints accept client_assertion + client_assertion_type parameters. Clients registered with token_endpoint_auth_method: "private_key_jwt" provide their public keys via jwks or jwks_uri at registration; the server verifies assertion signatures, enforces jti single-use via the verification table, caps assertion lifetime, and rejects any attempt to fall back to secret-based auth.

Client-side signing (@better-auth/core, @better-auth/sso, generic-oauth): a signClientAssertion() utility constructs RFC 7523 JWTs. The SSO plugin resolves private keys at runtime via a resolvePrivateKey callback (supporting HSM/KMS without storing keys in the database) or inline via defaultSSO. Discovery correctly selects private_key_jwt when the IdP requires it.

Security

  • SSRF protection: HTTPS-only jwks_uri, private/reserved IP rejection (IPv4, IPv6, IPv4-mapped IPv6), cloud metadata blocking, redirect disabled, trusted-origin enforcement
  • JWKS caching: 5-minute TTL with stale-while-revalidate; automatic refetch on key rotation (verify-then-refetch-then-retry)
  • JTI replay prevention: tombstones stored until assertion exp; in-flight deduplication via process-local Set; double-check on create failure for multi-instance resilience
  • Auth method enforcement: private_key_jwt clients cannot authenticate with client_secret; switching auth methods clears opposing credentials
  • Assertion lifetime: exp required, capped by assertionMaxLifetime (default 5 min), advisory iat check when present
  • Algorithm restriction: only asymmetric algorithms accepted (10 algorithms shared between client signer and server verifier via single ASSERTION_SIGNING_ALGORITHMS constant); HS256 and none rejected

Architecture decisions

  • Single algorithm source of truth: ASSERTION_SIGNING_ALGORITHMS exported from @better-auth/core, consumed by server verifier and metadata; AssertionSigningAlgorithm type derived from it
  • JWK auto-extraction: signClientAssertion falls back to privateKeyJwk.kid and privateKeyJwk.alg per RFC 7517, reducing configuration surface
  • Shared ClientAssertionConfig type: generic-oauth imports from core instead of duplicating the type
  • SSO key material separation: OIDCConfig (DB-persisted) holds metadata (privateKeyId, privateKeyAlgorithm); key material lives in defaultSSO.privateKey or resolvePrivateKey callback, never in the database
  • Deduplicated patterns: resolveAssertionParams() replaces 3 identical assertion-building blocks; destructureCredentials() replaces 5 identical credential-parsing blocks; CLIENT_ASSERTION_TYPE constant replaces 4 hardcoded URIs; buildClientAssertion() in generic-oauth replaces 2 inline spreads

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/better-auth/better-auth/pull/8836 **Author:** [@gustavovalverde](https://github.com/gustavovalverde) **Created:** 3/30/2026 **Status:** ✅ Merged **Merged:** 4/10/2026 **Merged by:** [@gustavovalverde](https://github.com/gustavovalverde) **Base:** `next` ← **Head:** `feat/private-key-jwt` --- ### 📝 Commits (8) - [`403c997`](https://github.com/better-auth/better-auth/commit/403c997ed065c153c09d9ad32416f04a2cbf78d2) feat(oauth): add private_key_jwt client authentication (RFC 7523) - [`7dc7be8`](https://github.com/better-auth/better-auth/commit/7dc7be841d2c563a906a78d1a1d1d6543647ee16) fix(oauth): harden private_key_jwt security, unify algorithm constants, and fix SSRF protection - [`3bdd90f`](https://github.com/better-auth/better-auth/commit/3bdd90fb0cff958eb33b9ba518cc18dd1e63289f) chore: add changeset and remove unused export flagged by knip - [`f8c569c`](https://github.com/better-auth/better-auth/commit/f8c569cedfd17f0a11ff81016e41af4e498438c2) refactor(sso): remove redundant kid/algorithm from defaultSSO.privateKey - [`e1d4859`](https://github.com/better-auth/better-auth/commit/e1d4859165ea527397f162bcb6778b91b70d57e1) refactor(oauth): deduplicate assertion resolution, credential destructuring, and constants - [`5d8af2c`](https://github.com/better-auth/better-auth/commit/5d8af2c732ff272d44cf9126a696d005704cf838) fix: resolve cspell errors in test hostnames and comments - [`07a2a05`](https://github.com/better-auth/better-auth/commit/07a2a05201cc5e349a2ffca936de63dc886442f8) fix(oauth): address review comments from Copilot - [`a9568b0`](https://github.com/better-auth/better-auth/commit/a9568b0e460d8082ad2d52c241784adcd33bf0fd) fix(sso): let signClientAssertion infer algorithm from JWK ### 📊 Changes **39 files changed** (+4749 additions, -1550 deletions) <details> <summary>View changed files</summary> ➕ `.changeset/private-key-jwt.md` (+8 -0) 📝 `docs/content/docs/plugins/generic-oauth.mdx` (+27 -1) 📝 `docs/content/docs/plugins/oauth-provider.mdx` (+783 -713) 📝 `docs/content/docs/plugins/sso.mdx` (+726 -635) 📝 `packages/better-auth/src/plugins/generic-oauth/index.ts` (+12 -0) 📝 `packages/better-auth/src/plugins/generic-oauth/routes.ts` (+8 -0) 📝 `packages/better-auth/src/plugins/generic-oauth/types.ts` (+11 -2) ➕ `packages/core/src/oauth2/client-assertion.test.ts` (+171 -0) ➕ `packages/core/src/oauth2/client-assertion.ts` (+136 -0) 📝 `packages/core/src/oauth2/client-credentials-token.ts` (+53 -16) 📝 `packages/core/src/oauth2/index.ts` (+10 -0) ➕ `packages/core/src/oauth2/private-key-jwt-authentication.test.ts` (+149 -0) 📝 `packages/core/src/oauth2/refresh-access-token.ts` (+37 -13) 📝 `packages/core/src/oauth2/validate-authorization-code.ts` (+36 -12) 📝 `packages/oauth-provider/src/introspect.ts` (+18 -18) 📝 `packages/oauth-provider/src/metadata.test.ts` (+6 -0) 📝 `packages/oauth-provider/src/metadata.ts` (+13 -0) 📝 `packages/oauth-provider/src/oauth.ts` (+22 -1) 📝 `packages/oauth-provider/src/oauthClient/endpoints.test.ts` (+153 -1) 📝 `packages/oauth-provider/src/oauthClient/endpoints.ts` (+20 -2) _...and 19 more files_ </details> ### 📄 Description ## Summary End-to-end `private_key_jwt` client authentication per [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), covering both sides of the OAuth exchange: server-side assertion verification in `@better-auth/oauth-provider`, and client-side assertion signing in the core OAuth2 primitives, SSO plugin, and generic OAuth plugin. Closes #5935 Closes #6053 ## What changed **Server-side verification** (`@better-auth/oauth-provider`): the token, introspect, and revoke endpoints accept `client_assertion` + `client_assertion_type` parameters. Clients registered with `token_endpoint_auth_method: "private_key_jwt"` provide their public keys via `jwks` or `jwks_uri` at registration; the server verifies assertion signatures, enforces `jti` single-use via the verification table, caps assertion lifetime, and rejects any attempt to fall back to secret-based auth. **Client-side signing** (`@better-auth/core`, `@better-auth/sso`, `generic-oauth`): a `signClientAssertion()` utility constructs RFC 7523 JWTs. The SSO plugin resolves private keys at runtime via a `resolvePrivateKey` callback (supporting HSM/KMS without storing keys in the database) or inline via `defaultSSO`. Discovery correctly selects `private_key_jwt` when the IdP requires it. ## Security - **SSRF protection**: HTTPS-only `jwks_uri`, private/reserved IP rejection (IPv4, IPv6, IPv4-mapped IPv6), cloud metadata blocking, redirect disabled, trusted-origin enforcement - **JWKS caching**: 5-minute TTL with stale-while-revalidate; automatic refetch on key rotation (verify-then-refetch-then-retry) - **JTI replay prevention**: tombstones stored until assertion `exp`; in-flight deduplication via process-local Set; double-check on create failure for multi-instance resilience - **Auth method enforcement**: `private_key_jwt` clients cannot authenticate with `client_secret`; switching auth methods clears opposing credentials - **Assertion lifetime**: `exp` required, capped by `assertionMaxLifetime` (default 5 min), advisory `iat` check when present - **Algorithm restriction**: only asymmetric algorithms accepted (10 algorithms shared between client signer and server verifier via single `ASSERTION_SIGNING_ALGORITHMS` constant); HS256 and `none` rejected ## Architecture decisions - **Single algorithm source of truth**: `ASSERTION_SIGNING_ALGORITHMS` exported from `@better-auth/core`, consumed by server verifier and metadata; `AssertionSigningAlgorithm` type derived from it - **JWK auto-extraction**: `signClientAssertion` falls back to `privateKeyJwk.kid` and `privateKeyJwk.alg` per RFC 7517, reducing configuration surface - **Shared `ClientAssertionConfig` type**: generic-oauth imports from core instead of duplicating the type - **SSO key material separation**: `OIDCConfig` (DB-persisted) holds metadata (`privateKeyId`, `privateKeyAlgorithm`); key material lives in `defaultSSO.privateKey` or `resolvePrivateKey` callback, never in the database - **Deduplicated patterns**: `resolveAssertionParams()` replaces 3 identical assertion-building blocks; `destructureCredentials()` replaces 5 identical credential-parsing blocks; `CLIENT_ASSERTION_TYPE` constant replaces 4 hardcoded URIs; `buildClientAssertion()` in generic-oauth replaces 2 inline spreads --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-04-15 22:44:21 -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#25143