Skip to content

Commit dc2379d

Browse files
committed
feat(js): add clerk.oauthApplication.fetchConsentInfo
1 parent 97735eb commit dc2379d

File tree

9 files changed

+297
-1
lines changed

9 files changed

+297
-1
lines changed

.changeset/few-stamps-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Add OAuthApplication resource and fetchConsentInfo method

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

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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ClerkGlobalHookError } from '@/errors/globalHookError';
22

33
import type { ClerkUIConstructor } from '../ui/types';
44
import type { APIKeysNamespace } from './apiKeys';
5+
import type { OAuthApplicationNamespace } from './oauthApplication';
56
import type {
67
BillingCheckoutResource,
78
BillingNamespace,
@@ -1027,6 +1028,11 @@ export interface Clerk {
10271028
*/
10281029
apiKeys: APIKeysNamespace;
10291030

1031+
/**
1032+
* OAuth application helpers (e.g. consent metadata for custom consent UIs).
1033+
*/
1034+
oauthApplication: OAuthApplicationNamespace;
1035+
10301036
/**
10311037
* Checkout API
10321038
*

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';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { ClerkResourceJSON } from './json';
2+
3+
/**
4+
* A single OAuth scope row returned by the Frontend API consent metadata endpoint.
5+
*/
6+
export type OAuthConsentScopeJSON = {
7+
scope: string;
8+
description: string | null;
9+
requires_consent: boolean;
10+
};
11+
12+
/**
13+
* OAuth consent screen metadata from `GET /v1/me/oauth/consent/{oauthClientId}`.
14+
* Field names match the Frontend API JSON (snake_case).
15+
*/
16+
export type OAuthConsentInfo = {
17+
oauth_application_name: string;
18+
oauth_application_logo_url: string;
19+
oauth_application_url: string;
20+
client_id: string;
21+
state: string;
22+
scopes: OAuthConsentScopeJSON[];
23+
};
24+
25+
/**
26+
* @internal
27+
*/
28+
export interface OAuthConsentInfoJSON extends ClerkResourceJSON {
29+
object: 'oauth_consent_info';
30+
oauth_application_name: string;
31+
oauth_application_logo_url: string;
32+
oauth_application_url: string;
33+
client_id: string;
34+
state: string;
35+
scopes: OAuthConsentScopeJSON[];
36+
}
37+
38+
export type FetchOAuthConsentInfoParams = {
39+
/** OAuth `client_id` from the authorize request. */
40+
oauthClientId: string;
41+
/** Optional normalized scope string (e.g. space-delimited). */
42+
scope?: string;
43+
};
44+
45+
/**
46+
* Namespace exposed on `Clerk` for OAuth application / consent helpers.
47+
*/
48+
export interface OAuthApplicationNamespace {
49+
/**
50+
* Loads consent metadata for the given OAuth client for the signed-in user.
51+
* Uses the Frontend API session (cookies, `_clerk_session_id`, dev browser, etc.) like other `/me` requests.
52+
*/
53+
fetchConsentInfo: (params: FetchOAuthConsentInfoParams) => Promise<OAuthConsentInfo>;
54+
}

0 commit comments

Comments
 (0)