Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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/chubby-memes-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

When a session already exists on sign in, complete the sign in and redirect instead of only rendering an error.
21 changes: 21 additions & 0 deletions integration/tests/oauth-flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,5 +256,26 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withLegalConsent] })(

await u.page.waitForAppUrl('/protected');
});

test('redirects when attempting OAuth sign in with existing session', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

// First, sign in the user via OAuth
await u.po.signIn.goTo();
await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click();
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.getByText('SignedIn').waitFor();
await u.po.expect.toBeSignedIn();

// Now attempt to sign in again via OAuth while already signed in
await u.po.signIn.goTo();
await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click();

// Should redirect and remain signed in instead of showing an error
await u.po.expect.toBeSignedIn();
});
},
);
41 changes: 41 additions & 0 deletions integration/tests/sign-in-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,45 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f

await u.po.expect.toBeSignedIn();
});

test('redirects when attempting to sign in with existing session', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

// First, sign in the user
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();

// Now attempt to go to sign-in page again while already signed in
await u.po.signIn.goTo();

// User should be redirected and remain signed in instead of seeing an error
await u.po.expect.toBeSignedIn();
});

test('redirects when attempting to sign in again with instant password and existing session', async ({
page,
context,
}) => {
const u = createTestUtils({ app, page, context });

// First, sign in the user
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

// Clear the page to go back to sign-in
await u.page.goToRelative('/');
await u.po.expect.toBeSignedIn();

// Attempt to sign in again with instant password
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });

// Should redirect and remain signed in without error
await u.po.expect.toBeSignedIn();
});
});
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ERROR_CODES = {
SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing',
USER_LOCKED: 'user_locked',
EXTERNAL_ACCOUNT_NOT_FOUND: 'external_account_not_found',
SESSION_EXISTS: 'session_exists',
SIGN_UP_MODE_RESTRICTED: 'sign_up_mode_restricted',
SIGN_UP_MODE_RESTRICTED_WAITLIST: 'sign_up_restricted_waitlist',
ENTERPRISE_SSO_USER_ATTRIBUTE_MISSING: 'enterprise_sso_user_attribute_missing',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { useOrganizationList, useUser } from '@clerk/shared/react';
import type { OrganizationResource } from '@clerk/shared/types';

import { isClerkAPIResponseError } from '@/index.headless';
import { sharedMainIdentifierSx } from '@/ui/common/organizations/OrganizationPreview';
import { localizationKeys, useLocalizations } from '@/ui/customizables';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
Expand Down
30 changes: 26 additions & 4 deletions packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { ClerkAPIError } from '@clerk/shared/error';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import type { PhoneCodeChannel } from '@clerk/shared/types';
import React from 'react';

import { handleError } from '@/ui/utils/errorHandler';
import { ERROR_CODES } from '@/core/constants';
import { handleError as _handleError } from '@/ui/utils/errorHandler';
import { originPrefersPopup } from '@/ui/utils/originPrefersPopup';
import { web3CallbackErrorHandler } from '@/ui/utils/web3CallbackErrorHandler';

Expand Down Expand Up @@ -30,10 +33,29 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps)
const shouldUsePopup = ctx.oauthFlow === 'popup' || (ctx.oauthFlow === 'auto' && originPrefersPopup());
const { onAlternativePhoneCodeProviderClick, ...rest } = props;

const handleError = (err: any) => {
if (isClerkAPIResponseError(err)) {
const sessionAlreadyExistsError: ClerkAPIError | undefined = err.errors.find(
(e: ClerkAPIError) => e.code === ERROR_CODES.SESSION_EXISTS,
);

if (sessionAlreadyExistsError) {
return clerk.setActive({
session: clerk.client.lastActiveSessionId,
navigate: async ({ session }) => {
await ctx.navigateOnSetActive({ session, redirectUrl: ctx.afterSignInUrl });
},
});
}
}

return _handleError(err, [], card.setError);
};

