From 8f3da46ae5b1fe577b38bd7952af1116af3087ff Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:02:05 -0700 Subject: [PATCH] fix(oauth-provider): return invalid_client for an unknown client at the token endpoint (#11176) Co-authored-by: Gustavo Valverde --- .changeset/oauth-provider-invalid-client.md | 5 ++ docs/content/docs/plugins/oauth-provider.mdx | 2 +- .../oauth-provider/src/device-code.test.ts | 16 ++++ packages/oauth-provider/src/device-code.ts | 8 +- packages/oauth-provider/src/token.test.ts | 83 +++++++++++++++++++ packages/oauth-provider/src/token.ts | 54 +++++++----- 6 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 .changeset/oauth-provider-invalid-client.md diff --git a/.changeset/oauth-provider-invalid-client.md b/.changeset/oauth-provider-invalid-client.md new file mode 100644 index 0000000000..98c64394dd --- /dev/null +++ b/.changeset/oauth-provider-invalid-client.md @@ -0,0 +1,5 @@ +--- +"@better-auth/oauth-provider": patch +--- + +The token endpoint now returns `invalid_client` instead of `invalid_grant` when a request names a `client_id` that is not registered, for the `authorization_code`, `refresh_token`, and device code grants. MCP clients whose registration was deleted, for example by a database reset or an upgrade that dropped OAuth tables, now register again and prompt the user to sign in. Before, they kept the deleted `client_id` and stayed signed out until the user cleared their saved credentials by hand. A registered client that sends wrong credentials also gets `invalid_client` now, even when its code or refresh token is also invalid. diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index e16152ca39..6e3c67c9ca 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -544,7 +544,7 @@ Important Notes: * With the JWT plugin enabled (default), sending `resource` results in a JWT access token with the selected resource in the `aud` claim. * With `disableJwtPlugin: true`, access tokens remain opaque. Requested resources are still bound to the token and surfaced through `/oauth2/introspect` and `customAccessTokenClaims` (via `resources`). -Token endpoint errors follow the OAuth taxonomy: missing required request fields return `invalid_request`, failed client authentication returns `invalid_client`, and invalid or mismatched grants return `invalid_grant`. A confidential client must use its registered `token_endpoint_auth_method`. Failed `client_secret_post` authentication returns `400`; failed `client_secret_basic` authentication returns `401` with a Basic `WWW-Authenticate` challenge. +Token endpoint errors follow the OAuth taxonomy: missing required request fields return `invalid_request`, failed client authentication returns `invalid_client`, and invalid or mismatched grants return `invalid_grant`. A confidential client must use its registered `token_endpoint_auth_method`. Failed `client_secret_post` authentication returns `400`; failed `client_secret_basic` authentication returns `401` with a Basic `WWW-Authenticate` challenge. For the built-in `authorization_code`, `refresh_token`, `client_credentials`, and device code grants, the client is authenticated before the grant is looked up, so an unknown `client_id` returns `invalid_client` rather than `invalid_grant`. An extension grant handler (see [Provider extensions](#provider-extensions)) gets the same result by calling `provider.authenticateClient()` before it looks up its grant. #### DPoP sender-constrained tokens diff --git a/packages/oauth-provider/src/device-code.test.ts b/packages/oauth-provider/src/device-code.test.ts index 7e439bcfde..19b3a0e0e3 100644 --- a/packages/oauth-provider/src/device-code.test.ts +++ b/packages/oauth-provider/src/device-code.test.ts @@ -1404,6 +1404,22 @@ describe("oauth-provider device-code grant", async () => { expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant"); }); + /** + * @see https://github.com/better-auth/better-auth/issues/11035 + */ + it("returns invalid_client for an unknown client_id before looking up the device code", async () => { + const res = await pollToken({ + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: "does-not-exist", + client_id: "unknown-client", + }); + expect(res.error?.status).toBe(400); + expect(res.error).toMatchObject({ + error: "invalid_client", + error_description: "client not found", + }); + }); + it("rejects a device code presented by a different client", async () => { const clientId = await createDeviceClient(); const otherClientId = await createDeviceClient(); diff --git a/packages/oauth-provider/src/device-code.ts b/packages/oauth-provider/src/device-code.ts index 9bb2d8e9b4..ca277ae007 100644 --- a/packages/oauth-provider/src/device-code.ts +++ b/packages/oauth-provider/src/device-code.ts @@ -117,6 +117,11 @@ async function exchangeOAuthDeviceCode( type ClientAuthorization = Awaited< ReturnType > & { scopes: string[] }; + // RFC 6749 §5.2: an unknown or unauthenticated client is `invalid_client`, + // so resolve it before the device code lookup can report `invalid_grant`. + const authentication = await provider.authenticateClient({ + requireCredentials: false, + }); const { authorizationContext: { client, confirmation, scopes }, claimedDeviceCode, @@ -140,9 +145,6 @@ async function exchangeOAuthDeviceCode( } const scopes = parseScopes(record.scope); - const authentication = await provider.authenticateClient({ - requireCredentials: false, - }); if (record.oauthClientId !== authentication.client.clientId) { tokenError("BAD_REQUEST", "invalid_grant", "Client ID mismatch"); } diff --git a/packages/oauth-provider/src/token.test.ts b/packages/oauth-provider/src/token.test.ts index 136791e624..c9caa39873 100644 --- a/packages/oauth-provider/src/token.test.ts +++ b/packages/oauth-provider/src/token.test.ts @@ -592,6 +592,33 @@ describe("oauth token - authorization_code", async () => { } }); + /** + * @see https://github.com/better-auth/better-auth/issues/11035 + */ + it("returns invalid_client for an unknown client_id before checking the code", async () => { + const response = await client.$fetch>( + "/oauth2/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: "unknown-client", + client_secret: "secret", + code: "not-a-real-code", + redirect_uri: redirectUri, + }), + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + }, + ); + + expect(response.error?.status).toBe(400); + expect((response.error as { error?: string })?.error).toBe( + "invalid_client", + ); + }); + /** * @see https://github.com/better-auth/better-auth/pull/10159 * @@ -947,6 +974,61 @@ describe("oauth token - refresh_token", async () => { ); }); + /** + * @see https://github.com/better-auth/better-auth/issues/11035 + */ + describe("unknown client_id", () => { + it("returns invalid_client to a public client before looking up the refresh token", async () => { + const response = await client.$fetch>( + "/oauth2/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "not-a-real-refresh-token", + client_id: "unknown-client", + }), + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + }, + ); + + expect(response.error?.status).toBe(400); + expect(response.error).toMatchObject({ + error: "invalid_client", + error_description: "client not found", + }); + }); + + it("returns 401 invalid_client to a Basic-authenticated client before looking up the refresh token", async () => { + let responseHeaders: Headers | undefined; + const response = await client.$fetch>( + "/oauth2/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "not-a-real-refresh-token", + }), + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${Buffer.from("unknown-client:secret").toString("base64")}`, + }, + onError(context) { + responseHeaders = context.response.headers; + }, + }, + ); + + expect(response.error?.status).toBe(401); + expect((response.error as { error?: string })?.error).toBe( + "invalid_client", + ); + expect(responseHeaders?.get("WWW-Authenticate")).toBe("Basic"); + }); + }); + it("should refresh token with same scopes, opaque access token", async ({ expect, }) => { @@ -1008,6 +1090,7 @@ describe("oauth token - refresh_token", async () => { const otherClient = await authorizationServer.api.adminCreateOAuthClient({ headers, body: { + token_endpoint_auth_method: "client_secret_post", grant_types: ["authorization_code", "refresh_token"], redirect_uris: [otherRedirectUri], application_type: "native", diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index e84e6b5a26..febb182034 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -67,6 +67,7 @@ import { toAudienceClaim, toResourceList, validateClientCredentials, + validateClientScopes, } from "./utils"; // Seed the ID token with the standard UserInfo claim names set to `undefined`, @@ -1526,6 +1527,20 @@ async function handleAuthorizationCodeGrant( }); } + /** Verify Client (RFC 6749 §5.2: an unknown or unauthenticated client is + * `invalid_client`, so resolve it before the code lookup can report + * `invalid_grant` and before a one-time code is consumed). */ + const client = await validateClientCredentials( + ctx, + opts, + client_id, + client_secret, + undefined, + preVerified, + "authorization_code", + authMethod, + ); + /** Get and check Verification Value */ const { verificationValue, @@ -1547,18 +1562,7 @@ async function handleAuthorizationCodeGrant( error: "invalid_scope", }); } - - /** Verify Client */ - const client = await validateClientCredentials( - ctx, - opts, - client_id, - client_secret, - scopes, - preVerified, - "authorization_code", - authMethod, - ); + validateClientScopes(client, scopes); // Parse scopes from the authorization request const requestedScopes = @@ -1822,6 +1826,21 @@ async function handleRefreshTokenGrant( error: "invalid_request", }); } + + /** Verify Client (RFC 6749 §5.2: an unknown or unauthenticated client is + * `invalid_client`, so resolve it before the refresh token lookup can + * report `invalid_grant`). */ + const client = await validateClientCredentials( + ctx, + opts, + client_id, + client_secret, // Optional for refresh_grant but required on confidential clients + undefined, + preVerified, + "refresh_token", + authMethod, + ); + const decodedRefresh = await decodeRefreshToken(opts, refresh_token); const refreshToken = await ctx.context.adapter.findOne< @@ -1886,16 +1905,7 @@ async function handleRefreshTokenGrant( } } - const client = await validateClientCredentials( - ctx, - opts, - client_id, - client_secret, // Optional for refresh_grant but required on confidential clients - requestedScopes ?? scopes, - preVerified, - "refresh_token", - authMethod, - ); + validateClientScopes(client, requestedScopes ?? scopes); if (refreshToken.revoked) { if (isWithinRefreshTokenReuseInterval(refreshToken)) {