Skip to content

Commit f4c4f28

Browse files
authored
Merge pull request #20104 from mozilla/fxa-13017-frontend-otp
feat(settings): add passwordless otp ui
2 parents 0d83395 + 9c91af7 commit f4c4f28

File tree

40 files changed

+2384
-165
lines changed

40 files changed

+2384
-165
lines changed

.circleci/config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ executors:
174174
CUSTOMS_SERVER_URL: none
175175
HUSKY_SKIP_INSTALL: 1
176176
AUTH_CLOUDTASKS_USE_LOCAL_EMULATOR: true
177+
# passwordless otp feature
178+
PASSWORDLESS_ENABLED: true
179+
PASSWORDLESS_ALLOWED_SERVICES: '98e6508e88680e1a,5882386c6d801776,dcdb5ae7add825d2'
177180
# Seeing if clear customs approach works! RATE_LIMIT__RULES: ""
178181
# RATE_LIMIT__IGNORE_EMAILS: .*@restmail.net$
179182

packages/functional-tests/lib/email.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export enum EmailType {
3434
passwordChanged,
3535
passwordChangeRequired,
3636
passwordForgotOtp,
37+
passwordlessSigninOtp,
38+
passwordlessSignupOtp,
3739
passwordReset,
3840
passwordResetAccountRecovery,
3941
passwordResetRequired,
@@ -74,6 +76,8 @@ export enum EmailHeader {
7476
shortCode = 'x-verify-short-code',
7577
unblockCode = 'x-unblock-code',
7678
signinCode = 'x-signin-verify-code',
79+
passwordlessSignupCode = 'x-passwordless-signup-otp',
80+
passwordlessSigninCode = 'x-passwordless-signin-otp',
7781
recoveryCode = 'x-recovery-code',
7882
uid = 'x-uid',
7983
serviceId = 'x-service-id',
@@ -292,6 +296,38 @@ export class EmailClient {
292296
return code;
293297
}
294298

299+
/**
300+
* Gets the passwordless OTP code from the email.
301+
* Note: Passwordless uses the same email template as password forgot OTP.
302+
* @param email - The email address that is expected to receive the code.
303+
* @returns The passwordless OTP code.
304+
*/
305+
async getPasswordlessSignupCode(email: string): Promise<string> {
306+
const code = await this.waitForEmail(
307+
email,
308+
EmailType.passwordlessSignupOtp,
309+
EmailHeader.passwordlessSignupCode
310+
);
311+
await this.clear(email);
312+
return code;
313+
}
314+
315+
/**
316+
* Gets the passwordless OTP code from the email.
317+
* Note: Passwordless uses the same email template as password forgot OTP.
318+
* @param email - The email address that is expected to receive the code.
319+
* @returns The passwordless OTP code.
320+
*/
321+
async getPasswordlessSigninCode(email: string): Promise<string> {
322+
const code = await this.waitForEmail(
323+
email,
324+
EmailType.passwordlessSigninOtp,
325+
EmailHeader.passwordlessSigninCode
326+
);
327+
await this.clear(email);
328+
return code;
329+
}
330+
295331
/** Creates a bounce record. Note, this only works on localhost. For stage / prod, we expect we can generate a real bounce. */
296332
async createBounce(
297333
email: string,

packages/functional-tests/lib/fixtures/standard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const test = base.extend<TestOptions, WorkerOptions>({
8484

8585
await use(testAccountTracker);
8686

87+
await target.clearRateLimits();
8788
await testAccountTracker.destroyAllAccounts();
8889
},
8990

packages/functional-tests/lib/testAccountTracker.ts

Lines changed: 175 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2730
type 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,

packages/functional-tests/pages/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { SigninRecoveryCodePage } from './signinRecoveryCode';
3232
import { SigninTokenCodePage } from './signinTokenCode';
3333
import { SigninTotpCodePage } from './signinTotpCode';
3434
import { SigninUnblockPage } from './signinUnblock';
35+
import { SigninPasswordlessCodePage } from './signinPasswordlessCode';
3536
import { SignupPage } from './signup';
3637
import { TermsOfService } from './termsOfService';
3738
import { TotpPage } from './settings/totp';
@@ -75,6 +76,7 @@ export function create(page: Page, target: BaseTarget) {
7576
signinTokenCode: new SigninTokenCodePage(page, target),
7677
signinTotpCode: new SigninTotpCodePage(page, target),
7778
signinUnblock: new SigninUnblockPage(page, target),
79+
signinPasswordlessCode: new SigninPasswordlessCodePage(page, target),
7880
signup: new SignupPage(page, target),
7981
signupConfirmedSync: new SignupConfirmedSyncPage(page, target),
8082
termsOfService: new TermsOfService(page, target),
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { BaseTokenCodePage } from './baseTokenCode';
6+
7+
export class SigninPasswordlessCodePage extends BaseTokenCodePage {
8+
readonly path = '/signin_passwordless_code';
9+
10+
get heading() {
11+
this.checkPath();
12+
return this.page.getByRole('heading', {
13+
name: /^(Enter confirmation code|Create your account)/,
14+
});
15+
}
16+
17+
get codeInput() {
18+
this.checkPath();
19+
return this.page.getByLabel('Enter 8-digit code');
20+
}
21+
22+
get submitButton() {
23+
this.checkPath();
24+
return this.page.getByRole('button', { name: 'Confirm' });
25+
}
26+
27+
get resendCodeButton() {
28+
this.checkPath();
29+
return this.page.getByRole('button', { name: /Email new code/ });
30+
}
31+
32+
get errorBanner() {
33+
return this.page.locator('[class*="banner"][class*="error"]');
34+
}
35+
36+
get totpRequiredError() {
37+
return this.page.getByText(
38+
/Two-step authentication is enabled on your account/
39+
);
40+
}
41+
42+
get resendSuccessBanner() {
43+
return this.page.getByText(/A new code was sent/);
44+
}
45+
}

0 commit comments

Comments
 (0)