From 7aaf6ed804be934035b4caae436ca04359da5093 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Fri, 10 Apr 2026 14:17:51 -0500 Subject: [PATCH 1/5] feat(react): add `useOAuthConsent` hook --- .changeset/oauth-consent-use-hook.md | 8 ++ packages/expo/src/hooks/index.ts | 1 + packages/nextjs/src/client-boundary/hooks.ts | 1 + packages/nextjs/src/index.ts | 1 + packages/react/src/hooks/index.ts | 1 + .../hooks/__tests__/useOAuthConsent.spec.tsx | 115 ++++++++++++++++++ packages/shared/src/react/hooks/index.ts | 2 + .../src/react/hooks/useOAuthConsent.shared.ts | 25 ++++ .../src/react/hooks/useOAuthConsent.tsx | 77 ++++++++++++ .../src/react/hooks/useOAuthConsent.types.ts | 43 +++++++ packages/shared/src/react/stable-keys.ts | 6 + 11 files changed, 280 insertions(+) create mode 100644 .changeset/oauth-consent-use-hook.md create mode 100644 packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx create mode 100644 packages/shared/src/react/hooks/useOAuthConsent.shared.ts create mode 100644 packages/shared/src/react/hooks/useOAuthConsent.tsx create mode 100644 packages/shared/src/react/hooks/useOAuthConsent.types.ts diff --git a/.changeset/oauth-consent-use-hook.md b/.changeset/oauth-consent-use-hook.md new file mode 100644 index 00000000000..f25ffcb9246 --- /dev/null +++ b/.changeset/oauth-consent-use-hook.md @@ -0,0 +1,8 @@ +--- +'@clerk/shared': minor +'@clerk/react': minor +'@clerk/nextjs': minor +'@clerk/expo': minor +--- + +Add the `useOAuthConsent` hook to fetch OAuth application consent metadata for signed-in users via Clerk React Query. diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 6c7f22b4d43..648efad02a4 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -12,6 +12,7 @@ export { useUser, useReverification, useAPIKeys, + useOAuthConsent, } from '@clerk/react'; export * from './useSSO'; diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index 73b5e14da4f..20df52364c0 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -15,6 +15,7 @@ export { useUser, useReverification, useAPIKeys, + useOAuthConsent, } from '@clerk/react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 65e061353bf..66cdf4266c3 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -57,6 +57,7 @@ export { useOrganizationList, useReverification, useAPIKeys, + useOAuthConsent, useSession, useSessionList, useSignIn, diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 0db65cab7f7..e33d87ea1c6 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -11,6 +11,7 @@ export { useSession, useReverification, useAPIKeys, + useOAuthConsent, __experimental_useCheckout, __experimental_CheckoutProvider, __experimental_usePaymentElement, 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..b283829ffd5 --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx @@ -0,0 +1,115 @@ +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, + }; + }); + + 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('does not call getConsentInfo when oauthApplication.getConsentInfo is missing', () => { + mockClerk.oauthApplication = undefined; + + const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid' }), { wrapper }); + + expect(getConsentInfoSpy).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); +}); 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..9b21222ffe1 --- /dev/null +++ b/packages/shared/src/react/hooks/useOAuthConsent.shared.ts @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; + +import type { GetOAuthConsentInfoParams } from '../../types'; +import { STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +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..a39f1665fdf --- /dev/null +++ b/packages/shared/src/react/hooks/useOAuthConsent.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useCallback } from 'react'; + +import { eventMethodCalled } from '../../telemetry/events/method-called'; +import type { GetOAuthConsentInfoParams } from '../../types'; +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 { 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). + * + * @example + * ### Basic usage + * + * ```tsx + * import { useOAuthConsent } from '@clerk/react' + * + * export default function OAuthConsentPage({ clientId, scope }: { clientId: string; scope?: string }) { + * const { data, isLoading, error } = useOAuthConsent({ oauthClientId: clientId, scope }) + * + * if (isLoading) return
Loading…
+ * if (error) return
Unable to load consent
+ * + * return
{data?.oauthApplicationName}
+ * } + * ``` + */ +export function useOAuthConsent(params: UseOAuthConsentParams): UseOAuthConsentReturn { + useAssertWrappedByClerkProvider(HOOK_NAME); + + const { oauthClientId, scope, keepPreviousData = true, enabled = true } = params; + const clerk = useClerkInstanceContext(); + const user = useUserBase(); + + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); + + const { queryKey } = useOAuthConsentCacheKeys({ + userId: user?.id ?? null, + oauthClientId, + scope, + }); + + const getConsentInfo = clerk.oauthApplication?.getConsentInfo; + const hasClientId = oauthClientId.length > 0; + const queryEnabled = Boolean(user) && hasClientId && enabled && clerk.loaded && typeof getConsentInfo === 'function'; + + const queryFn = useCallback(() => { + if (!getConsentInfo) { + return Promise.reject(new Error('OAuth consent is not available in this Clerk instance')); + } + const p: GetOAuthConsentInfoParams = scope !== undefined ? { oauthClientId, scope } : { oauthClientId }; + return getConsentInfo(p); + }, [getConsentInfo, oauthClientId, scope]); + + const query = useClerkQuery({ + queryKey, + queryFn, + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + }); + + return { + data: query.data, + error: (query.error ?? null) as UseOAuthConsentReturn['error'], + isLoading: query.isLoading, + isFetching: query.isFetching, + }; +} 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..26404c3c145 --- /dev/null +++ b/packages/shared/src/react/hooks/useOAuthConsent.types.ts @@ -0,0 +1,43 @@ +import type { ClerkAPIResponseError } from '../../errors/clerkApiResponseError'; +import type { GetOAuthConsentInfoParams, OAuthConsentInfo } from '../../types'; + +/** + * @interface + */ +export type UseOAuthConsentParams = GetOAuthConsentInfoParams & { + /** + * 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; +}; + +/** + * @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..7b1836e0191 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` (consent metadata GET; keep distinct from future submit/mutation keys) +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]; From 744421321d9b6949a21eda19dedef80e1659838c Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Fri, 10 Apr 2026 14:58:19 -0500 Subject: [PATCH 2/5] read `scope` and `client_id` from location search params --- .../__tests__/useOAuthConsent.shared.spec.ts | 31 ++++++++++++ .../hooks/__tests__/useOAuthConsent.spec.tsx | 42 ++++++++++++++++ .../src/react/hooks/useOAuthConsent.shared.ts | 18 +++++++ .../src/react/hooks/useOAuthConsent.tsx | 50 +++++++++++++++---- .../src/react/hooks/useOAuthConsent.types.ts | 16 +++++- 5 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts 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 index b283829ffd5..e864a748c53 100644 --- a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx @@ -50,6 +50,7 @@ describe('useOAuthConsent', () => { mockClerk.oauthApplication = { getConsentInfo: getConsentInfoSpy, }; + window.history.replaceState({}, '', '/'); }); it('fetches consent metadata when signed in', async () => { @@ -112,4 +113,45 @@ describe('useOAuthConsent', () => { 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/useOAuthConsent.shared.ts b/packages/shared/src/react/hooks/useOAuthConsent.shared.ts index 9b21222ffe1..e26b8074a8f 100644 --- a/packages/shared/src/react/hooks/useOAuthConsent.shared.ts +++ b/packages/shared/src/react/hooks/useOAuthConsent.shared.ts @@ -4,6 +4,24 @@ 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(() => { diff --git a/packages/shared/src/react/hooks/useOAuthConsent.tsx b/packages/shared/src/react/hooks/useOAuthConsent.tsx index a39f1665fdf..ad23556463a 100644 --- a/packages/shared/src/react/hooks/useOAuthConsent.tsx +++ b/packages/shared/src/react/hooks/useOAuthConsent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { eventMethodCalled } from '../../telemetry/events/method-called'; import type { GetOAuthConsentInfoParams } from '../../types'; @@ -8,7 +8,7 @@ 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 { useOAuthConsentCacheKeys } from './useOAuthConsent.shared'; +import { readOAuthConsentFromSearch, useOAuthConsentCacheKeys } from './useOAuthConsent.shared'; import type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types'; const HOOK_NAME = 'useOAuthConsent'; @@ -18,29 +18,57 @@ const HOOK_NAME = 'useOAuthConsent'; * (`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; see {@link UseOAuthConsentParams}. + * * @example - * ### Basic usage + * ### From the URL (`?client_id=...&scope=...`) * * ```tsx * import { useOAuthConsent } from '@clerk/react' * - * export default function OAuthConsentPage({ clientId, scope }: { clientId: string; scope?: string }) { - * const { data, isLoading, error } = useOAuthConsent({ oauthClientId: clientId, scope }) + * export default function OAuthConsentPage() { + * const { data, isLoading, error } = useOAuthConsent() + * // ... + * } + * ``` * - * if (isLoading) return
Loading…
- * if (error) return
Unable to load consent
+ * @example + * ### Explicit values (override URL) * - * return
{data?.oauthApplicationName}
- * } + * ```tsx + * const { data, isLoading, error } = useOAuthConsent({ + * oauthClientId: clientIdFromProps, + * scope: scopeFromProps, + * }) * ``` */ -export function useOAuthConsent(params: UseOAuthConsentParams): UseOAuthConsentReturn { +export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthConsentReturn { useAssertWrappedByClerkProvider(HOOK_NAME); - const { oauthClientId, scope, keepPreviousData = true, enabled = true } = params; + const { oauthClientId: oauthClientIdParam, scope: scopeParam, keepPreviousData = true, enabled = true } = params; const clerk = useClerkInstanceContext(); const user = useUserBase(); + const [searchSnapshot, setSearchSnapshot] = useState(() => + typeof window !== 'undefined' ? window.location.search : '', + ); + + useLayoutEffect(() => { + setSearchSnapshot(window.location.search); + }, []); + + const fromUrl = useMemo(() => readOAuthConsentFromSearch(searchSnapshot), [searchSnapshot]); + + const oauthClientId = useMemo(() => { + const raw = oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId; + return raw.trim(); + }, [oauthClientIdParam, fromUrl.oauthClientId]); + + const scope = useMemo(() => { + return scopeParam !== undefined ? scopeParam : fromUrl.scope; + }, [scopeParam, fromUrl.scope]); + clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); const { queryKey } = useOAuthConsentCacheKeys({ diff --git a/packages/shared/src/react/hooks/useOAuthConsent.types.ts b/packages/shared/src/react/hooks/useOAuthConsent.types.ts index 26404c3c145..9905cbee9b6 100644 --- a/packages/shared/src/react/hooks/useOAuthConsent.types.ts +++ b/packages/shared/src/react/hooks/useOAuthConsent.types.ts @@ -2,9 +2,23 @@ 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` after mount and uses the standard OAuth query keys **`client_id`** and **`scope`** + * when you omit the corresponding property here. Any value you pass explicitly **overrides** the snapshot + * for that field only (including an empty `oauthClientId`, which disables the fetch). + * + * **Assumption:** the consent page does not rely on the query string changing in-place after load (for example + * via `history.pushState` / client router updates). Typical OAuth redirects perform a full navigation with a + * fixed query; the snapshot is not updated if the URL search changes later. + * + * **SSR:** there is no `window` on the server, so URL values are unavailable until the client mounts. Pass + * `oauthClientId` / `scope` from the server when you need consent metadata during SSR, or wait for the client. + * * @interface */ -export type UseOAuthConsentParams = GetOAuthConsentInfoParams & { +export type UseOAuthConsentParams = Partial> & { /** * If `true`, the previous data will be kept in the cache until new data is fetched. * From 7bf0994262334ac27b187783b14789467c02a027 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 10 Apr 2026 16:51:38 -0700 Subject: [PATCH 3/5] chore: Clean up hook --- .changeset/oauth-consent-use-hook.md | 2 +- .../src/react/hooks/useOAuthConsent.tsx | 41 ++++++++----------- .../src/react/hooks/useOAuthConsent.types.ts | 16 +++----- packages/shared/src/react/stable-keys.ts | 2 +- 4 files changed, 25 insertions(+), 36 deletions(-) diff --git a/.changeset/oauth-consent-use-hook.md b/.changeset/oauth-consent-use-hook.md index f25ffcb9246..8ab7d1fee59 100644 --- a/.changeset/oauth-consent-use-hook.md +++ b/.changeset/oauth-consent-use-hook.md @@ -5,4 +5,4 @@ '@clerk/expo': minor --- -Add the `useOAuthConsent` hook to fetch OAuth application consent metadata for signed-in users via Clerk React Query. +Introduce internal `useOAuthConsent()` hook for fetching OAuth consent screen metadata for the signed-in user. diff --git a/packages/shared/src/react/hooks/useOAuthConsent.tsx b/packages/shared/src/react/hooks/useOAuthConsent.tsx index ad23556463a..8dc2823b927 100644 --- a/packages/shared/src/react/hooks/useOAuthConsent.tsx +++ b/packages/shared/src/react/hooks/useOAuthConsent.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { eventMethodCalled } from '../../telemetry/events/method-called'; -import type { GetOAuthConsentInfoParams } from '../../types'; +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'; @@ -18,8 +18,10 @@ const HOOK_NAME = 'useOAuthConsent'; * (`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; see {@link UseOAuthConsentParams}. + * `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=...`) @@ -50,16 +52,13 @@ export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthCon const clerk = useClerkInstanceContext(); const user = useUserBase(); - const [searchSnapshot, setSearchSnapshot] = useState(() => - typeof window !== 'undefined' ? window.location.search : '', - ); - - useLayoutEffect(() => { - setSearchSnapshot(window.location.search); + const fromUrl = useMemo(() => { + if (typeof window === 'undefined' || !window.location) { + return { oauthClientId: '' }; + } + return readOAuthConsentFromSearch(window.location.search); }, []); - const fromUrl = useMemo(() => readOAuthConsentFromSearch(searchSnapshot), [searchSnapshot]); - const oauthClientId = useMemo(() => { const raw = oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId; return raw.trim(); @@ -77,21 +76,12 @@ export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthCon scope, }); - const getConsentInfo = clerk.oauthApplication?.getConsentInfo; const hasClientId = oauthClientId.length > 0; - const queryEnabled = Boolean(user) && hasClientId && enabled && clerk.loaded && typeof getConsentInfo === 'function'; - - const queryFn = useCallback(() => { - if (!getConsentInfo) { - return Promise.reject(new Error('OAuth consent is not available in this Clerk instance')); - } - const p: GetOAuthConsentInfoParams = scope !== undefined ? { oauthClientId, scope } : { oauthClientId }; - return getConsentInfo(p); - }, [getConsentInfo, oauthClientId, scope]); + const queryEnabled = Boolean(user) && hasClientId && enabled && clerk.loaded && !!clerk.oauthApplication; const query = useClerkQuery({ queryKey, - queryFn, + queryFn: () => fetchConsentInfo(clerk, { oauthClientId, scope }), enabled: queryEnabled, placeholderData: defineKeepPreviousDataFn(keepPreviousData), }); @@ -103,3 +93,8 @@ export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthCon 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 index 9905cbee9b6..59ee444c7eb 100644 --- a/packages/shared/src/react/hooks/useOAuthConsent.types.ts +++ b/packages/shared/src/react/hooks/useOAuthConsent.types.ts @@ -4,18 +4,11 @@ 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` after mount and uses the standard OAuth query keys **`client_id`** and **`scope`** - * when you omit the corresponding property here. Any value you pass explicitly **overrides** the snapshot - * for that field only (including an empty `oauthClientId`, which disables the fetch). - * - * **Assumption:** the consent page does not rely on the query string changing in-place after load (for example - * via `history.pushState` / client router updates). Typical OAuth redirects perform a full navigation with a - * fixed query; the snapshot is not updated if the URL search changes later. - * - * **SSR:** there is no `window` on the server, so URL values are unavailable until the client mounts. Pass - * `oauthClientId` / `scope` from the server when you need consent metadata during SSR, or wait for the client. + * `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> & { @@ -34,6 +27,7 @@ export type UseOAuthConsentParams = Partial Date: Fri, 10 Apr 2026 19:51:11 -0700 Subject: [PATCH 4/5] refactor(shared): clean up useOAuthConsent hook - Simplify search snapshot logic (remove useState + useLayoutEffect) - Extract fetchConsentInfo into standalone function - Gate placeholderData on queryEnabled - Mark hook and types as @internal - Export from @clerk/react/internal and @clerk/nextjs/internal - Update export snapshots and changeset --- .changeset/oauth-consent-use-hook.md | 1 - packages/expo/src/hooks/index.ts | 1 - packages/nextjs/src/client-boundary/hooks.ts | 1 - packages/nextjs/src/index.ts | 1 - packages/nextjs/src/internal.ts | 1 + packages/react/src/hooks/index.ts | 1 - packages/react/src/internal.ts | 1 + .../react/hooks/__tests__/useOAuthConsent.spec.tsx | 9 --------- .../shared/src/react/hooks/useOAuthConsent.tsx | 14 ++++---------- 9 files changed, 6 insertions(+), 24 deletions(-) diff --git a/.changeset/oauth-consent-use-hook.md b/.changeset/oauth-consent-use-hook.md index 8ab7d1fee59..a05bbdc7253 100644 --- a/.changeset/oauth-consent-use-hook.md +++ b/.changeset/oauth-consent-use-hook.md @@ -2,7 +2,6 @@ '@clerk/shared': minor '@clerk/react': minor '@clerk/nextjs': minor -'@clerk/expo': minor --- Introduce internal `useOAuthConsent()` hook for fetching OAuth consent screen metadata for the signed-in user. diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 648efad02a4..6c7f22b4d43 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -12,7 +12,6 @@ export { useUser, useReverification, useAPIKeys, - useOAuthConsent, } from '@clerk/react'; export * from './useSSO'; diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index 20df52364c0..73b5e14da4f 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -15,7 +15,6 @@ export { useUser, useReverification, useAPIKeys, - useOAuthConsent, } from '@clerk/react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 66cdf4266c3..65e061353bf 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -57,7 +57,6 @@ export { useOrganizationList, useReverification, useAPIKeys, - useOAuthConsent, useSession, useSessionList, useSignIn, 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/hooks/index.ts b/packages/react/src/hooks/index.ts index e33d87ea1c6..0db65cab7f7 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -11,7 +11,6 @@ export { useSession, useReverification, useAPIKeys, - useOAuthConsent, __experimental_useCheckout, __experimental_CheckoutProvider, __experimental_usePaymentElement, 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.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx index e864a748c53..19136ed9341 100644 --- a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx @@ -105,15 +105,6 @@ describe('useOAuthConsent', () => { expect(result.current.isLoading).toBe(false); }); - it('does not call getConsentInfo when oauthApplication.getConsentInfo is missing', () => { - mockClerk.oauthApplication = undefined; - - const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid' }), { 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'); diff --git a/packages/shared/src/react/hooks/useOAuthConsent.tsx b/packages/shared/src/react/hooks/useOAuthConsent.tsx index 8dc2823b927..8ee9267f3e5 100644 --- a/packages/shared/src/react/hooks/useOAuthConsent.tsx +++ b/packages/shared/src/react/hooks/useOAuthConsent.tsx @@ -59,14 +59,8 @@ export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthCon return readOAuthConsentFromSearch(window.location.search); }, []); - const oauthClientId = useMemo(() => { - const raw = oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId; - return raw.trim(); - }, [oauthClientIdParam, fromUrl.oauthClientId]); - - const scope = useMemo(() => { - return scopeParam !== undefined ? scopeParam : fromUrl.scope; - }, [scopeParam, fromUrl.scope]); + const oauthClientId = (oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId).trim(); + const scope = scopeParam !== undefined ? scopeParam : fromUrl.scope; clerk.telemetry?.record(eventMethodCalled(HOOK_NAME)); @@ -77,13 +71,13 @@ export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthCon }); const hasClientId = oauthClientId.length > 0; - const queryEnabled = Boolean(user) && hasClientId && enabled && clerk.loaded && !!clerk.oauthApplication; + const queryEnabled = Boolean(user) && hasClientId && enabled && clerk.loaded; const query = useClerkQuery({ queryKey, queryFn: () => fetchConsentInfo(clerk, { oauthClientId, scope }), enabled: queryEnabled, - placeholderData: defineKeepPreviousDataFn(keepPreviousData), + placeholderData: defineKeepPreviousDataFn(keepPreviousData && queryEnabled), }); return { From 9e8a4fe7c0eb22759926e4691a73a53354e01723 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 10 Apr 2026 20:05:10 -0700 Subject: [PATCH 5/5] chore: fix jsdoc imports --- packages/shared/src/react/hooks/useOAuthConsent.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/react/hooks/useOAuthConsent.tsx b/packages/shared/src/react/hooks/useOAuthConsent.tsx index 8ee9267f3e5..c87b3d08ade 100644 --- a/packages/shared/src/react/hooks/useOAuthConsent.tsx +++ b/packages/shared/src/react/hooks/useOAuthConsent.tsx @@ -27,7 +27,7 @@ const HOOK_NAME = 'useOAuthConsent'; * ### From the URL (`?client_id=...&scope=...`) * * ```tsx - * import { useOAuthConsent } from '@clerk/react' + * import { useOAuthConsent } from '@clerk/react/internal' * * export default function OAuthConsentPage() { * const { data, isLoading, error } = useOAuthConsent() @@ -39,6 +39,8 @@ const HOOK_NAME = 'useOAuthConsent'; * ### Explicit values (override URL) * * ```tsx + * import { useOAuthConsent } from '@clerk/react/internal' + * * const { data, isLoading, error } = useOAuthConsent({ * oauthClientId: clientIdFromProps, * scope: scopeFromProps,