Skip to content

Commit ef43ff4

Browse files
authored
fix(clerk-js): reset Core 3 OAuth retry state (#8494)
1 parent 03c83ba commit ef43ff4

6 files changed

Lines changed: 480 additions & 17 deletions

File tree

.changeset/curly-cameras-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-js": patch
3+
---
4+
5+
Fix Core 3 OAuth retry routing to the previously selected provider after an abandoned redirect.

integration/templates/custom-flows-react-vite/src/main.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
22
import { createRoot } from 'react-dom/client';
33
import { BrowserRouter, Route, Routes } from 'react-router';
44
import './index.css';
5-
import { ClerkProvider } from '@clerk/react';
5+
import { AuthenticateWithRedirectCallback, ClerkProvider } from '@clerk/react';
66
import { Home } from './routes/Home';
77
import { SignIn } from './routes/SignIn';
88
import { SignUp } from './routes/SignUp';
@@ -44,6 +44,15 @@ createRoot(document.getElementById('root')!).render(
4444
path='/protected'
4545
element={<Protected />}
4646
/>
47+
<Route
48+
path='/sso-callback'
49+
element={
50+
<AuthenticateWithRedirectCallback
51+
signInForceRedirectUrl='/protected'
52+
signUpForceRedirectUrl='/protected'
53+
/>
54+
}
55+
/>
4756
</Routes>
4857
</BrowserRouter>
4958
</ClerkProvider>

integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { Button } from '@/components/ui/button';
55
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
66
import { Input } from '@/components/ui/input';
77
import { Label } from '@/components/ui/label';
8-
import { useSignIn, useUser } from '@clerk/react';
9-
import { useState } from 'react';
8+
import { useClerk, useSignIn, useUser } from '@clerk/react';
9+
import { useEffect, useState } from 'react';
1010
import { NavLink, useNavigate } from 'react-router';
1111

1212
type AvailableStrategy = 'email_code' | 'phone_code' | 'password' | 'reset_password_email_code';
@@ -16,15 +16,44 @@ export function SignIn({ className, ...props }: React.ComponentProps<'div'>) {
1616
const [selectedStrategy, setSelectedStrategy] = useState<AvailableStrategy | null>(null);
1717
const { isSignedIn } = useUser();
1818
const navigate = useNavigate();
19+
const clerk = useClerk();
1920

20-
const handleOauth = async (strategy: 'oauth_google') => {
21+
const computeProviders = () => {
22+
const social = (clerk as any)?.__internal_environment?.userSettings?.social ?? {};
23+
return Object.entries(social as Record<string, { strategy: string; name: string; enabled: boolean }>)
24+
.filter(([key, value]) => key.startsWith('oauth_') && value?.enabled)
25+
.map(([, value]) => ({ strategy: value.strategy, name: value.name }));
26+
};
27+
const [oauthProviders, setOauthProviders] = useState<{ strategy: string; name: string }[]>(computeProviders);
28+
useEffect(() => {
29+
setOauthProviders(computeProviders());
30+
return clerk.addListener?.(() => setOauthProviders(computeProviders()));
31+
}, [clerk]);
32+
33+
const handleOauth = async (strategy: string) => {
2134
await signIn.sso({
22-
strategy,
23-
redirectUrl: '/sso-callback',
24-
redirectUrlComplete: '/protected',
35+
strategy: strategy as Parameters<typeof signIn.sso>[0]['strategy'],
36+
redirectUrl: '/protected',
37+
redirectCallbackUrl: '/sso-callback',
2538
});
2639
};
2740

41+
const oauthButtons = (
42+
<>
43+
{oauthProviders.map(provider => (
44+
<Button
45+
key={provider.strategy}
46+
type='button'
47+
className='w-full'
48+
disabled={fetchStatus === 'fetching'}
49+
onClick={() => handleOauth(provider.strategy)}
50+
>
51+
Sign in with {provider.name}
52+
</Button>
53+
))}
54+
</>
55+
);
56+
2857
const handleSubmit = async (formData: FormData) => {
2958
const identifier = formData.get('identifier');
3059
if (!identifier) {
@@ -103,6 +132,7 @@ export function SignIn({ className, ...props }: React.ComponentProps<'div'>) {
103132
</CardHeader>
104133
<CardContent>
105134
<div className='grid gap-6'>
135+
{oauthButtons}
106136
{signIn.supportedFirstFactors
107137
.filter(({ strategy }) => strategy !== 'reset_password_email_code')
108138
.map(({ strategy }) => (
@@ -268,14 +298,7 @@ export function SignIn({ className, ...props }: React.ComponentProps<'div'>) {
268298
<CardContent>
269299
<form action={handleSubmit}>
270300
<div className='grid gap-6'>
271-
<Button
272-
type='button'
273-
className='w-full'
274-
disabled={fetchStatus === 'fetching'}
275-
onClick={() => handleOauth('oauth_google')}
276-
>
277-
Sign in with Google
278-
</Button>
301+
{oauthButtons}
279302
<div className='grid gap-6'>
280303
<div className='grid gap-3'>
281304
<Label htmlFor='identifier'>Username, email, or phone number</Label>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { createClerkClient } from '@clerk/backend';
2+
import { expect, test } from '@playwright/test';
3+
4+
import type { Application } from '../../models/application';
5+
import { appConfigs } from '../../presets';
6+
import { instanceKeys } from '../../presets/envs';
7+
import type { FakeUser } from '../../testUtils';
8+
import { createTestUtils } from '../../testUtils';
9+
import { createUserService } from '../../testUtils/usersService';
10+
11+
test.describe('Custom Flows OAuth @custom', () => {
12+
test.describe.configure({ mode: 'serial' });
13+
14+
let app: Application;
15+
let fakeUser: FakeUser;
16+
17+
test.beforeAll(async () => {
18+
test.setTimeout(150_000);
19+
app = await appConfigs.customFlows.reactVite.clone().commit();
20+
await app.setup();
21+
await app.withEnv(appConfigs.envs.withEmailCodes);
22+
await app.dev();
23+
24+
const client = createClerkClient({
25+
secretKey: instanceKeys.get('oauth-provider').sk,
26+
publishableKey: instanceKeys.get('oauth-provider').pk,
27+
});
28+
const users = createUserService(client);
29+
fakeUser = users.createFakeUser({ withUsername: true });
30+
await users.createBapiUser(fakeUser);
31+
});
32+
33+
test.afterAll(async () => {
34+
const u = createTestUtils({ app });
35+
await fakeUser.deleteIfExists();
36+
await u.services.users.deleteIfExists({ email: fakeUser.email });
37+
await app.teardown();
38+
});
39+
40+
test('SDK-75: retrying OAuth after an abandoned redirect creates a fresh sign-in', async ({ page, context }) => {
41+
const u = createTestUtils({ app, page, context });
42+
43+
// Block the OAuth provider redirect on the first attempt only. clerk-js sets
44+
// `firstFactorVerification.status='unverified'` and `externalVerificationRedirectURL`
45+
// the moment the POST resolves — before the navigation runs — so aborting the
46+
// navigation deterministically reproduces the SDK-75 abandoned-redirect state
47+
// without depending on browser back/BFCache semantics.
48+
let blockOnce = true;
49+
await page.route('**/oauth/authorize**', async route => {
50+
if (blockOnce && route.request().isNavigationRequest()) {
51+
blockOnce = false;
52+
await route.abort('aborted');
53+
return;
54+
}
55+
await route.continue();
56+
});
57+
58+
await u.page.goToRelative('/sign-in');
59+
await u.page.waitForClerkJsLoaded();
60+
61+
const oauthButton = u.page.getByRole('button', { name: /^Sign in with / });
62+
await oauthButton.first().waitFor();
63+
64+
// First attempt: capture the POST, then let the redirect get aborted.
65+
const firstPostPromise = page.waitForRequest(
66+
req => req.method() === 'POST' && /\/v1\/client\/sign_ins(\?|$)/.test(req.url()),
67+
);
68+
await oauthButton.first().click();
69+
await firstPostPromise;
70+
71+
// The redirect was aborted, so we stay on the app's sign-in page with stale
72+
// OAuth state lingering in the SignIn resource. Wait for the OAuth button to
73+
// be re-enabled (fetchStatus settles back to 'idle' once the navigation aborts).
74+
await u.page.waitForURL(url => url.toString().startsWith(app.serverUrl) && url.pathname.includes('/sign-in'));
75+
await oauthButton.first().waitFor();
76+
77+
// Second attempt: must POST to /client/sign_ins again. If the previous reuse
78+
// logic kicked in (pre-fix), SignInFuture.sso would skip create and silently
79+
// no-op — so the second POST not happening is exactly the regression.
80+
const secondPostPromise = page.waitForRequest(
81+
req => req.method() === 'POST' && /\/v1\/client\/sign_ins(\?|$)/.test(req.url()),
82+
);
83+
await oauthButton.first().click();
84+
const secondPost = await secondPostPromise;
85+
expect(secondPost.method()).toBe('POST');
86+
87+
// Complete the OAuth flow end-to-end and assert we're signed in on the app instance.
88+
await u.page.getByText('Sign in to oauth-provider').waitFor();
89+
await u.po.signIn.setIdentifier(fakeUser.email);
90+
await u.po.signIn.continue();
91+
await u.po.signIn.enterTestOtpCode();
92+
93+
await u.page.waitForAppUrl('/protected');
94+
await u.po.expect.toBeSignedIn();
95+
});
96+
});

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,20 @@ class SignInFuture implements SignInFutureResource {
11451145
routes.actionCompleteRedirectUrl = wrappedRoutes.redirectUrl;
11461146
}
11471147

1148-
if (!this.#resource.id) {
1148+
// Reuse the existing sign-in by default so any state already attached to it carries
1149+
// into the SSO attempt (e.g. a ticket from `signIn.create({ strategy: 'ticket' })`,
1150+
// or a discovered identifier). OAuth is the exception: its redirect URL only comes
1151+
// back from `_create` and cannot be refreshed in place, so any redirect already
1152+
// lingering on the resource is stale. Reusing it would replay a previous attempt's
1153+
// redirect, including a retry of the same provider after an abandoned redirect
1154+
// (SDK-75), so whenever an OAuth call finds a pending redirect we start fresh.
1155+
// `enterprise_sso` is always safe to reuse because the `prepare_first_factor` call
1156+
// below refreshes its redirect against the existing sign-in.
1157+
const hasPendingRedirect = !!this.#resource.firstFactorVerification.externalVerificationRedirectURL;
1158+
const wouldReplayStaleRedirect = strategy !== 'enterprise_sso' && hasPendingRedirect;
1159+
const shouldCreateSignIn = !this.#resource.id || wouldReplayStaleRedirect;
1160+
1161+
if (shouldCreateSignIn) {
11491162
await this._create({
11501163
strategy,
11511164
...routes,

0 commit comments

Comments
 (0)