mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-25 05:33:55 -05:00
fix(oauth-provider): preserve dcr client key metadata (#10144)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user