@@ -19,14 +19,21 @@ enum EmailPrefix {
1919 BOUNCED = 'bounced' ,
2020 BOUNCED_ALIAS = 'bounced+' ,
2121 FORCED_PWD_CHANGE = 'forcepwdchange' ,
22+ PASSWORDLESS = 'passwordless' ,
2223 SIGNIN = 'signin' ,
2324 SIGNUP = 'signup' ,
2425 SYNC = 'sync' ,
2526}
2627
28+ const RELIER_CLIENT_ID = 'dcdb5ae7add825d2' ;
29+
2730type AccountDetails = {
2831 email : string ;
2932 password : string ;
33+ /** For passwordless accounts that don't have a password yet */
34+ isPasswordless ?: boolean ;
35+ /** Preserved session token for cleanup (used for passwordless+TOTP accounts) */
36+ sessionToken ?: string ;
3037} ;
3138
3239/**
@@ -147,6 +154,31 @@ export class TestAccountTracker {
147154 return this . generateAccountDetails ( EmailPrefix . BLOCKED ) ;
148155 }
149156
157+ /**
158+ * Creates a new email address with the 'passwordless' prefix and a new
159+ * randomized password. The 'passwordless' prefix triggers the passwordless
160+ * flow due to server-side email regex matching.
161+ * Note: The account is marked as passwordless for special cleanup handling.
162+ * @returns AccountDetails
163+ */
164+ generatePasswordlessAccountDetails ( ) : AccountDetails {
165+ const account = {
166+ email : this . generateEmail ( EmailPrefix . PASSWORDLESS ) ,
167+ password : this . generatePassword ( ) ,
168+ isPasswordless : true ,
169+ } ;
170+ this . accounts . push ( account ) ;
171+ return account ;
172+ }
173+
174+ /**
175+ * Creates a new email with the 'passwordless' prefix
176+ * @returns email
177+ */
178+ generatePasswordlessEmail ( ) : string {
179+ return this . generateEmail ( EmailPrefix . PASSWORDLESS ) ;
180+ }
181+
150182 /**
151183 * Creates a new email address with a given prefix and a new randomized
152184 * password
@@ -211,6 +243,48 @@ export class TestAccountTracker {
211243 return await this . signUp ( options , EmailPrefix . SYNC ) ;
212244 }
213245
246+ /**
247+ * Creates a passwordless account via API (verifierSetAt: 0, no password).
248+ * Used for testing signin to existing passwordless accounts.
249+ * Note: The account is created WITHOUT a password to remain passwordless-eligible.
250+ * Cleanup will set a password before destroying the account.
251+ * @returns Partial credentials with email, uid, and sessionToken
252+ */
253+ async signUpPasswordless ( ) : Promise < {
254+ email : string ;
255+ uid : string ;
256+ sessionToken : string ;
257+ } > {
258+ const email = this . generateEmail ( EmailPrefix . PASSWORDLESS ) ;
259+ const password = this . generatePassword ( ) ;
260+
261+ // Send passwordless code
262+ await this . target . authClient . passwordlessSendCode ( email , {
263+ clientId : RELIER_CLIENT_ID ,
264+ } ) ;
265+
266+ // Get OTP from email
267+ const code = await this . target . emailClient . getPasswordlessSignupCode ( email ) ;
268+
269+ // Confirm code - creates account (NO password is set - remains passwordless)
270+ const result = await this . target . authClient . passwordlessConfirmCode (
271+ email ,
272+ code ,
273+ {
274+ clientId : RELIER_CLIENT_ID ,
275+ }
276+ ) ;
277+
278+ // Track for cleanup - mark as passwordless so cleanup knows to handle specially
279+ this . accounts . push ( { email, password, isPasswordless : true } ) ;
280+
281+ return {
282+ email,
283+ uid : result . uid ,
284+ sessionToken : result . sessionToken ,
285+ } ;
286+ }
287+
214288 /**
215289 * Signs up an account with the AuthClient with a new email address created
216290 * with a given prefix and a new randomized password
@@ -302,6 +376,13 @@ export class TestAccountTracker {
302376 * Once we have a valid sessionToken, we disconnect 2FA then destroy the account.
303377 */
304378 private async destroyAccount ( account : AccountDetails | Credentials ) {
379+ // Handle passwordless accounts - they need a password set before we can destroy them
380+ const isPasswordless =
381+ 'isPasswordless' in account && account . isPasswordless ;
382+ if ( isPasswordless ) {
383+ await this . setupPasswordForPasswordlessAccount ( account ) ;
384+ }
385+
305386 const { sessionToken } = await this . target . authClient . signIn (
306387 account . email ,
307388 account . password
@@ -341,7 +422,11 @@ export class TestAccountTracker {
341422
342423 if ( has2FA ) {
343424 // Get MFA JWT for 2FA scope to delete TOTP
344- const mfaJwt = await this . getMfaJwtForScope ( '2fa' , sessionToken , account . email ) ;
425+ const mfaJwt = await this . getMfaJwtForScope (
426+ '2fa' ,
427+ sessionToken ,
428+ account . email
429+ ) ;
345430 await this . target . authClient . deleteTotpTokenWithJwt ( mfaJwt ) ;
346431 }
347432
@@ -353,6 +438,93 @@ export class TestAccountTracker {
353438 ) ;
354439 }
355440
441+ /**
442+ * Sets up a password for a passwordless account so it can be destroyed.
443+ * Uses the passwordless API to get a session token, then creates a password.
444+ * If the password is already set (e.g., user set it during test via UI), this is a no-op.
445+ *
446+ * For accounts with TOTP enabled, passwordless API returns TOTP_REQUIRED.
447+ * In that case, we use the preserved session token if available.
448+ */
449+ private async setupPasswordForPasswordlessAccount (
450+ account : AccountDetails
451+ ) : Promise < void > {
452+ try {
453+ // Send passwordless code
454+ await this . target . authClient . passwordlessSendCode ( account . email , {
455+ clientId : RELIER_CLIENT_ID ,
456+ } ) ;
457+
458+ // Get OTP from email
459+ const code = await this . target . emailClient . getPasswordlessSigninCode (
460+ account . email
461+ ) ;
462+
463+ // Confirm code to get session token
464+ const result = await this . target . authClient . passwordlessConfirmCode (
465+ account . email ,
466+ code ,
467+ {
468+ clientId : RELIER_CLIENT_ID ,
469+ }
470+ ) ;
471+
472+ // Create password using the session token
473+ await this . target . authClient . createPassword (
474+ result . sessionToken ,
475+ account . email ,
476+ account . password
477+ ) ;
478+ } catch ( error : any ) {
479+ // If password is already set (e.g., user set it during test via SetPassword page),
480+ // that's fine - we can proceed with normal cleanup
481+ if (
482+ error . message ?. includes ( 'password already set' ) ||
483+ error . errno === 148 // ERRNO.CAN_NOT_CREATE_PASSWORD
484+ ) {
485+ console . log (
486+ `Password already set for ${ account . email } , proceeding with cleanup`
487+ ) ;
488+ } else if ( error . errno === 160 ) {
489+ // TOTP_REQUIRED - account has 2FA enabled, can't use passwordless
490+ // Try to use preserved session token if available
491+ if ( account . sessionToken ) {
492+ console . log (
493+ `TOTP_REQUIRED for ${ account . email } , using preserved session token`
494+ ) ;
495+ try {
496+ await this . target . authClient . createPassword (
497+ account . sessionToken ,
498+ account . email ,
499+ account . password
500+ ) ;
501+ } catch ( pwdError : any ) {
502+ if (
503+ pwdError . message ?. includes ( 'password already set' ) ||
504+ pwdError . errno === 148
505+ ) {
506+ console . log (
507+ `Password already set for ${ account . email } , proceeding with cleanup`
508+ ) ;
509+ } else {
510+ throw pwdError ;
511+ }
512+ }
513+ } else {
514+ throw new Error (
515+ `Cannot set password for ${ account . email } : TOTP is enabled and no session token was preserved. ` +
516+ `Store the sessionToken from signUpPasswordless in the account for cleanup.`
517+ ) ;
518+ }
519+ } else {
520+ throw error ;
521+ }
522+ }
523+
524+ // Mark as no longer passwordless for this session
525+ account . isPasswordless = false ;
526+ }
527+
356528 /**
357529 * Checks if an account has 2FA enabled by querying the profile.
358530 * Returns false if profile query fails (graceful degradation).
@@ -404,7 +576,8 @@ export class TestAccountTracker {
404576 throw new Error ( `Failed to request MFA OTP for scope: ${ scope } ` ) ;
405577 }
406578
407- const code = await this . target . emailClient . getVerifyAccountChangeCode ( email ) ;
579+ const code =
580+ await this . target . emailClient . getVerifyAccountChangeCode ( email ) ;
408581
409582 const { accessToken } = await this . target . authClient . mfaOtpVerify (
410583 sessionToken ,
0 commit comments