Skip to content

Commit f481d4e

Browse files
committed
feat(js): add scope query parameter to OAuth consent endpoint fetch
Add clientId and scope props to __internal_OAuthConsentProps so the OAuthConsent component can fetch consent details from /v1/me/oauth/consent/{client_id}?scope=<space-delimited-scopes>. When clientId is provided, the component fetches consent data from the FAPI endpoint. The scope prop is a space-delimited list of OAuth scopes included as a query parameter on the request. Fetched data is merged with any directly supplied props, maintaining backward compatibility. Changes: - packages/shared: Add clientId, scope to __internal_OAuthConsentProps; make data props optional (oAuthApplicationName, scopes, redirectUrl, onAllow, onDeny) - packages/shared: Add __internal_fetchOAuthConsent to Clerk interface - packages/clerk-js: Implement __internal_fetchOAuthConsent on Clerk class using the FAPI client - packages/react: Wire __internal_fetchOAuthConsent through IsomorphicClerk - packages/ui: OAuthConsent component fetches consent data when clientId is provided, with loading state - packages/clerk-js/sandbox: Support client_id and scope URL params Part of USER-4924
1 parent 7c3c2bf commit f481d4e

5 files changed

Lines changed: 121 additions & 10 deletions

File tree

packages/clerk-js/sandbox/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ void (async () => {
389389
},
390390
'/oauth-consent': () => {
391391
const searchParams = new URLSearchParams(window.location.search);
392+
const clientId = searchParams.get('client_id');
393+
const scope = searchParams.get('scope');
392394
const scopes = (searchParams.get('scopes')?.split(',') ?? []).map(scope => ({
393395
scope,
394396
description: scope === 'offline_access' ? null : `Grants access to your ${scope}`,
@@ -397,6 +399,7 @@ void (async () => {
397399
Clerk.__internal_mountOAuthConsent(
398400
app,
399401
componentControls.oauthConsent.getProps() ?? {
402+
...(clientId ? { clientId, scope: scope ?? undefined } : {}),
400403
scopes,
401404
oAuthApplicationName: searchParams.get('oauth-application-name'),
402405
redirectUrl: searchParams.get('redirect_uri'),

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,28 @@ export class Clerk implements ClerkInterface {
13311331
void this.#clerkUI?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
13321332
};
13331333

1334+
public __internal_fetchOAuthConsent = async (
1335+
clientId: string,
1336+
params?: { scope?: string },
1337+
): Promise<__internal_OAuthConsentProps> => {
1338+
const search: Record<string, string> = {};
1339+
if (params?.scope) {
1340+
search.scope = params.scope;
1341+
}
1342+
1343+
const response = await this.#fapiClient.request<__internal_OAuthConsentProps>({
1344+
method: 'GET',
1345+
path: `/me/oauth/consent/${encodeURIComponent(clientId)}`,
1346+
search,
1347+
});
1348+
1349+
if (!response.payload?.response) {
1350+
throw new Error('Failed to fetch OAuth consent details');
1351+
}
1352+
1353+
return response.payload.response;
1354+
};
1355+
13341356
/**
13351357
* @experimental This API is in early access and may change in future releases.
13361358
*

packages/react/src/isomorphicClerk.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,14 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
12911291
}
12921292
};
12931293

1294+
__internal_fetchOAuthConsent = async (
1295+
clientId: string,
1296+
params?: { scope?: string },
1297+
): Promise<__internal_OAuthConsentProps> => {
1298+
const clerkjs = await this.#waitForClerkJS();
1299+
return clerkjs.__internal_fetchOAuthConsent(clientId, params);
1300+
};
1301+
12941302
mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps): void => {
12951303
if (this.clerkjs && this.loaded) {
12961304
this.clerkjs.mountTaskChooseOrganization(node, props);

packages/shared/src/types/clerk.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,18 @@ export interface Clerk {
682682
*/
683683
__internal_unmountOAuthConsent: (targetNode: HTMLDivElement) => void;
684684

685+
/**
686+
* Fetches OAuth consent details from the FAPI for the given client_id.
687+
*
688+
* @param clientId - The OAuth application client_id.
689+
* @param params - Optional query parameters for the request.
690+
* @param params.scope - Space-delimited list of OAuth scopes.
691+
*/
692+
__internal_fetchOAuthConsent: (
693+
clientId: string,
694+
params?: { scope?: string },
695+
) => Promise<__internal_OAuthConsentProps>;
696+
685697
/**
686698
* Mounts a TaskChooseOrganization component at the target element.
687699
*
@@ -2271,10 +2283,21 @@ export type __experimental_SubscriptionDetailsButtonProps = {
22712283

22722284
export type __internal_OAuthConsentProps = {
22732285
appearance?: ClerkAppearanceTheme;
2286+
/**
2287+
* The OAuth application's client_id. When provided, the component will
2288+
* fetch consent details from the FAPI endpoint
2289+
* `/v1/me/oauth/consent/{clientId}`.
2290+
*/
2291+
clientId?: string;
2292+
/**
2293+
* Space-delimited list of OAuth scopes requested by the client.
2294+
* Sent as the `scope` query parameter when fetching consent details.
2295+
*/
2296+
scope?: string;
22742297
/**
22752298
* Name of the OAuth application.
22762299
*/
2277-
oAuthApplicationName: string;
2300+
oAuthApplicationName?: string;
22782301
/**
22792302
* Logo URL of the OAuth application.
22802303
*/
@@ -2286,23 +2309,23 @@ export type __internal_OAuthConsentProps = {
22862309
/**
22872310
* Scopes requested by the OAuth application.
22882311
*/
2289-
scopes: {
2312+
scopes?: {
22902313
scope: string;
22912314
description: string | null;
22922315
requires_consent: boolean;
22932316
}[];
22942317
/**
22952318
* Full URL or path to navigate to after the user allows access.
22962319
*/
2297-
redirectUrl: string;
2320+
redirectUrl?: string;
22982321
/**
22992322
* Called when user allows access.
23002323
*/
2301-
onAllow: () => void;
2324+
onAllow?: () => void;
23022325
/**
23032326
* Called when user denies access.
23042327
*/
2305-
onDeny: () => void;
2328+
onDeny?: () => void;
23062329
};
23072330

23082331
export interface HandleEmailLinkVerificationParams {

packages/ui/src/components/OAuthConsent/OAuthConsent.tsx

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useUser } from '@clerk/shared/react';
1+
import { useClerk, useUser } from '@clerk/shared/react';
2+
import type { __internal_OAuthConsentProps } from '@clerk/shared/types';
23
import type { ComponentProps } from 'react';
3-
import { useState } from 'react';
4+
import { useEffect, useState } from 'react';
45

56
import { useEnvironment, useOAuthConsentContext } from '@/ui/contexts';
67
import { Box, Button, Flex, Flow, Grid, Icon, Text } from '@/ui/customizables';
@@ -11,19 +12,55 @@ import { Header } from '@/ui/elements/Header';
1112
import { Modal } from '@/ui/elements/Modal';
1213
import { Tooltip } from '@/ui/elements/Tooltip';
1314
import { LockDottedCircle } from '@/ui/icons';
14-
import { Alert, Textarea } from '@/ui/primitives';
15+
import { Alert, Spinner, Textarea } from '@/ui/primitives';
1516
import type { ThemableCssProp } from '@/ui/styledSystem';
1617
import { common } from '@/ui/styledSystem';
1718
import { colors } from '@/ui/utils/colors';
1819

1920
const OFFLINE_ACCESS_SCOPE = 'offline_access';
2021

2122
export function OAuthConsentInternal() {
22-
const { scopes, oAuthApplicationName, oAuthApplicationLogoUrl, oAuthApplicationUrl, redirectUrl, onDeny, onAllow } =
23-
useOAuthConsentContext();
23+
const ctx = useOAuthConsentContext();
24+
const clerk = useClerk();
2425
const { user } = useUser();
2526
const { applicationName, logoImageUrl } = useEnvironment().displayConfig;
2627
const [isUriModalOpen, setIsUriModalOpen] = useState(false);
28+
const [fetchedData, setFetchedData] = useState<__internal_OAuthConsentProps | null>(null);
29+
const [isLoading, setIsLoading] = useState(!!ctx.clientId);
30+
31+
useEffect(() => {
32+
if (!ctx.clientId) {
33+
return;
34+
}
35+
36+
let cancelled = false;
37+
setIsLoading(true);
38+
39+
clerk
40+
.__internal_fetchOAuthConsent(ctx.clientId, { scope: ctx.scope })
41+
.then(data => {
42+
if (!cancelled) {
43+
setFetchedData(data);
44+
}
45+
})
46+
.finally(() => {
47+
if (!cancelled) {
48+
setIsLoading(false);
49+
}
50+
});
51+
52+
return () => {
53+
cancelled = true;
54+
};
55+
}, [ctx.clientId, ctx.scope, clerk]);
56+
57+
const scopes = fetchedData?.scopes ?? ctx.scopes;
58+
const oAuthApplicationName = fetchedData?.oAuthApplicationName ?? ctx.oAuthApplicationName ?? '';
59+
const oAuthApplicationLogoUrl = fetchedData?.oAuthApplicationLogoUrl ?? ctx.oAuthApplicationLogoUrl;
60+
const oAuthApplicationUrl = fetchedData?.oAuthApplicationUrl ?? ctx.oAuthApplicationUrl;
61+
const redirectUrl = fetchedData?.redirectUrl ?? ctx.redirectUrl ?? '';
62+
const onAllow = ctx.onAllow ?? (() => {});
63+
const onDeny = ctx.onDeny ?? (() => {});
2764

2865
const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber;
2966

@@ -40,6 +77,24 @@ export function OAuthConsentInternal() {
4077
}
4178
}
4279

80+
if (isLoading) {
81+
return (
82+
<Flow.Root flow='oauthConsent'>
83+
<Card.Root>
84+
<Card.Content>
85+
<Flex
86+
justify='center'
87+
align='center'
88+
sx={t => ({ padding: t.space.$10 })}
89+
>
90+
<Spinner />
91+
</Flex>
92+
</Card.Content>
93+
</Card.Root>
94+
</Flow.Root>
95+
);
96+
}
97+
4398
return (
4499
<Flow.Root flow='oauthConsent'>
45100
<Card.Root>

0 commit comments

Comments
 (0)