Skip to content

Commit 3fd586d

Browse files
jfosheewobsoriano
andauthored
feat(js): add clerk.oauthApplication.getConsentInfo (#8275)
Co-authored-by: Robert Soriano <sorianorobertc@gmail.com>
1 parent 2471d41 commit 3fd586d

9 files changed

Lines changed: 299 additions & 1 deletion

File tree

.changeset/few-stamps-retire.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/react': minor
4+
'@clerk/shared': minor
5+
---
6+
7+
Add `OAuthApplication` resource and `getConsentInfo()` method for retrieving OAuth consent information, enabling custom OAuth consent flows.

packages/clerk-js/src/core/clerk.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import type {
8888
ListenerOptions,
8989
LoadedClerk,
9090
NavigateOptions,
91+
OAuthApplicationNamespace,
9192
OrganizationListProps,
9293
OrganizationProfileProps,
9394
OrganizationResource,
@@ -178,7 +179,7 @@ import { APIKeys } from './modules/apiKeys';
178179
import { Billing } from './modules/billing';
179180
import { createCheckoutInstance } from './modules/checkout/instance';
180181
import { Protect } from './protect';
181-
import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
182+
import { BaseResource, Client, Environment, OAuthApplication, Organization, Waitlist } from './resources/internal';
182183
import { State } from './state';
183184

184185
type SetActiveHook = (intent?: 'sign-out') => void | Promise<void>;
@@ -224,6 +225,7 @@ export class Clerk implements ClerkInterface {
224225

225226
private static _billing: BillingNamespace;
226227
private static _apiKeys: APIKeysNamespace;
228+
private static _oauthApplication: OAuthApplicationNamespace;
227229
private _checkout: ClerkInterface['__experimental_checkout'] | undefined;
228230

229231
public client: ClientResource | undefined;
@@ -403,6 +405,15 @@ export class Clerk implements ClerkInterface {
403405
return Clerk._apiKeys;
404406
}
405407

408+
get oauthApplication(): OAuthApplicationNamespace {
409+
if (!Clerk._oauthApplication) {
410+
Clerk._oauthApplication = {
411+
getConsentInfo: params => OAuthApplication.getConsentInfo(params),
412+
};
413+
}
414+
return Clerk._oauthApplication;
415+
}
416+
406417
__experimental_checkout(options: __experimental_CheckoutOptions): CheckoutSignalValue {
407418
if (!this._checkout) {
408419
this._checkout = (params: any) => createCheckoutInstance(this, params);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ClerkRuntimeError } from '@clerk/shared/error';
2+
import type {
3+
ClerkResourceJSON,
4+
GetOAuthConsentInfoParams,
5+
OAuthConsentInfo,
6+
OAuthConsentInfoJSON,
7+
} from '@clerk/shared/types';
8+
9+
import { BaseResource } from './internal';
10+
11+
export class OAuthApplication extends BaseResource {
12+
pathRoot = '';
13+
14+
protected fromJSON(_data: ClerkResourceJSON | null): this {
15+
return this;
16+
}
17+
18+
static async getConsentInfo(params: GetOAuthConsentInfoParams): Promise<OAuthConsentInfo> {
19+
const { oauthClientId, scope } = params;
20+
const json = await BaseResource._fetch<OAuthConsentInfoJSON>(
21+
{
22+
method: 'GET',
23+
path: `/me/oauth/consent/${encodeURIComponent(oauthClientId)}`,
24+
search: scope !== undefined ? { scope } : undefined,
25+
},
26+
{ skipUpdateClient: true },
27+
);
28+
29+
if (!json) {
30+
throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' });
31+
}
32+
33+
// Handle in case we start wrapping the response in the future
34+
const data = json.response ?? json;
35+
return {
36+
oauthApplicationName: data.oauth_application_name,
37+
oauthApplicationLogoUrl: data.oauth_application_logo_url,
38+
oauthApplicationUrl: data.oauth_application_url,
39+
clientId: data.client_id,
40+
state: data.state,
41+
scopes:
42+
data.scopes?.map(scope => ({
43+
scope: scope.scope,
44+
description: scope.description,
45+
requiresConsent: scope.requires_consent,
46+
})) ?? [],
47+
};
48+
}
49+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { ClerkAPIResponseError } from '@clerk/shared/error';
2+
import type { InstanceType, OAuthConsentInfoJSON } from '@clerk/shared/types';
3+
import { afterEach, describe, expect, it, type Mock, vi } from 'vitest';
4+
5+
import { mockFetch } from '@/test/core-fixtures';
6+
7+
import { SUPPORTED_FAPI_VERSION } from '../../constants';
8+
import { createFapiClient } from '../../fapiClient';
9+
import { BaseResource } from '../internal';
10+
import { OAuthApplication } from '../OAuthApplication';
11+
12+
const consentPayload: OAuthConsentInfoJSON = {
13+
object: 'oauth_consent_info',
14+
id: 'client_abc',
15+
oauth_application_name: 'My App',
16+
oauth_application_logo_url: 'https://img.example/logo.png',
17+
oauth_application_url: 'https://app.example',
18+
client_id: 'client_abc',
19+
state: 'st',
20+
scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }],
21+
};
22+
23+
describe('OAuthApplication.getConsentInfo', () => {
24+
afterEach(() => {
25+
(global.fetch as Mock)?.mockClear?.();
26+
BaseResource.clerk = null as any;
27+
vi.restoreAllMocks();
28+
});
29+
30+
it('calls BaseResource._fetch with GET, encoded path, optional scope, and skipUpdateClient', async () => {
31+
const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
32+
response: consentPayload,
33+
} as any);
34+
35+
BaseResource.clerk = {} as any;
36+
37+
await OAuthApplication.getConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' });
38+
39+
expect(fetchSpy).toHaveBeenCalledWith(
40+
{
41+
method: 'GET',
42+
path: '/me/oauth/consent/my%2Fclient%20id',
43+
search: { scope: 'openid email' },
44+
},
45+
{ skipUpdateClient: true },
46+
);
47+
});
48+
49+
it('omits search when scope is undefined', async () => {
50+
const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
51+
response: consentPayload,
52+
} as any);
53+
54+
BaseResource.clerk = {} as any;
55+
56+
await OAuthApplication.getConsentInfo({ oauthClientId: 'cid' });
57+
58+
expect(fetchSpy).toHaveBeenCalledWith(
59+
expect.objectContaining({
60+
search: undefined,
61+
}),
62+
{ skipUpdateClient: true },
63+
);
64+
});
65+
66+
it('returns OAuthConsentInfo from the FAPI response', async () => {
67+
vi.spyOn(BaseResource, '_fetch').mockResolvedValue(consentPayload as any);
68+
69+
BaseResource.clerk = {} as any;
70+
71+
const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' });
72+
73+
expect(info).toEqual({
74+
oauthApplicationName: 'My App',
75+
oauthApplicationLogoUrl: 'https://img.example/logo.png',
76+
oauthApplicationUrl: 'https://app.example',
77+
clientId: 'client_abc',
78+
state: 'st',
79+
scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
80+
});
81+
});
82+
83+
it('returns OAuthConsentInfo from the FAPI response (enveloped)', async () => {
84+
vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
85+
response: consentPayload,
86+
} as any);
87+
88+
BaseResource.clerk = {} as any;
89+
90+
const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' });
91+
92+
expect(info).toEqual({
93+
oauthApplicationName: 'My App',
94+
oauthApplicationLogoUrl: 'https://img.example/logo.png',
95+
oauthApplicationUrl: 'https://app.example',
96+
clientId: 'client_abc',
97+
state: 'st',
98+
scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
99+
});
100+
});
101+
102+
it('defaults scopes to an empty array when absent', async () => {
103+
vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
104+
response: { ...consentPayload, scopes: undefined },
105+
} as any);
106+
107+
BaseResource.clerk = {} as any;
108+
109+
const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' });
110+
expect(info.scopes).toEqual([]);
111+
});
112+
113+
it('maps ClerkAPIResponseError from FAPI on non-2xx', async () => {
114+
mockFetch(false, 422, {
115+
errors: [{ code: 'oauth_consent_error', long_message: 'Consent metadata unavailable' }],
116+
});
117+
118+
BaseResource.clerk = {
119+
getFapiClient: () =>
120+
createFapiClient({
121+
frontendApi: 'clerk.example.com',
122+
getSessionId: () => undefined,
123+
instanceType: 'development' as InstanceType,
124+
}),
125+
__internal_setCountry: vi.fn(),
126+
handleUnauthenticated: vi.fn(),
127+
__internal_handleUnauthenticatedDevBrowser: vi.fn(),
128+
} as any;
129+
130+
await expect(OAuthApplication.getConsentInfo({ oauthClientId: 'cid' })).rejects.toSatisfy(
131+
(err: unknown) => err instanceof ClerkAPIResponseError && err.message === 'Consent metadata unavailable',
132+
);
133+
134+
expect(global.fetch).toHaveBeenCalledTimes(1);
135+
const [url] = (global.fetch as Mock).mock.calls[0];
136+
expect(url.toString()).toContain(`/v1/me/oauth/consent/cid`);
137+
expect(url.toString()).toContain(`__clerk_api_version=${SUPPORTED_FAPI_VERSION}`);
138+
});
139+
140+
it('throws ClerkRuntimeError when _fetch returns null (offline)', async () => {
141+
vi.spyOn(BaseResource, '_fetch').mockResolvedValue(null);
142+
143+
BaseResource.clerk = {} as any;
144+
145+
await expect(OAuthApplication.getConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({
146+
code: 'network_error',
147+
});
148+
});
149+
});

