Skip to content

Commit 1aa9e9f

Browse files
fix(clerk-js,types): Correctly determine destination first factor (#6789)
Co-authored-by: Nikos Douvlis <[email protected]>
1 parent 7f8f392 commit 1aa9e9f

File tree

5 files changed

+254
-36
lines changed

5 files changed

+254
-36
lines changed

.changeset/ready-days-relax.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/types': patch
4+
---
5+
6+
[Experimental] Correctly determine destination first factor based on identifier.

packages/clerk-js/bundlewatch.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "819KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "821KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "79KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "121KB" },
6-
{ "path": "./dist/clerk.headless*.js", "maxSize": "61KB" },
6+
{ "path": "./dist/clerk.headless*.js", "maxSize": "63KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "117.1KB" },
88
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "120KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "45KB" },

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

Lines changed: 120 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ import type {
1515
AuthenticateWithWeb3Params,
1616
CreateEmailLinkFlowReturn,
1717
EmailCodeConfig,
18+
EmailCodeFactor,
1819
EmailLinkConfig,
20+
EmailLinkFactor,
1921
EnterpriseSSOConfig,
2022
PassKeyConfig,
2123
PasskeyFactor,
2224
PhoneCodeConfig,
25+
PhoneCodeFactor,
2326
PrepareFirstFactorParams,
2427
PrepareSecondFactorParams,
2528
ResetPasswordEmailCodeFactorConfig,
@@ -544,6 +547,11 @@ export class SignIn extends BaseResource implements SignInResource {
544547
}
545548
}
546549

550+
type SelectFirstFactorParams =
551+
| { strategy: 'email_code'; emailAddressId?: string; phoneNumberId?: never }
552+
| { strategy: 'email_link'; emailAddressId?: string; phoneNumberId?: never }
553+
| { strategy: 'phone_code'; phoneNumberId?: string; emailAddressId?: never };
554+
547555
class SignInFuture implements SignInFutureResource {
548556
emailCode = {
549557
sendCode: this.sendEmailCode.bind(this),
@@ -692,22 +700,32 @@ class SignInFuture implements SignInFutureResource {
692700
});
693701
}
694702

695-
async sendEmailCode(params: SignInFutureEmailCodeSendParams): Promise<{ error: unknown }> {
696-
const { email } = params;
703+
async sendEmailCode(params: SignInFutureEmailCodeSendParams = {}): Promise<{ error: unknown }> {
704+
const { emailAddress, emailAddressId } = params;
705+
if (!this.resource.id && emailAddressId) {
706+
throw new Error(
707+
'signIn.emailCode.sendCode() cannot be called with an emailAddressId if an existing signIn does not exist.',
708+
);
709+
}
710+
711+
if (!this.resource.id && !emailAddress) {
712+
throw new Error(
713+
'signIn.emailCode.sendCode() cannot be called without an emailAddress if an existing signIn does not exist.',
714+
);
715+
}
716+
697717
return runAsyncResourceTask(this.resource, async () => {
698-
if (!this.resource.id) {
699-
await this.create({ identifier: email });
718+
if (emailAddress) {
719+
await this.create({ identifier: emailAddress });
700720
}
701721

702-
const emailCodeFactor = this.resource.supportedFirstFactors?.find(f => f.strategy === 'email_code');
703-
722+
const emailCodeFactor = this.selectFirstFactor({ strategy: 'email_code', emailAddressId });
704723
if (!emailCodeFactor) {
705724
throw new Error('Email code factor not found');
706725
}
707726

708-
const { emailAddressId } = emailCodeFactor;
709727
await this.resource.__internal_basePost({
710-
body: { emailAddressId, strategy: 'email_code' },
728+
body: { emailAddressId: emailCodeFactor.emailAddressId, strategy: 'email_code' },
711729
action: 'prepare_first_factor',
712730
});
713731
});
@@ -724,20 +742,29 @@ class SignInFuture implements SignInFutureResource {
724742
}
725743

726744
async sendEmailLink(params: SignInFutureEmailLinkSendParams): Promise<{ error: unknown }> {
727-
const { email, verificationUrl } = params;
745+
const { emailAddress, verificationUrl, emailAddressId } = params;
746+
if (!this.resource.id && emailAddressId) {
747+
throw new Error(
748+
'signIn.emailLink.sendLink() cannot be called with an emailAddressId if an existing signIn does not exist.',
749+
);
750+
}
751+
752+
if (!this.resource.id && !emailAddress) {
753+
throw new Error(
754+
'signIn.emailLink.sendLink() cannot be called without an emailAddress if an existing signIn does not exist.',
755+
);
756+
}
757+
728758
return runAsyncResourceTask(this.resource, async () => {
729-
if (!this.resource.id) {
730-
await this.create({ identifier: email });
759+
if (emailAddress) {
760+
await this.create({ identifier: emailAddress });
731761
}
732762

733-
const emailLinkFactor = this.resource.supportedFirstFactors?.find(f => f.strategy === 'email_link');
734-
763+
const emailLinkFactor = this.selectFirstFactor({ strategy: 'email_link', emailAddressId });
735764
if (!emailLinkFactor) {
736765
throw new Error('Email link factor not found');
737766
}
738767

739-
const { emailAddressId } = emailLinkFactor;
740-
741768
let absoluteVerificationUrl = verificationUrl;
742769
try {
743770
new URL(verificationUrl);
@@ -746,7 +773,11 @@ class SignInFuture implements SignInFutureResource {
746773
}
747774

748775
await this.resource.__internal_basePost({
749-
body: { emailAddressId, redirectUrl: absoluteVerificationUrl, strategy: 'email_link' },
776+
body: {
777+
emailAddressId: emailLinkFactor.emailAddressId,
778+
redirectUrl: absoluteVerificationUrl,
779+
strategy: 'email_link',
780+
},
750781
action: 'prepare_first_factor',
751782
});
752783
});
@@ -773,22 +804,32 @@ class SignInFuture implements SignInFutureResource {
773804
});
774805
}
775806

776-
async sendPhoneCode(params: SignInFuturePhoneCodeSendParams): Promise<{ error: unknown }> {
777-
const { phoneNumber, channel = 'sms' } = params;
807+
async sendPhoneCode(params: SignInFuturePhoneCodeSendParams = {}): Promise<{ error: unknown }> {
808+
const { phoneNumber, phoneNumberId, channel = 'sms' } = params;
809+
if (!this.resource.id && phoneNumberId) {
810+
throw new Error(
811+
'signIn.phoneCode.sendCode() cannot be called with an phoneNumberId if an existing signIn does not exist.',
812+
);
813+
}
814+
815+
if (!this.resource.id && !phoneNumber) {
816+
throw new Error(
817+
'signIn.phoneCode.sendCode() cannot be called without an phoneNumber if an existing signIn does not exist.',
818+
);
819+
}
820+
778821
return runAsyncResourceTask(this.resource, async () => {
779-
if (!this.resource.id) {
822+
if (phoneNumber) {
780823
await this.create({ identifier: phoneNumber });
781824
}
782825

783-
const phoneCodeFactor = this.resource.supportedFirstFactors?.find(f => f.strategy === 'phone_code');
784-
826+
const phoneCodeFactor = this.selectFirstFactor({ strategy: 'phone_code', phoneNumberId });
785827
if (!phoneCodeFactor) {
786828
throw new Error('Phone code factor not found');
787829
}
788830

789-
const { phoneNumberId } = phoneCodeFactor;
790831
await this.resource.__internal_basePost({
791-
body: { phoneNumberId, strategy: 'phone_code', channel },
832+
body: { phoneNumberId: phoneCodeFactor.phoneNumberId, strategy: 'phone_code', channel },
792833
action: 'prepare_first_factor',
793834
});
794835
});
@@ -886,4 +927,60 @@ class SignInFuture implements SignInFutureResource {
886927
await SignIn.clerk.setActive({ session: this.resource.createdSessionId, navigate });
887928
});
888929
}
930+
931+
private selectFirstFactor(
932+
params: Extract<SelectFirstFactorParams, { strategy: 'email_code' }>,
933+
): EmailCodeFactor | null;
934+
private selectFirstFactor(
935+
params: Extract<SelectFirstFactorParams, { strategy: 'email_link' }>,
936+
): EmailLinkFactor | null;
937+
private selectFirstFactor(
938+
params: Extract<SelectFirstFactorParams, { strategy: 'phone_code' }>,
939+
): PhoneCodeFactor | null;
940+
private selectFirstFactor({
941+
strategy,
942+
emailAddressId,
943+
phoneNumberId,
944+
}: SelectFirstFactorParams): EmailCodeFactor | EmailLinkFactor | PhoneCodeFactor | null {
945+
if (!this.resource.supportedFirstFactors) {
946+
return null;
947+
}
948+
949+
if (emailAddressId) {
950+
const factor = this.resource.supportedFirstFactors.find(
951+
f => f.strategy === strategy && f.emailAddressId === emailAddressId,
952+
) as EmailCodeFactor | EmailLinkFactor;
953+
if (factor) {
954+
return factor;
955+
}
956+
}
957+
958+
if (phoneNumberId) {
959+
const factor = this.resource.supportedFirstFactors.find(
960+
f => f.strategy === strategy && f.phoneNumberId === phoneNumberId,
961+
) as PhoneCodeFactor;
962+
if (factor) {
963+
return factor;
964+
}
965+
}
966+
967+
// Try to find a factor that matches the identifier.
968+
const factorForIdentifier = this.resource.supportedFirstFactors.find(
969+
f => f.strategy === strategy && f.safeIdentifier === this.resource.identifier,
970+
) as EmailCodeFactor | EmailLinkFactor | PhoneCodeFactor;
971+
if (factorForIdentifier) {
972+
return factorForIdentifier;
973+
}
974+
975+
// If no factor is found matching the identifier, try to find a factor that matches the strategy.
976+
const factorForStrategy = this.resource.supportedFirstFactors.find(f => f.strategy === strategy) as
977+
| EmailCodeFactor
978+
| EmailLinkFactor
979+
| PhoneCodeFactor;
980+
if (factorForStrategy) {
981+
return factorForStrategy;
982+
}
983+
984+
return null;
985+
}
889986
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { BaseResource } from '../internal';
4+
import { SignIn } from '../SignIn';
5+
6+
describe('SignIn', () => {
7+
describe('SignInFuture', () => {
8+
describe('selectFirstFactor', () => {
9+
const signInCreatedJSON = {
10+
id: 'test_id',
11+
12+
supported_first_factors: [
13+
{ strategy: 'email_code', emailAddressId: 'email_address_0', safe_identifier: '[email protected]' },
14+
{ strategy: 'email_code', emailAddressId: 'email_address_1', safe_identifier: '[email protected]' },
15+
{ strategy: 'phone_code', phoneNumberId: 'phone_number_1', safe_identifier: '+301234567890' },
16+
],
17+
};
18+
19+
const firstFactorPreparedJSON = {};
20+
21+
BaseResource._fetch = vi.fn().mockImplementation(({ method, path, body }) => {
22+
if (method === 'POST' && path === '/client/sign_ins') {
23+
return Promise.resolve({
24+
client: null,
25+
response: { ...signInCreatedJSON, identifier: body.identifier },
26+
});
27+
}
28+
29+
if (method === 'POST' && path === '/client/sign_ins/test_id/prepare_first_factor') {
30+
return Promise.resolve({
31+
client: null,
32+
response: firstFactorPreparedJSON,
33+
});
34+
}
35+
36+
throw new Error('Unexpected call to BaseResource._fetch');
37+
});
38+
39+
it('should select correct first factor by email address', async () => {
40+
const signIn = new SignIn();
41+
await signIn.__internal_future.emailCode.sendCode({ emailAddress: '[email protected]' });
42+
expect(BaseResource._fetch).toHaveBeenLastCalledWith({
43+
method: 'POST',
44+
path: '/client/sign_ins/test_id/prepare_first_factor',
45+
body: {
46+
emailAddressId: 'email_address_1',
47+
strategy: 'email_code',
48+
},
49+
});
50+
});
51+
52+
it('should select correct first factor by email address ID', async () => {
53+
const signIn = new SignIn();
54+
await signIn.__internal_future.create({ identifier: '[email protected]' });
55+
await signIn.__internal_future.emailCode.sendCode({ emailAddressId: 'email_address_1' });
56+
expect(BaseResource._fetch).toHaveBeenLastCalledWith({
57+
method: 'POST',
58+
path: '/client/sign_ins/test_id/prepare_first_factor',
59+
body: {
60+
emailAddressId: 'email_address_1',
61+
strategy: 'email_code',
62+
},
63+
});
64+
});
65+
66+
it('should select correct first factor matching identifier when nothing is provided', async () => {
67+
const signIn = new SignIn();
68+
await signIn.__internal_future.create({ identifier: '[email protected]' });
69+
await signIn.__internal_future.emailCode.sendCode();
70+
expect(BaseResource._fetch).toHaveBeenLastCalledWith({
71+
method: 'POST',
72+
path: '/client/sign_ins/test_id/prepare_first_factor',
73+
body: {
74+
emailAddressId: 'email_address_1',
75+
strategy: 'email_code',
76+
},
77+
});
78+
});
79+
80+
it('should select correct first factor when nothing is provided', async () => {
81+
const signIn = new SignIn();
82+
await signIn.__internal_future.create({ identifier: '+12255550000' });
83+
await signIn.__internal_future.emailCode.sendCode();
84+
expect(BaseResource._fetch).toHaveBeenLastCalledWith({
85+
method: 'POST',
86+
path: '/client/sign_ins/test_id/prepare_first_factor',
87+
body: {
88+
emailAddressId: 'email_address_0',
89+
strategy: 'email_code',
90+
},
91+
});
92+
});
93+
});
94+
});
95+
});

packages/types/src/signInFuture.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,27 @@ export type SignInFuturePasswordParams =
3838
email?: never;
3939
};
4040

41-
export interface SignInFutureEmailCodeSendParams {
42-
email: string;
43-
}
41+
export type SignInFutureEmailCodeSendParams =
42+
| {
43+
emailAddress?: string;
44+
emailAddressId?: never;
45+
}
46+
| {
47+
emailAddressId?: string;
48+
emailAddress?: never;
49+
};
4450

45-
export interface SignInFutureEmailLinkSendParams {
46-
email: string;
47-
verificationUrl: string;
48-
}
51+
export type SignInFutureEmailLinkSendParams =
52+
| {
53+
emailAddress?: string;
54+
verificationUrl: string;
55+
emailAddressId?: never;
56+
}
57+
| {
58+
emailAddressId?: string;
59+
verificationUrl: string;
60+
emailAddress?: never;
61+
};
4962

5063
export interface SignInFutureEmailCodeVerifyParams {
5164
code: string;
@@ -56,10 +69,17 @@ export interface SignInFutureResetPasswordSubmitParams {
5669
signOutOfOtherSessions?: boolean;
5770
}
5871

59-
export interface SignInFuturePhoneCodeSendParams {
60-
phoneNumber?: string;
61-
channel?: PhoneCodeChannel;
62-
}
72+
export type SignInFuturePhoneCodeSendParams =
73+
| {
74+
phoneNumber?: string;
75+
channel?: PhoneCodeChannel;
76+
phoneNumberId?: never;
77+
}
78+
| {
79+
phoneNumberId: string;
80+
channel?: PhoneCodeChannel;
81+
phoneNumber?: never;
82+
};
6383

6484
export interface SignInFuturePhoneCodeVerifyParams {
6585
code: string;

0 commit comments

Comments
 (0)