Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
12b1ebb
feat(shared,ui,react,nextjs): add public <OAuthConsent /> component
wobsoriano Apr 11, 2026
31a55e9
feat(ui): show error message when client_id or redirect_uri is missin…
wobsoriano Apr 11, 2026
e093465
refactor(ui): use Card.Alert primitive for OAuthConsent error state
wobsoriano Apr 11, 2026
9b72192
fix(ui): drop error prefix and add loading guard for OAuthConsent pub…
wobsoriano Apr 12, 2026
a7c1d3f
fix(ui): simplify error messages and revert loading guard in OAuthCon…
wobsoriano Apr 12, 2026
d7d995a
refactor(shared,ui): add lowercase oauth* fields to __internal_OAuthC…
wobsoriano Apr 12, 2026
a351bff
revert(shared,ui): remove unused lowercase fields from __internal_OAu…
wobsoriano Apr 12, 2026
320c1fb
docs(shared): add @deprecated tags to __internal_OAuthConsentProps fi…
wobsoriano Apr 12, 2026
a06990e
fix(ui): disable hook fetch on accounts portal path and add OAuthCons…
wobsoriano Apr 12, 2026
1ac638b
feat(react,nextjs,react-router,tanstack): export OAuthConsent and use…
wobsoriano Apr 12, 2026
4c31aea
chore: do not leak internal hook and component in public export
wobsoriano Apr 12, 2026
513a7d3
refactor(ui): extract oauthClientId, use canReadLocation helper, remo…
wobsoriano Apr 12, 2026
09ea6c0
chore: fix snapshot tests
wobsoriano Apr 12, 2026
58f4c46
chore: fix client exports in nextjs
wobsoriano Apr 12, 2026
486e299
fix(ui): use clerk.buildUrlWithAuth to forward dev browser JWT in OAu…
wobsoriano Apr 12, 2026
9ea245e
fix(ui): replace buildUrlWithAuth with explicit __clerk_db_jwt forwar…
wobsoriano Apr 13, 2026
9c49032
chore: clean up utils
wobsoriano Apr 13, 2026
b981d76
chore: add loading indicator
wobsoriano Apr 13, 2026
2cd0d9b
chore: update post consent endpoint
wobsoriano Apr 13, 2026
e00eef2
feat(ui): add OAuthConsent subcomponents, localization, and org selec…
wobsoriano Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/public-oauth-consent-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@clerk/nextjs': minor
'@clerk/react': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Introduce `<OAuthConsent />` component for rendering a zero-config OAuth consent screen on an OAuth authorize redirect page.

Usage example:

```tsx
import { OAuthConsent } from '@clerk/nextjs';

export default function OAuthConsentPage() {
return <OAuthConsent />;
}
```

