Skip to content

Commit 791abab

Browse files
committed
feat(settings): Add code resend for secondary email add
Because: * We want to allow a resend option directly on the verification page * The resend option on settings will no longer be available on the settings page (unconfirmed emails no longer stored in db and there not displayed in settings) * We want the resend to be authenticated with the email scoped JWT token This commit: * Add button to resend confirmation code in PageSecondaryEmailVerify * Adds success/error banner for code resend * Include cool-off to prevent successive clicks, including disabled state * Add unit tests, l10n, storybook states * Add mfa-authed secondary email code resend endpoint that relies on redis, not unconfirmed email in db, and returns an error if no valid reservation is found * Add route unit tests for the new resend endpoint * Add functional test for resend Closes #FXA-12649
1 parent 7635819 commit 791abab

File tree

11 files changed

+539
-40
lines changed

11 files changed

+539
-40
lines changed

packages/functional-tests/pages/settings/secondaryEmail.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,18 @@ export class SecondaryEmailPage extends SettingsLayout {
3333
}
3434

3535
get confirmButton() {
36-
return this.page.getByRole('button', { name: 'confirm' });
36+
return this.page.getByRole('button', { name: /^Confirm/ });
37+
}
38+
39+
get resendConfirmationCodeButton() {
40+
return this.page.getByRole('button', {
41+
name: 'Resend confirmation code',
42+
});
43+
}
44+
45+
async clickResendConfirmationCode() {
46+
await expect(this.step2Heading).toBeVisible();
47+
await this.resendConfirmationCodeButton.click();
3748
}
3849

3950
async submit() {

packages/functional-tests/tests/settings/changeEmail.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,33 @@ test.describe('severity-1 #smoke', () => {
217217

218218
await expect(settings.alertBar).toHaveText(/successfully deleted/);
219219
});
220+
221+
test('resend secondary email code and verify', async ({
222+
target,
223+
pages: { page, secondaryEmail, settings, signin },
224+
testAccountTracker,
225+
}) => {
226+
const credentials = await testAccountTracker.signUpAndPrimeMfa({
227+
scopes: 'email',
228+
});
229+
await signInAccount(target, page, settings, signin, credentials);
230+
231+
const newEmail = testAccountTracker.generateEmail();
232+
233+
await settings.goto();
234+
await settings.secondaryEmail.addButton.click();
235+
await secondaryEmail.fillOutEmail(newEmail);
236+
// let's make sure to retrieve and clear the initial code
237+
// to make sure that the next code we get is the one we just resent
238+
await target.emailClient.getVerifySecondaryCode(newEmail);
239+
await secondaryEmail.clickResendConfirmationCode();
240+
// Fetch the new secondary verification code and submit
241+
const resentCode: string =
242+
await target.emailClient.getVerifySecondaryCode(newEmail);
243+
await secondaryEmail.fillOutVerificationCode(resentCode);
244+
245+
await expect(settings.alertBar).toHaveText(/successfully added/);
246+
});
220247
});
221248
});
222249

packages/fxa-auth-client/lib/client.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1994,6 +1994,28 @@ export default class AuthClient {
19941994
);
19951995
}
19961996

