Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/sign-up-existing-account-after-challenge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix `<SignUp />` asking a returning user to fill in missing fields after a verification challenge. When someone signs up with a social account that already belongs to a user, they are now signed in to that account once the challenge clears, as they are when no challenge is shown.
123 changes: 123 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2176,6 +2176,129 @@ describe('Clerk singleton', () => {

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});

describe('a sign-up whose external account already belongs to a user', () => {
const existingAccountSignUp = (extra: Record<string, unknown> = {}) =>
new SignUp({
status: 'missing_requirements',
missing_fields: ['email_address', 'password'],
verifications: {
external_account: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: null,
error: {
code: ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS,
long_message: 'This external account already exists.',
message: 'already exists',
},
},
},
external_account: null,
...extra,
} as any as SignUpJSON);

it('is sent to its challenge by the callback before the transfer runs', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn(null),
signUp: existingAccountSignUp({
missing_fields: ['email_address', 'password', 'protect_check'],
protect_check: { status: 'pending', token: 'token', sdk_url: 'https://example.com/sdk.js' },
}),
}),
);

const mockSignInCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signIn.create = mockSignInCreate;
sut.setActive = vi.fn();

await sut.handleRedirectCallback();

await waitFor(() => expect(mockNavigate.mock.calls[0][0]).toBe('/sign-up#/protect-check'));
expect(mockSignInCreate).not.toHaveBeenCalled();
});

it('completes the transfer as a SIGN-IN and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn(null),
signUp: existingAccountSignUp(),
}),
);

const mockSetActive = vi.fn();
const mockSignInCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signIn.create = mockSignInCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck();