The component reads `client_id`, `scope`, and `redirect_uri` from the current URL by default and submits the consent decision internally, so no boilerplate is required for the common OAuth redirect flow. Customization options include `oauthClientId`, `scope`, `appearance`, and `fallback`.
17 changes: 17 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,23 @@ export const enUS: LocalizationResource = {
title: 'Choose an account',
titleWithoutPersonal: 'Choose an organization',
},
oauthConsent: {
action__allow: 'Allow',
action__deny: 'Deny',
offlineAccessNotice: " You'll stay signed in until you sign out or revoke access.",
redirectNotice: 'If you allow access, this app will redirect you to {{domainAction}}.',
redirectUriModal: {
subtitle: 'Make sure you trust {{applicationName}} and that this URL belongs to {{applicationName}}.',
title: 'Redirect URL',
},
scopeList: {
title: 'This will allow {{applicationName}} access to:',
},
subtitle: 'wants to access {{applicationName}} on behalf of {{identifier}}',
viewFullUrl: 'View full URL',
warning:
'Make sure that you trust {{applicationName}} ({{domainAction}}). You may be sharing sensitive data with this site or app.',
},
organizationProfile: {
apiKeysPage: {
title: 'API keys',
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/src/client-boundary/uiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export {
HandleSSOCallback,
} from '@clerk/react';

export { OAuthConsent } from '@clerk/react/internal';

// The assignment of UserProfile with BaseUserProfile props is used
// to support the CustomPage functionality (eg UserProfile.Page)
// Also the `typeof BaseUserProfile` is used to resolve the following error:
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
* If you do, app router will break.
*/
export { MultisessionAppSupport } from './client-boundary/controlComponents';
export { OAuthConsent } from './client-boundary/uiComponents';
export { useOAuthConsent } from '@clerk/shared/react';
7 changes: 7 additions & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
"types": "./dist/api/index.d.ts",
"default": "./dist/api/index.js"
},
"./internal": {
"types": "./dist/internal.d.ts",
"default": "./dist/internal.js"
},
"./errors": {
"types": "./dist/errors.d.ts",
"default": "./dist/errors.js"
Expand Down Expand Up @@ -73,6 +77,9 @@
"api.server": [
"dist/api/index.d.ts"
],
"internal": [
"dist/internal.d.ts"
],
"webhooks": [
"dist/webhooks.d.ts"
],
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useOAuthConsent, OAuthConsent } from '@clerk/react/internal';
29 changes: 29 additions & 0 deletions packages/react/src/components/uiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
APIKeysProps,
CreateOrganizationProps,
GoogleOneTapProps,
OAuthConsentProps,
OrganizationListProps,
OrganizationProfileProps,
OrganizationSwitcherProps,
Expand Down Expand Up @@ -643,6 +644,34 @@ export const APIKeys = withClerk(
{ component: 'ApiKeys', renderWhileLoading: true },
);

export const OAuthConsent = withClerk(
({ clerk, component, fallback, ...props }: WithClerkProp<OAuthConsentProps & FallbackProp>) => {
const mountingStatus = useWaitForComponentMount(component);
const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded;

const rendererRootProps = {
...(shouldShowFallback && fallback && { style: { display: 'none' } }),
};

return (
<>
{shouldShowFallback && fallback}
{clerk.loaded && (
<ClerkHostRenderer
component={component}
mount={clerk.__internal_mountOAuthConsent}
unmount={clerk.__internal_unmountOAuthConsent}
updateProps={(clerk as any).__internal_updateProps}
props={props}
rootProps={rendererRootProps}
/>
)}
</>
);
},
{ component: 'OAuthConsent', renderWhileLoading: true },
);

export const UserAvatar = withClerk(
({ clerk, component, fallback, ...props }: WithClerkProp<UserAvatarProps & FallbackProp>) => {
const mountingStatus = useWaitForComponentMount(component);
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ClerkProviderProps } from './types';
export { setErrorThrowerOptions } from './errors/errorThrower';
export { MultisessionAppSupport } from './components/controlComponents';
export { useOAuthConsent } from '@clerk/shared/react';
export { OAuthConsent } from './components/uiComponents';
export { useRoutingProps } from './hooks/useRoutingProps';
export { useDerivedAuth } from './hooks/useAuth';
export { IS_REACT_SHARED_VARIANT_COMPATIBLE } from './utils/versionCheck';
Expand Down

This file was deleted.

42 changes: 0 additions & 42 deletions packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ describe('useOAuthConsent', () => {
mockClerk.oauthApplication = {
getConsentInfo: getConsentInfoSpy,
};
window.history.replaceState({}, '', '/');
});

it('fetches consent metadata when signed in', async () => {
Expand Down Expand Up @@ -104,45 +103,4 @@ 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' });
});
});
18 changes: 0 additions & 18 deletions packages/shared/src/react/hooks/useOAuthConsent.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,6 @@ 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(() => {
Expand Down
37 changes: 6 additions & 31 deletions packages/shared/src/react/hooks/useOAuthConsent.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
'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 { useOAuthConsentCacheKeys } from './useOAuthConsent.shared';
import type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types';

const HOOK_NAME = 'useOAuthConsent';
Expand All @@ -18,26 +14,13 @@ 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.
* The hook is a pure data fetcher: it takes an explicit `oauthClientId` and optional `scope` and
* issues the fetch when both the user is signed in and `oauthClientId` is non-empty. The query is
* disabled when `oauthClientId` is empty or omitted.
*
* @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'
*
Expand All @@ -50,19 +33,11 @@ const HOOK_NAME = 'useOAuthConsent';
export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthConsentReturn {
useAssertWrappedByClerkProvider(HOOK_NAME);

const { oauthClientId: oauthClientIdParam, scope: scopeParam, keepPreviousData = true, enabled = true } = params;
const { oauthClientId: oauthClientIdParam, scope, 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;
const oauthClientId = (oauthClientIdParam ?? '').trim();

clerk.telemetry?.record(eventMethodCalled(HOOK_NAME));

Expand Down
5 changes: 2 additions & 3 deletions packages/shared/src/react/hooks/useOAuthConsent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ 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.
* Pass `oauthClientId` and `scope` explicitly. The hook does not read from `window.location` or
* any other ambient source. The hook is disabled when `oauthClientId` is empty or omitted.
*
* @internal
* @interface
Expand Down
37 changes: 32 additions & 5 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2269,36 +2269,63 @@ export type __internal_OAuthConsentProps = {
appearance?: ClerkAppearanceTheme;
/**
* Name of the OAuth application.
* @deprecated Used by the accounts portal. Use the `<OAuthConsent />` component instead.
*/
oAuthApplicationName: string;
oAuthApplicationName?: string;
/**
* Logo URL of the OAuth application.
* @deprecated Used by the accounts portal. Use the `<OAuthConsent />` component instead.
*/
oAuthApplicationLogoUrl?: string;
/**
* URL of the OAuth application.
* @deprecated Used by the accounts portal. Use the `<OAuthConsent />` component instead.
*/
oAuthApplicationUrl?: string;
/**
* Scopes requested by the OAuth application.
* @deprecated Used by the accounts portal. Use the `<OAuthConsent />` component instead.
*/
scopes: {
scopes?: {
scope: string;
description: string | null;
requires_consent: boolean;
}[];
/**
* Full URL or path to navigate to after the user allows access.
* @deprecated Used by the accounts portal. Use the `<OAuthConsent />` component instead.
*/
redirectUrl: string;
redirectUrl?: string;
/**
* Called when user allows access.
* @deprecated Used by the accounts portal. Use the `<OAuthConsent />` component instead.
*/
onAllow: () => void;
onAllow?: () => void;
/**
* Called when user denies access.
* @deprecated Used by the accounts portal. Use the `<OAuthConsent />` component instead.
*/
onDeny?: () => void;
};

/**
* Props for the public `<OAuthConsent />` React component.
*/
export type OAuthConsentProps = {
/**
* Override the OAuth client ID. Defaults to the `client_id` query parameter
* from the current URL.
*/
oauthClientId?: string;
/**
* Override the OAuth scope. Defaults to the `scope` query parameter from
* the current URL.
*/
onDeny: () => void;
scope?: string;
/**
* Customize the appearance of the component.
*/
appearance?: ClerkAppearanceTheme;
};

export interface HandleEmailLinkVerificationParams {
Expand Down
Loading
Loading