fix(oauth-provider): preserve dcr client key metadata (#10144)

This commit is contained in:
Gustavo Valverde
2026-06-18 22:46:03 -07:00
committed by GitHub
parent cc4440a290
commit 1f8b0282de
9 changed files with 195 additions and 150 deletions
@@ -1,25 +1,7 @@
---
"@better-auth/oauth-provider": patch
"@better-auth/oauth-provider": minor
---
fix(oauth-provider): override confidential auth methods to public in unauthenticated DCR
Dynamic client registration now preserves confidential client authentication methods for unauthenticated registrations instead of converting them to public clients. Requests that omit `token_endpoint_auth_method` receive the RFC 7591 default `client_secret_basic` method and a one-time `client_secret`; requests that explicitly use `token_endpoint_auth_method: "none"` still create public clients.
When `allowUnauthenticatedClientRegistration` is enabled, unauthenticated DCR
requests that specify `client_secret_post`, `client_secret_basic`, or omit
`token_endpoint_auth_method` (which defaults to `client_secret_basic` per
[RFC 7591 §2](https://datatracker.ietf.org/doc/html/rfc7591#section-2)) are
now silently overridden to `token_endpoint_auth_method: "none"` (public client)
instead of being rejected with HTTP 401.
This follows [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1),
which allows the server to "reject or replace any of the client's requested
metadata values submitted during the registration and substitute them with
suitable values." The registration response communicates the actual method
back to the client, allowing compliant clients to adjust.
This fixes interoperability with real-world MCP clients (Claude, Codex, Factory
Droid, and others) that send `token_endpoint_auth_method: "client_secret_post"`
in their DCR payload because the server metadata advertises it in
`token_endpoint_auth_methods_supported`.
Closes #8588
Registered client `jwks` metadata can now be used with secret-based clients and is returned as a JWKS document (`{ "keys": [...] }`). Inline JWKS metadata must contain public asymmetric keys.
+20 -18
View File
@@ -405,7 +405,7 @@ Revokes a user's consent for a specific client.
Once installed, you can utilize the OAuth Provider to manage authentication flows within your application.
After the client is created, you will receive a `client_id` and `client_secret` that you can display to the user. The `client_secret` can only be provided once, ensure the user saves it.
After a confidential client is created, you will receive a `client_id` and `client_secret` that you can display to the user. The `client_secret` can only be provided once, ensure the user saves it. Public clients receive only a `client_id`.
#### Setup
@@ -418,10 +418,10 @@ oauthProvider({
})
```
To enable unauthenticated client registration which allows for dynamically registered public clients, additionally set `allowUnauthenticatedClientRegistration: true` in your auth config.
To enable open client registration without a Better Auth session, additionally set `allowUnauthenticatedClientRegistration: true` in your auth config. Public clients are registered with `token_endpoint_auth_method: "none"`. Confidential clients receive a one-time `client_secret` in the registration response.
<Callout type="info">
For MCP, [Client ID Metadata Documents](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991) are [available](/docs/plugins/cimd). [`software_statement` and `jwks_uri`](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1032) may be added as an alternative for public client identification in the future.
For MCP public-client identity, [Client ID Metadata Documents](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991) are [available](/docs/plugins/cimd).
</Callout>
```ts title="auth.ts"
@@ -543,22 +543,24 @@ const response = await auth.api.adminCreateOAuthClient({
body: {
redirect_uris: ["https://app.example.com/callback"],
token_endpoint_auth_method: "private_key_jwt",
jwks: [
{
kty: "RSA",
kid: "my-key-1",
alg: "RS256",
use: "sig",
n: "...",
e: "...",
},
],
jwks: {
keys: [
{
kty: "RSA",
kid: "my-key-1",
alg: "RS256",
use: "sig",
n: "...",
e: "...",
},
],
},
},
});
```
<Callout type="info">
`jwks` and `jwks_uri` are mutually exclusive. When using `jwks_uri`, it must be an HTTPS URL pointing to a public (non-private) host.
`jwks` and `jwks_uri` are general OIDC client key metadata and are mutually exclusive. Inline `jwks` must contain only public asymmetric keys. When using `jwks_uri`, it must be an HTTPS URL pointing to a public (non-private) host.
</Callout>
When exchanging tokens, the client sends a `client_assertion` JWT instead of a `client_secret`:
@@ -731,7 +733,7 @@ await auth.api.adminCreateOAuthClient({
When `backchannel_logout_session_required` is `true`, the RP requires a `sid` claim in every Logout Token. Every Logout Token the OP sends already includes `sid`, so such clients are always served.
The `backchannel_logout_uri` is validated at registration. It must be an absolute URL with no fragment, and must use `https` for confidential clients (loopback `http` such as `http://127.0.0.1:<port>` is allowed for local development). It is also rejected when its host is private, reserved, tunneled (NAT64, 6to4, IPv4-mapped IPv6), or a cloud-metadata name. This host check is syntactic: it does not resolve DNS, so pin or re-check the resolved address if you need protection against DNS rebinding. The same host check guards a `private_key_jwt` client's `jwks_uri`, which separately requires `https` unconditionally and a trusted origin. A URI that fails validation is rejected with `invalid_client_metadata`.
The `backchannel_logout_uri` is validated at registration. It must be an absolute URL with no fragment, and must use `https` for confidential clients (loopback `http` such as `http://127.0.0.1:<port>` is allowed for local development). It is also rejected when its host is private, reserved, tunneled (NAT64, 6to4, IPv4-mapped IPv6), or a cloud-metadata name. This host check is syntactic: it does not resolve DNS, so pin or re-check the resolved address if you need protection against DNS rebinding. The same host check guards a client's `jwks_uri`, which separately requires `https` unconditionally and a trusted origin. A URI that fails validation is rejected with `invalid_client_metadata`.
The OP enumerates clients with active tokens bound to the ending session and POSTs one `logout_token` to each in parallel (5s per-RP timeout, no retry per spec §2.5). It then revokes the session's tokens:
@@ -1265,7 +1267,7 @@ oauthProvider({
})
```
Unauthenticated client registration additionally allows for public clients (never confidential) to register without an authorization header. This is especially useful for an MCP to dynamically register themselves as a public client without an active session. For MCP auth support, the recommended approach is via the [CIMD plugin](/docs/plugins/cimd) which maintains the identity of the public client.
Unauthenticated client registration additionally allows clients to register without an authorization header. Public clients are registered with `token_endpoint_auth_method: "none"`. Confidential clients receive a one-time `client_secret` in the registration response. For MCP auth support, the recommended approach is via the [CIMD plugin](/docs/plugins/cimd) which maintains the identity of public clients.
```ts title="auth.ts"
oauthProvider({
@@ -1275,7 +1277,7 @@ oauthProvider({
```
<Callout type="info">
For MCP, [`software_statement` and `jwks_uri`](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1032) may be added as an alternative for public client identification in the future.
For MCP public-client identity, use the [CIMD plugin](/docs/plugins/cimd).
</Callout>
Protected dynamic client registration allows machine callers to register public or confidential clients without a Better Auth user session. Issue an RFC 7591 initial access token out of band, then validate the token from the `Authorization: Bearer <token>` header. Defining `validateInitialAccessToken` enables this path; while it is undefined, a Bearer token sent to the registration endpoint is rejected.
@@ -1401,7 +1403,7 @@ Only disable PKCE for confidential clients when absolutely necessary for legacy
Some clients (notably MCP clients) need to connect to your authorization server without being registered in advance. The OAuth Provider plugin supports this through two mechanisms:
* **`allowUnauthenticatedClientRegistration`**: lets anonymous callers hit `/oauth2/register` to create a public client at request time.
* **`allowUnauthenticatedClientRegistration`**: lets anonymous callers hit `/oauth2/register` to create a client at request time. Confidential registrations receive a one-time `client_secret`; public registrations use `token_endpoint_auth_method: "none"`.
* **[`@better-auth/cimd`](/docs/plugins/cimd)**: an optional plugin that lets clients identify themselves by hosting a metadata document at an HTTPS URL. The URL itself becomes the `client_id`; the server fetches and validates the document. This is the pattern MCP calls [Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/).
### Provider extensions
@@ -377,7 +377,7 @@ describe("oauthClient", async () => {
* gate as the create-client endpoints. The gate lives in the shared creation
* chokepoint, so every creation route inherits it; a forbidden authenticated
* user cannot mint a client through registration, while the unauthenticated
* public-client path stays open and never consults the hook.
* open-registration path stays open and never consults the hook.
*
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-jmcv-4jfc-6qqj
*/
@@ -483,4 +483,15 @@ describe("oauthClient dynamic registration privileges", async () => {
expect(client.data?.public).toBe(true);
expect(clientPrivileges).not.toHaveBeenCalled();
});
it("should allow unauthenticated confidential registration without invoking the gate", async () => {
const client = await unauthedAuthClient.oauth2.register({
redirect_uris: [redirectUri],
});
expect(client.data?.client_id).toBeDefined();
expect(client.data?.client_secret).toBeDefined();
expect(client.data?.token_endpoint_auth_method).toBe("client_secret_basic");
expect(client.data?.public).toBe(false);
expect(clientPrivileges).not.toHaveBeenCalled();
});
});
@@ -264,21 +264,22 @@ describe("oauthClient private_key_jwt clients", async () => {
});
it("should create private_key_jwt clients with jwks and jwks_uri", async () => {
const inlineJwks = {
keys: [
{ ...publicJwk, kid: "crud-inline-key", alg: "RS256", use: "sig" },
],
};
const inlineClient = await authClient.oauth2.createClient({
redirect_uris: [redirectUri],
token_endpoint_auth_method: "private_key_jwt",
jwks: [
{ ...publicJwk, kid: "crud-inline-key", alg: "RS256", use: "sig" },
],
jwks: inlineJwks,
});
expect(inlineClient.data?.client_id).toBeDefined();
expect(inlineClient.data?.client_secret).toBeUndefined();
expect(inlineClient.data?.token_endpoint_auth_method).toBe(
"private_key_jwt",
);
expect(inlineClient.data?.jwks).toEqual([
{ ...publicJwk, kid: "crud-inline-key", alg: "RS256", use: "sig" },
]);
expect(inlineClient.data?.jwks).toEqual(inlineJwks);
jwksClient = inlineClient.data!;
const remoteClient = await authClient.oauth2.createClient({
@@ -759,7 +759,7 @@ describe("private_key_jwt registration validation", async () => {
expect(result.status).toBeGreaterThanOrEqual(400);
});
it("should reject jwks on non-private_key_jwt client", async () => {
it("should accept jwks on client_secret clients", async () => {
const result = await auth.api.adminCreateOAuthClient({
headers,
body: {
@@ -769,6 +769,6 @@ describe("private_key_jwt registration validation", async () => {
},
asResponse: true,
});
expect(result.status).toBeGreaterThanOrEqual(400);
expect(result.status).toBe(201);
});
});
+71 -33
View File
@@ -164,7 +164,7 @@ describe("oauth register", async () => {
contacts: ["test@example.com"],
tos_uri: "https://example.com/terms",
policy_uri: "https://example.com/policy",
//---- Jwks (only one can be used) ----//
//---- Client key metadata (only one can be used) ----//
// jwks: [],
// jwks_uri: "https://example.com/.well-known/jwks.json",
//---- User Software Identifiers ----//
@@ -382,6 +382,32 @@ describe("oauth register", async () => {
expect(response.error?.status).toBe(400);
});
it.for([
{ kty: "oct", k: "secret-key" },
{ kty: "RSA", n: "test", e: "test-exponent", d: "private-exponent" },
])("should reject private or symmetric jwks material", async (key) => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
jwks: { keys: [key] },
});
expect(response.error?.status).toBe(400);
});
it.for([
{},
{ use: "sig" },
{ kty: "RSA", n: "test" },
{ kty: "EC", crv: "P-256", x: "test" },
{ kty: "OKP", crv: "Ed25519" },
{ kty: "unsupported", n: "test", e: "test-exponent" },
])("should reject malformed jwks public keys", async (key) => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
jwks: { keys: [key] },
});
expect(response.error?.status).toBe(400);
});
it("should reject admin registration with an empty jwks array", async () => {
await expect(
auth.api.adminCreateOAuthClient({
@@ -560,73 +586,83 @@ describe("oauth register - unauthenticated", async () => {
/**
* RFC 7591 §2: when token_endpoint_auth_method is omitted, the default
* is "client_secret_basic". Unauthenticated DCR overrides this to "none"
* per RFC 7591 §3.2.1 ("the server MAY reject or replace any of the
* client's requested metadata values").
* is "client_secret_basic". Open registration may still create a
* confidential client and return the generated client_secret.
*
* @see https://github.com/better-auth/better-auth/issues/8588
*/
it("should override omitted auth method (RFC 7591 default) to public", async () => {
it("should apply the client_secret_basic default without authentication", async () => {
const response = await unauthenticatedClient.oauth2.register({
redirect_uris: [redirectUri],
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.client_secret).toBeUndefined();
expect(response.data?.token_endpoint_auth_method).toBe("none");
expect(response.data?.public).toBe(true);
expect(response.data?.client_secret).toBeDefined();
expect(response.data?.token_endpoint_auth_method).toBe(
"client_secret_basic",
);
expect(response.data?.public).toBe(false);
});
/**
* Real-world MCP clients (Claude, Codex, Factory Droid) send
* token_endpoint_auth_method: "client_secret_post" in their DCR payload.
* The server overrides this to "none" and communicates the actual method
* in the registration response so compliant clients can adjust.
* Open registration preserves the requested confidential client auth method
* and returns a secret for token endpoint authentication.
*
* @see https://github.com/better-auth/better-auth/issues/8588
*/
it("should override client_secret_post to public for unauthenticated DCR", async () => {
it("should preserve client_secret_post for unauthenticated DCR", async () => {
const response = await unauthenticatedClient.oauth2.register({
token_endpoint_auth_method: "client_secret_post",
redirect_uris: [redirectUri],
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.client_secret).toBeUndefined();
expect(response.data?.token_endpoint_auth_method).toBe("none");
expect(response.data?.public).toBe(true);
expect(response.data?.client_secret).toBeDefined();
expect(response.data?.token_endpoint_auth_method).toBe(
"client_secret_post",
);
expect(response.data?.public).toBe(false);
});
/**
* @see https://github.com/better-auth/better-auth/issues/8588
*/
it("should override client_secret_basic to public for unauthenticated DCR", async () => {
it("should accept client_secret_basic with inline jwks for unauthenticated DCR", async () => {
const jwks = {
keys: [{ kty: "RSA", kid: "client-key", n: "test", e: "test-exponent" }],
};
const response = await unauthenticatedClient.oauth2.register({
token_endpoint_auth_method: "client_secret_basic",
redirect_uris: [redirectUri],
jwks,
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.client_secret).toBeUndefined();
expect(response.data?.token_endpoint_auth_method).toBe("none");
expect(response.data?.public).toBe(true);
expect(response.data?.client_secret).toBeDefined();
expect(response.data?.token_endpoint_auth_method).toBe(
"client_secret_basic",
);
expect(response.data?.jwks).toEqual(jwks);
});
/**
* @see https://github.com/better-auth/better-auth/issues/8588
*/
it("should clear type 'web' when overriding confidential to public", async () => {
it("should preserve type 'web' for unauthenticated confidential DCR", async () => {
const response = await unauthenticatedClient.oauth2.register({
token_endpoint_auth_method: "client_secret_post",
type: "web",
redirect_uris: [redirectUri],
});
expect(response.data?.client_id).toBeDefined();
expect(response.data?.client_secret).toBeUndefined();
expect(response.data?.token_endpoint_auth_method).toBe("none");
expect(response.data?.type).toBeUndefined();
expect(response.data?.client_secret).toBeDefined();
expect(response.data?.token_endpoint_auth_method).toBe(
"client_secret_post",
);
expect(response.data?.type).toBe("web");
});
/**
* client_credentials requires a secret, which public clients never get.
* Reject the combination at registration rather than creating an unusable client.
* client_credentials can mint tokens without an end-user authorization step.
* Keep open registration on authorization-code clients unless the caller is
* authenticated through a session or an initial access token.
*
* @see https://github.com/better-auth/better-auth/issues/8588
*/
@@ -640,8 +676,8 @@ describe("oauth register - unauthenticated", async () => {
});
/**
* Verifies the overridden public client is actually usable end-to-end:
* DCR with client_secret_post (overridden to "none") -> authorize -> PKCE token exchange.
* Verifies the open-registration confidential client is actually usable end-to-end:
* DCR with client_secret_post -> authorize -> PKCE token exchange with client_secret.
*
* @see https://github.com/better-auth/better-auth/issues/8588
*/
@@ -681,17 +717,18 @@ describe("oauth register - unauthenticated DCR full flow", async () => {
const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`;
const state = "e2e-test-state";
it("should complete authorize + PKCE token exchange after override from client_secret_post", async () => {
// 1. Register via unauthenticated DCR with client_secret_post (gets overridden to "none")
it("should complete authorize + PKCE token exchange for client_secret_post", async () => {
// 1. Register via unauthenticated DCR with client_secret_post.
const reg = await unauthenticatedClient.oauth2.register({
token_endpoint_auth_method: "client_secret_post",
redirect_uris: [redirectUri],
});
expect(reg.data?.client_id).toBeDefined();
expect(reg.data?.client_secret).toBeUndefined();
expect(reg.data?.token_endpoint_auth_method).toBe("none");
expect(reg.data?.client_secret).toBeDefined();
expect(reg.data?.token_endpoint_auth_method).toBe("client_secret_post");
const clientId = reg.data!.client_id;
const clientSecret = reg.data!.client_secret!;
// 2. Build authorization URL with PKCE (no client secret)
const codeVerifier = generateRandomString(64);
@@ -735,7 +772,7 @@ describe("oauth register - unauthenticated DCR full flow", async () => {
const code = new URL(consentRes.url).searchParams.get("code")!;
// 5. Exchange code at token endpoint with PKCE (no client_secret)
// 5. Exchange code at token endpoint with PKCE and client_secret_post.
const { body: tokenBody, headers: tokenHeaders } =
await authorizationCodeRequest({
code,
@@ -743,6 +780,7 @@ describe("oauth register - unauthenticated DCR full flow", async () => {
redirectURI: redirectUri,
options: {
clientId,
clientSecret,
redirectURI: redirectUri,
},
});
+73 -63
View File
@@ -17,40 +17,46 @@ import type {
SchemaClient,
Scope,
} from "./types";
import type {
GrantType,
OAuthClient,
TokenEndpointAuthMethod,
} from "./types/oauth";
import type { GrantType, OAuthClient } from "./types/oauth";
import { parseClientMetadata, storeClientSecret } from "./utils";
import { isPrivateHostname } from "./utils/client-assertion";
import { authorizeInitialAccessToken } from "./utils/initial-access-token";
/**
* Resolves the auth method and type for unauthenticated DCR.
* Overrides confidential methods to "none" per RFC 7591 Section 3.2.1.
* When overriding, clears type "web" since it is only valid for confidential clients.
*/
function resolveUnauthenticatedAuth(body: OAuthClient): {
tokenEndpointAuthMethod: TokenEndpointAuthMethod;
type: OAuthClient["type"];
} {
if (body.token_endpoint_auth_method === "none") {
return {
tokenEndpointAuthMethod: "none",
type: body.type,
};
}
return {
tokenEndpointAuthMethod: "none",
type: body.type === "web" ? undefined : body.type,
};
}
const DEFAULT_REGISTRATION_GRANT_TYPES = [
"authorization_code",
] as const satisfies GrantType[];
const PRIVATE_JWK_MEMBER_NAMES = [
"d",
"p",
"q",
"dp",
"dq",
"qi",
"oth",
] as const;
function hasStringJwkMember(key: Record<string, unknown>, memberName: string) {
return typeof key[memberName] === "string" && key[memberName].length > 0;
}
function isSupportedPublicJwk(key: Record<string, unknown>) {
switch (key.kty) {
case "RSA":
return hasStringJwkMember(key, "n") && hasStringJwkMember(key, "e");
case "EC":
return (
hasStringJwkMember(key, "crv") &&
hasStringJwkMember(key, "x") &&
hasStringJwkMember(key, "y")
);
case "OKP":
return hasStringJwkMember(key, "crv") && hasStringJwkMember(key, "x");
default:
return false;
}
}
function resolveRegistrationGrantTypes(client: OAuthClient): GrantType[] {
const grantTypes = client.grant_types ?? [
...DEFAULT_REGISTRATION_GRANT_TYPES,
@@ -83,6 +89,36 @@ function applyOAuthClientRegistrationDefaults(
};
}
function validatePublicJwks(jwks: NonNullable<OAuthClient["jwks"]>) {
const keys = Array.isArray(jwks) ? jwks : jwks.keys;
if (!Array.isArray(keys) || keys.length === 0) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"jwks must be a non-empty array of JWK objects or a JWKS document {keys:[...]}",
});
}
for (const key of keys) {
if (
key.kty === "oct" ||
"k" in key ||
PRIVATE_JWK_MEMBER_NAMES.some((name) => name in key)
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "jwks must contain only public asymmetric keys",
});
}
if (!isSupportedPublicJwk(key)) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"jwks keys must be supported public JWKs with required key parameters",
});
}
}
}
export async function registerEndpoint(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
@@ -140,10 +176,6 @@ export async function registerEndpoint(
"client_credentials grant requires authenticated registration",
});
}
const resolved = resolveUnauthenticatedAuth(body);
body.token_endpoint_auth_method = resolved.tokenEndpointAuthMethod;
body.type = resolved.type;
}
if (!body.scope) {
@@ -364,22 +396,10 @@ export async function checkOAuthClient(
});
}
// Validate client key material (jwks / jwks_uri). These belong to
// assertion-based authentication: private_key_jwt and any extension method
// that consumes them. The validation (mutual exclusion, jwks_uri origin and
// SSRF guards, structure) is the same for all of them, so an extension cannot
// register an unvalidated jwks_uri or both jwks and jwks_uri.
const usesAssertionKeyMaterial =
tokenEndpointAuthMethod === "private_key_jwt" ||
isExtensionTokenEndpointAuthMethod(opts, tokenEndpointAuthMethod);
// Validate client key metadata (jwks / jwks_uri). OIDC Dynamic Client
// Registration treats these as general client metadata, not only
// private_key_jwt key material. private_key_jwt still requires one below.
if (clientWithDefaults.jwks || clientWithDefaults.jwks_uri) {
if (!usesAssertionKeyMaterial) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"jwks and jwks_uri are only allowed with private_key_jwt or an assertion-based authentication method",
});
}
// OIDC Registration: jwks and jwks_uri must not both be present.
if (clientWithDefaults.jwks && clientWithDefaults.jwks_uri) {
throw new APIError("BAD_REQUEST", {
@@ -418,20 +438,11 @@ export async function checkOAuthClient(
}
}
if (clientWithDefaults.jwks) {
// Accept both RFC 7517 JWKS object {"keys":[...]} and bare key array
const keys = Array.isArray(clientWithDefaults.jwks)
? clientWithDefaults.jwks
: (clientWithDefaults.jwks as { keys?: unknown[] }).keys;
if (!Array.isArray(keys) || keys.length === 0) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"jwks must be a non-empty array of JWK objects or a JWKS document {keys:[...]}",
});
}
validatePublicJwks(clientWithDefaults.jwks);
}
}
// private_key_jwt requires key material; extension methods may carry their own.
// private_key_jwt requires key material; other methods may still register
// client keys for OIDC features such as request objects or encrypted responses.
if (
tokenEndpointAuthMethod === "private_key_jwt" &&
!clientWithDefaults.jwks &&
@@ -540,8 +551,7 @@ export async function createOAuthClientEndpoint(
// Single authorization chokepoint for OAuth client creation. Admin creation
// always requires create privileges. DCR re-checks them only for
// session-backed requests; non-session DCR was already authorized in
// registerEndpoint (a valid initial access token, or open registration
// constrained to public clients).
// registerEndpoint (a valid initial access token, or open registration).
if (!settings.isRegister || session) {
await assertClientPrivileges(ctx, session, opts, "create");
}
@@ -687,7 +697,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
contacts,
tos_uri: tos,
policy_uri: policy,
// Jwks (only one can be used)
// Client key metadata (only one can be used)
jwks: inputJwks,
jwks_uri: jwksUri,
// User Software Identifiers
@@ -761,7 +771,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
tokenEndpointAuthMethod,
grantTypes,
responseTypes,
// Jwks for private_key_jwt
// Client key metadata
jwks: inputJwks
? JSON.stringify({
keys: Array.isArray(inputJwks)
@@ -865,9 +875,9 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
contacts: contacts ?? undefined,
tos_uri: tos ?? undefined,
policy_uri: policy ?? undefined,
// Jwks (only one can be used)
// Client key metadata (only one can be used)
jwks: jwks
? (JSON.parse(jwks) as { keys: Record<string, unknown>[] }).keys
? (JSON.parse(jwks) as { keys: Record<string, unknown>[] })
: undefined,
jwks_uri: jwksUri ?? undefined,
// User Software Identifiers
+6 -5
View File
@@ -623,8 +623,9 @@ export interface OAuthOptions<
* Allow unauthenticated dynamic client registration.
*
* When enabled, the `/oauth2/register` endpoint accepts requests
* without a session, but only for public clients
* (`token_endpoint_auth_method: "none"`).
* without a session. Public clients use
* `token_endpoint_auth_method: "none"`; confidential clients receive a
* one-time `client_secret` in the registration response.
*
* For verified client discovery (MCP), consider installing the
* `@better-auth/cimd` plugin, which verifies client identity through
@@ -641,8 +642,8 @@ export interface OAuthOptions<
* - session-backed: a logged-in user with client-create privileges.
* - token-backed: a valid initial access token, when
* {@link OAuthOptions.validateInitialAccessToken} is defined.
* - open public-only: unauthenticated registration constrained to public
* clients, when {@link OAuthOptions.allowUnauthenticatedClientRegistration}
* - open: unauthenticated registration, when
* {@link OAuthOptions.allowUnauthenticatedClientRegistration}
* is enabled.
*
* @default false
@@ -1620,7 +1621,7 @@ export interface SchemaClient<
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
grantTypes?: GrantType[];
responseTypes?: "code"[];
/** Client's JSON Web Key Set for `private_key_jwt` authentication. Mutually exclusive with `jwksUri`. */
/** Client's JSON Web Key Set metadata. Mutually exclusive with `jwksUri`. */
jwks?: string;
/** URI for the client's JSON Web Key Set. Mutually exclusive with `jwks`. Must be HTTPS. */
jwksUri?: string;
+1 -1
View File
@@ -332,7 +332,7 @@ export interface OAuthClient {
contacts?: string[];
tos_uri?: string;
policy_uri?: string;
//---- Jwks (only one can be used) ----//
//---- Client key metadata (only one can be used) ----//
/** JWK Set — accepts either a bare key array or an RFC 7517 JWKS object `{"keys":[...]}` */
jwks?: Record<string, unknown>[] | { keys: Record<string, unknown>[] };
jwks_uri?: string;