From 1a9be41d184ee2fcbc7f39b1ef1ab5cb492f7147 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sat, 10 Oct 2026 14:52:35 +0100 Subject: [PATCH] feat(oauth-provider): allow client disable through admin update (#11654) --- .../oauth-provider-managed-client-disable.md | 5 ++ docs/content/docs/plugins/oauth-provider.mdx | 11 +++ .../oauth-provider/src/oauthClient/index.ts | 1 + packages/oauth-provider/src/token.test.ts | 83 +++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 .changeset/oauth-provider-managed-client-disable.md diff --git a/.changeset/oauth-provider-managed-client-disable.md b/.changeset/oauth-provider-managed-client-disable.md new file mode 100644 index 0000000000..c861d0c475 --- /dev/null +++ b/.changeset/oauth-provider-managed-client-disable.md @@ -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. diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 44492bc151..de4780b0b8 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -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 diff --git a/packages/oauth-provider/src/oauthClient/index.ts b/packages/oauth-provider/src/oauthClient/index.ts index 3119de1e13..cc062e9898 100644 --- a/packages/oauth-provider/src/oauthClient/index.ts +++ b/packages/oauth-provider/src/oauthClient/index.ts @@ -519,6 +519,7 @@ export const adminUpdateOAuthClient = (opts: OAuthOptions) => .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(), diff --git a/packages/oauth-provider/src/token.test.ts b/packages/oauth-provider/src/token.test.ts index ce1fb5780e..842951dc0b 100644 --- a/packages/oauth-provider/src/token.test.ts +++ b/packages/oauth-provider/src/token.test.ts @@ -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( + "/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>( "/oauth2/token",