diff --git a/.changeset/oauth-consent-use-hook.md b/.changeset/oauth-consent-use-hook.md new file mode 100644 index 00000000000..a05bbdc7253 --- /dev/null +++ b/.changeset/oauth-consent-use-hook.md @@ -0,0 +1,7 @@ +--- +'@clerk/shared': minor +'@clerk/react': minor +'@clerk/nextjs': minor +--- + +Introduce internal `useOAuthConsent()` hook for fetching OAuth consent screen metadata for the signed-in user. diff --git a/packages/nextjs/src/internal.ts b/packages/nextjs/src/internal.ts index 76d62a23f06..3c877f8df45 100644 --- a/packages/nextjs/src/internal.ts +++ b/packages/nextjs/src/internal.ts @@ -3,3 +3,4 @@ * If you do, app router will break. */ export { MultisessionAppSupport } from './client-boundary/controlComponents'; +export { useOAuthConsent } from '@clerk/shared/react'; diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index ff4db48fe49..fb6cad96021 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -7,6 +7,7 @@ import type { ClerkProviderProps } from './types'; export { setErrorThrowerOptions } from './errors/errorThrower'; export { MultisessionAppSupport } from './components/controlComponents'; +export { useOAuthConsent } from '@clerk/shared/react'; export { useRoutingProps } from './hooks/useRoutingProps'; export { useDerivedAuth } from './hooks/useAuth'; export { IS_REACT_SHARED_VARIANT_COMPATIBLE } from './utils/versionCheck'; diff --git a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts new file mode 100644 index 00000000000..4e0549e9c4a --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { readOAuthConsentFromSearch } from '../useOAuthConsent.shared'; + +describe('readOAuthConsentFromSearch', () => { + it('parses client_id and scope from a location.search-style string', () => { + expect(readOAuthConsentFromSearch('?client_id=myapp&scope=openid%20email')).toEqual({ + oauthClientId: 'myapp', + scope: 'openid email', + }); + }); + + it('parses without a leading question mark', () => { + expect(readOAuthConsentFromSearch('client_id=x&scope=y')).toEqual({ + oauthClientId: 'x', + scope: 'y', + }); + }); + + it('returns empty client id and undefined scope when search is empty', () => { + expect(readOAuthConsentFromSearch('')).toEqual({ + oauthClientId: '', + }); + }); + + it('omits scope in the result when scope is absent', () => { + expect(readOAuthConsentFromSearch('?client_id=only')).toEqual({ + oauthClientId: 'only', + }); + }); +}); diff --git a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx new file mode 100644 index 00000000000..19136ed9341 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx @@ -0,0 +1,148 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useOAuthConsent } from '../useOAuthConsent'; +import { createMockClerk, createMockQueryClient, createMockUser } from './mocks/clerk'; +import { wrapper } from './wrapper'; + +const consentInfo = { + oauthApplicationName: 'My App', + oauthApplicationLogoUrl: 'https://img.example/logo.png', + oauthApplicationUrl: 'https://app.example', + clientId: 'client_abc', + state: 's', + scopes: [] as { scope: string; description: string | null; requiresConsent: boolean }[], +}; + +const getConsentInfoSpy = vi.fn(() => Promise.resolve(consentInfo)); + +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ + oauthApplication: { + getConsentInfo: getConsentInfoSpy, + }, + queryClient: defaultQueryClient, +}); + +const userState: { current: { id: string } | null } = { + current: createMockUser(), +}; + +vi.mock('../../contexts', () => { + return { + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useInitialStateContext: () => undefined, + }; +}); + +vi.mock('../base/useUserBase', () => ({ + useUserBase: () => userState.current, +})); + +describe('useOAuthConsent', () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; + userState.current = createMockUser(); + mockClerk.oauthApplication = { + getConsentInfo: getConsentInfoSpy, + }; + window.history.replaceState({}, '', '/'); + }); + + it('fetches consent metadata when signed in', async () => { + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'my_client' }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getConsentInfoSpy).toHaveBeenCalledTimes(1); + expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'my_client' }); + expect(result.current.data).toEqual(consentInfo); + expect(result.current.error).toBeNull(); + }); + + it('passes scope to getConsentInfo when provided', async () => { + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid', scope: 'openid email' }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'cid', scope: 'openid email' }); + expect(result.current.data).toEqual(consentInfo); + }); + + it('does not call getConsentInfo when user is null', () => { + userState.current = null; + + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid' }), { wrapper }); + + expect(getConsentInfoSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('does not call getConsentInfo when clerk.loaded is false', () => { + mockClerk.loaded = false; + + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid' }), { wrapper }); + + expect(getConsentInfoSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('does not call getConsentInfo when enabled is false', () => { + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid', enabled: false }), { wrapper }); + + expect(getConsentInfoSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('does not call getConsentInfo when oauthClientId is empty', () => { + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: '' }), { wrapper }); + + expect(getConsentInfoSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('uses client_id and scope from the URL when hook params omit them', async () => { + window.history.replaceState({}, '', '/?client_id=from_url&scope=openid%20email'); + + const { result } = renderHook(() => useOAuthConsent(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getConsentInfoSpy).toHaveBeenCalledTimes(1); + expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'from_url', scope: 'openid email' }); + expect(result.current.data).toEqual(consentInfo); + }); + + it('prefers explicit oauthClientId over URL client_id', async () => { + window.history.replaceState({}, '', '/?client_id=from_url'); + + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'explicit_id' }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'explicit_id' }); + }); + + it('does not fall back to URL client_id when oauthClientId is explicitly empty', () => { + window.history.replaceState({}, '', '/?client_id=from_url'); + + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: '' }), { wrapper }); + + expect(getConsentInfoSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('prefers explicit scope over URL scope', async () => { + window.history.replaceState({}, '', '/?client_id=cid&scope=from_url'); + + const { result } = renderHook(() => useOAuthConsent({ scope: 'explicit_scope' }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'cid', scope: 'explicit_scope' }); + }); +}); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 0303c80e5ff..4029e9087c6 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -1,5 +1,7 @@ export { assertContextExists, createContextAndHook } from './createContextAndHook'; export { useAPIKeys } from './useAPIKeys'; +export { useOAuthConsent } from './useOAuthConsent'; +export type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types'; export { useOrganization } from './useOrganization'; export { useOrganizationCreationDefaults } from './useOrganizationCreationDefaults'; export type { diff --git a/packages/shared/src/react/hooks/useOAuthConsent.shared.ts b/packages/shared/src/react/hooks/useOAuthConsent.shared.ts new file mode 100644 index 00000000000..e26b8074a8f --- /dev/null +++ b/packages/shared/src/react/hooks/useOAuthConsent.shared.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; + +import type { GetOAuthConsentInfoParams } from '../../types'; +import { STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +/** + * Parses OAuth authorize-style query data from a search string (typically `window.location.search`). + * + * @internal + */ +export function readOAuthConsentFromSearch(search: string): { + oauthClientId: string; + scope?: string; +} { + const sp = new URLSearchParams(search); + const oauthClientId = sp.get('client_id') ?? ''; + const scopeValue = sp.get('scope'); + if (scopeValue === null) { + return { oauthClientId }; + } + return { oauthClientId, scope: scopeValue }; +} + +export function useOAuthConsentCacheKeys(params: { userId: string | null; oauthClientId: string; scope?: string }) { + const { userId, oauthClientId, scope } = params; + return useMemo(() => { + const args: Pick & { scope?: string } = { oauthClientId }; + if (scope !== undefined) { + args.scope = scope; + } + return createCacheKeys({ + stablePrefix: STABLE_KEYS.OAUTH_CONSENT_INFO_KEY, + authenticated: true, + tracked: { + userId: userId ?? null, + }, + untracked: { + args, + }, + }); + }, [userId, oauthClientId, scope]); +} diff --git a/packages/shared/src/react/hooks/useOAuthConsent.tsx b/packages/shared/src/react/hooks/useOAuthConsent.tsx new file mode 100644 index 00000000000..c87b3d08ade --- /dev/null +++ b/packages/shared/src/react/hooks/useOAuthConsent.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useMemo } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events/method-called'; +import type { LoadedClerk } from '../../types/clerk'; +import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data'; +import { useClerkQuery } from '../clerk-rq/useQuery'; +import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts'; +import { useUserBase } from './base/useUserBase'; +import { readOAuthConsentFromSearch, useOAuthConsentCacheKeys } from './useOAuthConsent.shared'; +import type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types'; + +const HOOK_NAME = 'useOAuthConsent'; + +/** + * The `useOAuthConsent()` hook loads OAuth application consent metadata for the **signed-in** user + * (`GET /me/oauth/consent/{oauthClientId}`). Ensure the user is authenticated before relying on this hook + * (for example, redirect to sign-in on your custom consent route). + * + * `oauthClientId` and `scope` are optional. On the client, values default from a single snapshot of + * `window.location.search` (`client_id` and `scope`). Pass them explicitly to override. + * + * @internal + * + * @example + * ### From the URL (`?client_id=...&scope=...`) + * + * ```tsx + * import { useOAuthConsent } from '@clerk/react/internal' + * + * export default function OAuthConsentPage() { + * const { data, isLoading, error } = useOAuthConsent() + * // ... + * } + * ``` + * + * @example + * ### Explicit values (override URL) + * + * ```tsx + * import { useOAuthConsent } from '@clerk/react/internal' + * + * const { data, isLoading, error } = useOAuthConsent({ + * oauthClientId: clientIdFromProps, + * scope: scopeFromProps, + * }) + * ``` + */ +export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthConsentReturn { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { oauthClientId: oauthClientIdParam, scope: scopeParam, keepPreviousData = true, enabled = true } = params; + const clerk = useClerkInstanceContext(); + const user = useUserBase(); + + const fromUrl = useMemo(() => { + if (typeof window === 'undefined' || !window.location) { + return { oauthClientId: '' }; + } + return readOAuthConsentFromSearch(window.location.search); + }, []); + + const oauthClientId = (oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId).trim(); + const scope = scopeParam !== undefined ? scopeParam : fromUrl.scope; + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + const { queryKey } = useOAuthConsentCacheKeys({ + userId: user?.id ?? null, + oauthClientId, + scope, + }); + + const hasClientId = oauthClientId.length > 0; + const queryEnabled = Boolean(user) && hasClientId && enabled && clerk.loaded; + + const query = useClerkQuery({ + queryKey, + queryFn: () => fetchConsentInfo(clerk, { oauthClientId, scope }), + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData && queryEnabled), + }); + + return { + data: query.data, + error: (query.error ?? null) as UseOAuthConsentReturn['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} + +function fetchConsentInfo(clerk: LoadedClerk, params: { oauthClientId: string; scope?: string }) { + const { oauthClientId, scope } = params; + return clerk.oauthApplication.getConsentInfo(scope !== undefined ? { oauthClientId, scope } : { oauthClientId }); +} diff --git a/packages/shared/src/react/hooks/useOAuthConsent.types.ts b/packages/shared/src/react/hooks/useOAuthConsent.types.ts new file mode 100644 index 00000000000..59ee444c7eb --- /dev/null +++ b/packages/shared/src/react/hooks/useOAuthConsent.types.ts @@ -0,0 +1,51 @@ +import type { ClerkAPIResponseError } from '../../errors/clerkApiResponseError'; +import type { GetOAuthConsentInfoParams, OAuthConsentInfo } from '../../types'; + +/** + * Options for {@link useOAuthConsent}. + * + * `oauthClientId` and `scope` are optional. On the browser, the hook reads a one-time snapshot of + * `window.location.search` and uses `client_id` and `scope` query keys when you omit them here. + * Any value you pass explicitly overrides the snapshot for that field only. + * + * @internal + * @interface + */ +export type UseOAuthConsentParams = Partial> & { + /** + * If `true`, the previous data will be kept in the cache until new data is fetched. + * + * @default true + */ + keepPreviousData?: boolean; + /** + * If `true`, a request will be triggered when the hook is mounted and the user is signed in. + * + * @default true + */ + enabled?: boolean; +}; + +/** + * @internal + * @interface + */ +export type UseOAuthConsentReturn = { + /** + * OAuth consent screen metadata from Clerk, or `undefined` before the first successful fetch. + * Additional fields (e.g. submission helpers) may be added in the future without renaming this hook. + */ + data: OAuthConsentInfo | undefined; + /** + * Any error that occurred during the data fetch, or `null` if no error occurred. + */ + error: ClerkAPIResponseError | null; + /** + * Whether the initial consent metadata fetch is still in progress. + */ + isLoading: boolean; + /** + * Whether any request is still in flight, including background updates. + */ + isFetching: boolean; +}; diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index 61ca163dfd0..415d1daccfd 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -15,6 +15,9 @@ const API_KEYS_KEY = 'apiKeys'; // Keys for `useOrganizationCreationDefaults` const ORGANIZATION_CREATION_DEFAULTS_KEY = 'organizationCreationDefaults'; +// Keys for `useOAuthConsent` +const OAUTH_CONSENT_INFO_KEY = 'oauthConsentInfo'; + // Keys for `usePlans` const PLANS_KEY = 'billing-plans'; @@ -54,6 +57,9 @@ export const STABLE_KEYS = { // Keys for `useOrganizationCreationDefaults` ORGANIZATION_CREATION_DEFAULTS_KEY, + + // Keys for `useOAuthConsent` + OAUTH_CONSENT_INFO_KEY, } as const; export type ResourceCacheStableKey = (typeof STABLE_KEYS)[keyof typeof STABLE_KEYS];