Skip to content

Commit 31a55e9

Browse files
committed
feat(ui): show error message when client_id or redirect_uri is missing in OAuthConsent
1 parent 12b1ebb commit 31a55e9

2 files changed

Lines changed: 102 additions & 3 deletions

File tree

packages/ui/src/components/OAuthConsent/OAuthConsent.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function _OAuthConsent() {
2727

2828
// Public path: fetch via hook. Falls back to URL when context did not provide the data.
2929
const fromUrl = readOAuthConsentFromSearch();
30-
const { data } = useOAuthConsent({
30+
const { data, error: hookError } = useOAuthConsent({
3131
oauthClientId: ctx.oauthClientId ?? fromUrl.oauthClientId,
3232
scope: ctx.scope ?? fromUrl.scope,
3333
});
@@ -48,6 +48,35 @@ function _OAuthConsent() {
4848

4949
const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny);
5050

51+
// Error states only apply to the public flow. The accounts portal path
52+
// provides everything via context, so these checks are skipped.
53+
const isPublicFlow = !hasContextCallbacks;
54+
if (isPublicFlow) {
55+
const oauthClientId = ctx.oauthClientId ?? fromUrl.oauthClientId;
56+
const errorMessage = !oauthClientId
57+
? 'Authorization failed: the client ID is missing. Please ensure your application is properly configured.'
58+
: !redirectUrl
59+
? 'Authorization failed: the redirect URI is missing.'
60+
: hookError
61+
? hookError.message || 'Failed to load consent information.'
62+
: null;
63+
64+
if (errorMessage) {
65+
return (
66+
<Flow.Root flow='oauthConsent'>
67+
<Card.Root>
68+
<Card.Content>
69+
<Alert colorScheme='danger'>
70+
<Text variant='caption'>{errorMessage}</Text>
71+
</Alert>
72+
</Card.Content>
73+
<Card.Footer />
74+
</Card.Root>
75+
</Flow.Root>
76+
);
77+
}
78+
}
79+
5180
const actionUrl = (() => {
5281
const url = new URL(`https://${clerk.frontendApi}/v1/internal/oauth-consent`);
5382
if (clerk.session?.id) {

packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.spec.tsx

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ describe('OAuthConsent', () => {
3939
writable: true,
4040
value: {
4141
...originalLocation,
42-
search: '?client_id=client_test',
43-
href: 'https://app.example/?client_id=client_test',
42+
search: '?client_id=client_test&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback',
43+
href: 'https://app.example/?client_id=client_test&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback',
4444
},
4545
});
4646
});
@@ -237,4 +237,74 @@ describe('OAuthConsent', () => {
237237
getByText('Allow').click();
238238
expect(onAllowSpy).toHaveBeenCalledTimes(1);
239239
});
240+
241+
it('shows missing client_id error in the public flow', async () => {
242+
Object.defineProperty(window, 'location', {
243+
configurable: true,
244+
writable: true,
245+
value: {
246+
...window.location,
247+
search: '',
248+
href: 'https://app.example/',
249+
},
250+
});
251+
252+
const { wrapper, fixtures, props } = await createFixtures(f => {
253+
f.withUser({ email_addresses: ['jane@example.com'] });
254+
});
255+
256+
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
257+
258+
props.setProps({ componentName: 'OAuthConsent' } as any);
259+
260+
const { getByText } = render(<OAuthConsent />, { wrapper });
261+
262+
await waitFor(() => {
263+
expect(getByText(/client ID is missing/i)).toBeVisible();
264+
});
265+
});
266+
267+
it('shows missing redirect_uri error in the public flow', async () => {
268+
Object.defineProperty(window, 'location', {
269+
configurable: true,
270+
writable: true,
271+
value: {
272+
...window.location,
273+
search: '?client_id=client_test',
274+
href: 'https://app.example/?client_id=client_test',
275+
},
276+
});
277+
278+
const { wrapper, fixtures, props } = await createFixtures(f => {
279+
f.withUser({ email_addresses: ['jane@example.com'] });
280+
});
281+
282+
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
283+
284+
props.setProps({ componentName: 'OAuthConsent' } as any);
285+
286+
const { getByText } = render(<OAuthConsent />, { wrapper });
287+
288+
await waitFor(() => {
289+
expect(getByText(/redirect URI is missing/i)).toBeVisible();
290+
});
291+
});
292+
293+
it('shows error message when the consent fetch fails in the public flow', async () => {
294+
const { wrapper, fixtures, props } = await createFixtures(f => {
295+
f.withUser({ email_addresses: ['jane@example.com'] });
296+
});
297+
298+
mockOAuthApplication(fixtures.clerk, {
299+
getConsentInfo: vi.fn().mockRejectedValue(new Error('Invalid OAuth client')),
300+
});
301+
302+
props.setProps({ componentName: 'OAuthConsent' } as any);
303+
304+
const { getByText } = render(<OAuthConsent />, { wrapper });
305+
306+
await waitFor(() => {
307+
expect(getByText(/Invalid OAuth client/i)).toBeVisible();
308+
});
309+
});
240310
});

0 commit comments

Comments
 (0)