Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
381bb43
fix: reset core 3 oauth retry state
jacekradko May 6, 2026
d2d1a49
Merge branch 'main' into jacek/fix-core3-oauth-retry
jacekradko May 6, 2026
35b2aa9
Merge branch 'main' into jacek/fix-core3-oauth-retry
jacekradko May 6, 2026
ab7de70
chore: tighten changeset wording
jacekradko May 6, 2026
655ed7e
chore: scope oauth retry fix
jacekradko May 6, 2026
f9f0784
chore: re-trigger ci for updated title
jacekradko May 6, 2026
18b40af
Merge branch 'main' into jacek/fix-core3-oauth-retry
jacekradko May 7, 2026
0d62e94
refactor: clarify sign in sso retry condition
jacekradko May 7, 2026
d3bef86
refactor: simplify sign in sso retry condition
jacekradko May 7, 2026
bf22549
refactor: always create new sign-in for sso flow
jacekradko May 7, 2026
40261a6
test: rename sso test to reflect unconditional create
jacekradko May 7, 2026
daa5c4a
fix: preserve enterprise sso sign-in reuse
jacekradko May 7, 2026
e0526fd
test: cover enterprise sso retry after abandoned redirect
jacekradko May 7, 2026
67452e8
Merge branch 'main' into jacek/fix-core3-oauth-retry
jacekradko May 7, 2026
3cc7300
Merge branch 'main' into jacek/fix-core3-oauth-retry
jacekradko May 8, 2026
129d610
docs: explain why enterprise sso reuse is safe and oauth is not
jacekradko May 8, 2026
78cc8b8
test(integration): cover oauth retry after abandoned redirect
jacekradko May 8, 2026
af50e36
fix(integration): make custom-flows oauth buttons reactive and visibl…
jacekradko May 8, 2026
474dea0
test(integration): abort oauth redirect to deterministically test sdk…
jacekradko May 8, 2026
7a3b4ea
fix(integration): use correct future-api redirect params for sso
jacekradko May 8, 2026
61fadd4
Merge remote-tracking branch 'origin/main' into jacek/fix-core3-oauth…
jacekradko May 8, 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
5 changes: 5 additions & 0 deletions .changeset/curly-cameras-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-js": patch
---

Fix Core 3 OAuth retry routing to the previously selected provider after an abandoned redirect.
11 changes: 10 additions & 1 deletion integration/templates/custom-flows-react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Route, Routes } from 'react-router';
import './index.css';
import { ClerkProvider } from '@clerk/react';
import { AuthenticateWithRedirectCallback, ClerkProvider } from '@clerk/react';
import { Home } from './routes/Home';
import { SignIn } from './routes/SignIn';
import { SignUp } from './routes/SignUp';
Expand Down Expand Up @@ -44,6 +44,15 @@ createRoot(document.getElementById('root')!).render(
path='/protected'
element={<Protected />}
/>
<Route
path='/sso-callback'
element={
<AuthenticateWithRedirectCallback
signInForceRedirectUrl='/protected'
signUpForceRedirectUrl='/protected'
/>
}
/>
</Routes>
</BrowserRouter>
</ClerkProvider>
Expand Down
51 changes: 37 additions & 14 deletions integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useSignIn, useUser } from '@clerk/react';
import { useState } from 'react';
import { useClerk, useSignIn, useUser } from '@clerk/react';
import { useEffect, useState } from 'react';
import { NavLink, useNavigate } from 'react-router';

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