await waitFor(() => {
expect(mockSignInCreate).toHaveBeenCalledTimes(1);
expect(mockSignInCreate).toHaveBeenCalledWith({ transfer: true });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('activates the transferred sign-in when resumed again after the transfer has committed', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'complete',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: '123',
created_user_id: 'user_1',
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignInCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signIn.create = mockSignInCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck();

await waitFor(() => {
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
expect(mockSignInCreate).not.toHaveBeenCalled();
});
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { HandleOAuthCallbackParams, SignInResource } from '@clerk/shared/types';
import type { HandleOAuthCallbackParams, SignInResource, SignUpResource } from '@clerk/shared/types';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -23,12 +23,16 @@ const signInProtectCheckFallbackUrl = `${window.location.origin}/sign-in#/protec

type Fixtures = Awaited<ReturnType<typeof createFixtures>>['fixtures'];

const setup = async (opts: { pendingOAuthTransfer?: boolean } = {}) => {
const setup = async (opts: { pendingOAuthTransfer?: boolean; gatedSignUp?: boolean } = {}) => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withEmailAddress();
f.withSocialProvider({ provider: 'google' });
f.withPasskey();
f.withPasskeySettings({ allow_autofill: true, show_sign_in_button: false });
if (opts.gatedSignUp) {
f.startSignUpWithProtectCheck();
return;
}
f.startSignInWithProtectCheck(
opts.pendingOAuthTransfer ? { pendingOAuthTransfer: true, status: 'needs_identifier' } : undefined,
);
Expand Down Expand Up @@ -140,4 +144,43 @@ describe('SignIn combined-flow SSO callback gated by a Protect check', () => {
expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' });
expectNoReplacementSignIn(fixtures);
});

it('resumes an existing-account transfer after the embedded sign-up challenge', async () => {
const { wrapper, fixtures } = await setup({ gatedSignUp: true });
vi.mocked(fixtures.clerk.handleRedirectCallback).mockImplementation(
async (params: HandleOAuthCallbackParams, navigate?: (to: string) => Promise<unknown>) =>
navigate!(params.signUpProtectCheckUrl!),
);
fixtures.signUp.submitProtectCheck.mockResolvedValue({
status: 'missing_requirements',
missingFields: ['email_address', 'password'],
unverifiedFields: [],
protectCheck: null,
createdSessionId: null,
verifications: {
externalAccount: {
status: 'transferable',
strategy: 'oauth_google',
error: { code: 'external_account_exists' },
},
},
} as unknown as SignUpResource);

renderAtCallback(wrapper);

await waitFor(() => {
expect(fixtures.clerk.__internal_resumeAfterProtectCheck).toHaveBeenCalledWith(
expect.objectContaining({
firstFactorUrl: '../../factor-one',
secondFactorUrl: '../../factor-two',
resetPasswordUrl: '../../reset-password',
signInProtectCheckUrl: '../../protect-check',
}),
expect.any(Function),
);
});
expect(window.location.pathname).toBe('/sign-in/create/protect-check');
expect(fixtures.signUp.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' });
expectNoReplacementSignIn(fixtures);
});
});
2 changes: 1 addition & 1 deletion packages/ui/src/components/SignIn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function SignInRoutes(): JSX.Element {
{/* No canActivate guard — same resolution race as the sign-in
protect-check route above; the card owns its own routing. */}
<Route path='protect-check'>
<LazySignUpProtectCheck />
<LazySignUpProtectCheck oauthCallbackParams={buildCombinedFlowOAuthCallbackParams(signUpContext)} />
</Route>
<Route
path='verify-email-address'
Expand Down
25 changes: 23 additions & 2 deletions packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import type { SignUpProps, SignUpResource } from '@clerk/shared/types';
import { useClerk } from '@clerk/shared/react';
import type { HandleOAuthCallbackParams, SignUpProps, SignUpResource } from '@clerk/shared/types';
import { type ComponentType, useEffect, useRef, useState } from 'react';

import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked';

import { ActionBlockedCard, withRedirectToAfterSignUp } from '../../common';
import { useCoreSignUp } from '../../contexts';
import { useCoreSignUp, useSignUpContext } from '../../contexts';
import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart';
import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
import { useRouter } from '../../router';
import { ProtectCheckCard } from '../ProtectCheck/ProtectCheckCard';
import { useCompleteSignUpFlow } from './useCompleteSignUpFlow';
import { isSignUpPendingOAuthTransfer } from './util';

/**
* Continuation paths default to the standalone `/sign-up/protect-check` mount. When the card is
Expand All @@ -23,16 +26,21 @@ type SignUpProtectCheckProps = Partial<SignUpProps> & {
verifyPhonePath?: string;
continuePath?: string;
protectCheckPath?: string;
oauthCallbackParams?: HandleOAuthCallbackParams;
};

function SignUpProtectCheckInternal({
verifyEmailPath = '../verify-email-address',
verifyPhonePath = '../verify-phone-number',
continuePath = '../continue',
protectCheckPath = '.',
oauthCallbackParams,
}: SignUpProtectCheckProps = {}): JSX.Element | null {
const card = useCardState();
const signUp = useCoreSignUp();
const { navigate } = useRouter();
const { __internal_resumeAfterProtectCheck } = useClerk();
const { navigateOnSetActive } = useSignUpContext();
const { navigateToFlowStart } = useNavigateToFlowStart();
const completeSignUpFlow = useCompleteSignUpFlow();
// Latches that a protect check existed at some point, so the resolution race
Expand All @@ -41,6 +49,7 @@ function SignUpProtectCheckInternal({
// write, which React disallows in the render body.
const [everSawProtectCheck, setEverSawProtectCheck] = useState(!!signUp.protectCheck);
const didStartNoCheckFallbackRef = useRef(false);
const didResumeOAuthCallbackRef = useRef(false);

if (signUp.protectCheck && !everSawProtectCheck) {
setEverSawProtectCheck(true);
Expand All @@ -66,6 +75,18 @@ function SignUpProtectCheckInternal({
if (isCancelled()) {
return;
}
if (
oauthCallbackParams &&
typeof __internal_resumeAfterProtectCheck === 'function' &&
(didResumeOAuthCallbackRef.current || isSignUpPendingOAuthTransfer(updatedSignUp))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve OAuth-transfer intent across challenge submission

When the gated sign-up initially carries external_account_exists but submitProtectCheck returns a resource without that verification marker, this first-run predicate is false because didResumeOAuthCallbackRef is also still false. SignUp.fromJSON replaces verifications wholesale, so the marker is not guaranteed to survive the challenge response; the code then falls through to completeSignUpFlow and sends the returning user to fill the missing email/password fields—the original regression. Snapshot the transfer state before submitting, as SignInProtectCheck already does with startedAsOAuthTransfer, and include that snapshot in this condition.

AGENTS.md reference: AGENTS.md:L12-L12

Useful? React with 👍 / 👎.

) {
didResumeOAuthCallbackRef.current = true;
await __internal_resumeAfterProtectCheck(
{ ...oauthCallbackParams, __internal_navigateOnSetActive: navigateOnSetActive },
navigate,
);
return;
}
await completeSignUpFlow({
signUp: updatedSignUp,
verifyEmailPath,
Expand Down
Loading
Loading