Skip to content

Commit f9ff9e9

Browse files
jfosheewobsoriano
andauthored
feat(shared,nextjs,react): Introduce useOAuthConsent hook (#8286)
Co-authored-by: Robert Soriano <sorianorobertc@gmail.com>
1 parent 81d4df1 commit f9ff9e9

File tree

10 files changed

+386
-0
lines changed

10 files changed

+386
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/shared': minor
3+
'@clerk/react': minor
4+
'@clerk/nextjs': minor
5+
---
6+
7+
Introduce internal `useOAuthConsent()` hook for fetching OAuth consent screen metadata for the signed-in user.

packages/nextjs/src/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
* If you do, app router will break.
44
*/
55
export { MultisessionAppSupport } from './client-boundary/controlComponents';
6+
export { useOAuthConsent } from '@clerk/shared/react';

packages/react/src/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ClerkProviderProps } from './types';
77

88
export { setErrorThrowerOptions } from './errors/errorThrower';
99
export { MultisessionAppSupport } from './components/controlComponents';
10+
export { useOAuthConsent } from '@clerk/shared/react';
1011
export { useRoutingProps } from './hooks/useRoutingProps';
1112
export { useDerivedAuth } from './hooks/useAuth';
1213
export { IS_REACT_SHARED_VARIANT_COMPATIBLE } from './utils/versionCheck';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { readOAuthConsentFromSearch } from '../useOAuthConsent.shared';
4+
5+
describe('readOAuthConsentFromSearch', () => {
6+
it('parses client_id and scope from a location.search-style string', () => {
7+
expect(readOAuthConsentFromSearch('?client_id=myapp&scope=openid%20email')).toEqual({
8+
oauthClientId: 'myapp',
9+
scope: 'openid email',
10+
});
11+
});
12+
13+
it('parses without a leading question mark', () => {
14+
expect(readOAuthConsentFromSearch('client_id=x&scope=y')).toEqual({
15+
oauthClientId: 'x',
16+
scope: 'y',
17+
});
18+
});
19+
20+
it('returns empty client id and undefined scope when search is empty', () => {
21+
expect(readOAuthConsentFromSearch('')).toEqual({
22+
oauthClientId: '',
23+
});
24+
});
25+
26+
it('omits scope in the result when scope is absent', () => {
27+
expect(readOAuthConsentFromSearch('?client_id=only')).toEqual({
28+
oauthClientId: 'only',
29+
});
30+
});
31+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { renderHook, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { useOAuthConsent } from '../useOAuthConsent';
5+
import { createMockClerk, createMockQueryClient, createMockUser } from './mocks/clerk';
6+
import { wrapper } from './wrapper';
7+
8+
const consentInfo = {
9+
oauthApplicationName: 'My App',
10+
oauthApplicationLogoUrl: 'https://img.example/logo.png',
11+
oauthApplicationUrl: 'https://app.example',
12+
clientId: 'client_abc',
13+
state: 's',
14+
scopes: [] as { scope: string; description: string | null; requiresConsent: boolean }[],
15+
};
16+
17+
const getConsentInfoSpy = vi.fn(() => Promise.resolve(consentInfo));
18+
19+
const defaultQueryClient = createMockQueryClient();
20+
21+
const mockClerk = createMockClerk({
22+
oauthApplication: {
23+
getConsentInfo: getConsentInfoSpy,
24+
},
25+
queryClient: defaultQueryClient,
26+
});
27+
28+
const userState: { current: { id: string } | null } = {
29+
current: createMockUser(),
30+
};
31+
32+
vi.mock('../../contexts', () => {
33+
return {
34+
useAssertWrappedByClerkProvider: () => {},
35+
useClerkInstanceContext: () => mockClerk,
36+
useInitialStateContext: () => undefined,
37+
};
38+
});
39+
40+
vi.mock('../base/useUserBase', () => ({
41+
useUserBase: () => userState.current,
42+
}));
43+
44+
describe('useOAuthConsent', () => {
45+
beforeEach(() => {
46+
vi.clearAllMocks();
47+
defaultQueryClient.client.clear();
48+
mockClerk.loaded = true;
49+
userState.current = createMockUser();
50+
mockClerk.oauthApplication = {
51+
getConsentInfo: getConsentInfoSpy,
52+
};
53+
window.history.replaceState({}, '', '/');
54+
});
55+
56+
it('fetches consent metadata when signed in', async () => {
57+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'my_client' }), { wrapper });
58+
59+
await waitFor(() => expect(result.current.isLoading).toBe(false));
60+
61+
expect(getConsentInfoSpy).toHaveBeenCalledTimes(1);
62+
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'my_client' });
63+
expect(result.current.data).toEqual(consentInfo);
64+
expect(result.current.error).toBeNull();
65+
});
66+
67+
it('passes scope to getConsentInfo when provided', async () => {
68+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid', scope: 'openid email' }), { wrapper });
69+
70+
await waitFor(() => expect(result.current.isLoading).toBe(false));
71+
72+
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'cid', scope: 'openid email' });
73+
expect(result.current.data).toEqual(consentInfo);
74+
});
75+
76+
it('does not call getConsentInfo when user is null', () => {
77+
userState.current = null;
78+
79+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid' }), { wrapper });
80+
81+
expect(getConsentInfoSpy).not.toHaveBeenCalled();
82+
expect(result.current.isLoading).toBe(false);
83+
});
84+
85+
it('does not call getConsentInfo when clerk.loaded is false', () => {
86+
mockClerk.loaded = false;
87+
88+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid' }), { wrapper });
89+
90+
expect(getConsentInfoSpy).not.toHaveBeenCalled();
91+
expect(result.current.isLoading).toBe(false);
92+
});
93+
94+
it('does not call getConsentInfo when enabled is false', () => {
95+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'cid', enabled: false }), { wrapper });
96+
97+
expect(getConsentInfoSpy).not.toHaveBeenCalled();
98+
expect(result.current.isLoading).toBe(false);
99+
});
100+
101+
it('does not call getConsentInfo when oauthClientId is empty', () => {
102+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: '' }), { wrapper });
103+
104+
expect(getConsentInfoSpy).not.toHaveBeenCalled();
105+
expect(result.current.isLoading).toBe(false);
106+
});
107+
108+
it('uses client_id and scope from the URL when hook params omit them', async () => {
109+
window.history.replaceState({}, '', '/?client_id=from_url&scope=openid%20email');
110+
111+
const { result } = renderHook(() => useOAuthConsent(), { wrapper });
112+
113+
await waitFor(() => expect(result.current.isLoading).toBe(false));
114+
115+
expect(getConsentInfoSpy).toHaveBeenCalledTimes(1);
116+
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'from_url', scope: 'openid email' });
117+
expect(result.current.data).toEqual(consentInfo);
118+
});
119+
120+
it('prefers explicit oauthClientId over URL client_id', async () => {
121+
window.history.replaceState({}, '', '/?client_id=from_url');
122+
123+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'explicit_id' }), { wrapper });
124+
125+
await waitFor(() => expect(result.current.isLoading).toBe(false));
126+
127+
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'explicit_id' });
128+
});
129+
130+
it('does not fall back to URL client_id when oauthClientId is explicitly empty', () => {
131+
window.history.replaceState({}, '', '/?client_id=from_url');
132+
133+
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: '' }), { wrapper });
134+
135+
expect(getConsentInfoSpy).not.toHaveBeenCalled();
136+
expect(result.current.isLoading).toBe(false);
137+
});
138+
139+
it('prefers explicit scope over URL scope', async () => {
140+
window.history.replaceState({}, '', '/?client_id=cid&scope=from_url');
141+
142+
const { result } = renderHook(() => useOAuthConsent({ scope: 'explicit_scope' }), { wrapper });
143+
144+
await waitFor(() => expect(result.current.isLoading).toBe(false));
145+
146+
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'cid', scope: 'explicit_scope' });
147+
});
148+
});