packages/clerk-js/src/core/resources/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export * from './ExternalAccount';
2222
export * from './Feature';
2323
export * from './IdentificationLink';
2424
export * from './Image';
25+
export * from './OAuthApplication';
2526
export * from './Organization';
2627
export * from './OrganizationDomain';
2728
export * from './OrganizationInvitation';

packages/react/src/isomorphicClerk.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
ListenerCallback,
3636
ListenerOptions,
3737
LoadedClerk,
38+
OAuthApplicationNamespace,
3839
OrganizationListProps,
3940
OrganizationProfileProps,
4041
OrganizationResource,
@@ -118,11 +119,13 @@ type IsomorphicLoadedClerk = Without<
118119
| '__internal_reloadInitialResources'
119120
| 'billing'
120121
| 'apiKeys'
122+
| 'oauthApplication'
121123
| '__internal_setActiveInProgress'
122124
> & {
123125
client: ClientResource | undefined;
124126
billing: BillingNamespace | undefined;
125127
apiKeys: APIKeysNamespace | undefined;
128+
oauthApplication: OAuthApplicationNamespace | undefined;
126129
};
127130

128131
export class IsomorphicClerk implements IsomorphicLoadedClerk {
@@ -844,6 +847,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
844847
return this.clerkjs?.apiKeys;
845848
}
846849

