From f481d4e1aaf10be71c157dde70bd68a7bd3bfa7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 22:31:37 +0000 Subject: [PATCH] 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=. 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 --- packages/clerk-js/sandbox/app.ts | 3 + packages/clerk-js/src/core/clerk.ts | 22 +++++++ packages/react/src/isomorphicClerk.ts | 8 +++ packages/shared/src/types/clerk.ts | 33 ++++++++-- .../components/OAuthConsent/OAuthConsent.tsx | 65 +++++++++++++++++-- 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 56d2624b11d..33b424c7bab 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -389,6 +389,8 @@ void (async () => { }, '/oauth-consent': () => { const searchParams = new URLSearchParams(window.location.search); + const clientId = searchParams.get('client_id'); + const scope = searchParams.get('scope'); const scopes = (searchParams.get('scopes')?.split(',') ?? []).map(scope => ({ scope, description: scope === 'offline_access' ? null : `Grants access to your ${scope}`, @@ -397,6 +399,7 @@ void (async () => { Clerk.__internal_mountOAuthConsent( app, componentControls.oauthConsent.getProps() ?? { + ...(clientId ? { clientId, scope: scope ?? undefined } : {}), scopes, oAuthApplicationName: searchParams.get('oauth-application-name'), redirectUrl: searchParams.get('redirect_uri'), diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1a362b2edac..c4ea9f99229 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1331,6 +1331,28 @@ export class Clerk implements ClerkInterface { void this.#clerkUI?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node })); }; + public __internal_fetchOAuthConsent = async ( + clientId: string, + params?: { scope?: string }, + ): Promise<__internal_OAuthConsentProps> => { + const search: Record = {}; + if (params?.scope) { + search.scope = params.scope; + } + + const response = await this.#fapiClient.request<__internal_OAuthConsentProps>({ + method: 'GET', + path: `/me/oauth/consent/${encodeURIComponent(clientId)}`, + search, + }); + + if (!response.payload?.response) { + throw new Error('Failed to fetch OAuth consent details'); + } + + return response.payload.response; + }; + /** * @experimental This API is in early access and may change in future releases. * diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 314d736f0e6..2280394bb06 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -1291,6 +1291,14 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __internal_fetchOAuthConsent = async ( + clientId: string, + params?: { scope?: string }, + ): Promise<__internal_OAuthConsentProps> => { + const clerkjs = await this.#waitForClerkJS(); + return clerkjs.__internal_fetchOAuthConsent(clientId, params); + }; + mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps): void => { if (this.clerkjs && this.loaded) { this.clerkjs.mountTaskChooseOrganization(node, props); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 670a1a21ba0..326d817c7df 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -682,6 +682,18 @@ export interface Clerk { */ __internal_unmountOAuthConsent: (targetNode: HTMLDivElement) => void; + /** + * Fetches OAuth consent details from the FAPI for the given client_id. + * + * @param clientId - The OAuth application client_id. + * @param params - Optional query parameters for the request. + * @param params.scope - Space-delimited list of OAuth scopes. + */ + __internal_fetchOAuthConsent: ( + clientId: string, + params?: { scope?: string }, + ) => Promise<__internal_OAuthConsentProps>; + /** * Mounts a TaskChooseOrganization component at the target element. * @@ -2271,10 +2283,21 @@ export type __experimental_SubscriptionDetailsButtonProps = { export type __internal_OAuthConsentProps = { appearance?: ClerkAppearanceTheme; + /** + * The OAuth application's client_id. When provided, the component will + * fetch consent details from the FAPI endpoint + * `/v1/me/oauth/consent/{clientId}`. + */ + clientId?: string; + /** + * Space-delimited list of OAuth scopes requested by the client. + * Sent as the `scope` query parameter when fetching consent details. + */ + scope?: string; /** * Name of the OAuth application. */ - oAuthApplicationName: string; + oAuthApplicationName?: string; /** * Logo URL of the OAuth application. */ @@ -2286,7 +2309,7 @@ export type __internal_OAuthConsentProps = { /** * Scopes requested by the OAuth application. */ - scopes: { + scopes?: { scope: string; description: string | null; requires_consent: boolean; @@ -2294,15 +2317,15 @@ export type __internal_OAuthConsentProps = { /** * Full URL or path to navigate to after the user allows access. */ - redirectUrl: string; + redirectUrl?: string; /** * Called when user allows access. */ - onAllow: () => void; + onAllow?: () => void; /** * Called when user denies access. */ - onDeny: () => void; + onDeny?: () => void; }; export interface HandleEmailLinkVerificationParams { diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 496eddb787a..b0881533014 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,6 +1,7 @@ -import { useUser } from '@clerk/shared/react'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { __internal_OAuthConsentProps } from '@clerk/shared/types'; import type { ComponentProps } from 'react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useEnvironment, useOAuthConsentContext } from '@/ui/contexts'; import { Box, Button, Flex, Flow, Grid, Icon, Text } from '@/ui/customizables'; @@ -11,7 +12,7 @@ import { Header } from '@/ui/elements/Header'; import { Modal } from '@/ui/elements/Modal'; import { Tooltip } from '@/ui/elements/Tooltip'; import { LockDottedCircle } from '@/ui/icons'; -import { Alert, Textarea } from '@/ui/primitives'; +import { Alert, Spinner, Textarea } from '@/ui/primitives'; import type { ThemableCssProp } from '@/ui/styledSystem'; import { common } from '@/ui/styledSystem'; import { colors } from '@/ui/utils/colors'; @@ -19,11 +20,47 @@ import { colors } from '@/ui/utils/colors'; const OFFLINE_ACCESS_SCOPE = 'offline_access'; export function OAuthConsentInternal() { - const { scopes, oAuthApplicationName, oAuthApplicationLogoUrl, oAuthApplicationUrl, redirectUrl, onDeny, onAllow } = - useOAuthConsentContext(); + const ctx = useOAuthConsentContext(); + const clerk = useClerk(); const { user } = useUser(); const { applicationName, logoImageUrl } = useEnvironment().displayConfig; const [isUriModalOpen, setIsUriModalOpen] = useState(false); + const [fetchedData, setFetchedData] = useState<__internal_OAuthConsentProps | null>(null); + const [isLoading, setIsLoading] = useState(!!ctx.clientId); + + useEffect(() => { + if (!ctx.clientId) { + return; + } + + let cancelled = false; + setIsLoading(true); + + clerk + .__internal_fetchOAuthConsent(ctx.clientId, { scope: ctx.scope }) + .then(data => { + if (!cancelled) { + setFetchedData(data); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ctx.clientId, ctx.scope, clerk]); + + const scopes = fetchedData?.scopes ?? ctx.scopes; + const oAuthApplicationName = fetchedData?.oAuthApplicationName ?? ctx.oAuthApplicationName ?? ''; + const oAuthApplicationLogoUrl = fetchedData?.oAuthApplicationLogoUrl ?? ctx.oAuthApplicationLogoUrl; + const oAuthApplicationUrl = fetchedData?.oAuthApplicationUrl ?? ctx.oAuthApplicationUrl; + const redirectUrl = fetchedData?.redirectUrl ?? ctx.redirectUrl ?? ''; + const onAllow = ctx.onAllow ?? (() => {}); + const onDeny = ctx.onDeny ?? (() => {}); const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber; @@ -40,6 +77,24 @@ export function OAuthConsentInternal() { } } + if (isLoading) { + return ( + + + + ({ padding: t.space.$10 })} + > + + + + + + ); + } + return (