feat(oauth-provider): allow client disable through admin update (#11654)

This commit is contained in:
Gustavo Valverde
2026-10-10 14:52:35 +01:00
committed by GitHub
parent 8409fb250d
commit 1a9be41d18
4 changed files with 100 additions and 0 deletions
@@ -0,0 +1,5 @@
---
"@better-auth/oauth-provider": patch
---
Authorized client owners can pause and resume an OAuth client by updating its `disabled` field through `adminUpdateOAuthClient`. A disabled client cannot obtain new tokens, and token introspection reports its existing tokens as inactive.
@@ -292,6 +292,17 @@ await auth.api.adminUpdateOAuthClient({
});
```
To pause token issuance without deleting a client, set `disabled` through the server-only update API:
```ts
await auth.api.adminUpdateOAuthClient({
headers,
body: { client_id, update: { disabled: true } },
});
```
Set `disabled: false` to resume issuance. A disabled client cannot obtain new tokens, and introspection reports its existing tokens as inactive. A resource server that verifies an existing JWT locally must still check current client or application authorization if it needs immediate disconnection; the JWT signature remains valid until expiry. The session-bound `updateOAuthClient` endpoint cannot change `disabled`.
#### Rotate Client Secret
<Callout type="warn">
@@ -519,6 +519,7 @@ export const adminUpdateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
.optional(),
skip_consent: z.boolean().optional(),
enable_end_session: z.boolean().optional(),
disabled: z.boolean().optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
+83
View File
@@ -2125,6 +2125,89 @@ describe("oauth token - client_credentials", async () => {
expect(tokens.data?.expires_at).toBeDefined();
});
it("lets an admin disable and re-enable a client without deleting it", async () => {
const registered = await auth.api.adminCreateOAuthClient({
headers,
body: {
grant_types: ["client_credentials"],
client_credentials_scopes: ["read:posts"],
},
});
if (!registered.client_secret) throw new Error("missing client secret");
const requestToken = () =>
client.$fetch<{ access_token?: string }>("/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "client_credentials",
resource: validResource,
}),
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${Buffer.from(`${registered.client_id}:${registered.client_secret}`).toString("base64")}`,
},
});
const issuedToken = (await requestToken()).data?.access_token;
expect(issuedToken).toBeDefined();
const resourceServer = await auth.api.adminCreateOAuthClient({
headers,
body: {
grant_types: ["client_credentials"],
token_endpoint_auth_method: "client_secret_post",
},
});
if (!resourceServer.client_secret)
throw new Error("missing resource server secret");
await auth.api.adminLinkClientResource({
headers,
params: {
identifier: encodeURIComponent(validResource),
client_id: resourceServer.client_id,
},
});
const introspect = () =>
client.oauth2.introspect(
{
client_id: resourceServer.client_id,
client_secret: resourceServer.client_secret,
token: issuedToken!,
},
{ headers: { "content-type": "application/x-www-form-urlencoded" } },
);
const initialIntrospection = await introspect();
expect(initialIntrospection.error).toBeNull();
expect(initialIntrospection.data?.active).toBe(true);
const disabled = await auth.api.adminUpdateOAuthClient({
headers,
body: { client_id: registered.client_id, update: { disabled: true } },
});
expect(disabled.disabled).toBe(true);
expect((await introspect()).data).toEqual({ active: false });
const blocked = await requestToken();
expect(blocked.error?.status).toBe(401);
expect((blocked.error as { error?: string } | null)?.error).toBe(
"invalid_client",
);
const userUpdate = await client.$fetch<OAuthClient>(
"/oauth2/update-client",
{
method: "POST",
body: {
client_id: registered.client_id,
update: { disabled: false },
},
},
);
expect(userUpdate.data?.disabled).toBe(true);
const enabled = await auth.api.adminUpdateOAuthClient({
headers,
body: { client_id: registered.client_id, update: { disabled: false } },
});
expect(enabled.disabled).toBe(false);
expect((await requestToken()).data?.access_token).toBeDefined();
});
it("returns invalid_request when client_credentials omits client_id", async () => {
const response = await client.$fetch<Record<string, unknown>>(
"/oauth2/token",