Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
44 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
a506618
chore: comment out org selection for now
wobsoriano Apr 13, 2026
fc2ecde
feat(clerk-js): migrate oauthApplication to module class, add buildCo…
wobsoriano Apr 13, 2026
5e56f29
chore: formatting
wobsoriano Apr 13, 2026
a614e5a
chore: test credentials
wobsoriano Apr 13, 2026
ae536c7
chore: clean up internal component type prop
wobsoriano Apr 13, 2026
c9e4c86
Merge branch 'main' into rob/oauth-consent
wobsoriano Apr 13, 2026
4647f7f
chore: lint fix
wobsoriano Apr 13, 2026
f2faffa
chore: sort imports
wobsoriano Apr 13, 2026
6aae87a
chore: update changeset
wobsoriano Apr 13, 2026
f312df2
chore: fix incorrect sandbox client id for oauth
wobsoriano Apr 13, 2026
13d9299
docs: add spec for OAuthConsent organization selection
wobsoriano Apr 13, 2026
b963816
docs: rename enableOrganizationSelection to enableOrgSelection in spec
wobsoriano Apr 13, 2026
8e908fb
feat(ui): wire __internal_enableOrgSelection into OAuthConsentCtx
wobsoriano Apr 13, 2026
5319b5c
feat(ui): add org selection to OAuthConsent, submit organization_id w…
wobsoriano Apr 13, 2026
bea3330
chore: enable organization selection via __internal_enableOrgSelectio…
wobsoriano Apr 13, 2026
328e7c9
chore: remove doc
wobsoriano Apr 13, 2026
e90f06b
Merge branch 'main' into rob/oauth-consent
wobsoriano Apr 14, 2026
393ac8f
fix(ui): Derive effectiveOrg vs syncing with use state (#8306)
alexcarpenter Apr 14, 2026
68d538c
fix logo radius
alexcarpenter Apr 14, 2026
8ebcb9a
chore: set temporary infinite default
wobsoriano Apr 14, 2026
c49e397
Merge branch 'main' into rob/oauth-consent
wobsoriano Apr 14, 2026
d0a02b3
fix(ui): disable useOrganizationList fetch when enableOrgSelection is…
wobsoriano Apr 14, 2026
e7b9287
chore: disable org list if enableOrg is not enabled
wobsoriano Apr 14, 2026
c93b163
chore: add localizations to changeset
wobsoriano Apr 14, 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`.
1 change: 1 addition & 0 deletions packages/nextjs/src/client-boundary/uiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
APIKeys,
CreateOrganization,
GoogleOneTap,
OAuthConsent,
OrganizationList,
OrganizationSwitcher,
PricingTable,
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
APIKeys,
CreateOrganization,
GoogleOneTap,
OAuthConsent,
OrganizationList,
OrganizationProfile,
OrganizationSwitcher,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
"OAuthConsent",
"OrganizationList",
"OrganizationProfile",
"OrganizationSwitcher",
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
APIKeys,
CreateOrganization,
GoogleOneTap,
OAuthConsent,
OrganizationList,
OrganizationProfile,
OrganizationSwitcher,
Expand Down
32 changes: 32 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,37 @@ export const APIKeys = withClerk(
{ component: 'ApiKeys', renderWhileLoading: true },
);

/**
* @internal
*/
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

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
35 changes: 6 additions & 29 deletions packages/shared/src/react/hooks/useOAuthConsent.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
'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 +16,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 +35,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
30 changes: 25 additions & 5 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2270,7 +2270,7 @@ export type __internal_OAuthConsentProps = {
/**
* Name of the OAuth application.
*/
oAuthApplicationName: string;
oAuthApplicationName?: string;
/**
* Logo URL of the OAuth application.
*/
Expand All @@ -2282,23 +2282,43 @@ export type __internal_OAuthConsentProps = {
/**
* Scopes requested by the OAuth application.
*/
scopes: {
scopes?: {
scope: string;
description: string | null;
requires_consent: boolean;
}[];
/**
* 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;
};

/**
* 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.
*/
scope?: string;
/**
* Customize the appearance of the component.
*/
appearance?: ClerkAppearanceTheme;
};

export interface HandleEmailLinkVerificationParams {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
"OAuthConsent",
"OrganizationList",
"OrganizationProfile",
"OrganizationSwitcher",
Expand Down
Loading
Loading