diff --git a/.changeset/few-stamps-retire.md b/.changeset/few-stamps-retire.md new file mode 100644 index 00000000000..17e306723bf --- /dev/null +++ b/.changeset/few-stamps-retire.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/react': minor +'@clerk/shared': minor +--- + +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 e199021fa03..50957d7b7e3 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 = { + getConsentInfo: params => OAuthApplication.getConsentInfo(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..87a45b509a3 --- /dev/null +++ b/packages/clerk-js/src/core/resources/OAuthApplication.ts @@ -0,0 +1,49 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { + ClerkResourceJSON, + GetOAuthConsentInfoParams, + 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 getConsentInfo(params: GetOAuthConsentInfoParams): Promise { + const { oauthClientId, scope } = params; + const json = await BaseResource._fetch( + { + method: 'GET', + path: `/me/oauth/consent/${encodeURIComponent(oauthClientId)}`, + search: scope !== undefined ? { scope } : undefined, + }, + { skipUpdateClient: true }, + ); + + if (!json) { + 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, + oauthApplicationLogoUrl: data.oauth_application_logo_url, + oauthApplicationUrl: data.oauth_application_url, + clientId: data.client_id, + state: data.state, + 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 new file mode 100644 index 00000000000..0a56c70f2d9 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts @@ -0,0 +1,149 @@ +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.getConsentInfo', () => { + afterEach(() => { + (global.fetch as Mock)?.mockClear?.(); + BaseResource.clerk = null as any; + vi.restoreAllMocks(); + }); + + 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 = {} as any; + + await OAuthApplication.getConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' }); + + expect(fetchSpy).toHaveBeenCalledWith( + { + method: 'GET', + path: '/me/oauth/consent/my%2Fclient%20id', + search: { scope: 'openid email' }, + }, + { skipUpdateClient: true }, + ); + }); + + it('omits search when scope is undefined', async () => { + const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ + response: consentPayload, + } as any); + + BaseResource.clerk = {} as any; + + await OAuthApplication.getConsentInfo({ oauthClientId: 'cid' }); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + search: undefined, + }), + { skipUpdateClient: true }, + ); + }); + + it('returns OAuthConsentInfo from the FAPI response', async () => { + vi.spyOn(BaseResource, '_fetch').mockResolvedValue(consentPayload as any); + + BaseResource.clerk = {} as any; + + const info = await OAuthApplication.getConsentInfo({ 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); + + BaseResource.clerk = {} as any; + + const info = await OAuthApplication.getConsentInfo({ 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('defaults scopes to an empty array when absent', async () => { + vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ + response: { ...consentPayload, scopes: undefined }, + } as any); + + BaseResource.clerk = {} as any; + + const info = await OAuthApplication.getConsentInfo({ 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 = { + getFapiClient: () => + createFapiClient({ + frontendApi: 'clerk.example.com', + getSessionId: () => undefined, + instanceType: 'development' as InstanceType, + }), + __internal_setCountry: vi.fn(), + handleUnauthenticated: vi.fn(), + __internal_handleUnauthenticatedDevBrowser: vi.fn(), + } as any; + + await expect(OAuthApplication.getConsentInfo({ 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 = {} as any; + + await expect(OAuthApplication.getConsentInfo({ 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..5e8174f0a5f 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -19,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'; @@ -168,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; @@ -1027,6 +1029,11 @@ export interface Clerk { */ apiKeys: APIKeysNamespace; + /** + * OAuth application helpers (e.g. consent metadata for custom consent UIs). + */ + oauthApplication: OAuthApplicationNamespace; + /** * Checkout API * @@ -2496,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; 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..33f1c580383 --- /dev/null +++ b/packages/shared/src/types/oauthApplication.ts @@ -0,0 +1,62 @@ +import type { ClerkResourceJSON } from './json'; + +/** + * @internal + */ +export type OAuthConsentScopeJSON = { + scope: string; + description: string | null; + requires_consent: boolean; +}; + +/** + * @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[]; +} + +/** + * A single OAuth scope with its description and whether it requires consent. + */ +export type OAuthConsentScope = { + scope: string; + description: string | null; + requiresConsent: boolean; +}; + +/** + * OAuth consent screen metadata from `GET /v1/me/oauth/consent/{oauthClientId}`. + * Includes information needed to populate the consent dialog. + */ +export type OAuthConsentInfo = { + oauthApplicationName: string; + oauthApplicationLogoUrl: string; + oauthApplicationUrl: string; + clientId: string; + state: string; + scopes: OAuthConsentScope[]; +}; + +export type GetOAuthConsentInfoParams = { + /** OAuth `client_id` from the authorize request. */ + oauthClientId: string; + /** Optional space-delimited scope string from the authorize request. */ + 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. + */ + getConsentInfo: (params: GetOAuthConsentInfoParams) => Promise; +}