const handleOauth = async (strategy: 'oauth_google') => {
const computeProviders = () => {
const social = (clerk as any)?.__internal_environment?.userSettings?.social ?? {};
return Object.entries(social as Record<string, { strategy: string; name: string; enabled: boolean }>)
.filter(([key, value]) => key.startsWith('oauth_') && value?.enabled)
.map(([, value]) => ({ strategy: value.strategy, name: value.name }));
};
const [oauthProviders, setOauthProviders] = useState<{ strategy: string; name: string }[]>(computeProviders);
useEffect(() => {
setOauthProviders(computeProviders());
return clerk.addListener?.(() => setOauthProviders(computeProviders()));
}, [clerk]);

const handleOauth = async (strategy: string) => {
await signIn.sso({
strategy,
redirectUrl: '/sso-callback',
redirectUrlComplete: '/protected',
strategy: strategy as Parameters<typeof signIn.sso>[0]['strategy'],
redirectUrl: '/protected',
redirectCallbackUrl: '/sso-callback',
});
};

const oauthButtons = (
<>
{oauthProviders.map(provider => (
<Button
key={provider.strategy}
type='button'
className='w-full'
disabled={fetchStatus === 'fetching'}
onClick={() => handleOauth(provider.strategy)}
>
Sign in with {provider.name}
</Button>
))}
</>
);

const handleSubmit = async (formData: FormData) => {
const identifier = formData.get('identifier');
if (!identifier) {
Expand Down Expand Up @@ -103,6 +132,7 @@ export function SignIn({ className, ...props }: React.ComponentProps<'div'>) {
</CardHeader>
<CardContent>
<div className='grid gap-6'>
{oauthButtons}
{signIn.supportedFirstFactors
.filter(({ strategy }) => strategy !== 'reset_password_email_code')
.map(({ strategy }) => (
Expand Down Expand Up @@ -268,14 +298,7 @@ export function SignIn({ className, ...props }: React.ComponentProps<'div'>) {
<CardContent>
<form action={handleSubmit}>
<div className='grid gap-6'>
<Button
type='button'
className='w-full'
disabled={fetchStatus === 'fetching'}
onClick={() => handleOauth('oauth_google')}
>
Sign in with Google
</Button>
{oauthButtons}
<div className='grid gap-6'>
<div className='grid gap-3'>
<Label htmlFor='identifier'>Username, email, or phone number</Label>
Expand Down
96 changes: 96 additions & 0 deletions integration/tests/custom-flows/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { createClerkClient } from '@clerk/backend';
import { expect, test } from '@playwright/test';

import type { Application } from '../../models/application';
import { appConfigs } from '../../presets';
import { instanceKeys } from '../../presets/envs';
import type { FakeUser } from '../../testUtils';
import { createTestUtils } from '../../testUtils';
import { createUserService } from '../../testUtils/usersService';

test.describe('Custom Flows OAuth @custom', () => {
test.describe.configure({ mode: 'serial' });

let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
test.setTimeout(150_000);
app = await appConfigs.customFlows.reactVite.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withEmailCodes);
await app.dev();

const client = createClerkClient({
secretKey: instanceKeys.get('oauth-provider').sk,
publishableKey: instanceKeys.get('oauth-provider').pk,
});
const users = createUserService(client);
fakeUser = users.createFakeUser({ withUsername: true });
await users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
const u = createTestUtils({ app });
await fakeUser.deleteIfExists();
await u.services.users.deleteIfExists({ email: fakeUser.email });
await app.teardown();
});

test('SDK-75: retrying OAuth after an abandoned redirect creates a fresh sign-in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

// Block the OAuth provider redirect on the first attempt only. clerk-js sets
// `firstFactorVerification.status='unverified'` and `externalVerificationRedirectURL`
// the moment the POST resolves — before the navigation runs — so aborting the
// navigation deterministically reproduces the SDK-75 abandoned-redirect state
// without depending on browser back/BFCache semantics.
let blockOnce = true;
await page.route('**/oauth/authorize**', async route => {
if (blockOnce && route.request().isNavigationRequest()) {
blockOnce = false;
await route.abort('aborted');
return;
}
await route.continue();
});

await u.page.goToRelative('/sign-in');
await u.page.waitForClerkJsLoaded();

const oauthButton = u.page.getByRole('button', { name: /^Sign in with / });
await oauthButton.first().waitFor();

// First attempt: capture the POST, then let the redirect get aborted.
const firstPostPromise = page.waitForRequest(
req => req.method() === 'POST' && /\/v1\/client\/sign_ins(\?|$)/.test(req.url()),
);
await oauthButton.first().click();
await firstPostPromise;

// The redirect was aborted, so we stay on the app's sign-in page with stale
// OAuth state lingering in the SignIn resource. Wait for the OAuth button to
// be re-enabled (fetchStatus settles back to 'idle' once the navigation aborts).
await u.page.waitForURL(url => url.toString().startsWith(app.serverUrl) && url.pathname.includes('/sign-in'));
await oauthButton.first().waitFor();

// Second attempt: must POST to /client/sign_ins again. If the previous reuse
// logic kicked in (pre-fix), SignInFuture.sso would skip create and silently
// no-op — so the second POST not happening is exactly the regression.
const secondPostPromise = page.waitForRequest(
req => req.method() === 'POST' && /\/v1\/client\/sign_ins(\?|$)/.test(req.url()),
);
await oauthButton.first().click();
const secondPost = await secondPostPromise;
expect(secondPost.method()).toBe('POST');

// Complete the OAuth flow end-to-end and assert we're signed in on the app instance.
await u.page.getByText('Sign in to oauth-provider').waitFor();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.enterTestOtpCode();

await u.page.waitForAppUrl('/protected');
await u.po.expect.toBeSignedIn();
});
});
9 changes: 8 additions & 1 deletion packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1145,7 +1145,14 @@ class SignInFuture implements SignInFutureResource {
routes.actionCompleteRedirectUrl = wrappedRoutes.redirectUrl;
}

if (!this.#resource.id) {
// Enterprise SSO has a `prepare_first_factor` call below that runs against the
// existing sign-in and refreshes server state, so reuse is safe for ticket-based
// and identifier-discovery flows. OAuth strategies have no equivalent refresh —
// the redirect URL only comes back from `_create` — so reusing a stale resource
// would replay the previous provider's redirect (SDK-75). Always start fresh.
const shouldCreateSignIn = !this.#resource.id || strategy !== 'enterprise_sso';

if (shouldCreateSignIn) {
await this._create({
strategy,
...routes,
Expand Down
Loading
Loading