packages/shared/src/react/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export { assertContextExists, createContextAndHook } from './createContextAndHook';
22
export { useAPIKeys } from './useAPIKeys';
3+
export { useOAuthConsent } from './useOAuthConsent';
4+
export type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types';
35
export { useOrganization } from './useOrganization';
46
export { useOrganizationCreationDefaults } from './useOrganizationCreationDefaults';
57
export type {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useMemo } from 'react';
2+
3+
import type { GetOAuthConsentInfoParams } from '../../types';
4+
import { STABLE_KEYS } from '../stable-keys';
5+
import { createCacheKeys } from './createCacheKeys';
6+
7+
/**
8+
* Parses OAuth authorize-style query data from a search string (typically `window.location.search`).
9+
*
10+
* @internal
11+
*/
12+
export function readOAuthConsentFromSearch(search: string): {
13+
oauthClientId: string;
14+
scope?: string;
15+
} {
16+
const sp = new URLSearchParams(search);
17+
const oauthClientId = sp.get('client_id') ?? '';
18+
const scopeValue = sp.get('scope');
19+
if (scopeValue === null) {
20+
return { oauthClientId };
21+
}
22+
return { oauthClientId, scope: scopeValue };
23+
}
24+
25+
export function useOAuthConsentCacheKeys(params: { userId: string | null; oauthClientId: string; scope?: string }) {
26+
const { userId, oauthClientId, scope } = params;
27+
return useMemo(() => {
28+
const args: Pick<GetOAuthConsentInfoParams, 'oauthClientId'> & { scope?: string } = { oauthClientId };
29+
if (scope !== undefined) {
30+
args.scope = scope;
31+
}
32+
return createCacheKeys({
33+
stablePrefix: STABLE_KEYS.OAUTH_CONSENT_INFO_KEY,
34+
authenticated: true,
35+
tracked: {
36+
userId: userId ?? null,
37+
},
38+
untracked: {
39+
args,
40+
},
41+
});
42+
}, [userId, oauthClientId, scope]);
43+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use client';
2+
3+
import { useMemo } from 'react';
4+
5+
import { eventMethodCalled } from '../../telemetry/events/method-called';
6+
import type { LoadedClerk } from '../../types/clerk';
7+
import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data';
8+
import { useClerkQuery } from '../clerk-rq/useQuery';
9+
import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts';
10+
import { useUserBase } from './base/useUserBase';
11+
import { readOAuthConsentFromSearch, useOAuthConsentCacheKeys } from './useOAuthConsent.shared';
12+
import type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types';
13+
14+
const HOOK_NAME = 'useOAuthConsent';
15+
16+
/**
17+
* The `useOAuthConsent()` hook loads OAuth application consent metadata for the **signed-in** user
18+
* (`GET /me/oauth/consent/{oauthClientId}`). Ensure the user is authenticated before relying on this hook
19+
* (for example, redirect to sign-in on your custom consent route).
20+
*
21+
* `oauthClientId` and `scope` are optional. On the client, values default from a single snapshot of
22+
* `window.location.search` (`client_id` and `scope`). Pass them explicitly to override.
23+
*
24+
* @internal
25+
*
26+
* @example
27+
* ### From the URL (`?client_id=...&scope=...`)
28+
*
29+
* ```tsx
30+
* import { useOAuthConsent } from '@clerk/react/internal'
31+
*
32+
* export default function OAuthConsentPage() {
33+
* const { data, isLoading, error } = useOAuthConsent()
34+
* // ...
35+
* }
36+
* ```
37+
*
38+
* @example
39+
* ### Explicit values (override URL)
40+
*
41+
* ```tsx
42+
* import { useOAuthConsent } from '@clerk/react/internal'
43+
*
44+
* const { data, isLoading, error } = useOAuthConsent({
45+
* oauthClientId: clientIdFromProps,
46+
* scope: scopeFromProps,
47+
* })
48+
* ```
49+
*/
50+
export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthConsentReturn {
51+
useAssertWrappedByClerkProvider(HOOK_NAME);
52+
53+
const { oauthClientId: oauthClientIdParam, scope: scopeParam, keepPreviousData = true, enabled = true } = params;
54+
const clerk = useClerkInstanceContext();
55+
const user = useUserBase();
56+
57+
const fromUrl = useMemo(() => {
58+
if (typeof window === 'undefined' || !window.location) {
59+
return { oauthClientId: '' };
60+
}
61+
return readOAuthConsentFromSearch(window.location.search);
62+
}, []);
63+
64+
const oauthClientId = (oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId).trim();
65+
const scope = scopeParam !== undefined ? scopeParam : fromUrl.scope;
66+
67+
clerk.telemetry?.record(eventMethodCalled(HOOK_NAME));
68+
69+
const { queryKey } = useOAuthConsentCacheKeys({
70+
userId: user?.id ?? null,
71+
oauthClientId,
72+
scope,
73+
});
74+
75+
const hasClientId = oauthClientId.length > 0;
76+
const queryEnabled = Boolean(user) && hasClientId && enabled && clerk.loaded;
77+
78+
const query = useClerkQuery({
79+
queryKey,
80+
queryFn: () => fetchConsentInfo(clerk, { oauthClientId, scope }),
81+
enabled: queryEnabled,
82+
placeholderData: defineKeepPreviousDataFn(keepPreviousData && queryEnabled),
83+
});
84+
85+
return {
86+
data: query.data,
87+
error: (query.error ?? null) as UseOAuthConsentReturn['error'],
88+
isLoading: query.isLoading,
89+
isFetching: query.isFetching,
90+
};
91+
}
92+
93+
function fetchConsentInfo(clerk: LoadedClerk, params: { oauthClientId: string; scope?: string }) {
94+
const { oauthClientId, scope } = params;
95+
return clerk.oauthApplication.getConsentInfo(scope !== undefined ? { oauthClientId, scope } : { oauthClientId });
96+
}

0 commit comments

Comments
 (0)