diff --git a/.changeset/oauth-application-revoke-token.md b/.changeset/oauth-application-revoke-token.md new file mode 100644 index 00000000000..3a1a6acc432 --- /dev/null +++ b/.changeset/oauth-application-revoke-token.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": minor +--- + +Add `clerkClient.oauthApplications.revokeToken()` for revoking opaque OAuth application access and refresh tokens. diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts new file mode 100644 index 00000000000..bd2a5c997b4 --- /dev/null +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -0,0 +1,51 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('OAuthApplications', () => { + const oauthApplicationId = 'oauthapp_xxxxx'; + + describe('revokeToken', () => { + it('revokes an OAuth application token', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + `https://api.clerk.test/v1/oauth_applications/${oauthApplicationId}/revoke_token`, + validateHeaders(async ({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx'); + const body = (await request.json()) as Record; + expect(body.token).toBe('oat_xxxxx'); + return new HttpResponse(null, { status: 204 }); + }), + ), + ); + + const response = await apiClient.oauthApplications.revokeToken({ + oauthApplicationId, + token: 'oat_xxxxx', + }); + + expect(response).toBeUndefined(); + }); + + it('throws error when OAuth application ID is missing', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + await expect( + apiClient.oauthApplications.revokeToken({ + oauthApplicationId: '', + token: 'oat_xxxxx', + }), + ).rejects.toThrow('A valid resource ID is required.'); + }); + }); +}); diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 37acf982456..62594cf0339 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -37,6 +37,17 @@ type UpdateOAuthApplicationParams = CreateOAuthApplicationParams & { oauthApplicationId: string; }; +type RevokeOAuthApplicationTokenParams = { + /** + * The ID of the OAuth application for which to revoke the token. + */ + oauthApplicationId: string; + /** + * The opaque OAuth access token or refresh token to revoke. + */ + token: string; +}; + type GetOAuthApplicationListParams = ClerkPaginationRequest<{ /** * Sorts OAuth applications by name or created_at. @@ -100,4 +111,16 @@ export class OAuthApplicationsApi extends AbstractAPI { path: joinPaths(basePath, oauthApplicationId, 'rotate_secret'), }); } + + public async revokeToken(params: RevokeOAuthApplicationTokenParams) { + const { oauthApplicationId, ...bodyParams } = params; + + this.requireId(oauthApplicationId); + + return this.request({ + method: 'POST', + path: joinPaths(basePath, oauthApplicationId, 'revoke_token'), + bodyParams, + }); + } } diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index b9a99f283cc..da00740c541 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -184,6 +184,13 @@ export function buildRequest(options: BuildRequestOptions) { }); } + if (res.status === 204) { + return { + data: undefined as T, + errors: null, + }; + } + // TODO: Parse JSON or Text response based on a response header const isJSONResponse = res?.headers && res.headers?.get(constants.Headers.ContentType) === constants.ContentTypes.Json;