Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
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
'@clerk/tanstack-react-start': minor
'@clerk/react-router': minor
---

Introduce internal `<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 />;
}
```
9 changes: 4 additions & 5 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ void (async () => {
},
'/oauth-consent': () => {
const searchParams = new URLSearchParams(window.location.search);
const scopes = (searchParams.get('scopes')?.split(',') ?? []).map(scope => ({
const scopes = (searchParams.get('scope')?.split(',') ?? []).map(scope => ({
scope,
description: scope === 'offline_access' ? null : `Grants access to your ${scope}`,
requires_consent: true,
Expand All @@ -479,10 +479,9 @@ void (async () => {
app,
componentControls.oauthConsent.getProps() ?? {
scopes,
oAuthApplicationName: searchParams.get('oauth-application-name'),
redirectUrl: searchParams.get('redirect_uri'),
oAuthApplicationLogoUrl: searchParams.get('logo-url'),
oAuthApplicationUrl: searchParams.get('app-url'),
oauthClientId: 'Wg9fP2d0pSFXCZ1u',
redirectUrl: searchParams.get('redirect_uri') ?? 'http://localhost:4000/oauth/callback',
__internal_enableOrgSelection: true,
},
);
},
Expand Down
7 changes: 3 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ import { createClientFromJwt } from './jwt-client';
import { APIKeys } from './modules/apiKeys';
import { Billing } from './modules/billing';
import { createCheckoutInstance } from './modules/checkout/instance';
import { OAuthApplication } from './modules/oauthApplication';
import { Protect } from './protect';
import { BaseResource, Client, Environment, OAuthApplication, Organization, Waitlist } from './resources/internal';
import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
import { State } from './state';

type SetActiveHook = (intent?: 'sign-out') => void | Promise<void>;
Expand Down Expand Up @@ -407,9 +408,7 @@ export class Clerk implements ClerkInterface {

get oauthApplication(): OAuthApplicationNamespace {
if (!Clerk._oauthApplication) {
Clerk._oauthApplication = {
getConsentInfo: params => OAuthApplication.getConsentInfo(params),
};
Clerk._oauthApplication = new OAuthApplication();
}
return Clerk._oauthApplication;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import type { InstanceType, OAuthConsentInfoJSON } from '@clerk/shared/types';
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';

import { mockFetch } from '@/test/core-fixtures';

import { SUPPORTED_FAPI_VERSION } from '../../../constants';
import { createFapiClient } from '../../../fapiClient';
import { BaseResource } from '../../../resources/internal';
import { OAuthApplication } from '../index';

const consentPayload: OAuthConsentInfoJSON = {
object: 'oauth_consent_info',
id: 'client_abc',
oauth_application_name: 'My App',
oauth_application_logo_url: 'https://img.example/logo.png',
oauth_application_url: 'https://app.example',
client_id: 'client_abc',
state: 'st',
scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }],
};

describe('OAuthApplication', () => {
let oauthApp: OAuthApplication;

beforeEach(() => {
oauthApp = new OAuthApplication();
});

afterEach(() => {
(global.fetch as Mock)?.mockClear?.();
BaseResource.clerk = null as any;
vi.restoreAllMocks();
});

describe('getConsentInfo', () => {
it('calls _fetch with GET, encoded path, optional scope, and skipUpdateClient', async () => {
const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
response: consentPayload,
} as any);

BaseResource.clerk = {} as any;

await oauthApp.getConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' });

expect(fetchSpy).toHaveBeenCalledWith(
{
method: 'GET',
path: '/me/oauth/consent/my%2Fclient%20id',
search: { scope: 'openid email' },
},
{ skipUpdateClient: true },
);
});

it('omits search when scope is undefined', async () => {
const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
response: consentPayload,
} as any);

BaseResource.clerk = {} as any;

await oauthApp.getConsentInfo({ oauthClientId: 'cid' });

expect(fetchSpy).toHaveBeenCalledWith(expect.objectContaining({ search: undefined }), { skipUpdateClient: true });
});

it('returns OAuthConsentInfo from a non-enveloped FAPI response', async () => {
vi.spyOn(BaseResource, '_fetch').mockResolvedValue(consentPayload as any);

BaseResource.clerk = {} as any;

const info = await oauthApp.getConsentInfo({ oauthClientId: 'client_abc' });

expect(info).toEqual({
oauthApplicationName: 'My App',
oauthApplicationLogoUrl: 'https://img.example/logo.png',
oauthApplicationUrl: 'https://app.example',
clientId: 'client_abc',
state: 'st',
scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
});
});

it('returns OAuthConsentInfo from an enveloped FAPI response', async () => {
vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ response: consentPayload } as any);

BaseResource.clerk = {} as any;

const info = await oauthApp.getConsentInfo({ oauthClientId: 'client_abc' });

expect(info).toEqual({
oauthApplicationName: 'My App',
oauthApplicationLogoUrl: 'https://img.example/logo.png',
oauthApplicationUrl: 'https://app.example',
clientId: 'client_abc',
state: 'st',
scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
});
});

it('defaults scopes to [] when absent', async () => {
vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
response: { ...consentPayload, scopes: undefined },
} as any);

BaseResource.clerk = {} as any;

const info = await oauthApp.getConsentInfo({ oauthClientId: 'client_abc' });
expect(info.scopes).toEqual([]);
});

it('throws ClerkAPIResponseError on non-2xx', async () => {
mockFetch(false, 422, {
errors: [{ code: 'oauth_consent_error', long_message: 'Consent metadata unavailable' }],
});

BaseResource.clerk = {
getFapiClient: () =>
createFapiClient({
frontendApi: 'clerk.example.com',
getSessionId: () => undefined,
instanceType: 'development' as InstanceType,
}),
__internal_setCountry: vi.fn(),
handleUnauthenticated: vi.fn(),
__internal_handleUnauthenticatedDevBrowser: vi.fn(),
} as any;

await expect(oauthApp.getConsentInfo({ oauthClientId: 'cid' })).rejects.toSatisfy(
(err: unknown) => err instanceof ClerkAPIResponseError && err.message === 'Consent metadata unavailable',
);

const [url] = (global.fetch as Mock).mock.calls[0];
expect(url.toString()).toContain('/v1/me/oauth/consent/cid');
expect(url.toString()).toContain(`__clerk_api_version=${SUPPORTED_FAPI_VERSION}`);
});

it('throws ClerkRuntimeError with network_error when _fetch returns null', async () => {
vi.spyOn(BaseResource, '_fetch').mockResolvedValue(null);

BaseResource.clerk = {} as any;

await expect(oauthApp.getConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({
code: 'network_error',
});
});
});

describe('buildConsentActionUrl', () => {
// Minimal fapiClient mock: constructs a URL from path + sessionId the same
// way the real fapiClient does, so assertions on the returned URL still work.
const makeFapiClient = () => ({
buildUrl: ({ path, sessionId }: { path?: string; sessionId?: string }) => {
const url = new URL(`https://clerk.example.com/v1${path}`);
if (sessionId) {
url.searchParams.set('_clerk_session_id', sessionId);
}
return url;
},
});

it('returns a URL with the correct FAPI path', () => {
BaseResource.clerk = {
session: { id: 'sess_123' },
buildUrlWithAuth: (url: string) => url,
getFapiClient: () => makeFapiClient(),
} as any;

const result = oauthApp.buildConsentActionUrl({ clientId: 'client_abc' });

expect(result).toContain('/v1/me/oauth/consent/client_abc');
});

it('URL-encodes the client ID', () => {
BaseResource.clerk = {
session: { id: 'sess_123' },
buildUrlWithAuth: (url: string) => url,
getFapiClient: () => makeFapiClient(),
} as any;

const result = oauthApp.buildConsentActionUrl({ clientId: 'my/client id' });

expect(result).toContain('/v1/me/oauth/consent/my%2Fclient%20id');
});

it('appends _clerk_session_id when session exists', () => {
BaseResource.clerk = {
session: { id: 'sess_123' },
buildUrlWithAuth: (url: string) => url,
getFapiClient: () => makeFapiClient(),
} as any;

const result = oauthApp.buildConsentActionUrl({ clientId: 'cid' });

expect(new URL(result).searchParams.get('_clerk_session_id')).toBe('sess_123');
});

it('omits _clerk_session_id when session is null', () => {
BaseResource.clerk = {
session: null,
buildUrlWithAuth: (url: string) => url,
getFapiClient: () => makeFapiClient(),
} as any;

const result = oauthApp.buildConsentActionUrl({ clientId: 'cid' });

expect(new URL(result).searchParams.has('_clerk_session_id')).toBe(false);
});

it('delegates to buildUrlWithAuth for dev browser JWT', () => {
const buildUrlWithAuth = vi.fn((url: string) => `${url}&__clerk_db_jwt=devjwt`);
BaseResource.clerk = {
session: { id: 'sess_123' },
buildUrlWithAuth,
getFapiClient: () => makeFapiClient(),
} as any;

const result = oauthApp.buildConsentActionUrl({ clientId: 'cid' });

expect(buildUrlWithAuth).toHaveBeenCalledOnce();
expect(result).toContain('__clerk_db_jwt=devjwt');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
import { ClerkRuntimeError } from '@clerk/shared/error';
import type {
ClerkResourceJSON,
GetOAuthConsentInfoParams,
OAuthApplicationNamespace,
OAuthConsentInfo,
OAuthConsentInfoJSON,
} from '@clerk/shared/types';

import { BaseResource } from './internal';
import { BaseResource } from '../../resources/internal';

export class OAuthApplication extends BaseResource {
pathRoot = '';

protected fromJSON(_data: ClerkResourceJSON | null): this {
return this;
}

static async getConsentInfo(params: GetOAuthConsentInfoParams): Promise<OAuthConsentInfo> {
export class OAuthApplication implements OAuthApplicationNamespace {
async getConsentInfo(params: GetOAuthConsentInfoParams): Promise<OAuthConsentInfo> {
const { oauthClientId, scope } = params;
const json = await BaseResource._fetch<OAuthConsentInfoJSON>(
{
Expand All @@ -30,7 +24,6 @@ export class OAuthApplication extends BaseResource {
throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' });
}

// Handle in case we start wrapping the response in the future
const data = json.response ?? json;
return {
oauthApplicationName: data.oauth_application_name,
Expand All @@ -39,11 +32,19 @@ export class OAuthApplication extends BaseResource {
clientId: data.client_id,
state: data.state,
scopes:
data.scopes?.map(scope => ({
scope: scope.scope,
description: scope.description,
requiresConsent: scope.requires_consent,
data.scopes?.map(s => ({
scope: s.scope,
description: s.description,
requiresConsent: s.requires_consent,
})) ?? [],
};
}

buildConsentActionUrl({ clientId }: { clientId: string }): string {
const url = BaseResource.fapiClient.buildUrl({
path: `/me/oauth/consent/${encodeURIComponent(clientId)}`,
sessionId: BaseResource.clerk.session?.id,
});
return BaseResource.clerk.buildUrlWithAuth(url.toString());
}
}
Loading
Loading