850+
get oauthApplication(): OAuthApplicationNamespace | undefined {
851+
return this.clerkjs?.oauthApplication;
852+
}
853+
847854
__experimental_checkout = (...args: Parameters<Clerk['__experimental_checkout']>) => {
848855
return this.loaded && this.clerkjs
849856
? this.clerkjs.__experimental_checkout(...args)

packages/shared/src/types/clerk.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { DisplayThemeJSON } from './json';
1919
import type { LocalizationResource } from './localization';
2020
import type { DomainOrProxyUrl, MultiDomainAndOrProxy } from './multiDomain';
2121
import type { OAuthProvider, OAuthScope } from './oauth';
22+
import type { OAuthApplicationNamespace } from './oauthApplication';
2223
import type { OrganizationResource } from './organization';
2324
import type { OrganizationCustomRoleKey } from './organizationMembership';
2425
import type { ClerkPaginationParams } from './pagination';
@@ -168,6 +169,7 @@ export type SetActiveNavigate = (params: {
168169
session: SessionResource;
169170
/**
170171
* Decorate the destination URL to enable Safari ITP cookie refresh when needed.
172+
*
171173
* @see {@link DecorateUrl}
172174
*/
173175
decorateUrl: DecorateUrl;
@@ -1027,6 +1029,11 @@ export interface Clerk {
10271029
*/
10281030
apiKeys: APIKeysNamespace;
10291031

1032+
/**
1033+
* OAuth application helpers (e.g. consent metadata for custom consent UIs).
1034+
*/
1035+
oauthApplication: OAuthApplicationNamespace;
1036+
10301037
/**
10311038
* Checkout API
10321039
*
@@ -2496,21 +2503,25 @@ export type IsomorphicClerkOptions = Without<ClerkOptions, 'isSatellite'> & {
24962503
Clerk?: ClerkProp;
24972504
/**
24982505
* The URL that `@clerk/clerk-js` should be hot-loaded from.
2506+
*
24992507
* @internal
25002508
*/
25012509
__internal_clerkJSUrl?: string;
25022510
/**
25032511
* The npm version for `@clerk/clerk-js`.
2512+
*
25042513
* @internal
25052514
*/
25062515
__internal_clerkJSVersion?: string;
25072516
/**
25082517
* The URL that `@clerk/ui` should be hot-loaded from.
2518+
*
25092519
* @internal
25102520
*/
25112521
__internal_clerkUIUrl?: string;
25122522
/**
25132523
* The npm version for `@clerk/ui`.
2524+
*
25142525
* @internal
25152526
*/
25162527
__internal_clerkUIVersion?: string;

packages/shared/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type * from './key';
3333
export type * from './localization';
3434
export type * from './multiDomain';
3535
export type * from './oauth';
36+
export type * from './oauthApplication';
3637
export type * from './organization';
3738
export type * from './organizationCreationDefaults';
3839
export type * from './organizationDomain';

0 commit comments

Comments
 (0)