From 0c712f0dd9bb13713468fbfe8ff76e66d37f874a Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 8 Apr 2026 15:58:02 -0500 Subject: [PATCH 01/10] feat(js): add clerk.oauthApplication.fetchConsentInfo --- .changeset/few-stamps-retire.md | 5 + packages/clerk-js/src/core/clerk.ts | 13 +- .../src/core/resources/OAuthApplication.ts | 53 ++++++ .../__tests__/OAuthApplication.test.ts | 157 ++++++++++++++++++ .../clerk-js/src/core/resources/internal.ts | 1 + packages/react/src/isomorphicClerk.ts | 7 + packages/shared/src/types/clerk.ts | 6 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/oauthApplication.ts | 54 ++++++ 9 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 .changeset/few-stamps-retire.md create mode 100644 packages/clerk-js/src/core/resources/OAuthApplication.ts create mode 100644 packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts create mode 100644 packages/shared/src/types/oauthApplication.ts diff --git a/.changeset/few-stamps-retire.md b/.changeset/few-stamps-retire.md new file mode 100644 index 00000000000..77b8c93082b --- /dev/null +++ b/.changeset/few-stamps-retire.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Add OAuthApplication resource and fetchConsentInfo method diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index e199021fa03..f465c0ec564 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -88,6 +88,7 @@ import type { ListenerOptions, LoadedClerk, NavigateOptions, + OAuthApplicationNamespace, OrganizationListProps, OrganizationProfileProps, OrganizationResource, @@ -178,7 +179,7 @@ import { APIKeys } from './modules/apiKeys'; import { Billing } from './modules/billing'; import { createCheckoutInstance } from './modules/checkout/instance'; import { Protect } from './protect'; -import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal'; +import { BaseResource, Client, Environment, OAuthApplication, Organization, Waitlist } from './resources/internal'; import { State } from './state'; type SetActiveHook = (intent?: 'sign-out') => void | Promise; @@ -224,6 +225,7 @@ export class Clerk implements ClerkInterface { private static _billing: BillingNamespace; private static _apiKeys: APIKeysNamespace; + private static _oauthApplication: OAuthApplicationNamespace; private _checkout: ClerkInterface['__experimental_checkout'] | undefined; public client: ClientResource | undefined; @@ -403,6 +405,15 @@ export class Clerk implements ClerkInterface { return Clerk._apiKeys; } + get oauthApplication(): OAuthApplicationNamespace { + if (!Clerk._oauthApplication) { + Clerk._oauthApplication = { + fetchConsentInfo: params => OAuthApplication.fetchConsentInfo(params), + }; + } + return Clerk._oauthApplication; + } + __experimental_checkout(options: __experimental_CheckoutOptions): CheckoutSignalValue { if (!this._checkout) { this._checkout = (params: any) => createCheckoutInstance(this, params); diff --git a/packages/clerk-js/src/core/resources/OAuthApplication.ts b/packages/clerk-js/src/core/resources/OAuthApplication.ts new file mode 100644 index 00000000000..310270e071f --- /dev/null +++ b/packages/clerk-js/src/core/resources/OAuthApplication.ts @@ -0,0 +1,53 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { + ClerkResourceJSON, + FetchOAuthConsentInfoParams, + OAuthConsentInfo, + OAuthConsentInfoJSON, +} from '@clerk/shared/types'; + +import { BaseResource } from './internal'; + +export class OAuthApplication extends BaseResource { + pathRoot = ''; + + protected fromJSON(_data: ClerkResourceJSON | null): this { + return this; + } + + static async fetchConsentInfo(params: FetchOAuthConsentInfoParams): Promise { + const sessionId = BaseResource.clerk.session?.id; + if (!sessionId) { + throw new ClerkRuntimeError( + 'Clerk: `oauthApplication.fetchConsentInfo` requires an active session. Ensure a user is signed in before calling this method.', + { code: 'cannot_fetch_oauth_consent_no_session' }, + ); + } + + const { oauthClientId, scope } = params; + const json = await BaseResource._fetch( + { + method: 'GET', + path: `/me/oauth/consent/${encodeURIComponent(oauthClientId)}`, + search: scope !== undefined ? { scope } : undefined, + sessionId, + }, + { skipUpdateClient: true }, + ); + + if (!json) { + throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' }); + } + + const envelope = json; + const data = envelope.response ?? json; + return { + oauth_application_name: data.oauth_application_name, + oauth_application_logo_url: data.oauth_application_logo_url, + oauth_application_url: data.oauth_application_url, + client_id: data.client_id, + state: data.state, + scopes: data.scopes ?? [], + }; + } +} diff --git a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts new file mode 100644 index 00000000000..a940a4c296f --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts @@ -0,0 +1,157 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { InstanceType, OAuthConsentInfoJSON } from '@clerk/shared/types'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import { mockFetch } from '@/test/core-fixtures'; + +import { SUPPORTED_FAPI_VERSION } from '../../constants'; +import { createFapiClient } from '../../fapiClient'; +import { BaseResource } from '../internal'; +import { OAuthApplication } from '../OAuthApplication'; + +const consentPayload: OAuthConsentInfoJSON = { + object: 'oauth_consent_info', + id: 'client_abc', + oauth_application_name: 'My App', + oauth_application_logo_url: 'https://img.example/logo.png', + oauth_application_url: 'https://app.example', + client_id: 'client_abc', + state: 'st', + scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }], +}; + +describe('OAuthApplication.fetchConsentInfo', () => { + afterEach(() => { + (global.fetch as Mock)?.mockClear?.(); + BaseResource.clerk = null as any; + vi.restoreAllMocks(); + }); + + it('throws ClerkRuntimeError when there is no active session', async () => { + const fetchSpy = vi.spyOn(BaseResource, '_fetch'); + + BaseResource.clerk = { + session: undefined, + } as any; + + await expect(OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({ + code: 'cannot_fetch_oauth_consent_no_session', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('calls BaseResource._fetch with GET, encoded path, sessionId, optional scope, and skipUpdateClient', async () => { + const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ + response: consentPayload, + } as any); + + BaseResource.clerk = { + session: { id: 'sess_test' }, + } as any; + + await OAuthApplication.fetchConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' }); + + expect(fetchSpy).toHaveBeenCalledWith( + { + method: 'GET', + path: '/me/oauth/consent/my%2Fclient%20id', + search: { scope: 'openid email' }, + sessionId: 'sess_test', + }, + { skipUpdateClient: true }, + ); + }); + + it('omits search when scope is undefined', async () => { + const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ + response: consentPayload, + } as any); + + BaseResource.clerk = { + session: { id: 'sess_test' }, + } as any; + + await OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' }); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + search: undefined, + }), + { skipUpdateClient: true }, + ); + }); + + it('returns OAuthConsentInfo from the FAPI response envelope', async () => { + vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ + response: consentPayload, + } as any); + + BaseResource.clerk = { + session: { id: 'sess_test' }, + } as any; + + const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); + + expect(info).toEqual({ + oauth_application_name: 'My App', + oauth_application_logo_url: 'https://img.example/logo.png', + oauth_application_url: 'https://app.example', + client_id: 'client_abc', + state: 'st', + scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }], + }); + }); + + it('defaults scopes to an empty array when absent', async () => { + vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ + response: { ...consentPayload, scopes: undefined }, + } as any); + + BaseResource.clerk = { + session: { id: 'sess_test' }, + } as any; + + const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); + expect(info.scopes).toEqual([]); + }); + + it('maps ClerkAPIResponseError from FAPI on non-2xx', async () => { + mockFetch(false, 422, { + errors: [{ code: 'oauth_consent_error', long_message: 'Consent metadata unavailable' }], + }); + + BaseResource.clerk = { + session: { id: 'sess_1' }, + getFapiClient: () => + createFapiClient({ + frontendApi: 'clerk.example.com', + getSessionId: () => 'sess_1', + instanceType: 'development' as InstanceType, + }), + __internal_setCountry: vi.fn(), + handleUnauthenticated: vi.fn(), + __internal_handleUnauthenticatedDevBrowser: vi.fn(), + } as any; + + await expect(OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' })).rejects.toSatisfy( + (err: unknown) => err instanceof ClerkAPIResponseError && err.message === 'Consent metadata unavailable', + ); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url] = (global.fetch as Mock).mock.calls[0]; + expect(url.toString()).toContain(`/v1/me/oauth/consent/cid`); + expect(url.toString()).toContain(`__clerk_api_version=${SUPPORTED_FAPI_VERSION}`); + }); + + it('throws ClerkRuntimeError when _fetch returns null (offline)', async () => { + vi.spyOn(BaseResource, '_fetch').mockResolvedValue(null); + + BaseResource.clerk = { + session: { id: 'sess_test' }, + } as any; + + await expect(OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({ + code: 'network_error', + }); + }); +}); diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 0cdb99971d1..d9294e3e8f8 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -22,6 +22,7 @@ export * from './ExternalAccount'; export * from './Feature'; export * from './IdentificationLink'; export * from './Image'; +export * from './OAuthApplication'; export * from './Organization'; export * from './OrganizationDomain'; export * from './OrganizationInvitation'; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 314d736f0e6..32b050d36c4 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -35,6 +35,7 @@ import type { ListenerCallback, ListenerOptions, LoadedClerk, + OAuthApplicationNamespace, OrganizationListProps, OrganizationProfileProps, OrganizationResource, @@ -118,11 +119,13 @@ type IsomorphicLoadedClerk = Without< | '__internal_reloadInitialResources' | 'billing' | 'apiKeys' + | 'oauthApplication' | '__internal_setActiveInProgress' > & { client: ClientResource | undefined; billing: BillingNamespace | undefined; apiKeys: APIKeysNamespace | undefined; + oauthApplication: OAuthApplicationNamespace | undefined; }; export class IsomorphicClerk implements IsomorphicLoadedClerk { @@ -844,6 +847,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.apiKeys; } + get oauthApplication(): OAuthApplicationNamespace | undefined { + return this.clerkjs?.oauthApplication; + } + __experimental_checkout = (...args: Parameters) => { return this.loaded && this.clerkjs ? this.clerkjs.__experimental_checkout(...args) diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 1a6921a6717..6a0ace10455 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -2,6 +2,7 @@ import type { ClerkGlobalHookError } from '@/errors/globalHookError'; import type { ClerkUIConstructor } from '../ui/types'; import type { APIKeysNamespace } from './apiKeys'; +import type { OAuthApplicationNamespace } from './oauthApplication'; import type { BillingCheckoutResource, BillingNamespace, @@ -1027,6 +1028,11 @@ export interface Clerk { */ apiKeys: APIKeysNamespace; + /** + * OAuth application helpers (e.g. consent metadata for custom consent UIs). + */ + oauthApplication: OAuthApplicationNamespace; + /** * Checkout API * diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3b599ed0ac4..2849a74a140 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -33,6 +33,7 @@ export type * from './key'; export type * from './localization'; export type * from './multiDomain'; export type * from './oauth'; +export type * from './oauthApplication'; export type * from './organization'; export type * from './organizationCreationDefaults'; export type * from './organizationDomain'; diff --git a/packages/shared/src/types/oauthApplication.ts b/packages/shared/src/types/oauthApplication.ts new file mode 100644 index 00000000000..b06691a0731 --- /dev/null +++ b/packages/shared/src/types/oauthApplication.ts @@ -0,0 +1,54 @@ +import type { ClerkResourceJSON } from './json'; + +/** + * A single OAuth scope row returned by the Frontend API consent metadata endpoint. + */ +export type OAuthConsentScopeJSON = { + scope: string; + description: string | null; + requires_consent: boolean; +}; + +/** + * OAuth consent screen metadata from `GET /v1/me/oauth/consent/{oauthClientId}`. + * Field names match the Frontend API JSON (snake_case). + */ +export type OAuthConsentInfo = { + oauth_application_name: string; + oauth_application_logo_url: string; + oauth_application_url: string; + client_id: string; + state: string; + scopes: OAuthConsentScopeJSON[]; +}; + +/** + * @internal + */ +export interface OAuthConsentInfoJSON extends ClerkResourceJSON { + object: 'oauth_consent_info'; + oauth_application_name: string; + oauth_application_logo_url: string; + oauth_application_url: string; + client_id: string; + state: string; + scopes: OAuthConsentScopeJSON[]; +} + +export type FetchOAuthConsentInfoParams = { + /** OAuth `client_id` from the authorize request. */ + oauthClientId: string; + /** Optional normalized scope string (e.g. space-delimited). */ + scope?: string; +}; + +/** + * Namespace exposed on `Clerk` for OAuth application / consent helpers. + */ +export interface OAuthApplicationNamespace { + /** + * Loads consent metadata for the given OAuth client for the signed-in user. + * Uses the Frontend API session (cookies, `_clerk_session_id`, dev browser, etc.) like other `/me` requests. + */ + fetchConsentInfo: (params: FetchOAuthConsentInfoParams) => Promise; +} From 46c893cebe619cbab666b96dcbafcb64969a1ab0 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 8 Apr 2026 16:30:58 -0500 Subject: [PATCH 02/10] remove redundant session ID retrieval as the FAPI client will set it automatically --- packages/clerk-js/src/core/resources/OAuthApplication.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/clerk-js/src/core/resources/OAuthApplication.ts b/packages/clerk-js/src/core/resources/OAuthApplication.ts index 310270e071f..4dfee649365 100644 --- a/packages/clerk-js/src/core/resources/OAuthApplication.ts +++ b/packages/clerk-js/src/core/resources/OAuthApplication.ts @@ -16,21 +16,12 @@ export class OAuthApplication extends BaseResource { } static async fetchConsentInfo(params: FetchOAuthConsentInfoParams): Promise { - const sessionId = BaseResource.clerk.session?.id; - if (!sessionId) { - throw new ClerkRuntimeError( - 'Clerk: `oauthApplication.fetchConsentInfo` requires an active session. Ensure a user is signed in before calling this method.', - { code: 'cannot_fetch_oauth_consent_no_session' }, - ); - } - const { oauthClientId, scope } = params; const json = await BaseResource._fetch( { method: 'GET', path: `/me/oauth/consent/${encodeURIComponent(oauthClientId)}`, search: scope !== undefined ? { scope } : undefined, - sessionId, }, { skipUpdateClient: true }, ); From f54b79d09e20d86b278f1265c9431e876f446bcb Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 8 Apr 2026 17:06:23 -0500 Subject: [PATCH 03/10] make it a minor revision --- .changeset/few-stamps-retire.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/few-stamps-retire.md b/.changeset/few-stamps-retire.md index 77b8c93082b..14ac874cc9d 100644 --- a/.changeset/few-stamps-retire.md +++ b/.changeset/few-stamps-retire.md @@ -1,5 +1,5 @@ --- -'@clerk/clerk-js': patch +'@clerk/clerk-js': minor --- Add OAuthApplication resource and fetchConsentInfo method From 8fc1f9da5f6cd0d687ac2c3931aa28dad94399e5 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 8 Apr 2026 17:19:43 -0500 Subject: [PATCH 04/10] update tests --- .../__tests__/OAuthApplication.test.ts | 39 ++++--------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts index a940a4c296f..e1177f351d4 100644 --- a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts @@ -27,27 +27,12 @@ describe('OAuthApplication.fetchConsentInfo', () => { vi.restoreAllMocks(); }); - it('throws ClerkRuntimeError when there is no active session', async () => { - const fetchSpy = vi.spyOn(BaseResource, '_fetch'); - - BaseResource.clerk = { - session: undefined, - } as any; - - await expect(OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({ - code: 'cannot_fetch_oauth_consent_no_session', - }); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('calls BaseResource._fetch with GET, encoded path, sessionId, optional scope, and skipUpdateClient', async () => { + it('calls BaseResource._fetch with GET, encoded path, optional scope, and skipUpdateClient', async () => { const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ response: consentPayload, } as any); - BaseResource.clerk = { - session: { id: 'sess_test' }, - } as any; + BaseResource.clerk = {} as any; await OAuthApplication.fetchConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' }); @@ -56,7 +41,6 @@ describe('OAuthApplication.fetchConsentInfo', () => { method: 'GET', path: '/me/oauth/consent/my%2Fclient%20id', search: { scope: 'openid email' }, - sessionId: 'sess_test', }, { skipUpdateClient: true }, ); @@ -67,9 +51,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { response: consentPayload, } as any); - BaseResource.clerk = { - session: { id: 'sess_test' }, - } as any; + BaseResource.clerk = {} as any; await OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' }); @@ -86,9 +68,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { response: consentPayload, } as any); - BaseResource.clerk = { - session: { id: 'sess_test' }, - } as any; + BaseResource.clerk = {} as any; const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); @@ -107,9 +87,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { response: { ...consentPayload, scopes: undefined }, } as any); - BaseResource.clerk = { - session: { id: 'sess_test' }, - } as any; + BaseResource.clerk = {} as any; const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); expect(info.scopes).toEqual([]); @@ -121,11 +99,10 @@ describe('OAuthApplication.fetchConsentInfo', () => { }); BaseResource.clerk = { - session: { id: 'sess_1' }, getFapiClient: () => createFapiClient({ frontendApi: 'clerk.example.com', - getSessionId: () => 'sess_1', + getSessionId: () => undefined, instanceType: 'development' as InstanceType, }), __internal_setCountry: vi.fn(), @@ -146,9 +123,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { it('throws ClerkRuntimeError when _fetch returns null (offline)', async () => { vi.spyOn(BaseResource, '_fetch').mockResolvedValue(null); - BaseResource.clerk = { - session: { id: 'sess_test' }, - } as any; + BaseResource.clerk = {} as any; await expect(OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({ code: 'network_error', From 8d6e852180cfc2547270e30c24e1fa2d2a7c8f89 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 8 Apr 2026 18:05:56 -0500 Subject: [PATCH 05/10] style --- packages/shared/src/types/clerk.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 6a0ace10455..5e8174f0a5f 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -2,7 +2,6 @@ import type { ClerkGlobalHookError } from '@/errors/globalHookError'; import type { ClerkUIConstructor } from '../ui/types'; import type { APIKeysNamespace } from './apiKeys'; -import type { OAuthApplicationNamespace } from './oauthApplication'; import type { BillingCheckoutResource, BillingNamespace, @@ -20,6 +19,7 @@ import type { DisplayThemeJSON } from './json'; import type { LocalizationResource } from './localization'; import type { DomainOrProxyUrl, MultiDomainAndOrProxy } from './multiDomain'; import type { OAuthProvider, OAuthScope } from './oauth'; +import type { OAuthApplicationNamespace } from './oauthApplication'; import type { OrganizationResource } from './organization'; import type { OrganizationCustomRoleKey } from './organizationMembership'; import type { ClerkPaginationParams } from './pagination'; @@ -169,6 +169,7 @@ export type SetActiveNavigate = (params: { session: SessionResource; /** * Decorate the destination URL to enable Safari ITP cookie refresh when needed. + * * @see {@link DecorateUrl} */ decorateUrl: DecorateUrl; @@ -2502,21 +2503,25 @@ export type IsomorphicClerkOptions = Without & { Clerk?: ClerkProp; /** * The URL that `@clerk/clerk-js` should be hot-loaded from. + * * @internal */ __internal_clerkJSUrl?: string; /** * The npm version for `@clerk/clerk-js`. + * * @internal */ __internal_clerkJSVersion?: string; /** * The URL that `@clerk/ui` should be hot-loaded from. + * * @internal */ __internal_clerkUIUrl?: string; /** * The npm version for `@clerk/ui`. + * * @internal */ __internal_clerkUIVersion?: string; From fdf2b9f0e3373a58e5caba4c69f0954bc22fc06c Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Thu, 9 Apr 2026 11:11:38 -0500 Subject: [PATCH 06/10] map response DTO to camelCase --- .../src/core/resources/OAuthApplication.ts | 18 ++++++---- .../__tests__/OAuthApplication.test.ts | 10 +++--- packages/shared/src/types/oauthApplication.ts | 36 +++++++++++-------- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/packages/clerk-js/src/core/resources/OAuthApplication.ts b/packages/clerk-js/src/core/resources/OAuthApplication.ts index 4dfee649365..84e304be7df 100644 --- a/packages/clerk-js/src/core/resources/OAuthApplication.ts +++ b/packages/clerk-js/src/core/resources/OAuthApplication.ts @@ -30,15 +30,19 @@ export class OAuthApplication extends BaseResource { throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' }); } - const envelope = json; - const data = envelope.response ?? json; + const data = json.response ?? json; return { - oauth_application_name: data.oauth_application_name, - oauth_application_logo_url: data.oauth_application_logo_url, - oauth_application_url: data.oauth_application_url, - client_id: data.client_id, + oauthApplicationName: data.oauth_application_name, + oauthApplicationLogoUrl: data.oauth_application_logo_url, + oauthApplicationUrl: data.oauth_application_url, + clientId: data.client_id, state: data.state, - scopes: data.scopes ?? [], + scopes: + data.scopes?.map(scope => ({ + scope: scope.scope, + description: scope.description, + requiresConsent: scope.requires_consent, + })) ?? [], }; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts index e1177f351d4..7963bd2874d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts @@ -73,12 +73,12 @@ describe('OAuthApplication.fetchConsentInfo', () => { const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); expect(info).toEqual({ - oauth_application_name: 'My App', - oauth_application_logo_url: 'https://img.example/logo.png', - oauth_application_url: 'https://app.example', - client_id: 'client_abc', + oauthApplicationName: 'My App', + oauthApplicationLogoUrl: 'https://img.example/logo.png', + oauthApplicationUrl: 'https://app.example', + clientId: 'client_abc', state: 'st', - scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }], + scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }], }); }); diff --git a/packages/shared/src/types/oauthApplication.ts b/packages/shared/src/types/oauthApplication.ts index b06691a0731..35e9ebee8ca 100644 --- a/packages/shared/src/types/oauthApplication.ts +++ b/packages/shared/src/types/oauthApplication.ts @@ -1,8 +1,5 @@ import type { ClerkResourceJSON } from './json'; -/** - * A single OAuth scope row returned by the Frontend API consent metadata endpoint. - */ export type OAuthConsentScopeJSON = { scope: string; description: string | null; @@ -10,30 +7,39 @@ export type OAuthConsentScopeJSON = { }; /** - * OAuth consent screen metadata from `GET /v1/me/oauth/consent/{oauthClientId}`. - * Field names match the Frontend API JSON (snake_case). + * @internal */ -export type OAuthConsentInfo = { +export interface OAuthConsentInfoJSON extends ClerkResourceJSON { + object: 'oauth_consent_info'; oauth_application_name: string; oauth_application_logo_url: string; oauth_application_url: string; client_id: string; state: string; scopes: OAuthConsentScopeJSON[]; +} + +/** + * A single OAuth scope row returned by the Frontend API consent metadata endpoint. + */ +export type OAuthConsentScope = { + scope: string; + description: string | null; + requiresConsent: boolean; }; /** - * @internal + * OAuth consent screen metadata from `GET /v1/me/oauth/consent/{oauthClientId}`. + * Field names match the Frontend API JSON (snake_case). */ -export interface OAuthConsentInfoJSON extends ClerkResourceJSON { - object: 'oauth_consent_info'; - oauth_application_name: string; - oauth_application_logo_url: string; - oauth_application_url: string; - client_id: string; +export type OAuthConsentInfo = { + oauthApplicationName: string; + oauthApplicationLogoUrl: string; + oauthApplicationUrl: string; + clientId: string; state: string; - scopes: OAuthConsentScopeJSON[]; -} + scopes: OAuthConsentScope[]; +}; export type FetchOAuthConsentInfoParams = { /** OAuth `client_id` from the authorize request. */ From 337279571e38b94079bf5b0a086eb3deba4a4324 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Thu, 9 Apr 2026 11:43:36 -0500 Subject: [PATCH 07/10] test non-enveloped response (which is the actual case today) --- .../src/core/resources/OAuthApplication.ts | 1 + .../__tests__/OAuthApplication.test.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/OAuthApplication.ts b/packages/clerk-js/src/core/resources/OAuthApplication.ts index 84e304be7df..8b7419a3d7d 100644 --- a/packages/clerk-js/src/core/resources/OAuthApplication.ts +++ b/packages/clerk-js/src/core/resources/OAuthApplication.ts @@ -30,6 +30,7 @@ export class OAuthApplication extends BaseResource { throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' }); } + // Handle in case we start wrapping the response in the future const data = json.response ?? json; return { oauthApplicationName: data.oauth_application_name, diff --git a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts index 7963bd2874d..411cd6b3e9e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts @@ -63,7 +63,24 @@ describe('OAuthApplication.fetchConsentInfo', () => { ); }); - it('returns OAuthConsentInfo from the FAPI response envelope', async () => { + it('returns OAuthConsentInfo from the FAPI response', async () => { + vi.spyOn(BaseResource, '_fetch').mockResolvedValue(consentPayload as any); + + BaseResource.clerk = {} as any; + + const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); + + expect(info).toEqual({ + oauthApplicationName: 'My App', + oauthApplicationLogoUrl: 'https://img.example/logo.png', + oauthApplicationUrl: 'https://app.example', + clientId: 'client_abc', + state: 'st', + scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }], + }); + }); + + it('returns OAuthConsentInfo from the FAPI response (enveloped)', async () => { vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ response: consentPayload, } as any); From e570b287e59998696211958ef2aa7bedfb8dcbc6 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Thu, 9 Apr 2026 11:49:23 -0500 Subject: [PATCH 08/10] improve docs --- packages/shared/src/types/oauthApplication.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/types/oauthApplication.ts b/packages/shared/src/types/oauthApplication.ts index 35e9ebee8ca..dad97652913 100644 --- a/packages/shared/src/types/oauthApplication.ts +++ b/packages/shared/src/types/oauthApplication.ts @@ -1,5 +1,8 @@ import type { ClerkResourceJSON } from './json'; +/** + * @internal + */ export type OAuthConsentScopeJSON = { scope: string; description: string | null; @@ -20,7 +23,7 @@ export interface OAuthConsentInfoJSON extends ClerkResourceJSON { } /** - * A single OAuth scope row returned by the Frontend API consent metadata endpoint. + * A single OAuth scope with its description and whether it requires consent. */ export type OAuthConsentScope = { scope: string; @@ -30,7 +33,7 @@ export type OAuthConsentScope = { /** * OAuth consent screen metadata from `GET /v1/me/oauth/consent/{oauthClientId}`. - * Field names match the Frontend API JSON (snake_case). + * Includes information needed to populate the consent dialog. */ export type OAuthConsentInfo = { oauthApplicationName: string; @@ -44,7 +47,7 @@ export type OAuthConsentInfo = { export type FetchOAuthConsentInfoParams = { /** OAuth `client_id` from the authorize request. */ oauthClientId: string; - /** Optional normalized scope string (e.g. space-delimited). */ + /** Optional space-delimited scope string from the authorize request. */ scope?: string; }; @@ -54,7 +57,6 @@ export type FetchOAuthConsentInfoParams = { export interface OAuthApplicationNamespace { /** * Loads consent metadata for the given OAuth client for the signed-in user. - * Uses the Frontend API session (cookies, `_clerk_session_id`, dev browser, etc.) like other `/me` requests. */ fetchConsentInfo: (params: FetchOAuthConsentInfoParams) => Promise; } From f6785c818ca81ab351cdaee9d0efb0cf12b66dd5 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Thu, 9 Apr 2026 13:54:22 -0500 Subject: [PATCH 09/10] Update .changeset/few-stamps-retire.md Co-authored-by: Robert Soriano --- .changeset/few-stamps-retire.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/few-stamps-retire.md b/.changeset/few-stamps-retire.md index 14ac874cc9d..6b2f1138d14 100644 --- a/.changeset/few-stamps-retire.md +++ b/.changeset/few-stamps-retire.md @@ -2,4 +2,10 @@ '@clerk/clerk-js': minor --- -Add OAuthApplication resource and fetchConsentInfo method +--- +'@clerk/clerk-js': minor +'@clerk/react': minor +'@clerk/shared': minor +--- + +Add `OAuthApplication` resource and `fetchConsentInfo()` method for retrieving OAuth consent information, enabling custom OAuth consent flows. From d623ef18a55d4529e6e66616376220cb66d0db34 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Thu, 9 Apr 2026 14:05:10 -0500 Subject: [PATCH 10/10] rename to `getConsentInfo` following convention --- .changeset/few-stamps-retire.md | 6 +----- packages/clerk-js/src/core/clerk.ts | 2 +- .../src/core/resources/OAuthApplication.ts | 4 ++-- .../resources/__tests__/OAuthApplication.test.ts | 16 ++++++++-------- packages/shared/src/types/oauthApplication.ts | 4 ++-- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/.changeset/few-stamps-retire.md b/.changeset/few-stamps-retire.md index 6b2f1138d14..17e306723bf 100644 --- a/.changeset/few-stamps-retire.md +++ b/.changeset/few-stamps-retire.md @@ -1,11 +1,7 @@ ---- -'@clerk/clerk-js': minor ---- - --- '@clerk/clerk-js': minor '@clerk/react': minor '@clerk/shared': minor --- -Add `OAuthApplication` resource and `fetchConsentInfo()` method for retrieving OAuth consent information, enabling custom OAuth consent flows. +Add `OAuthApplication` resource and `getConsentInfo()` method for retrieving OAuth consent information, enabling custom OAuth consent flows. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index f465c0ec564..50957d7b7e3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -408,7 +408,7 @@ export class Clerk implements ClerkInterface { get oauthApplication(): OAuthApplicationNamespace { if (!Clerk._oauthApplication) { Clerk._oauthApplication = { - fetchConsentInfo: params => OAuthApplication.fetchConsentInfo(params), + getConsentInfo: params => OAuthApplication.getConsentInfo(params), }; } return Clerk._oauthApplication; diff --git a/packages/clerk-js/src/core/resources/OAuthApplication.ts b/packages/clerk-js/src/core/resources/OAuthApplication.ts index 8b7419a3d7d..87a45b509a3 100644 --- a/packages/clerk-js/src/core/resources/OAuthApplication.ts +++ b/packages/clerk-js/src/core/resources/OAuthApplication.ts @@ -1,7 +1,7 @@ import { ClerkRuntimeError } from '@clerk/shared/error'; import type { ClerkResourceJSON, - FetchOAuthConsentInfoParams, + GetOAuthConsentInfoParams, OAuthConsentInfo, OAuthConsentInfoJSON, } from '@clerk/shared/types'; @@ -15,7 +15,7 @@ export class OAuthApplication extends BaseResource { return this; } - static async fetchConsentInfo(params: FetchOAuthConsentInfoParams): Promise { + static async getConsentInfo(params: GetOAuthConsentInfoParams): Promise { const { oauthClientId, scope } = params; const json = await BaseResource._fetch( { diff --git a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts index 411cd6b3e9e..0a56c70f2d9 100644 --- a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts @@ -20,7 +20,7 @@ const consentPayload: OAuthConsentInfoJSON = { scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }], }; -describe('OAuthApplication.fetchConsentInfo', () => { +describe('OAuthApplication.getConsentInfo', () => { afterEach(() => { (global.fetch as Mock)?.mockClear?.(); BaseResource.clerk = null as any; @@ -34,7 +34,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { BaseResource.clerk = {} as any; - await OAuthApplication.fetchConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' }); + await OAuthApplication.getConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' }); expect(fetchSpy).toHaveBeenCalledWith( { @@ -53,7 +53,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { BaseResource.clerk = {} as any; - await OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' }); + await OAuthApplication.getConsentInfo({ oauthClientId: 'cid' }); expect(fetchSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -68,7 +68,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { BaseResource.clerk = {} as any; - const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); + const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' }); expect(info).toEqual({ oauthApplicationName: 'My App', @@ -87,7 +87,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { BaseResource.clerk = {} as any; - const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); + const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' }); expect(info).toEqual({ oauthApplicationName: 'My App', @@ -106,7 +106,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { BaseResource.clerk = {} as any; - const info = await OAuthApplication.fetchConsentInfo({ oauthClientId: 'client_abc' }); + const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' }); expect(info.scopes).toEqual([]); }); @@ -127,7 +127,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { __internal_handleUnauthenticatedDevBrowser: vi.fn(), } as any; - await expect(OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' })).rejects.toSatisfy( + await expect(OAuthApplication.getConsentInfo({ oauthClientId: 'cid' })).rejects.toSatisfy( (err: unknown) => err instanceof ClerkAPIResponseError && err.message === 'Consent metadata unavailable', ); @@ -142,7 +142,7 @@ describe('OAuthApplication.fetchConsentInfo', () => { BaseResource.clerk = {} as any; - await expect(OAuthApplication.fetchConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({ + await expect(OAuthApplication.getConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({ code: 'network_error', }); }); diff --git a/packages/shared/src/types/oauthApplication.ts b/packages/shared/src/types/oauthApplication.ts index dad97652913..33f1c580383 100644 --- a/packages/shared/src/types/oauthApplication.ts +++ b/packages/shared/src/types/oauthApplication.ts @@ -44,7 +44,7 @@ export type OAuthConsentInfo = { scopes: OAuthConsentScope[]; }; -export type FetchOAuthConsentInfoParams = { +export type GetOAuthConsentInfoParams = { /** OAuth `client_id` from the authorize request. */ oauthClientId: string; /** Optional space-delimited scope string from the authorize request. */ @@ -58,5 +58,5 @@ export interface OAuthApplicationNamespace { /** * Loads consent metadata for the given OAuth client for the signed-in user. */ - fetchConsentInfo: (params: FetchOAuthConsentInfoParams) => Promise; + getConsentInfo: (params: GetOAuthConsentInfoParams) => Promise; }