mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
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:
co-authored by
Gustavo Valverde
parent
44f14bbf82
commit
8f3da46ae5
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user