return (
<SocialButtons
{...rest}
showLastAuthenticationStrategy={true}
showLastAuthenticationStrategy
idleAfterDelay={!shouldUsePopup}
oauthCallback={strategy => {
if (shouldUsePopup) {
Expand All @@ -50,12 +72,12 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps)

return signIn
.authenticateWithPopup({ strategy, redirectUrl, redirectUrlComplete, popup, oidcPrompt: ctx.oidcPrompt })
.catch(err => handleError(err, [], card.setError));
.catch(err => handleError(err));
}

return signIn
.authenticateWithRedirect({ strategy, redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt })
.catch(err => handleError(err, [], card.setError));
.catch(err => handleError(err));
}}
web3Callback={strategy => {
return clerk
Expand Down
10 changes: 10 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,9 @@ function SignInStartInternal(): JSX.Element {
e.code === ERROR_CODES.FORM_PASSWORD_PWNED,
);

const sessionAlreadyExistsError: ClerkAPIError = e.errors.find(
(e: ClerkAPIError) => e.code === ERROR_CODES.SESSION_EXISTS,
);
const alreadySignedInError: ClerkAPIError = e.errors.find(
(e: ClerkAPIError) => e.code === 'identifier_already_signed_in',
);
Expand All @@ -442,6 +445,13 @@ function SignInStartInternal(): JSX.Element {

if (instantPasswordError) {
await signInWithFields(identifierField);
} else if (sessionAlreadyExistsError) {
await clerk.setActive({
session: clerk.client.lastActiveSessionId,
navigate: async ({ session }) => {
await navigateOnSetActive({ session, redirectUrl: afterSignInUrl });
},
});
} else if (alreadySignedInError) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sid = alreadySignedInError.meta!.sessionId!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,38 @@ describe('SignInStart', () => {
expect(icon.length).toEqual(1);
});
});

it('redirects user when session_exists error is returned during OAuth sign-in', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withSocialProvider({ provider: 'google' });
});

const sessionExistsError = new ClerkAPIResponseError('Error', {
data: [
{
code: 'session_exists',
long_message: 'A session already exists',
message: 'Session exists',
},
],
status: 422,
});

fixtures.clerk.client.lastActiveSessionId = 'sess_123';
fixtures.signIn.authenticateWithRedirect.mockRejectedValueOnce(sessionExistsError);

const { userEvent } = render(<SignInStart />, { wrapper });

const googleButton = screen.getByText('Continue with Google');
await userEvent.click(googleButton);

await waitFor(() => {
expect(fixtures.clerk.setActive).toHaveBeenCalledWith({
session: 'sess_123',
navigate: expect.any(Function),
});
});
});
});

describe('navigation', () => {
Expand Down Expand Up @@ -523,6 +555,76 @@ describe('SignInStart', () => {
});
});

describe('Session already exists error handling', () => {
it('redirects user when session_exists error is returned during sign-in', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
});

const sessionExistsError = new ClerkAPIResponseError('Error', {
data: [
{
code: 'session_exists',
long_message: 'A session already exists',
message: 'Session exists',
},
],
status: 422,
});

fixtures.clerk.client.lastActiveSessionId = 'sess_123';
fixtures.signIn.create.mockRejectedValueOnce(sessionExistsError);

const { userEvent } = render(<SignInStart />, { wrapper });

await userEvent.type(screen.getByLabelText(/email address/i), '[email protected]');
await userEvent.click(screen.getByText('Continue'));

await waitFor(() => {
expect(fixtures.clerk.setActive).toHaveBeenCalledWith({
session: 'sess_123',
navigate: expect.any(Function),
});
});
});

it('calls navigate after setting session active on session_exists error', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
});

const sessionExistsError = new ClerkAPIResponseError('Error', {
data: [
{
code: 'session_exists',
long_message: 'A session already exists',
message: 'Session exists',
},
],
status: 422,
});

fixtures.clerk.client.lastActiveSessionId = 'sess_123';
fixtures.signIn.create.mockRejectedValueOnce(sessionExistsError);

const mockSession = { id: 'sess_123' } as any;
(fixtures.clerk.setActive as any).mockImplementation(
async ({ navigate }: { navigate: ({ session }: { session: any }) => Promise<void> }) => {
await navigate({ session: mockSession });
},
);

const { userEvent } = render(<SignInStart />, { wrapper });

await userEvent.type(screen.getByLabelText(/email address/i), '[email protected]');
await userEvent.click(screen.getByText('Continue'));

await waitFor(() => {
expect(fixtures.clerk.setActive).toHaveBeenCalled();
});
});
});

describe('ticket flow', () => {
it('calls the appropriate resource function upon detecting the ticket', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import type { PhoneCodeChannel, PhoneCodeChannelData, SignUpResource } from '@clerk/shared/types';
import React from 'react';

import { isClerkAPIResponseError } from '@/index.headless';
import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { Header } from '@/ui/elements/Header';
Expand Down
Loading