fix(oauth-provider): return invalid_client for an unknown client at the token endpoint (#11176)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Bereket Engida
2026-10-15 13:02:05 +01:00
committed by GitHub
co-authored by Gustavo Valverde
parent 44f14bbf82
commit 8f3da46ae5
6 changed files with 142 additions and 26 deletions
@@ -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.
+1 -1
View File
@@ -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
@@ -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();
+5 -3
View File
@@ -117,6 +117,11 @@ async function exchangeOAuthDeviceCode(
type ClientAuthorization = Awaited<
ReturnType<typeof provider.authenticateClient>
> & { 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");
}
+83
View File
@@ -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<Record<string, unknown>>(
"/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<Record<string, unknown>>(
"/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<Record<string, unknown>>(
"/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",
+32 -22
View File
@@ -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)) {