1997+
/**
1998+
* Resend secondary email verification code using MFA JWT (email scope).
1999+
* This route supports resending when the email is reserved in Redis but
2000+
* not yet persisted in the DB.
2001+
*
2002+
* @param jwt MFA JWT with scope 'mfa:email'
2003+
* @param email Secondary email address to resend code to
2004+
* @param headers Optional extra headers
2005+
*/
2006+
async recoveryEmailSecondaryResendCodeWithJwt(
2007+
jwt: string,
2008+
email: string,
2009+
headers?: Headers
2010+
) {
2011+
return this.jwtPost(
2012+
'/mfa/recovery_email/secondary/resend_code',
2013+
jwt,
2014+
{ email },
2015+
headers
2016+
);
2017+
}
2018+
19972019
/**
19982020
* @deprecated Use createTotpTokenWithJwt instead
19992021
*

packages/fxa-auth-server/lib/routes/emails.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,111 @@ module.exports = (
14071407
},
14081408
handler: handlers.recoveryEmailSecondaryVerifyCodePost,
14091409
},
1410+
{
1411+
method: 'POST',
1412+
path: '/mfa/recovery_email/secondary/resend_code',
1413+
options: {
1414+
...EMAILS_DOCS.RECOVERY_EMAIL_SECONDARY_RESEND_CODE_POST,
1415+
auth: {
1416+
strategy: 'mfa',
1417+
scope: ['mfa:email'],
1418+
payload: false,
1419+
},
1420+
validate: {
1421+
payload: isA.object({
1422+
email: validators
1423+
.email()
1424+
.required()
1425+
.description(DESCRIPTION.emailSecondaryVerify),
1426+
}),
1427+
},
1428+
response: {},
1429+
},
1430+
handler: async function (request) {
1431+
// This MFA-protected route resends a secondary email verification code
1432+
// when the email is reserved in Redis but not yet stored in the DB.
1433+
// It avoids returning "unowned email" errors for reserved-but-not-stored emails.
1434+
log.begin('Account.RecoveryEmailSecondaryResend.mfa', request);
1435+
const sessionToken = request.auth.credentials;
1436+
const { email } = request.payload;
1437+
const normalizedEmail = normalizeEmail(email);
1438+
1439+
await customs.checkAuthenticated(
1440+
request,
1441+
sessionToken.uid,
1442+
sessionToken.email,
1443+
'recoveryEmailSecondaryResendCode'
1444+
);
1445+
1446+
// Verify Redis reservation exists and belongs to this account
1447+
const key = toRedisSecondaryEmailReservationKey(normalizedEmail);
1448+
const rawRecord = await authServerCacheRedis.get(key);
1449+
let parsedRecord;
1450+
if (rawRecord) {
1451+
try {
1452+
parsedRecord = JSON.parse(rawRecord);
1453+
} catch (err) {
1454+
// Bad record: cleanup and throw unowned email error
1455+
await authServerCacheRedis.del(key);
1456+
throw error.cannotResendEmailCodeToUnownedEmail();
1457+
}
1458+
}
1459+
1460+
const uidStr = Buffer.isBuffer(sessionToken.uid)
1461+
? sessionToken.uid.toString('base64')
1462+
: String(sessionToken.uid);
1463+
1464+
// If there is no reservation or it belongs to another user, throw.
1465+
if (!parsedRecord || parsedRecord.uid !== uidStr) {
1466+
throw error.cannotResendEmailCodeToUnownedEmail();
1467+
}
1468+
1469+
// Generate a new OTP code using the reserved secret and resend the email.
1470+
const secret = parsedRecord.secret;
1471+
const code = otpUtils.generateOtpCode(secret, otpOptions);
1472+
1473+
const {
1474+
deviceId,
1475+
uaBrowser,
1476+
uaBrowserVersion,
1477+
uaOS,
1478+
uaOSVersion,
1479+
uaDeviceType,
1480+
uid,
1481+
} = sessionToken;
1482+
1483+
const geoData = request.app.geo;
1484+
await mailer.sendVerifySecondaryCodeEmail(
1485+
[
1486+
{
1487+
email,
1488+
normalizedEmail,
1489+
isVerified: false,
1490+
isPrimary: false,
1491+
uid,
1492+
},
1493+
],
1494+
sessionToken,
1495+
{
1496+
code,
1497+
deviceId,
1498+
acceptLanguage: request.app.acceptLanguage,
1499+
email,
1500+
primaryEmail: sessionToken.email,
1501+
location: geoData.location,
1502+
timeZone: geoData.timeZone,
1503+
uaBrowser,
1504+
uaBrowserVersion,
1505+
uaOS,
1506+
uaOSVersion,
1507+
uaDeviceType,
1508+
uid,
1509+
}
1510+
);
1511+
1512+
return {};
1513+
},
1514+
},
14101515
{
14111516
method: 'POST',
14121517
path: '/recovery_email/secondary/verify_code',

packages/fxa-auth-server/test/local/routes/emails.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,6 +1790,120 @@ describe('/recovery_email', () => {
17901790
});
17911791
});
17921792

1793+
describe('/mfa/recovery_email/secondary/resend_code', () => {
1794+
it('resends code when redis reservation exists for this uid', async () => {
1795+
const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
1796+
const email = TEST_EMAIL_ADDITIONAL;
1797+
const normalized = normalizeEmail(email);
1798+
const mockLog = mocks.mockLog();
1799+
const mockMailer = mocks.mockMailer();
1800+
const secret = 'abcd1234abcd1234abcd1234abcd1234';
1801+
1802+
const authServerCacheRedis = {
1803+
get: sinon.stub().resolves(JSON.stringify({ uid, secret })),
1804+
set: sinon.stub().resolves('OK'),
1805+
del: sinon.stub().resolves(1),
1806+
};
1807+
1808+
const routes = makeRoutes(
1809+
{
1810+
authServerCacheRedis,
1811+
mailer: mockMailer,
1812+
log: mockLog,
1813+
},
1814+
{}
1815+
);
1816+
const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code');
1817+
1818+
const request = mocks.mockRequest({
1819+
credentials: {
1820+
uid,
1821+
email: TEST_EMAIL,
1822+
deviceId: 'device-xyz',
1823+
},
1824+
payload: { email },
1825+
app: { geo: knownIpLocation },
1826+
});
1827+
1828+
const otpUtilsLocal = require('../../../lib/routes/utils/otp').default(
1829+
{},
1830+
{ histogram: () => {} }
1831+
);
1832+
const expectedCode = otpUtilsLocal.generateOtpCode(secret, otpOptions);
1833+
1834+
const response = await runTest(route, request);
1835+
assert.ok(response);
1836+
assert.calledOnce(mockMailer.sendVerifySecondaryCodeEmail);
1837+
const args = mockMailer.sendVerifySecondaryCodeEmail.args[0];
1838+
assert.equal(args[0][0].normalizedEmail, normalized);
1839+
assert.equal(args[2].code, expectedCode, 'verification codes match');
1840+
});
1841+
1842+
it('errors when no reservation exists', async () => {
1843+
const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
1844+
const email = TEST_EMAIL_ADDITIONAL;
1845+
const mockMailer = mocks.mockMailer();
1846+
const authServerCacheRedis = {
1847+
get: sinon.stub().resolves(null),
1848+
set: sinon.stub().resolves('OK'),
1849+
del: sinon.stub().resolves(1),
1850+
};
1851+
const routes = makeRoutes({ authServerCacheRedis, mailer: mockMailer }, {});
1852+
const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code');
1853+
const request = mocks.mockRequest({
1854+
credentials: { uid, email: TEST_EMAIL },
1855+
payload: { email },
1856+
});
1857+
await assert.failsAsync(runTest(route, request), {
1858+
errno: error.ERRNO.RESEND_EMAIL_CODE_TO_UNOWNED_EMAIL,
1859+
});
1860+
});
1861+
1862+
it('errors when reservation belongs to a different uid', async () => {
1863+
const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
1864+
const otherUid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
1865+
const email = TEST_EMAIL_ADDITIONAL;
1866+
const mockMailer = mocks.mockMailer();
1867+
const authServerCacheRedis = {
1868+
get: sinon
1869+
.stub()
1870+
.resolves(JSON.stringify({ uid: otherUid, secret: 'abc' })),
1871+
set: sinon.stub().resolves('OK'),
1872+
del: sinon.stub().resolves(1),
1873+
};
1874+
const routes = makeRoutes({ authServerCacheRedis, mailer: mockMailer }, {});
1875+
const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code');
1876+
const request = mocks.mockRequest({
1877+
credentials: { uid, email: TEST_EMAIL },
1878+
payload: { email },
1879+
});
1880+
await assert.failsAsync(runTest(route, request), {
1881+
errno: error.ERRNO.RESEND_EMAIL_CODE_TO_UNOWNED_EMAIL,
1882+
});
1883+
});
1884+
1885+
it('cleans invalid redis record and errors', async () => {
1886+
const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
1887+
const email = TEST_EMAIL_ADDITIONAL;
1888+
const mockMailer = mocks.mockMailer();
1889+
const authServerCacheRedis = {
1890+
get: sinon.stub().resolves('not-json'),
1891+
set: sinon.stub().resolves('OK'),
1892+
del: sinon.stub().resolves(1),
1893+
};
1894+
const routes = makeRoutes({ authServerCacheRedis, mailer: mockMailer }, {});
1895+
const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code');
1896+
const request = mocks.mockRequest({
1897+
credentials: { uid, email: TEST_EMAIL },
1898+
payload: { email },
1899+
});
1900+
await assert.failsAsync(runTest(route, request), {
1901+
errno: error.ERRNO.RESEND_EMAIL_CODE_TO_UNOWNED_EMAIL,
1902+
});
1903+
assert.calledOnce(authServerCacheRedis.del);
1904+
});
1905+
});
1906+
17931907
describe('/emails/reminders/cad', () => {
17941908
const mockLog = mocks.mockLog();
17951909
let accountRoutes, mockRequest, route, uid;

packages/fxa-settings/src/components/Settings/PageSecondaryEmailVerify/en.ftl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
## Verify secondary email page
22

33
add-secondary-email-step-2 = Step 2 of 2
4-
verify-secondary-email-error-3 = There was a problem sending the confirmation code
54
verify-secondary-email-page-title =
65
.title = Secondary email
76
verify-secondary-email-verification-code-2 =
@@ -16,5 +15,6 @@ verify-secondary-email-please-enter-code-2 = Please enter the confirmation code
1615
# Variables:
1716
# $email (String) - the user's email address, which does not need translation.
1817
verify-secondary-email-success-alert-2 = { $email } successfully added
18+
verify-secondary-email-resend-code-button = Resend confirmation code
1919
2020
##

0 commit comments

Comments
 (0)