diff --git a/packages/fxa-auth-server/test/remote/attached_clients_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/attached_clients_tests.in.spec.ts new file mode 100644 index 00000000000..20b78c654fd --- /dev/null +++ b/packages/fxa-auth-server/test/remote/attached_clients_tests.in.spec.ts @@ -0,0 +1,232 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); +const ScopeSet = require('fxa-shared').oauth.scopes; +const hashRefreshToken = require('fxa-shared/auth/encrypt').hash; + +const buf = (v: any) => (Buffer.isBuffer(v) ? v : Buffer.from(v, 'hex')); +const PUBLIC_CLIENT_ID = '3c49430b43dfba77'; + +let server: TestServerInstance; +let oauthServerDb: any; +let tokens: any; + +beforeAll(async () => { + server = await createTestServer(); + + const config = require('../../config').default.getProperties(); + tokens = require('../../lib/tokens')({ trace: () => {} }, config); + oauthServerDb = require('../../lib/oauth/db'); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - attached clients listing', + ({ version, tag }) => { + const testOptions = { version }; + + it('correctly lists a variety of attached clients', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + const mySessionTokenId = ( + await tokens.SessionToken.fromHex(client.sessionToken) + ).id; + const deviceInfo = { + name: 'test device \ud83c\udf53\ud83d\udd25\u5728\ud834\udf06', + type: 'mobile', + availableCommands: { foo: 'bar' }, + pushCallback: '', + pushPublicKey: '', + pushAuthKey: '', + }; + + let allClients = await client.attachedClients(); + expect(allClients.length).toBe(1); + expect(allClients[0].sessionTokenId).toBe(mySessionTokenId); + expect(allClients[0].deviceId).toBeNull(); + expect(allClients[0].lastAccessTimeFormatted).toBe('a few seconds ago'); + + const device = await client.updateDevice(deviceInfo); + + allClients = await client.attachedClients(); + expect(allClients.length).toBe(1); + expect(allClients[0].sessionTokenId).toBe(mySessionTokenId); + expect(allClients[0].deviceId).toBe(device.id); + expect(allClients[0].name).toBe(deviceInfo.name); + + const refreshToken = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: ScopeSet.fromArray([ + 'profile', + 'https://identity.mozilla.com/apps/oldsync', + ]), + }); + const refreshTokenId = hashRefreshToken(refreshToken.token).toString('hex'); + + allClients = await client.attachedClients(); + expect(allClients.length).toBe(2); + expect(allClients[0].sessionTokenId).toBe(mySessionTokenId); + expect(allClients[1].sessionTokenId).toBeNull(); + expect(allClients[1].refreshTokenId).toBe(refreshTokenId); + expect(allClients[1].lastAccessTimeFormatted).toBe('a few seconds ago'); + expect(allClients[1].name).toBe('Android Components Reference Browser'); + + const device2 = await client.updateDeviceWithRefreshToken( + refreshToken.token.toString('hex'), + { name: 'test device', type: 'mobile' } + ); + + allClients = await client.attachedClients(); + expect(allClients.length).toBe(2); + const one = allClients.findIndex((c: any) => c.name === 'test device'); + const zero = (one + 1) % allClients.length; + expect(allClients[zero].sessionTokenId).toBe(mySessionTokenId); + expect(allClients[zero].deviceId).toBe(device.id); + expect(allClients[one].refreshTokenId).toBe(refreshTokenId); + expect(allClients[one].deviceId).toBe(device2.id); + expect(allClients[one].name).toBe('test device'); + }); + + it('correctly deletes by device id', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + const mySessionTokenId = ( + await tokens.SessionToken.fromHex(client.sessionToken) + ).id; + + const client2 = await Client.login(server.publicUrl, email, password, testOptions); + const device = await client2.updateDevice({ name: 'test', type: 'desktop' }); + + let allClients = await client.attachedClients(); + expect(allClients.length).toBe(2); + + await client.destroyAttachedClient({ deviceId: device.id }); + + allClients = await client.attachedClients(); + expect(allClients.length).toBe(1); + expect(allClients[0].sessionTokenId).toBe(mySessionTokenId); + }); + + it('correctly deletes by sessionTokenId', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + const mySessionTokenId = ( + await tokens.SessionToken.fromHex(client.sessionToken) + ).id; + + const client2 = await Client.login(server.publicUrl, email, password, testOptions); + const otherSessionTokenId = ( + await tokens.SessionToken.fromHex(client2.sessionToken) + ).id; + + let allClients = await client.attachedClients(); + expect(allClients.length).toBe(2); + + await client.destroyAttachedClient({ sessionTokenId: otherSessionTokenId }); + + allClients = await client.attachedClients(); + expect(allClients.length).toBe(1); + expect(allClients[0].sessionTokenId).toBe(mySessionTokenId); + }); + + it('correctly deletes by refreshTokenId', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + const mySessionTokenId = ( + await tokens.SessionToken.fromHex(client.sessionToken) + ).id; + + const refreshToken = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: ScopeSet.fromArray([ + 'profile', + 'https://identity.mozilla.com/apps/oldsync', + ]), + }); + const refreshTokenId = hashRefreshToken(refreshToken.token).toString('hex'); + + let allClients = await client.attachedClients(); + expect(allClients.length).toBe(2); + + await client.destroyAttachedClient({ + refreshTokenId, + clientId: PUBLIC_CLIENT_ID, + }); + + allClients = await client.attachedClients(); + expect(allClients.length).toBe(1); + expect(allClients[0].sessionTokenId).toBe(mySessionTokenId); + expect(allClients[0].refreshTokenId).toBeNull(); + }); + + it('correctly lists a unique list of clientIds for refresh tokens', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + let oauthClients = await client.attachedOAuthClients(); + expect(oauthClients.length).toBe(0); + + const clientId = buf(PUBLIC_CLIENT_ID); + const userId = buf(client.uid); + const scope = ScopeSet.fromArray([ + 'profile', + 'https://identity.mozilla.com/apps/oldsync', + ]); + + await oauthServerDb.generateRefreshToken({ + clientId, userId, email: client.email, scope, + }); + + const refreshToken2 = await oauthServerDb.generateRefreshToken({ + clientId, userId, email: client.email, scope, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const newerTimestamp = new Date(Date.now() + 5000); + await oauthServerDb.mysql._touchRefreshToken( + refreshToken2.tokenId, + newerTimestamp + ); + + oauthClients = await client.attachedOAuthClients(); + expect(oauthClients.length).toBe(1); + expect(oauthClients[0].clientId).toBe(PUBLIC_CLIENT_ID); + const timeDiff = Math.abs( + oauthClients[0].lastAccessTime - newerTimestamp.getTime() + ); + expect(timeDiff).toBeLessThan(1000); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/device_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/device_tests.in.spec.ts new file mode 100644 index 00000000000..7374ea7d1f2 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/device_tests.in.spec.ts @@ -0,0 +1,464 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import crypto from 'crypto'; +import base64url from 'base64url'; +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); +const mocks = require('../mocks'); + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote device', + ({ version, tag }) => { + const testOptions = { version }; + + it('device registration after account creation', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'test device \ud83c\udf53\ud83d\udd25\u5728\ud834\udf06', + type: 'mobile', + availableCommands: { foo: 'bar' }, + pushCallback: '', + pushPublicKey: '', + pushAuthKey: '', + }; + + let devices = await client.devices(); + expect(devices.length).toBe(0); + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(deviceInfo.name); + expect(device.type).toBe(deviceInfo.type); + expect(device.availableCommands).toEqual(deviceInfo.availableCommands); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + expect(device.pushPublicKey).toBe(deviceInfo.pushPublicKey); + expect(device.pushAuthKey).toBe(deviceInfo.pushAuthKey); + expect(device.pushEndpointExpired).toBe(false); + + devices = await client.devices(); + expect(devices.length).toBe(1); + expect(devices[0].name).toBe(deviceInfo.name); + expect(devices[0].type).toBe(deviceInfo.type); + expect(devices[0].availableCommands).toEqual(deviceInfo.availableCommands); + expect(devices[0].pushCallback).toBe(''); + expect(devices[0].pushPublicKey).toBe(''); + expect(devices[0].pushAuthKey).toBe(''); + expect(devices[0].pushEndpointExpired).toBeFalsy(); + + await client.destroyDevice(devices[0].id); + }); + + it('device registration without optional parameters', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'test device', + type: 'mobile', + }; + + let devices = await client.devices(); + expect(devices.length).toBe(0); + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(deviceInfo.name); + expect(device.type).toBe(deviceInfo.type); + expect(device.pushCallback == null).toBe(true); + expect(device.pushPublicKey == null).toBe(true); + expect(device.pushAuthKey == null).toBe(true); + expect(device.pushEndpointExpired).toBe(false); + + devices = await client.devices(); + expect(devices.length).toBe(1); + expect(devices[0].name).toBe(deviceInfo.name); + expect(devices[0].type).toBe(deviceInfo.type); + expect(devices[0].pushCallback == null).toBe(true); + expect(devices[0].pushPublicKey == null).toBe(true); + expect(devices[0].pushAuthKey == null).toBe(true); + expect(devices[0].pushEndpointExpired).toBe(false); + + await client.destroyDevice(devices[0].id); + }); + + it('device registration with unicode characters in the name', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'Firefox \u5728 \u03b2 test\ufffd', + type: 'desktop', + }; + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(deviceInfo.name); + + const devices = await client.devices(); + expect(devices.length).toBe(1); + expect(devices[0].name).toBe(deviceInfo.name); + }); + + it('device registration without required name parameter', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const device = await client.updateDevice({ type: 'mobile' }); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(''); + expect(device.type).toBe('mobile'); + }); + + it('device registration without required type parameter', async () => { + const email = server.uniqueEmail(); + const deviceName = 'test device'; + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const device = await client.updateDevice({ name: 'test device' }); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(deviceName); + }); + + it('update device fails with bad callbackUrl', async () => { + const badPushCallback = + 'https://updates.push.services.mozilla.com.different-push-server.technology'; + const email = server.uniqueEmail(); + const password = 'test password'; + const deviceInfo = { + id: crypto.randomBytes(16).toString('hex'), + name: 'test device', + type: 'desktop', + availableCommands: {}, + pushCallback: badPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const client = await Client.create(server.publicUrl, email, password, testOptions); + try { + await client.updateDevice(deviceInfo); + throw new Error('request should have failed'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(107); + expect(err.validation.keys[0]).toBe('pushCallback'); + } + }); + + it('update device fails with non-normalized callbackUrl', async () => { + const badPushCallback = + 'https://updates.push.services.mozilla.com/invalid/\u010D/char'; + const email = server.uniqueEmail(); + const password = 'test password'; + const deviceInfo = { + id: crypto.randomBytes(16).toString('hex'), + name: 'test device', + type: 'desktop', + availableCommands: {}, + pushCallback: badPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const client = await Client.create(server.publicUrl, email, password, testOptions); + try { + await client.updateDevice(deviceInfo); + throw new Error('request should have failed'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(107); + expect(err.validation.keys[0]).toBe('pushCallback'); + } + }); + + it('update device works with stage servers', async () => { + const goodPushCallback = 'https://updates-autopush.stage.mozaws.net'; + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'test device', + type: 'mobile', + availableCommands: {}, + pushCallback: goodPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const devices = await client.devices(); + expect(devices.length).toBe(0); + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + }); + + it('update device works with dev servers', async () => { + const goodPushCallback = 'https://updates-autopush.dev.mozaws.net'; + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'test device', + type: 'mobile', + pushCallback: goodPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const devices = await client.devices(); + expect(devices.length).toBe(0); + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + }); + + it('update device works with callback urls that :443 as a port', async () => { + const goodPushCallback = + 'https://updates.push.services.mozilla.com:443/wpush/v1/gAAAAABbkq0Eafe6IANS4OV3pmoQ5Z8AhqFSGKtozz5FIvu0CfrTGmcv07CYziPaysTv_9dgisB0yr3UjEIlGEyoprRFX1WU5VA4nG-9tofPdA3FYREPf6xh3JL1qBhTa9mEFS2dSn--'; + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'test device', + type: 'mobile', + pushCallback: goodPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const devices = await client.devices(); + expect(devices.length).toBe(0); + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + }); + + it('update device works with callback urls that :4430 as a port', async () => { + const goodPushCallback = 'https://updates.push.services.mozilla.com:4430'; + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'test device', + type: 'mobile', + pushCallback: goodPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const devices = await client.devices(); + expect(devices.length).toBe(0); + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + }); + + it('update device works with callback urls that a custom port', async () => { + const goodPushCallback = + 'https://updates.push.services.mozilla.com:10332'; + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'test device', + type: 'mobile', + pushCallback: goodPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const devices = await client.devices(); + expect(devices.length).toBe(0); + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + }); + + it('update device fails with bad dev callbackUrl', async () => { + const badPushCallback = 'https://evil.mozaws.net'; + const email = server.uniqueEmail(); + const password = 'test password'; + const deviceInfo = { + id: crypto.randomBytes(16).toString('hex'), + name: 'test device', + type: 'desktop', + pushCallback: badPushCallback, + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const client = await Client.create(server.publicUrl, email, password, testOptions); + try { + await client.updateDevice(deviceInfo); + throw new Error('request should have failed'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(107); + expect(err.validation.keys[0]).toBe('pushCallback'); + } + }); + + it('device registration ignores deprecated "capabilities" field', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const deviceInfo = { + name: 'a very capable device', + type: 'desktop', + capabilities: [], + }; + + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(deviceInfo.name); + expect(device.capabilities).toBeFalsy(); + }); + + it('device registration from a different session', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const deviceInfo = [ + { name: 'first device', type: 'mobile' }, + { name: 'second device', type: 'desktop' }, + ]; + + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + const secondClient = await Client.login(server.publicUrl, email, password, testOptions); + await secondClient.updateDevice(deviceInfo[0]); + + let devices = await client.devices(); + expect(devices.length).toBe(1); + expect(devices[0].isCurrentDevice).toBe(false); + expect(devices[0].name).toBe(deviceInfo[0].name); + expect(devices[0].type).toBe(deviceInfo[0].type); + + await client.updateDevice(deviceInfo[1]); + devices = await client.devices(); + expect(devices.length).toBe(2); + + if (devices[0].name === deviceInfo[1].name) { + const swap: any = {}; + Object.keys(devices[0]).forEach((key: string) => { + swap[key] = devices[0][key]; + devices[0][key] = devices[1][key]; + devices[1][key] = swap[key]; + }); + } + + expect(devices[0].isCurrentDevice).toBe(false); + expect(devices[0].name).toBe(deviceInfo[0].name); + expect(devices[0].type).toBe(deviceInfo[0].type); + expect(devices[1].isCurrentDevice).toBe(true); + expect(devices[1].name).toBe(deviceInfo[1].name); + expect(devices[1].type).toBe(deviceInfo[1].type); + + await Promise.all([ + client.destroyDevice(devices[0].id), + client.destroyDevice(devices[1].id), + ]); + }); + + it('ensures all device push fields appear together', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const deviceInfo = { + name: 'test device', + type: 'desktop', + pushCallback: 'https://updates.push.services.mozilla.com/qux', + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const client = await Client.create(server.publicUrl, email, password, testOptions); + await client.updateDevice(deviceInfo); + + const devices = await client.devices(); + expect(devices[0].pushCallback).toBe(deviceInfo.pushCallback); + expect(devices[0].pushPublicKey).toBe(deviceInfo.pushPublicKey); + expect(devices[0].pushAuthKey).toBe(deviceInfo.pushAuthKey); + expect(devices[0].pushEndpointExpired).toBe(false); + + try { + await client.updateDevice({ + id: client.device.id, + pushCallback: 'https://updates.push.services.mozilla.com/foo', + }); + throw new Error('request should have failed'); + } catch (err: any) { + expect(err.errno).toBe(107); + expect(err.message).toBe('Invalid parameter in request body'); + } + }); + + it('invalid public keys are cleanly rejected', async () => { + const email = server.uniqueEmail(); + const password = 'test password'; + const invalidPublicKey = Buffer.alloc(65); + invalidPublicKey.fill('\0'); + const deviceInfo = { + name: 'test device', + type: 'desktop', + pushCallback: 'https://updates.push.services.mozilla.com/qux', + pushPublicKey: base64url(invalidPublicKey), + pushAuthKey: base64url(crypto.randomBytes(16)), + }; + + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + try { + await client.updateDevice(deviceInfo); + throw new Error('request should have failed'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(107); + } + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/device_tests_refresh_tokens.in.spec.ts b/packages/fxa-auth-server/test/remote/device_tests_refresh_tokens.in.spec.ts new file mode 100644 index 00000000000..d2d72d9b67b --- /dev/null +++ b/packages/fxa-auth-server/test/remote/device_tests_refresh_tokens.in.spec.ts @@ -0,0 +1,364 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import crypto from 'crypto'; +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); +const encrypt = require('fxa-shared/auth/encrypt'); + +const buf = (v: any) => (Buffer.isBuffer(v) ? v : Buffer.from(v, 'hex')); + +const PUBLIC_CLIENT_ID = '3c49430b43dfba77'; +const NON_PUBLIC_CLIENT_ID = 'dcdb5ae7add825d2'; +const OAUTH_CLIENT_NAME = 'Android Components Reference Browser'; +const UNKNOWN_REFRESH_TOKEN = + 'B53DF2CE2BDB91820CB0A5D68201EF87D8D8A0DFC11829FB074B6426F537EE78'; + +let server: TestServerInstance; +let oauthServerDb: any; +let db: any; + +beforeAll(async () => { + server = await createTestServer(); + + const log = { trace() {}, info() {}, error() {}, debug() {}, warn() {} }; + const config = require('../../config').default.getProperties(); + const lastAccessTimeUpdates = { + enabled: true, + sampleRate: 1, + earliestSaneTimestamp: config.lastAccessTimeUpdates.earliestSaneTimestamp, + }; + const Token = require('../../lib/tokens')(log, { + lastAccessTimeUpdates, + tokenLifetimes: { sessionTokenWithoutDevice: 2419200000 }, + }); + const { createDB } = require('../../lib/db'); + const DB = createDB(config, log, Token); + db = await DB.connect(config); + oauthServerDb = require('../../lib/oauth/db'); +}, 120000); + +afterAll(async () => { + await db.close(); + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote device with refresh tokens', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + let password: string; + let refreshToken: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + password = 'test password'; + client = await Client.create(server.publicUrl, email, password, testOptions); + }); + + it('device registration with unknown refresh token', async () => { + const deviceInfo = { + name: 'test device \ud83c\udf53\ud83d\udd25\u5728\ud834\udf06', + type: 'mobile', + availableCommands: { foo: 'bar' }, + pushCallback: '', + pushPublicKey: '', + pushAuthKey: '', + }; + + try { + await client.updateDeviceWithRefreshToken(UNKNOWN_REFRESH_TOKEN, deviceInfo); + throw new Error('should have failed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('devicesWithRefreshToken fails with unknown refresh token', async () => { + try { + await client.devicesWithRefreshToken(UNKNOWN_REFRESH_TOKEN); + throw new Error('should have failed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('destroyDeviceWithRefreshToken fails with unknown refresh token', async () => { + try { + await client.destroyDeviceWithRefreshToken(UNKNOWN_REFRESH_TOKEN, '1'); + throw new Error('should have failed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('deviceCommandsWithRefreshToken fails with unknown refresh token', async () => { + try { + await client.deviceCommandsWithRefreshToken(UNKNOWN_REFRESH_TOKEN, 0, 50); + throw new Error('should have failed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('devicesInvokeCommandWithRefreshToken fails with unknown refresh token', async () => { + try { + await client.devicesInvokeCommandWithRefreshToken( + UNKNOWN_REFRESH_TOKEN, 'target', 'command', {}, 5 + ); + throw new Error('should have failed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('devicesNotifyWithRefreshToken fails with unknown refresh token', async () => { + try { + await client.devicesNotifyWithRefreshToken(UNKNOWN_REFRESH_TOKEN, '123'); + throw new Error('should have failed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('device registration after account creation', async () => { + const refresh = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }); + refreshToken = refresh.token.toString('hex'); + + const deviceInfo = { + name: 'test device \ud83c\udf53\ud83d\udd25\u5728\ud834\udf06', + type: 'mobile', + availableCommands: { foo: 'bar' }, + pushCallback: '', + pushPublicKey: '', + pushAuthKey: '', + }; + + let devices = await client.devicesWithRefreshToken(refreshToken); + expect(devices.length).toBe(0); + + const device = await client.updateDeviceWithRefreshToken(refreshToken, deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(deviceInfo.name); + expect(device.type).toBe(deviceInfo.type); + expect(device.availableCommands).toEqual(deviceInfo.availableCommands); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + expect(device.pushPublicKey).toBe(deviceInfo.pushPublicKey); + expect(device.pushAuthKey).toBe(deviceInfo.pushAuthKey); + expect(device.pushEndpointExpired).toBe(false); + + devices = await client.devicesWithRefreshToken(refreshToken); + expect(devices.length).toBe(1); + expect(devices[0].name).toBe(deviceInfo.name); + expect(devices[0].type).toBe(deviceInfo.type); + expect(devices[0].availableCommands).toEqual(deviceInfo.availableCommands); + expect(devices[0].pushCallback).toBe(deviceInfo.pushCallback); + expect(devices[0].pushPublicKey).toBe(deviceInfo.pushPublicKey); + expect(devices[0].pushAuthKey).toBe(deviceInfo.pushAuthKey); + expect(devices[0].pushEndpointExpired).toBeFalsy(); + + await client.destroyDeviceWithRefreshToken(refreshToken, devices[0].id); + }); + + it('device registration without optional parameters', async () => { + const refresh = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }); + refreshToken = refresh.token.toString('hex'); + + const deviceInfo = { + name: 'test device', + type: 'mobile', + }; + + let devices = await client.devicesWithRefreshToken(refreshToken); + expect(devices.length).toBe(0); + + const device = await client.updateDeviceWithRefreshToken(refreshToken, deviceInfo); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(deviceInfo.name); + expect(device.type).toBe(deviceInfo.type); + expect(device.pushCallback == null).toBe(true); + expect(device.pushPublicKey == null).toBe(true); + expect(device.pushAuthKey == null).toBe(true); + expect(device.pushEndpointExpired).toBe(false); + + devices = await client.devicesWithRefreshToken(refreshToken); + const deviceId = devices[0].id; + expect(devices.length).toBe(1); + expect(devices[0].name).toBe(deviceInfo.name); + expect(devices[0].type).toBe(deviceInfo.type); + expect(devices[0].pushCallback == null).toBe(true); + expect(devices[0].pushPublicKey == null).toBe(true); + expect(devices[0].pushAuthKey == null).toBe(true); + expect(devices[0].pushEndpointExpired).toBe(false); + + const tokenObj = await oauthServerDb.getRefreshToken(encrypt.hash(refreshToken)); + expect(tokenObj).toBeTruthy(); + + await client.destroyDeviceWithRefreshToken(refreshToken, deviceId); + + const tokenObjAfter = await oauthServerDb.getRefreshToken(encrypt.hash(refreshToken)); + expect(tokenObjAfter).toBeFalsy(); + }); + + it('device registration using oauth client name', async () => { + const refresh = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }); + refreshToken = refresh.token.toString('hex'); + + const refresh2 = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }); + const refreshToken2 = refresh2.token.toString('hex'); + + let devices = await client.devicesWithRefreshToken(refreshToken); + expect(devices.length).toBe(0); + + const device = await client.updateDeviceWithRefreshToken(refreshToken, {}); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(OAUTH_CLIENT_NAME); + expect(device.type).toBe('mobile'); + expect(device.pushCallback).toBeUndefined(); + expect(device.pushPublicKey).toBeUndefined(); + expect(device.pushAuthKey).toBeUndefined(); + expect(device.pushEndpointExpired).toBe(false); + + devices = await client.devicesWithRefreshToken(refreshToken2); + expect(devices.length).toBe(1); + expect(devices[0].name).toBe(OAUTH_CLIENT_NAME); + expect(devices[0].type).toBe('mobile'); + }); + + it('device registration without required name parameter', async () => { + const refresh = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }); + refreshToken = refresh.token.toString('hex'); + + const device = await client.updateDeviceWithRefreshToken(refreshToken, { + type: 'mobile', + }); + expect(device.id).toBeTruthy(); + expect(device.createdAt).toBeGreaterThan(0); + expect(device.name).toBe(OAUTH_CLIENT_NAME); + expect(device.type).toBe('mobile'); + }); + + it('sets isCurrentDevice correctly', async () => { + const generateTokenInfo = { + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }; + + const rt1 = await oauthServerDb.generateRefreshToken(generateTokenInfo); + const rt2 = await oauthServerDb.generateRefreshToken(generateTokenInfo); + + await client.updateDeviceWithRefreshToken(rt1.token.toString('hex'), { + name: 'first device', + }); + await client.updateDeviceWithRefreshToken(rt2.token.toString('hex'), { + name: 'second device', + }); + + const devices = await client.devicesWithRefreshToken(rt1.token.toString('hex')); + expect(devices.length).toBe(2); + + if (devices[0].name === 'first device') { + const swap: any = {}; + Object.keys(devices[0]).forEach((key: string) => { + swap[key] = devices[0][key]; + devices[0][key] = devices[1][key]; + devices[1][key] = swap[key]; + }); + } + + expect(devices[0].isCurrentDevice).toBe(false); + expect(devices[0].name).toBe('second device'); + expect(devices[1].isCurrentDevice).toBe(true); + expect(devices[1].name).toBe('first device'); + }); + + it('does not allow non-public clients', async () => { + const refresh = await oauthServerDb.generateRefreshToken({ + clientId: buf(NON_PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }); + refreshToken = refresh.token.toString('hex'); + + try { + await client.updateDeviceWithRefreshToken(refreshToken, { type: 'mobile' }); + throw new Error('must fail'); + } catch (err: any) { + expect(err.message).toBe('Not a public client'); + expect(err.errno).toBe(166); + } + }); + + it('throws conflicting device errors', async () => { + const conflictingDeviceInfo: any = { + id: crypto.randomBytes(16).toString('hex'), + name: 'Device', + }; + + const refresh = await oauthServerDb.generateRefreshToken({ + clientId: buf(PUBLIC_CLIENT_ID), + userId: buf(client.uid), + email: client.email, + scope: 'profile https://identity.mozilla.com/apps/oldsync', + }); + refreshToken = refresh.token.toString('hex'); + conflictingDeviceInfo.refreshTokenId = refreshToken; + + await db.createDevice(client.uid, conflictingDeviceInfo); + + conflictingDeviceInfo.id = crypto.randomBytes(16).toString('hex'); + try { + await db.createDevice(client.uid, conflictingDeviceInfo); + throw new Error('must fail'); + } catch (err: any) { + expect(err.message).toBe('Session already registered by another device'); + } + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/mfa_totp.in.spec.ts b/packages/fxa-auth-server/test/remote/mfa_totp.in.spec.ts new file mode 100644 index 00000000000..51f1a4ad0ad --- /dev/null +++ b/packages/fxa-auth-server/test/remote/mfa_totp.in.spec.ts @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import crypto from 'crypto'; + +const Client = require('../client')(); +const otplib = require('otplib'); +const { default: Container } = require('typedi'); +const { + PlaySubscriptions, +} = require('../../lib/payments/iap/google-play/subscriptions'); +const { + AppStoreSubscriptions, +} = require('../../lib/payments/iap/apple-app-store/subscriptions'); + +let server: TestServerInstance; + +// Ensure tests generate TOTP codes using the same encoding as the server +otplib.authenticator.options = { + crypto: crypto, + encoding: 'hex', + window: 10, +}; + +beforeAll(async () => { + Container.set(PlaySubscriptions, {}); + Container.set(AppStoreSubscriptions, {}); + + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: {} }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + mfa: { + enabled: true, + actions: ['2fa', 'test'], + }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +const password = 'pssssst'; +const metricsContext = { + flowBeginTime: Date.now(), + flowId: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', +}; + +describe.each(testVersions)( + '#integration$tag - remote mfa totp', + ({ version, tag }) => { + const testOptions = { version }; + let mfaEmail: string; + let mfaClient: any; + + beforeEach(async () => { + mfaEmail = server.uniqueEmail(); + mfaClient = await Client.createAndVerify( + server.publicUrl, + mfaEmail, + password, + server.mailbox, + testOptions + ); + }); + + async function getMfaAccessTokenFor2fa(clientInstance: any) { + // Request an OTP for MFA action '2fa' + await clientInstance.api.doRequest( + 'POST', + `${clientInstance.api.baseURL}/mfa/otp/request`, + await clientInstance.api.Token.SessionToken.fromHex( + clientInstance.sessionToken + ), + { action: '2fa' } + ); + + // Read OTP code from mailbox + const code = await server.mailbox.waitForMfaCode(clientInstance.email); + + // Verify OTP and get back a JWT access token + const verifyRes = await clientInstance.api.doRequest( + 'POST', + `${clientInstance.api.baseURL}/mfa/otp/verify`, + await clientInstance.api.Token.SessionToken.fromHex( + clientInstance.sessionToken + ), + { action: '2fa', code } + ); + return verifyRes.accessToken; + } + + async function createSetupCompleteTOTPUsingJwt( + clientInstance: any, + accessToken: string + ) { + // Create (start) TOTP via JWT route + const createRes = await clientInstance.api.doRequestWithBearerToken( + 'POST', + `${clientInstance.api.baseURL}/mfa/totp/create`, + accessToken, + { metricsContext } + ); + + // Verify setup code using the returned secret + const setupAuthenticator = new otplib.authenticator.Authenticator(); + setupAuthenticator.options = Object.assign( + {}, + otplib.authenticator.options, + { secret: createRes.secret } + ); + const code = setupAuthenticator.generate(); + const verifySetupRes = await clientInstance.api.doRequestWithBearerToken( + 'POST', + `${clientInstance.api.baseURL}/mfa/totp/setup/verify`, + accessToken, + { code, metricsContext } + ); + + // Complete setup + const completeRes = await clientInstance.api.doRequestWithBearerToken( + 'POST', + `${clientInstance.api.baseURL}/mfa/totp/setup/complete`, + accessToken, + { metricsContext } + ); + + return { createRes, verifySetupRes, completeRes }; + } + + it('should create/setup/complete TOTP using jwt', async () => { + const accessToken = await getMfaAccessTokenFor2fa(mfaClient); + const { createRes, verifySetupRes, completeRes } = + await createSetupCompleteTOTPUsingJwt(mfaClient, accessToken); + + expect(createRes.secret).toBeTruthy(); + expect(createRes.qrCodeUrl).toBeTruthy(); + expect(verifySetupRes.success).toBe(true); + expect(completeRes.success).toBe(true); + + const emailData = await server.mailbox.waitForEmail(mfaEmail); + expect(emailData.headers['x-template-name']).toBe( + 'postAddTwoStepAuthentication' + ); + }); + + it('should replace TOTP using jwt', async () => { + const accessToken = await getMfaAccessTokenFor2fa(mfaClient); + const { completeRes } = await createSetupCompleteTOTPUsingJwt( + mfaClient, + accessToken + ); + expect(completeRes.success).toBe(true); + + const email1 = await server.mailbox.waitForEmail(mfaEmail); + expect(email1.headers['x-template-name']).toBe( + 'postAddTwoStepAuthentication' + ); + + // Start replace + const startRes = await mfaClient.api.doRequestWithBearerToken( + 'POST', + `${mfaClient.api.baseURL}/mfa/totp/replace/start`, + accessToken, + { metricsContext } + ); + expect(startRes.secret).toBeTruthy(); + expect(startRes.qrCodeUrl).toBeTruthy(); + + // Confirm replace with valid code + const replaceAuthenticator = new otplib.authenticator.Authenticator(); + replaceAuthenticator.options = Object.assign( + {}, + otplib.authenticator.options, + { secret: startRes.secret } + ); + const code = replaceAuthenticator.generate(); + const confirmRes = await mfaClient.api.doRequestWithBearerToken( + 'POST', + `${mfaClient.api.baseURL}/mfa/totp/replace/confirm`, + accessToken, + { code } + ); + expect(confirmRes.success).toBe(true); + + const email2 = await server.mailbox.waitForEmail(mfaEmail); + expect(email2.headers['x-template-name']).toBe( + 'postChangeTwoStepAuthentication' + ); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/misc_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/misc_tests.in.spec.ts new file mode 100644 index 00000000000..0328f311b4a --- /dev/null +++ b/packages/fxa-auth-server/test/remote/misc_tests.in.spec.ts @@ -0,0 +1,258 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import http from 'http'; +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); +const packageJson = require('../../package.json'); + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +// Simple HTTP GET using Node's built-in http module. +// Avoids superagent's circular references (which crash Jest workers) +// and fetch's behavioral quirks with certain hapi routes. +function httpGet( + url: string, + options?: { headers?: Record } +): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string; json: () => any }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.get( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + headers: options?.headers || {}, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers, + body: data, + json: () => JSON.parse(data), + }); + }); + } + ); + req.on('error', reject); + }); +} + +function httpPost( + url: string, + payload: any, + headers?: Record +): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const body = JSON.stringify(payload); + const req = http.request( + { + method: 'POST', + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + ...headers, + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers, + body: data, + }); + }); + } + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +describe.each(testVersions)( + '#integration$tag - remote misc', + ({ version, tag }) => { + const testOptions = { version }; + + it('unsupported api version', async () => { + const res = await httpGet(`${server.publicUrl}/v0/account/create`); + expect(res.statusCode).toBe(410); + }); + + it('/__heartbeat__ returns a 200 OK', async () => { + const res = await httpGet(`${server.publicUrl}/__heartbeat__`); + expect(res.statusCode).toBe(200); + }); + + it('/__lbheartbeat__ returns a 200 OK', async () => { + const res = await httpGet(`${server.publicUrl}/__lbheartbeat__`); + expect(res.statusCode).toBe(200); + }); + + it('/ returns version, git hash and source repo', async () => { + const res = await httpGet(server.publicUrl + '/'); + const json = res.json(); + expect(Object.keys(json)).toEqual(['version', 'commit', 'source']); + expect(json.version).toBe(packageJson.version); + expect(json.source && json.source !== 'unknown').toBeTruthy(); + expect(json.commit).toMatch(/^[0-9a-f]{40}$/); + }); + + it('/__version__ returns version, git hash and source repo', async () => { + const res = await httpGet(server.publicUrl + '/__version__'); + const json = res.json(); + expect(Object.keys(json)).toEqual(['version', 'commit', 'source']); + expect(json.version).toBe(packageJson.version); + expect(json.source && json.source !== 'unknown').toBeTruthy(); + expect(json.commit).toMatch(/^[0-9a-f]{40}$/); + }); + + it('returns no Access-Control-Allow-Origin with no Origin set', async () => { + const res = await httpGet(`${server.publicUrl}/`); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('returns correct Access-Control-Allow-Origin with whitelisted Origin', async () => { + const corsOrigin = (server.config as any).corsOrigin; + const randomAllowedOrigin = + corsOrigin[Math.floor(Math.random() * corsOrigin.length)]; + const res = await httpGet(`${server.publicUrl}/`, { + headers: { Origin: randomAllowedOrigin }, + }); + expect(res.headers['access-control-allow-origin']).toBe(randomAllowedOrigin); + }); + + it('returns no Access-Control-Allow-Origin with not whitelisted Origin', async () => { + const res = await httpGet(`${server.publicUrl}/`, { + headers: { Origin: 'http://notallowed' }, + }); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('/verify_email redirects', async () => { + const path = '/v1/verify_email?code=0000&uid=0000'; + const res = await httpGet(server.publicUrl + path); + expect(res.statusCode).toBe(302); + }); + + it('/complete_reset_password redirects', async () => { + const path = + '/v1/complete_reset_password?code=0000&email=a@b.c&token=0000'; + const res = await httpGet(server.publicUrl + path); + expect(res.statusCode).toBe(302); + }); + + it('timestamp header', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + await client.login(); + const url = `${client.api.baseURL}/account/keys`; + const token = await client.api.Token.KeyFetchToken.fromHex( + client.getState().keyFetchToken + ); + + const res = await httpGet(url, { + headers: { 'Authorization': `Hawk id="${token.id}"` }, + }); + const now = +new Date() / 1000; + expect(Number(res.headers.timestamp)).toBeGreaterThan(now - 60); + expect(Number(res.headers.timestamp)).toBeLessThan(now + 60); + }); + + it('Strict-Transport-Security header', async () => { + const res = await httpGet(`${server.publicUrl}/`); + expect(res.headers['strict-transport-security']).toBe( + 'max-age=31536000; includeSubDomains' + ); + }); + + it('oversized payload', async () => { + const client = new Client(server.publicUrl, testOptions); + try { + await client.api.doRequest( + 'POST', + `${client.api.baseURL}/get_random_bytes`, + null, + { big: Buffer.alloc(8192).toString('hex') } + ); + throw new Error('request should have failed'); + } catch (err: any) { + if (err.errno) { + expect(err.errno).toBe(113); + } else { + expect(/413 Request Entity Too Large/.test(err)).toBeTruthy(); + } + } + }); + + it('random bytes', async () => { + const client = new Client(server.publicUrl, testOptions); + const x = await client.api.getRandomBytes(); + expect(x.data.length).toBe(64); + }); + + it('fetch /.well-known/browserid support document', async () => { + const client = new Client(server.publicUrl, testOptions); + const doc = await client.api.doRequest( + 'GET', + server.publicUrl + '/.well-known/browserid' + ); + expect(Object.prototype.hasOwnProperty.call(doc, 'public-key')).toBe(true); + expect(/^[0-9]+$/.test(doc['public-key'].n)).toBe(true); + expect(/^[0-9]+$/.test(doc['public-key'].e)).toBe(true); + expect(Object.prototype.hasOwnProperty.call(doc, 'authentication')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(doc, 'provisioning')).toBe(true); + expect(doc.keys.length).toBe(1); + }); + + it('ignores fail on hawk payload mismatch', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + const token = await client.api.Token.SessionToken.fromHex(client.sessionToken); + const url = `${client.api.baseURL}/account/device`; + const payload: any = { + name: 'my cool device', + type: 'desktop', + }; + + payload.name = 'my stealthily-changed device name'; + const res = await httpPost(url, payload, { + 'Authorization': `Hawk id="${token.id}"`, + }); + expect(res.statusCode).toBe(200); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/oauth_session_token_scope_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/oauth_session_token_scope_tests.in.spec.ts new file mode 100644 index 00000000000..f3cf365867b --- /dev/null +++ b/packages/fxa-auth-server/test/remote/oauth_session_token_scope_tests.in.spec.ts @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); +const { + OAUTH_SCOPE_SESSION_TOKEN, + OAUTH_SCOPE_OLD_SYNC, +} = require('fxa-shared/oauth/constants'); +const { AppError: error } = require('@fxa/accounts/errors'); + +const OAUTH_CLIENT_NAME = 'Android Components Reference Browser'; +const PUBLIC_CLIENT_ID = '3c49430b43dfba77'; +const MOCK_CODE_VERIFIER = 'abababababababababababababababababababababa'; +const MOCK_CODE_CHALLENGE = 'YPhkZqm08uTfwjNSiYcx80-NPT9Zn94kHboQW97KyV0'; + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - /oauth/ session token scope', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + let password: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + password = 'test password'; + client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + }); + + it('provides a session token using the session token scope', async () => { + const SCOPE = OAUTH_SCOPE_SESSION_TOKEN; + const res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + scope: SCOPE, + state: 'xyz', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + }); + expect(res.redirect).toBeTruthy(); + expect(res.code).toBeTruthy(); + expect(res.state).toBe('xyz'); + + const tokenRes = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + code: res.code, + code_verifier: MOCK_CODE_VERIFIER, + }); + expect(tokenRes.access_token).toBeTruthy(); + expect(tokenRes.session_token).toBeTruthy(); + expect(tokenRes.session_token).not.toBe(client.sessionToken); + expect(tokenRes.session_token_id).toBeFalsy(); + expect(tokenRes.scope).toBe(SCOPE); + expect(tokenRes.auth_at).toBeTruthy(); + expect(tokenRes.expires_in).toBeTruthy(); + expect(tokenRes.token_type).toBeTruthy(); + }); + + it('works with oldsync and session token scopes', async () => { + const SCOPE = `${OAUTH_SCOPE_SESSION_TOKEN} ${OAUTH_SCOPE_OLD_SYNC}`; + const res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + scope: SCOPE, + state: 'xyz', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + access_type: 'offline', + }); + + const tokenRes = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + code: res.code, + code_verifier: MOCK_CODE_VERIFIER, + }); + expect(tokenRes.access_token).toBeTruthy(); + expect(tokenRes.session_token).toBeTruthy(); + expect(tokenRes.refresh_token).toBeTruthy(); + + const allClients = await client.attachedClients(); + expect(allClients.length).toBe(2); + expect(allClients[0].sessionTokenId).toBeTruthy(); + expect(allClients[0].name).toBe(OAUTH_CLIENT_NAME); + expect(allClients[1].sessionTokenId).toBeTruthy(); + expect(allClients[0].isCurrentSession).toBe(false); + expect(allClients[1].isCurrentSession).toBe(true); + expect(allClients[0].sessionTokenId).not.toBe(allClients[1].sessionTokenId); + }); + + it('rejects invalid sessionToken', async () => { + const res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + scope: OAUTH_SCOPE_SESSION_TOKEN, + state: 'xyz', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + }); + + await client.destroySession(); + try { + await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + code: res.code, + code_verifier: MOCK_CODE_VERIFIER, + }); + throw new Error('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(error.ERRNO.INVALID_TOKEN); + } + }); + + it('contains no token when scopes is not set', async () => { + const res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + scope: 'profile', + state: 'xyz', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + }); + + const tokenRes = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + code: res.code, + code_verifier: MOCK_CODE_VERIFIER, + }); + expect(tokenRes.access_token).toBeTruthy(); + expect(tokenRes.session_token).toBeFalsy(); + expect(tokenRes.session_token_id).toBeFalsy(); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts new file mode 100644 index 00000000000..8a27bade7c2 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts @@ -0,0 +1,535 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); +const { OAUTH_SCOPE_OLD_SYNC } = require('fxa-shared/oauth/constants'); +const { AppError: error } = require('@fxa/accounts/errors'); +const testUtils = require('../lib/util'); + +const PUBLIC_CLIENT_ID = '3c49430b43dfba77'; +const OAUTH_CLIENT_NAME = 'Android Components Reference Browser'; +const MOCK_CODE_VERIFIER = 'abababababababababababababababababababababa'; +const MOCK_CODE_CHALLENGE = 'YPhkZqm08uTfwjNSiYcx80-NPT9Zn94kHboQW97KyV0'; + +const JWT_ACCESS_TOKEN_CLIENT_ID = '325b4083e32fe8e7'; +const JWT_ACCESS_TOKEN_SECRET = + 'a084f4c36501ea1eb2de33258421af97b2e67ffbe107d2812f4a14f3579900ef'; + +const FIREFOX_IOS_CLIENT_ID = '1b1a3e44c54fbb58'; +const RELAY_SCOPE = 'https://identity.mozilla.com/apps/relay'; +const GRANT_TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +const SUBJECT_TOKEN_TYPE_REFRESH = + 'urn:ietf:params:oauth:token-type:refresh_token'; + +const { decodeJWT } = testUtils; + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - /oauth/ routes', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + let password: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + password = 'test password'; + client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + }); + + it('successfully grants an authorization code', async () => { + const res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + scope: 'abc', + state: 'xyz', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + }); + expect(res.redirect).toBeTruthy(); + expect(res.code).toBeTruthy(); + expect(res.state).toBe('xyz'); + }); + + it('rejects `assertion` parameter in /authorization request', async () => { + try { + await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + state: 'xyz', + assertion: 'a~b', + }); + throw new Error('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(error.ERRNO.INVALID_PARAMETER); + expect(err.validation.keys[0]).toBe('assertion'); + } + }); + + it('rejects `resource` parameter in /authorization request', async () => { + try { + await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + state: 'xyz', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + resource: 'https://resource.server.com', + }); + throw new Error('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(error.ERRNO.INVALID_PARAMETER); + expect(err.validation.keys[0]).toBe('resource'); + } + }); + + it('successfully grants tokens from sessionToken and notifies user', async () => { + const SCOPE = OAUTH_SCOPE_OLD_SYNC; + + let devices = await client.devices(); + expect(devices.length).toBe(0); + + const res = await client.grantOAuthTokensFromSessionToken({ + grant_type: 'fxa-credentials', + client_id: PUBLIC_CLIENT_ID, + access_type: 'offline', + scope: SCOPE, + }); + + expect(res.access_token).toBeTruthy(); + expect(res.refresh_token).toBeTruthy(); + expect(res.scope).toBe(SCOPE); + expect(res.auth_at).toBeTruthy(); + expect(res.expires_in).toBeTruthy(); + expect(res.token_type).toBeTruthy(); + + devices = await client.devicesWithRefreshToken(res.refresh_token); + expect(devices.length).toBe(1); + expect(devices[0].name).toBe(OAUTH_CLIENT_NAME); + }); + + it('successfully grants tokens via authentication code flow, and refresh token flow', async () => { + const SCOPE = `${OAUTH_SCOPE_OLD_SYNC} openid`; + + let devices = await client.devices(); + expect(devices.length).toBe(0); + + let res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + state: 'abc', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + scope: SCOPE, + access_type: 'offline', + }); + expect(res.code).toBeTruthy(); + + devices = await client.devices(); + expect(devices.length).toBe(0); + + res = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + code: res.code, + code_verifier: MOCK_CODE_VERIFIER, + }); + expect(res.access_token).toBeTruthy(); + expect(res.refresh_token).toBeTruthy(); + expect(res.id_token).toBeTruthy(); + expect(res.scope).toBe(SCOPE); + expect(res.auth_at).toBeTruthy(); + expect(res.expires_in).toBeTruthy(); + expect(res.token_type).toBeTruthy(); + + const idToken = decodeJWT(res.id_token); + expect(idToken.claims.aud).toBe(PUBLIC_CLIENT_ID); + + devices = await client.devices(); + expect(devices.length).toBe(1); + + const res2 = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: res.refresh_token, + }); + expect(res2.access_token).toBeTruthy(); + expect(res2.id_token).toBeFalsy(); + expect(res2.scope).toBe(OAUTH_SCOPE_OLD_SYNC); + expect(res2.expires_in).toBeTruthy(); + expect(res2.token_type).toBeTruthy(); + expect(res.access_token).not.toBe(res2.access_token); + + devices = await client.devices(); + expect(devices.length).toBe(1); + }); + + it('successfully propagates `resource` and `clientId` in the ID token `aud` claim', async () => { + const SCOPE = `${OAUTH_SCOPE_OLD_SYNC} openid`; + + let devices = await client.devices(); + expect(devices.length).toBe(0); + + let res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + state: 'abc', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + scope: SCOPE, + access_type: 'offline', + }); + expect(res.code).toBeTruthy(); + + devices = await client.devices(); + expect(devices.length).toBe(0); + + res = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + code: res.code, + code_verifier: MOCK_CODE_VERIFIER, + resource: 'https://resource.server.com', + }); + expect(res.access_token).toBeTruthy(); + expect(res.refresh_token).toBeTruthy(); + expect(res.id_token).toBeTruthy(); + expect(res.scope).toBe(SCOPE); + expect(res.auth_at).toBeTruthy(); + expect(res.expires_in).toBeTruthy(); + expect(res.token_type).toBeTruthy(); + + const idToken = decodeJWT(res.id_token); + expect(idToken.claims.aud).toEqual([ + PUBLIC_CLIENT_ID, + 'https://resource.server.com', + ]); + }); + + it('successfully grants JWT access tokens via authentication code flow, and refresh token flow', async () => { + const SCOPE = 'openid'; + + const codeRes = await client.createAuthorizationCode({ + client_id: JWT_ACCESS_TOKEN_CLIENT_ID, + state: 'abc', + scope: SCOPE, + access_type: 'offline', + }); + expect(codeRes.code).toBeTruthy(); + + const tokenRes = await client.grantOAuthTokens({ + client_id: JWT_ACCESS_TOKEN_CLIENT_ID, + client_secret: JWT_ACCESS_TOKEN_SECRET, + code: codeRes.code, + ppid_seed: 100, + }); + expect(tokenRes.access_token).toBeTruthy(); + expect(tokenRes.refresh_token).toBeTruthy(); + expect(tokenRes.id_token).toBeTruthy(); + expect(tokenRes.scope).toBe(SCOPE); + expect(tokenRes.auth_at).toBeTruthy(); + expect(tokenRes.expires_in).toBeTruthy(); + expect(tokenRes.token_type).toBeTruthy(); + + const tokenJWT = decodeJWT(tokenRes.access_token); + expect(tokenJWT.claims.sub).toBeTruthy(); + expect(tokenJWT.claims.aud).toBe(JWT_ACCESS_TOKEN_CLIENT_ID); + + const refreshTokenRes = await client.grantOAuthTokens({ + client_id: JWT_ACCESS_TOKEN_CLIENT_ID, + client_secret: JWT_ACCESS_TOKEN_SECRET, + refresh_token: tokenRes.refresh_token, + grant_type: 'refresh_token', + ppid_seed: 100, + resource: 'https://resource.server1.com', + scope: SCOPE, + }); + expect(refreshTokenRes.access_token).toBeTruthy(); + expect(refreshTokenRes.id_token).toBeFalsy(); + expect(refreshTokenRes.scope).toBe(''); + expect(refreshTokenRes.expires_in).toBeTruthy(); + expect(refreshTokenRes.token_type).toBeTruthy(); + + const refreshTokenJWT = decodeJWT(refreshTokenRes.access_token); + expect(tokenJWT.claims.sub).toBe(refreshTokenJWT.claims.sub); + expect(refreshTokenJWT.claims.aud).toEqual([ + JWT_ACCESS_TOKEN_CLIENT_ID, + 'https://resource.server1.com', + ]); + + const clientRotatedRes = await client.grantOAuthTokens({ + client_id: JWT_ACCESS_TOKEN_CLIENT_ID, + client_secret: JWT_ACCESS_TOKEN_SECRET, + refresh_token: tokenRes.refresh_token, + grant_type: 'refresh_token', + ppid_seed: 101, + scope: SCOPE, + }); + expect(clientRotatedRes.access_token).toBeTruthy(); + expect(clientRotatedRes.id_token).toBeFalsy(); + expect(clientRotatedRes.scope).toBe(''); + expect(clientRotatedRes.expires_in).toBeTruthy(); + expect(clientRotatedRes.token_type).toBeTruthy(); + + const clientRotatedJWT = decodeJWT(clientRotatedRes.access_token); + expect(tokenJWT.claims.sub).not.toBe(clientRotatedJWT.claims.sub); + }); + + it('successfully revokes access tokens, and refresh tokens', async () => { + let res = await client.createAuthorizationCode({ + client_id: PUBLIC_CLIENT_ID, + state: 'abc', + code_challenge: MOCK_CODE_CHALLENGE, + code_challenge_method: 'S256', + scope: 'profile openid', + access_type: 'offline', + }); + expect(res.code).toBeTruthy(); + + res = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + code: res.code, + code_verifier: MOCK_CODE_VERIFIER, + }); + expect(res.access_token).toBeTruthy(); + expect(res.refresh_token).toBeTruthy(); + + let tokenStatus = await client.api.introspect(res.access_token); + expect(tokenStatus.active).toBe(true); + + await client.revokeOAuthToken({ + client_id: PUBLIC_CLIENT_ID, + token: res.access_token, + }); + + tokenStatus = await client.api.introspect(res.access_token); + expect(tokenStatus.active).toBe(false); + + const res2 = await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: res.refresh_token, + }); + expect(res2.access_token).toBeTruthy(); + expect(res2.refresh_token).toBeFalsy(); + + tokenStatus = await client.api.introspect(res.refresh_token); + expect(tokenStatus.active).toBe(true); + + await client.revokeOAuthToken({ + client_id: PUBLIC_CLIENT_ID, + token: res.refresh_token, + }); + + try { + await client.grantOAuthTokens({ + client_id: PUBLIC_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: res.refresh_token, + }); + throw new Error('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(error.ERRNO.INVALID_TOKEN); + } + }); + + it('successfully revokes JWT access tokens', async () => { + const codeRes = await client.createAuthorizationCode({ + client_id: JWT_ACCESS_TOKEN_CLIENT_ID, + state: 'abc', + scope: 'openid', + }); + expect(codeRes.code).toBeTruthy(); + + const token = ( + await client.grantOAuthTokens({ + client_id: JWT_ACCESS_TOKEN_CLIENT_ID, + client_secret: JWT_ACCESS_TOKEN_SECRET, + code: codeRes.code, + ppid_seed: 100, + }) + ).access_token; + expect(token).toBeTruthy(); + + const tokenJWT = decodeJWT(token); + expect(tokenJWT.claims.sub).toBeTruthy(); + + await client.revokeOAuthToken({ + client_id: JWT_ACCESS_TOKEN_CLIENT_ID, + client_secret: JWT_ACCESS_TOKEN_SECRET, + token, + }); + }); + + it('sees correct keyRotationTimestamp after password change and password reset', async () => { + const keyData1 = ( + await client.getScopedKeyData({ + client_id: PUBLIC_CLIENT_ID, + scope: OAUTH_SCOPE_OLD_SYNC, + }) + )[OAUTH_SCOPE_OLD_SYNC]; + + await client.changePassword('new password', undefined, client.sessionToken); + await server.mailbox.waitForEmail(email); + + client = await Client.login(server.publicUrl, email, 'new password', testOptions); + await server.mailbox.waitForEmail(email); + + const keyData2 = ( + await client.getScopedKeyData({ + client_id: PUBLIC_CLIENT_ID, + scope: OAUTH_SCOPE_OLD_SYNC, + }) + )[OAUTH_SCOPE_OLD_SYNC]; + + expect(keyData1.keyRotationTimestamp).toBe(keyData2.keyRotationTimestamp); + + await client.forgotPassword(); + const otpCode = await server.mailbox.waitForCode(email); + const result = await client.verifyPasswordForgotOtp(otpCode); + await client.verifyPasswordResetCode(result.code); + await client.resetPassword(password, {}); + await server.mailbox.waitForEmail(email); + + const keyData3 = ( + await client.getScopedKeyData({ + client_id: PUBLIC_CLIENT_ID, + scope: OAUTH_SCOPE_OLD_SYNC, + }) + )[OAUTH_SCOPE_OLD_SYNC]; + + expect(keyData2.keyRotationTimestamp).toBeLessThan(keyData3.keyRotationTimestamp); + }); + } +); + +describe.each(testVersions)( + '#integration$tag - /oauth/token token exchange', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + let password: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + password = 'test password'; + client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + }); + + it('successfully exchanges a refresh token for a new token with additional scope', async () => { + const initialTokens = await client.grantOAuthTokensFromSessionToken({ + grant_type: 'fxa-credentials', + client_id: FIREFOX_IOS_CLIENT_ID, + access_type: 'offline', + scope: OAUTH_SCOPE_OLD_SYNC, + }); + + expect(initialTokens.access_token).toBeTruthy(); + expect(initialTokens.refresh_token).toBeTruthy(); + expect(initialTokens.scope).toBe(OAUTH_SCOPE_OLD_SYNC); + + const clientsBefore = await client.attachedClients(); + const oauthClientBefore = clientsBefore.find( + (c: any) => c.refreshTokenId !== null + ); + expect(oauthClientBefore).toBeTruthy(); + const originalDeviceId = oauthClientBefore.deviceId; + + const exchangedTokens = await client.grantOAuthTokens({ + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: initialTokens.refresh_token, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }); + + expect(exchangedTokens.access_token).toBeTruthy(); + expect(exchangedTokens.refresh_token).toBeTruthy(); + expect(exchangedTokens.scope).toContain(OAUTH_SCOPE_OLD_SYNC); + expect(exchangedTokens.scope).toContain(RELAY_SCOPE); + expect(exchangedTokens.expires_in).toBeTruthy(); + expect(exchangedTokens.token_type).toBe('bearer'); + expect(exchangedTokens._clientId).toBeUndefined(); + expect(exchangedTokens._existingDeviceId).toBeUndefined(); + + const clientsAfter = await client.attachedClients(); + const oauthClientAfter = clientsAfter.find( + (c: any) => c.refreshTokenId !== null + ); + expect(oauthClientAfter).toBeTruthy(); + expect(oauthClientAfter.deviceId).toBe(originalDeviceId); + + try { + await client.grantOAuthTokens({ + grant_type: 'refresh_token', + client_id: FIREFOX_IOS_CLIENT_ID, + refresh_token: initialTokens.refresh_token, + }); + throw new Error('should have thrown - original token should be revoked'); + } catch (err: any) { + expect(err.errno).toBe(110); + } + }); + } +); + +describe('#integrationV2 - /oauth/token fxa-credentials with reason', () => { + const testOptions = { version: 'V2' }; + let client: any; + let email: string; + let password: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + password = 'test password'; + client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + }); + + it('grants tokens with reason=token_migration and links to existing device', async () => { + const deviceInfo = { + name: 'Test Device', + type: 'desktop', + }; + const device = await client.updateDevice(deviceInfo); + expect(device.id).toBeTruthy(); + + const clientsBefore = await client.attachedClients(); + expect(clientsBefore.length).toBe(1); + expect(clientsBefore[0].deviceId).toBe(device.id); + expect(clientsBefore[0].refreshTokenId).toBeNull(); + + const tokens = await client.grantOAuthTokensFromSessionToken({ + grant_type: 'fxa-credentials', + client_id: FIREFOX_IOS_CLIENT_ID, + access_type: 'offline', + scope: OAUTH_SCOPE_OLD_SYNC, + reason: 'token_migration', + }); + + expect(tokens.access_token).toBeTruthy(); + expect(tokens.refresh_token).toBeTruthy(); + expect(tokens.scope).toBe(OAUTH_SCOPE_OLD_SYNC); + + const clientsAfter = await client.attachedClients(); + expect(clientsAfter.length).toBe(1); + expect(clientsAfter[0].deviceId).toBe(device.id); + expect(clientsAfter[0].refreshTokenId).toBeTruthy(); + }); +}); diff --git a/packages/fxa-auth-server/test/remote/password_change.in.spec.ts b/packages/fxa-auth-server/test/remote/password_change.in.spec.ts new file mode 100644 index 00000000000..215973189e2 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/password_change.in.spec.ts @@ -0,0 +1,636 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, getSharedTestServer, TestServerInstance } from '../support/helpers/test-server'; +import url from 'url'; + +const Client = require('../client')(); +const tokens = require('../../lib/tokens')({ trace: function () {} }); +const jwt = require('jsonwebtoken'); +const uuid = require('uuid'); + +interface AuthServerError extends Error { + errno: number; + code: number; + error: string; + email?: string; +} + +function getSessionTokenId(sessionTokenHex: string) { + return tokens.SessionToken.fromHex(sessionTokenHex).then((token: any) => { + return token.id; + }); +} + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: { allowedRecency: 0 } }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote password change', + ({ version, tag }) => { + const testOptions = { version }; + + it('password change, with unverified session', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const newPassword = 'foobar'; + + await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + // Login from different location to create unverified session + const client = await Client.login(server.publicUrl, email, password, { + ...testOptions, + keys: true, + }); + + const status = await client.emailStatus(); + expect(status.verified).toBe(false); + expect(status.emailVerified).toBe(true); + expect(status.sessionVerified).toBe(false); + + try { + await client.changePassword(newPassword, undefined, client.sessionToken); + fail('should have thrown'); + } catch (err: unknown) { + const error = err as AuthServerError; + expect(error.errno).toBe(138); + expect(error.error).toBe('Bad Request'); + expect(error.message).toBe('Unconfirmed session'); + } + }); + + it('password change, with verified session', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const newPassword = 'foobar'; + + let client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + const originalSessionToken = client.sessionToken; + const firstAuthPW = client.authPW.toString('hex'); + const keys = await client.keys(); + + const status = await client.emailStatus(); + expect(status.verified).toBe(true); + + const response = await client.changePassword( + newPassword, + undefined, + client.sessionToken + ); + expect(response.sessionToken).not.toBe(originalSessionToken); + expect(response.keyFetchToken).toBeTruthy(); + expect(client.authPW.toString('hex')).not.toBe(firstAuthPW); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['subject']).toBe('Password updated'); + const link = emailData.headers['x-link']; + const query = url.parse(link, true).query; + expect(query.email).toBeTruthy(); + + const statusAfter = await client.emailStatus(); + expect(statusAfter.verified).toBe(true); + + client = await Client.loginAndVerify( + server.publicUrl, + email, + newPassword, + server.mailbox, + { ...testOptions, keys: true } + ); + const newKeys = await client.keys(); + expect(newKeys.kB).toBe(keys.kB); + expect(newKeys.kA).toBe(keys.kA); + }); + + it('cannot password change w/o sessionToken', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const newPassword = 'foobar'; + + const client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + try { + await client.changePassword(newPassword, undefined, undefined); + fail('should have thrown'); + } catch (err: unknown) { + const error = err as AuthServerError; + expect(error.errno).toBe(110); + expect(error.error).toBe('Unauthorized'); + } + }); + + it('password change does not update keysChangedAt', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const newPassword = 'foobar'; + + let client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + + const profileBefore = await client.accountProfile(); + + await client.changePassword(newPassword, undefined, client.sessionToken); + await server.mailbox.waitForEmail(email); + + client = await Client.loginAndVerify( + server.publicUrl, + email, + newPassword, + server.mailbox, + testOptions + ); + + const profileAfter = await client.accountProfile(); + expect(profileBefore['keysChangedAt']).toBe(profileAfter['keysChangedAt']); + }); + + it('wrong password on change start', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + + const client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + await client.keys(); + + client.authPW = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' + ); + + try { + await client.changePassword('foobar', undefined, client.sessionToken); + fail('should have thrown'); + } catch (err: unknown) { + const error = err as AuthServerError; + expect(error.errno).toBe(103); + } + }); + + it("shouldn't change password on account with TOTP without passing sessionToken", async () => { + const email = server.uniqueEmail(); + const password = 'ok'; + + const client = await Client.createAndVerifyAndTOTP( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + try { + await client.changePassword('foobar', undefined, undefined); + fail('should have thrown'); + } catch (err: unknown) { + const error = err as AuthServerError; + expect(error.errno).toBe(110); + expect(error.error).toBe('Unauthorized'); + } + }); + + it('should change password on account with TOTP with verified TOTP sessionToken', async () => { + const email = server.uniqueEmail(); + const password = 'ok'; + + const client = await Client.createAndVerifyAndTOTP( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + const firstAuthPW = client.authPW.toString('hex'); + const response1 = await client.changePassword( + 'foobar', + undefined, + client.sessionToken + ); + + const secondAuthPW = client.authPW.toString('hex'); + expect(response1.sessionToken).toBeTruthy(); + expect(response1.keyFetchToken).toBeTruthy(); + expect(secondAuthPW).not.toBe(firstAuthPW); + + // Do it again to see if the new session is also verified + await getSessionTokenId(response1.sessionToken); + + const response2 = await client.changePassword( + 'fizzbuzz', + undefined, + client.sessionToken + ); + expect(client.authPW.toString('hex')).not.toBe(secondAuthPW); + expect(response2.sessionToken).toBeTruthy(); + expect(response2.keyFetchToken).toBeTruthy(); + }); + + it("shouldn't change password on account with TOTP with unverified sessionToken", async () => { + const email = server.uniqueEmail(); + const password = 'ok'; + + await Client.createAndVerifyAndTOTP( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + // Create new unverified client + const client = await Client.login(server.publicUrl, email, password, { + ...testOptions, + keys: true, + }); + + try { + await client.changePassword('foobar', undefined, client.sessionToken); + fail('should have thrown'); + } catch (err: unknown) { + const error = err as AuthServerError; + expect(error.message).toBe('Unconfirmed session'); + expect(error.errno).toBe(138); + } + }); + + // See FXA-11960 and FXA-12107 for more context + describe('extra password change checks', () => { + const defaultPassword = 'ok'; + + async function createVerifiedUser() { + return await Client.createAndVerify( + server.publicUrl, + server.uniqueEmail(), + defaultPassword, + server.mailbox, + { ...testOptions, keys: true } + ); + } + + async function loginUser(loginEmail: string, loginPassword: string, options?: any) { + return await Client.login(server.publicUrl, loginEmail, loginPassword, { + ...testOptions, + ...options, + }); + } + + async function createVerifiedUserWithVerifiedTOTP() { + return await Client.createAndVerifyAndTOTP( + server.publicUrl, + server.uniqueEmail(), + defaultPassword, + server.mailbox, + { ...testOptions, keys: true } + ); + } + + async function changePassword(victim: any, attacker: any) { + let startResult: any = undefined; + let startError: any = undefined; + try { + startResult = await attacker.api.passwordChangeStart( + victim.email, + victim.authPW, + undefined, + attacker.sessionToken + ); + } catch (err) { + startError = err; + } + + await victim.setupCredentials(victim.email, 'bogus'); + + let finishResult: any = undefined; + let finishError: any = undefined; + if (startResult) { + try { + finishResult = await attacker.api.passwordChangeFinish( + startResult.passwordChangeToken, + victim.authPW, + victim.unwrapBKey, + undefined, + attacker.sessionToken + ); + } catch (err) { + finishError = err; + } + } + + await victim.setupCredentials(victim.email, 'ok'); + + return { + unwrapBKey: startResult?.unwrapBKey, + keyFetchToken: startResult?.keyFetchToken, + res: startResult || finishResult, + error: startError || finishError, + }; + } + + async function validatePasswordChanged(victim: any, res: any, error: any) { + try { + await victim.setupCredentials(victim.email, 'ok'); + await victim.auth(); + } catch { + throw new Error("Victim's password changed!"); + } + + expect(res?.sessionToken).toBeUndefined(); + expect(error.error).toMatch(/Unauthorized|Bad Request/); + } + + it('requires session to call /password/change/start', async () => { + const victim = await createVerifiedUserWithVerifiedTOTP(); + const badActor = await createVerifiedUserWithVerifiedTOTP(); + + try { + await badActor.api.passwordChangeStart( + victim.email, + victim.authPW, + undefined, + undefined + ); + fail('Should have failed.'); + } catch (err: any) { + expect(err.message).toBe( + 'Invalid authentication token: Missing authentication' + ); + } + }); + + it('requires session to call /password/change/finish', async () => { + const victim = await createVerifiedUserWithVerifiedTOTP(); + const badActor = await createVerifiedUserWithVerifiedTOTP(); + + const startResult = await badActor.api.passwordChangeStart( + victim.email, + victim.authPW, + undefined, + victim.sessionToken + ); + + try { + await victim.setupCredentials(victim.email, 'bogus'); + await badActor.api.passwordChangeFinish( + startResult.passwordChangeToken, + victim.authPW, + victim.unwrapBKey, + undefined, + undefined + ); + fail('Should have failed.'); + } catch (err: any) { + expect(err.message).toBe( + 'Missing parameter in request body: sessionToken' + ); + } + }); + + it('can get keys after /password/change/start for verified user', async () => { + const user = await createVerifiedUser(); + + const result = await user.api.passwordChangeStart( + user.email, + user.authPW, + undefined, + user.sessionToken + ); + const keys = await user.api.accountKeys(result.keyFetchToken); + expect(keys.bundle).toBeDefined(); + }); + + it('can get keys after /password/change/start for verified 2FA user', async () => { + const user = await createVerifiedUserWithVerifiedTOTP(); + const result = await user.api.passwordChangeStart( + user.email, + user.authPW, + undefined, + user.sessionToken + ); + const keys = await user.api.accountKeys(result.keyFetchToken); + expect(keys.bundle).toBeDefined(); + }); + + it('cannot get key fetch token from /password/change/start for unverified 2FA user', async () => { + let user = await createVerifiedUserWithVerifiedTOTP(); + await user.destroySession(); + user = await loginUser(user.email, defaultPassword, { keys: true }); + + try { + const result = await user.api.passwordChangeStart( + user.email, + user.authPW, + undefined, + user.sessionToken + ); + await user.api.accountKeys(result.keyFetchToken); + fail('Should have failed.'); + } catch (err: any) { + expect(err.message).toBe('Unconfirmed session'); + } + }); + + it('cannot get key fetch token from /password/change/start without providing sessionToken', async () => { + const victim = await createVerifiedUserWithVerifiedTOTP(); + const badActor = await createVerifiedUser(); + try { + const result = await badActor.api.passwordChangeStart( + victim.email, + victim.authPW, + undefined, + undefined + ); + await badActor.api.accountKeys(result.keyFetchToken); + fail('Should have failed.'); + } catch (err: any) { + expect(err.message).toBe( + 'Invalid authentication token: Missing authentication' + ); + } + }); + + it('cannot get keys after /password/change/start by providing verified session token from a different user', async () => { + const victim = await createVerifiedUser(); + const badActor = await createVerifiedUser(); + + try { + await badActor.api.passwordChangeStart( + victim.email, + victim.authPW, + undefined, + badActor.sessionToken + ); + fail('Should have failed.'); + } catch (err: any) { + expect(err.message).toBe('Invalid session token'); + } + }); + + it('cannot change password using session token from a different verified user', async () => { + const victim = await createVerifiedUser(); + const badActor = await createVerifiedUser(); + const { error, res } = await changePassword(victim, badActor); + await validatePasswordChanged(victim, res, error); + }); + + it('cannot change password using session token with verified 2FA from a different user', async () => { + const victim = await createVerifiedUser(); + const badActor = await createVerifiedUserWithVerifiedTOTP(); + const { error, res } = await changePassword(victim, badActor); + await validatePasswordChanged(victim, res, error); + }); + + it('cannot change password of 2FA user by using session token from a different verified user', async () => { + const victim = await createVerifiedUserWithVerifiedTOTP(); + const badActor = await createVerifiedUser(); + const { error, res } = await changePassword(victim, badActor); + await validatePasswordChanged(victim, res, error); + }); + + it('cannot change password of 2FA user by using session token with verified 2FA from a different user', async () => { + const victim = await createVerifiedUserWithVerifiedTOTP(); + const badActor = await createVerifiedUserWithVerifiedTOTP(); + const { error, res } = await changePassword(victim, badActor); + await validatePasswordChanged(victim, res, error); + }); + }); + } +); + +describe.each(testVersions)( + '#integration$tag - remote password change with JWT', + ({ version, tag }) => { + const testOptions = { version }; + let sharedServer: TestServerInstance; + + beforeAll(async () => { + // Use the shared server (default config) so that new accounts + // get verified sessions without needing signin confirmation + sharedServer = await getSharedTestServer(); + }); + + it('should change password with valid JWT', async () => { + const email = sharedServer.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const newPassword = 'foobar'; + const config = sharedServer.config as any; + + const client = await Client.createAndVerify( + sharedServer.publicUrl, + email, + password, + sharedServer.mailbox, + { ...testOptions, keys: true } + ); + + const oldAuthPW = client.authPW.toString('hex'); + const originalKeys = await client.keys(); + + const sessionTokenHex = client.sessionToken; + const sessionToken = await tokens.SessionToken.fromHex(sessionTokenHex); + const sessionTokenId = sessionToken.id; + + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: client.uid, + scope: ['mfa:password'], + iat: now, + jti: uuid.v4(), + stid: sessionTokenId, + }; + + const jwtToken = jwt.sign(claims, config.mfa.jwt.secretKey, { + algorithm: 'HS256', + expiresIn: config.mfa.jwt.expiresInSec, + audience: config.mfa.jwt.audience, + issuer: config.mfa.jwt.issuer, + }); + + const newCreds = await client.setupCredentials(email, newPassword); + client.deriveWrapKbFromKb(); + + const payload: any = { + email, + oldAuthPW, + authPW: newCreds.authPW.toString('hex'), + wrapKb: client.wrapKb, + clientSalt: client.clientSalt, + }; + + if (testOptions.version === 'V2') { + await client.setupCredentialsV2(email, newPassword); + client.deriveWrapKbVersion2FromKb(); + payload.authPWVersion2 = newCreds.authPWVersion2.toString('hex'); + payload.wrapKbVersion2 = client.wrapKbVersion2; + } + + const response = await client.changePasswordJWT(jwtToken, payload); + + expect(response.uid).toBeTruthy(); + expect(response.sessionToken).toBeTruthy(); + expect(response.authAt).toBeTruthy(); + + const newClient = await Client.login( + sharedServer.publicUrl, + email, + newPassword, + { ...testOptions, keys: true } + ); + expect(newClient.sessionToken).toBeTruthy(); + + const newClientKeys = await newClient.keys(); + expect(newClientKeys.kA.toString('hex')).toBe( + originalKeys.kA.toString('hex') + ); + expect(newClientKeys.kB.toString('hex')).toBe( + originalKeys.kB.toString('hex') + ); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/password_forgot.in.spec.ts b/packages/fxa-auth-server/test/remote/password_forgot.in.spec.ts new file mode 100644 index 00000000000..a044fabeb74 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/password_forgot.in.spec.ts @@ -0,0 +1,247 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import url from 'url'; +import crypto from 'crypto'; + +const Client = require('../client')(); +const base64url = require('base64url'); +const mocks = require('../mocks'); + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: { allowedRecency: 0 } }, + signinConfirmation: { skipForNewAccounts: { enabled: true } }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote password forgot', + ({ version, tag }) => { + const testOptions = { version }; + + async function resetPassword( + client: any, + otpCode: string, + newPassword: string, + headers?: any, + options?: any + ) { + const result = await client.verifyPasswordForgotOtp(otpCode, options); + await client.verifyPasswordResetCode(result.code, headers, options); + await client.resetPassword(newPassword, {}, options); + } + + async function upgradeCredentials(email: string, newPassword: string) { + if (testOptions.version === 'V2') { + await Client.upgradeCredentials(server.publicUrl, email, newPassword, { + version: '', + key: true, + }); + } + } + + it('forgot password', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + const newPassword = 'ez'; + const options = { + ...testOptions, + keys: true, + metricsContext: mocks.generateMetricsContext(), + }; + + let client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + options + ); + const keys = await client.keys(); + + await client.forgotPassword(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('passwordForgotOtp'); + const code = emailData.headers['x-password-forgot-otp']; + + await expect(client.resetPassword(newPassword)).rejects.toBeDefined(); + await resetPassword(client, code, newPassword, undefined, options); + + const resetEmailData = await server.mailbox.waitForEmail(email); + const link = resetEmailData.headers['x-link']; + const query = url.parse(link, true).query; + expect(query.email).toBeTruthy(); + expect(resetEmailData.headers['x-template-name']).toBe('passwordReset'); + + await upgradeCredentials(email, newPassword); + + client = await Client.login(server.publicUrl, email, newPassword, { + ...testOptions, + keys: true, + }); + const newKeys = await client.keys(); + expect(typeof newKeys.wrapKb).toBe('string'); + expect(newKeys.wrapKb).not.toBe(keys.wrapKb); + expect(newKeys.kA).toBe(keys.kA); + expect(typeof client.kB).toBe('string'); + expect(client.kB.length).toBe(64); + }); + + it('forgot password limits verify attempts', async () => { + const email = server.uniqueEmail(); + const password = 'hothamburger'; + + await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + + const client = new Client(server.publicUrl, testOptions); + client.email = email; + + await client.forgotPassword(); + const code = await server.mailbox.waitForCode(email); + + try { + await client.verifyPasswordForgotOtp('00000000'); + fail('verify otp with bad code should fail'); + } catch (err: any) { + expect(err.message).toBe('Invalid confirmation code'); + } + + try { + await client.verifyPasswordForgotOtp('11111111'); + fail('verify otp with bad code should fail'); + } catch (err: any) { + expect(err.message).toBe('Invalid confirmation code'); + } + + await resetPassword(client, code, 'newpassword'); + }); + + it('recovery email contains OTP code', async () => { + const email = server.uniqueEmail(); + const password = 'something'; + const options = { ...testOptions, service: 'sync' }; + + const client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + options + ); + + await client.forgotPassword(); + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('passwordForgotOtp'); + const otpCode = emailData.headers['x-password-forgot-otp']; + expect(otpCode).toBeTruthy(); + expect(otpCode).toMatch(/^\d{8}$/); + }); + + it.skip('password forgot status with valid token', async () => {}); + it.skip('password forgot status with invalid token', () => {}); + + it('OTP flow rejects unverified accounts', async () => { + const email = server.uniqueEmail(); + const password = 'something'; + + const client = await Client.create( + server.publicUrl, + email, + password, + testOptions + ); + + const status = await client.emailStatus(); + expect(status.verified).toBe(false); + + await server.mailbox.waitForCode(email); + + try { + await client.forgotPassword(); + fail('forgotPassword should fail for unverified account'); + } catch (err: any) { + expect(err.errno).toBe(102); + } + }); + + it('forgot password with service query parameter', async () => { + const email = server.uniqueEmail(); + const options = { ...testOptions, serviceQuery: 'sync' }; + + const client = await Client.createAndVerify( + server.publicUrl, + email, + 'wibble', + server.mailbox, + options + ); + + await client.forgotPassword(); + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('passwordForgotOtp'); + expect(emailData.headers['x-password-forgot-otp']).toBeTruthy(); + }); + + it('forgot password, then get device list', async () => { + const email = server.uniqueEmail(); + const newPassword = 'foo'; + + let client = await Client.createAndVerify( + server.publicUrl, + email, + 'bar', + server.mailbox, + testOptions + ); + + await client.updateDevice({ + name: 'baz', + type: 'mobile', + pushCallback: 'https://updates.push.services.mozilla.com/qux', + pushPublicKey: mocks.MOCK_PUSH_KEY, + pushAuthKey: base64url(crypto.randomBytes(16)), + }); + + let devices = await client.devices(); + expect(devices.length).toBe(1); + + await client.forgotPassword(); + const code = await server.mailbox.waitForCode(email); + await resetPassword(client, code, newPassword); + + await upgradeCredentials(email, newPassword); + + client = await Client.login( + server.publicUrl, + email, + newPassword, + testOptions + ); + devices = await client.devices(); + expect(devices.length).toBe(0); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/push_db_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/push_db_tests.in.spec.ts new file mode 100644 index 00000000000..dbff1f1f0a7 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/push_db_tests.in.spec.ts @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import crypto from 'crypto'; +import base64url from 'base64url'; +import * as uuid from 'uuid'; +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const log = { trace() {}, info() {}, error() {}, debug() {}, warn() {} }; +const config = require('../../config').default.getProperties(); +const Token = require('../../lib/tokens')(log); +const { createDB } = require('../../lib/db'); +const mockStatsD = { increment: () => {} }; +const DB = createDB(config, log, Token); + +const zeroBuffer16 = Buffer.from( + '00000000000000000000000000000000', + 'hex' +).toString('hex'); +const zeroBuffer32 = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' +).toString('hex'); + +const SESSION_TOKEN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:41.0) Gecko/20100101 Firefox/41.0'; +const ACCOUNT = { + uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'), + email: `push${Math.random()}@bar.com`, + emailCode: zeroBuffer16, + emailVerified: false, + verifierVersion: 1, + verifyHash: zeroBuffer32, + authSalt: zeroBuffer32, + kA: zeroBuffer32, + wrapWrapKb: zeroBuffer32, + tokenVerificationId: zeroBuffer16, +}; +const mockLog2 = { + debug() {}, + error() {}, + warn() {}, + increment() {}, + trace() {}, + info() {}, +}; + +let server: TestServerInstance; +let db: any; + +beforeAll(async () => { + server = await createTestServer(); + db = await DB.connect(config); +}, 120000); + +afterAll(async () => { + await db.close(); + await server.stop(); +}); + +describe('#integration - remote push db', () => { + it('push db tests', async () => { + const deviceInfo: any = { + id: crypto.randomBytes(16).toString('hex'), + name: 'my push device', + type: 'mobile', + availableCommands: { foo: 'bar' }, + pushCallback: 'https://foo/bar', + pushPublicKey: base64url( + Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)]) + ), + pushAuthKey: base64url(crypto.randomBytes(16)), + pushEndpointExpired: false, + }; + + await db.createAccount(ACCOUNT); + const emailRecord = await db.emailRecord(ACCOUNT.email); + emailRecord.createdAt = Date.now(); + const sessionToken = await db.createSessionToken(emailRecord, SESSION_TOKEN_UA); + + deviceInfo.sessionTokenId = sessionToken.id; + const device = await db.createDevice(ACCOUNT.uid, deviceInfo); + expect(device.name).toBe(deviceInfo.name); + expect(device.pushCallback).toBe(deviceInfo.pushCallback); + expect(device.pushPublicKey).toBe(deviceInfo.pushPublicKey); + expect(device.pushAuthKey).toBe(deviceInfo.pushAuthKey); + + let devices = await db.devices(ACCOUNT.uid); + + // First: unknown 400 level error — device push info should stay the same + jest.resetModules(); + jest.doMock('web-push', () => ({ + sendNotification() { + const err: any = new Error('Failed 429 level'); + err.statusCode = 429; + return Promise.reject(err); + }, + })); + const pushWithUnknown400 = require('../../lib/push')( + mockLog2, db, {}, mockStatsD + ); + await pushWithUnknown400.sendPush(ACCOUNT.uid, devices, 'accountVerify'); + + devices = await db.devices(ACCOUNT.uid); + let d = devices[0]; + expect(d.name).toBe(deviceInfo.name); + expect(d.pushCallback).toBe(deviceInfo.pushCallback); + expect(d.pushPublicKey).toBe(deviceInfo.pushPublicKey); + expect(d.pushAuthKey).toBe(deviceInfo.pushAuthKey); + expect(d.pushEndpointExpired).toBe(deviceInfo.pushEndpointExpired); + + // Second: known 400 error (410) — device endpoint should be marked expired + jest.resetModules(); + jest.doMock('web-push', () => ({ + sendNotification() { + const err: any = new Error('Failed 400 level'); + err.statusCode = 410; + return Promise.reject(err); + }, + })); + const pushWithKnown400 = require('../../lib/push')( + mockLog2, db, {}, mockStatsD + ); + await pushWithKnown400.sendPush(ACCOUNT.uid, devices, 'accountVerify'); + + devices = await db.devices(ACCOUNT.uid); + d = devices[0]; + expect(d.name).toBe(deviceInfo.name); + expect(d.pushEndpointExpired).toBe(true); + }); +}); diff --git a/packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts b/packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts new file mode 100644 index 00000000000..8436065d658 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import base64url from 'base64url'; +import sinon from 'sinon'; +import { StatsD } from 'hot-shots'; + +import PushboxDB from '../../lib/pushbox/db'; + +const sandbox = sinon.createSandbox(); +const config = require('../../config').default.getProperties(); +const statsd = { + increment: sandbox.stub(), + timing: sandbox.stub(), +} as unknown as StatsD; +const log = { + info: sandbox.stub(), + trace: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + debug: sandbox.stub(), +}; + +const pushboxDb = new PushboxDB({ + config: config.pushbox.database, + log, + statsd, +}); + +const data = base64url.encode(JSON.stringify({ wibble: 'quux' })); +const r = { + uid: 'xyz', + deviceId: 'ff9000', + data, + ttl: 999999, +}; +let insertIdx: number; + +describe('#integration - pushbox db', () => { + afterEach(() => { + sandbox.restore(); + }); + + describe('store', () => { + it('returns the inserted record', async () => { + const record = await pushboxDb.store(r); + expect(record.user_id).toBe(r.uid); + expect(record.device_id).toBe(r.deviceId); + expect(record.data.toString()).toBe(data); + expect(record.ttl).toBe(999999); + + insertIdx = record.idx; + }); + }); + + describe('retrieve', () => { + it('found no record', async () => { + const results = await pushboxDb.retrieve({ + uid: 'nope', + deviceId: 'pdp-11', + limit: 10, + }); + expect(results).toEqual({ last: true, index: 0, messages: [] }); + }); + + it('fetches up to max index', async () => { + sandbox.stub(Date, 'now').returns(111111000); + const currentClientSideIdx = insertIdx; + const insertUpTo = insertIdx + 3; + while (insertIdx < insertUpTo) { + const record = await pushboxDb.store(r); + insertIdx = record.idx; + } + const result = await pushboxDb.retrieve({ + uid: r.uid, + deviceId: r.deviceId, + limit: 10, + index: currentClientSideIdx, + }); + + expect(result.last).toBe(true); + expect(result.index).toBe(insertIdx); + result.messages.forEach((x: any) => { + expect(x.user_id).toBe(r.uid); + expect(x.device_id).toBe(r.deviceId); + expect(x.data.toString()).toBe(data); + expect(x.ttl).toBe(999999); + }); + }); + + it('fetches up to less than max', async () => { + sandbox.stub(Date, 'now').returns(111111000); + const insertUpTo = insertIdx + 3; + while (insertIdx < insertUpTo) { + const record = await pushboxDb.store(r); + insertIdx = record.idx; + } + const result = await pushboxDb.retrieve({ + uid: r.uid, + deviceId: r.deviceId, + limit: 2, + index: insertIdx - 3, + }); + + expect(result.last).toBe(false); + expect(result.index).toBe(insertIdx - 2); + }); + }); + + describe('deleteDevice', () => { + it('deletes without error', async () => { + await pushboxDb.deleteDevice({ uid: r.uid, deviceId: r.deviceId }); + }); + }); + + describe('deleteAccount', () => { + it('deletes without error', async () => { + await pushboxDb.deleteAccount(r.uid); + }); + }); +}); diff --git a/packages/fxa-auth-server/test/remote/recovery_codes.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_codes.in.spec.ts new file mode 100644 index 00000000000..e59ad9d647c --- /dev/null +++ b/packages/fxa-auth-server/test/remote/recovery_codes.in.spec.ts @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); +const otplib = require('otplib'); +const random = require('../../lib/crypto/random'); +const jwt = require('jsonwebtoken'); +const uuid = require('uuid'); +const tokens = require('../../lib/tokens')({ trace: function () {} }); +const baseConfig = require('../../config').default.getProperties(); + +const recoveryCodeCount = 9; + +async function generateMfaJwt(client: any) { + const sessionTokenHex = client.sessionToken; + const sessionToken = await tokens.SessionToken.fromHex(sessionTokenHex); + const sessionTokenId = sessionToken.id; + + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: client.uid, + scope: ['mfa:2fa'], + iat: now, + jti: uuid.v4(), + stid: sessionTokenId, + }; + + return jwt.sign(claims, baseConfig.mfa.jwt.secretKey, { + algorithm: 'HS256', + expiresIn: baseConfig.mfa.jwt.expiresInSec, + audience: baseConfig.mfa.jwt.audience, + issuer: baseConfig.mfa.jwt.issuer, + }); +} + +async function generateRecoveryCodes(): Promise { + const codes: string[] = []; + const gen = random.base32(baseConfig.totp.recoveryCodes.length); + while (codes.length < recoveryCodeCount) { + const rc = (await gen()).toLowerCase(); + if (codes.indexOf(rc) === -1) { + codes.push(rc); + } + } + return codes; +} + +let server: TestServerInstance; +let recoveryCodes: string[]; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + totp: { recoveryCodes: { count: recoveryCodeCount, notifyLowCount: recoveryCodeCount - 2 } }, + }, + }); + recoveryCodes = await generateRecoveryCodes(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +const password = 'pssssst'; +const metricsContext = { + flowBeginTime: Date.now(), + flowId: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', +}; + +otplib.authenticator.options = { + encoding: 'hex', + window: 10, +}; + +describe.each(testVersions)( + '#integration$tag - remote backup authentication codes', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + expect(client.authAt).toBeTruthy(); + + const result = await client.createTotpToken({ metricsContext }); + otplib.authenticator.options = Object.assign( + {}, + otplib.authenticator.options, + { secret: result.secret } + ); + + const code = otplib.authenticator.generate(); + const verifyResponse = await client.verifyTotpSetupCode(code, { metricsContext }); + expect(verifyResponse.success).toBe(true); + + const setResponse = await client.setRecoveryCodes(recoveryCodes); + expect(setResponse.success).toBe(true); + + await client.completeTotpSetup({ metricsContext }); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('postAddTwoStepAuthentication'); + }); + + it('should replace backup authentication codes', async () => { + const result = await client.replaceRecoveryCodes(); + expect(result.recoveryCodes.length).toBe(recoveryCodeCount); + expect(result.recoveryCodes).not.toEqual(recoveryCodes); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('postNewRecoveryCodes'); + }); + + describe('backup authentication code verification', () => { + beforeEach(async () => { + client = await Client.login( + server.publicUrl, + email, + password, + testOptions + ); + const res = await client.emailStatus(); + expect(res.sessionVerified).toBe(false); + }); + + it('should fail to consume unknown backup authentication code', async () => { + try { + await client.consumeRecoveryCode('1234abcd', { metricsContext }); + fail('should have thrown'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(156); + } + }); + + it('should consume backup authentication code and verify session', async () => { + const res = await client.consumeRecoveryCode(recoveryCodes[0], { metricsContext }); + expect(res.remaining).toBe(recoveryCodeCount - 1); + + const status = await client.emailStatus(); + expect(status.sessionVerified).toBe(true); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('postSigninRecoveryCode'); + }); + + it('should consume backup authentication code and can remove TOTP token', async () => { + const res = await client.consumeRecoveryCode(recoveryCodes[0], { metricsContext }); + expect(res.remaining).toBe(recoveryCodeCount - 1); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('postSigninRecoveryCode'); + + const mfaJwt = await generateMfaJwt(client); + const result = await client.deleteTotpToken(mfaJwt); + expect(result).toBeTruthy(); + + const deleteEmailData = await server.mailbox.waitForEmail(email); + expect(deleteEmailData.headers['x-template-name']).toBe('postRemoveTwoStepAuthentication'); + }); + }); + + describe('should notify user when backup authentication codes are low', () => { + beforeEach(async () => { + client = await Client.login( + server.publicUrl, + email, + password, + testOptions + ); + const res = await client.emailStatus(); + expect(res.sessionVerified).toBe(false); + }); + + it('should consume backup authentication code and verify session', async () => { + const res1 = await client.consumeRecoveryCode(recoveryCodes[0], { metricsContext }); + expect(res1.remaining).toBe(recoveryCodeCount - 1); + + const emailData1 = await server.mailbox.waitForEmail(email); + expect(emailData1.headers['x-template-name']).toBe('postSigninRecoveryCode'); + + const res2 = await client.consumeRecoveryCode(recoveryCodes[1], { metricsContext }); + expect(res2.remaining).toBe(recoveryCodeCount - 2); + + const emails = await server.mailbox.waitForEmails(email, 2); + const templates = emails.map((e) => e.headers['x-template-name']); + expect(templates).toContain('postSigninRecoveryCode'); + expect(templates).toContain('lowRecoveryCodes'); + }); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/recovery_email_change_email.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_email_change_email.in.spec.ts new file mode 100644 index 00000000000..b5482065235 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/recovery_email_change_email.in.spec.ts @@ -0,0 +1,524 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import crypto from 'crypto'; + +const Client = require('../client')(); +const jwt = require('jsonwebtoken'); +const uuid = require('uuid'); +const tokens = require('../../lib/tokens')({ trace: function () {} }); +const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account'); +const { email: emailHelper } = require('fxa-shared'); +const baseConfig = require('../../config').default.getProperties(); + +async function generateMfaJwt(client: any) { + const sessionTokenHex = client.sessionToken; + const sessionToken = await tokens.SessionToken.fromHex(sessionTokenHex); + const sessionTokenId = sessionToken.id; + + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: client.uid, + scope: ['mfa:email'], + iat: now, + jti: uuid.v4(), + stid: sessionTokenId, + }; + + return jwt.sign(claims, baseConfig.mfa.jwt.secretKey, { + algorithm: 'HS256', + expiresIn: baseConfig.mfa.jwt.expiresInSec, + audience: baseConfig.mfa.jwt.audience, + issuer: baseConfig.mfa.jwt.issuer, + }); +} + +async function resetPassword( + client: any, + otpCode: string, + newPassword: string, + headers?: any, + options?: any +) { + const result = await client.verifyPasswordForgotOtp(otpCode, options); + await client.verifyPasswordResetCode(result.code, headers, options); + return client.resetPassword(newPassword, {}, options); +} + +const password = 'allyourbasearebelongtous'; +const newPassword = 'newpassword'; + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: {} }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote change email', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + let secondEmail: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + secondEmail = server.uniqueEmail('@notrestmail.com'); + + client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + expect(client.authAt).toBeTruthy(); + + const status = await client.emailStatus(); + expect(status.verified).toBe(true); + + const mfaJwt = await generateMfaJwt(client); + let res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + + await server.mailbox.waitForEmail(email); + }); + + describe('should change primary email', () => { + it('fails to change email to an that is not owned by user', async () => { + const userEmail2 = server.uniqueEmail(); + const anotherEmail = server.uniqueEmail(); + const client2 = await Client.createAndVerify( + server.publicUrl, + userEmail2, + password, + server.mailbox, + testOptions + ); + + const client2Jwt = await generateMfaJwt(client2); + await client2.createEmail(client2Jwt, anotherEmail); + const emailData = await server.mailbox.waitForEmail(anotherEmail); + const code = emailData.headers['x-verify-code']; + expect(code).toBeTruthy(); + await client2.verifySecondaryEmailWithCode(client2Jwt, code, anotherEmail); + + const mfaJwt = await generateMfaJwt(client); + try { + await client.setPrimaryEmail(mfaJwt, anotherEmail); + fail('Should not have set email that belongs to another account'); + } catch (err: any) { + expect(err.errno).toBe(148); + expect(err.code).toBe(400); + } + }); + + it('fails to change email to unverified email', async () => { + const someEmail = server.uniqueEmail(); + const mfaJwt = await generateMfaJwt(client); + await client.createEmail(mfaJwt, someEmail); + + try { + await client.setPrimaryEmail(mfaJwt, someEmail); + fail('Should not have set email to an unverified email'); + } catch (err: any) { + expect(err.errno).toBe(143); + expect(err.code).toBe(400); + } + }); + + it('fails to to change primary email to an unverified email stored in database (legacy)', async () => { + const someEmail = server.uniqueEmail(); + const db = await setupAccountDatabase(baseConfig.database.mysql.auth); + try { + await db + .insertInto('emails') + .values({ + email: someEmail, + normalizedEmail: emailHelper.helpers.normalizeEmail(someEmail), + uid: Buffer.from(client.uid, 'hex'), + emailCode: Buffer.from( + crypto.randomBytes(16).toString('hex'), + 'hex' + ), + isVerified: 0, + isPrimary: 0, + createdAt: Date.now(), + }) + .execute(); + } finally { + await db.destroy(); + } + + const mfaJwt = await generateMfaJwt(client); + try { + await client.setPrimaryEmail(mfaJwt, someEmail); + fail('Should not have set email to an unverified email'); + } catch (err: any) { + expect(err.errno).toBe(147); + expect(err.code).toBe(400); + } + }); + + it('can change primary email', async () => { + const mfaJwt = await generateMfaJwt(client); + let res = await client.setPrimaryEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[0].email).toBe(secondEmail); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + expect(res[1].email).toBe(email); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData.headers['to']).toBe(secondEmail); + expect(emailData.headers['cc']).toBe(email); + expect(emailData.headers['x-template-name']).toBe('postChangePrimary'); + }); + + it('can login', async () => { + const mfaJwt = await generateMfaJwt(client); + let res = await client.setPrimaryEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + if (testOptions.version === 'V2') { + res = await Client.login( + server.publicUrl, + secondEmail, + password, + testOptions + ); + expect(res).toBeTruthy(); + } else { + try { + await Client.login( + server.publicUrl, + secondEmail, + password, + testOptions + ); + fail('Should have returned correct email for user to login'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(120); + expect(err.email).toBe(email); + + res = await Client.login(server.publicUrl, err.email, password, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + expect(res).toBeTruthy(); + } + } + }); + + it('can change password', async () => { + const mfaJwt = await generateMfaJwt(client); + let res = await client.setPrimaryEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + res = await Client.login(server.publicUrl, email, password, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + client = res; + + res = await client.changePassword( + newPassword, + undefined, + client.sessionToken + ); + expect(res).toBeTruthy(); + + res = await Client.login(server.publicUrl, email, newPassword, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + expect(res).toBeTruthy(); + }); + + it('can reset password', async () => { + const mfaJwt = await generateMfaJwt(client); + let res = await client.setPrimaryEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData.headers['to']).toBe(secondEmail); + expect(emailData.headers['cc']).toBe(email); + expect(emailData.headers['x-template-name']).toBe('postChangePrimary'); + + client.email = secondEmail; + await client.forgotPassword(); + + const code = await server.mailbox.waitForCode(secondEmail); + expect(code).toBeTruthy(); + + res = await resetPassword(client, code, newPassword, undefined, { + emailToHashWith: email, + }); + expect(res).toBeTruthy(); + + if (testOptions.version === 'V2') { + await Client.upgradeCredentials( + server.publicUrl, + email, + newPassword, + { + originalLoginEmail: secondEmail, + version: '', + keys: true, + } + ); + } + + res = await Client.login(server.publicUrl, email, newPassword, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + expect(res).toBeTruthy(); + }); + + it('can delete account', async () => { + const mfaJwt = await generateMfaJwt(client); + const res = await client.setPrimaryEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + await client.destroyAccount(); + + try { + await Client.login(server.publicUrl, email, newPassword, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + fail('Should not have been able to login after deleting account'); + } catch (err: any) { + expect(err.errno).toBe(102); + expect(err.email).toBe(secondEmail); + } + }); + }); + + it('change primary email with multiple accounts', async () => { + let emailData, emailCode; + const password2 = 'asdf'; + const client1Email = server.uniqueEmail(); + const client1SecondEmail = server.uniqueEmail(); + const client2Email = server.uniqueEmail(); + const client2SecondEmail = server.uniqueEmail(); + + const client1 = await Client.createAndVerify( + server.publicUrl, + client1Email, + password, + server.mailbox, + testOptions + ); + + const client2 = await Client.createAndVerify( + server.publicUrl, + client2Email, + password2, + server.mailbox, + testOptions + ); + + const client1Jwt = await generateMfaJwt(client1); + const client2Jwt = await generateMfaJwt(client2); + + await client1.createEmail(client1Jwt, client1SecondEmail); + emailData = await server.mailbox.waitForEmail(client1SecondEmail); + emailCode = emailData['headers']['x-verify-code']; + await client1.verifySecondaryEmailWithCode(client1Jwt, emailCode, client1SecondEmail); + + await client2.createEmail(client2Jwt, client2SecondEmail); + emailData = await server.mailbox.waitForEmail(client2SecondEmail); + emailCode = emailData['headers']['x-verify-code']; + await client2.verifySecondaryEmailWithCode(client2Jwt, emailCode, client2SecondEmail); + + await client1.setPrimaryEmail(client1Jwt, client1SecondEmail); + await client1.deleteEmail(client1Jwt, client1Email); + + await client2.setPrimaryEmail(client2Jwt, client2SecondEmail); + await client2.deleteEmail(client2Jwt, client2Email); + + await client1.createEmail(client1Jwt, client2Email); + emailCode = await server.mailbox.waitForEmailByHeader(client2Email, 'x-verify-code'); + await client1.verifySecondaryEmailWithCode(client1Jwt, emailCode, client2Email); + await client1.setPrimaryEmail(client1Jwt, client2Email); + await client1.deleteEmail(client1Jwt, client1SecondEmail); + + await client2.createEmail(client2Jwt, client1Email); + emailCode = await server.mailbox.waitForEmailByHeader(client1Email, 'x-verify-code'); + await client2.verifySecondaryEmailWithCode(client2Jwt, emailCode, client1Email); + await client2.setPrimaryEmail(client2Jwt, client1Email); + await client2.deleteEmail(client2Jwt, client2SecondEmail); + + const res = await Client.login(server.publicUrl, client1Email, password, { + originalLoginEmail: client2Email, + ...testOptions, + }); + expect(res).toBeTruthy(); + }); + + describe('change primary email, deletes old primary', () => { + beforeEach(async () => { + const mfaJwt = await generateMfaJwt(client); + + let res = await client.setPrimaryEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + let emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData.headers['to']).toBe(secondEmail); + expect(emailData.headers['cc']).toBe(email); + expect(emailData.headers['x-template-name']).toBe('postChangePrimary'); + + res = await client.deleteEmail(mfaJwt, email); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(1); + expect(res[0].email).toBe(secondEmail); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + + emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('postRemoveSecondary'); + }); + + it('can login', async () => { + if (testOptions.version === 'V2') { + const res = await Client.login( + server.publicUrl, + secondEmail, + password, + testOptions + ); + expect(res.sessionToken).toBeDefined(); + return; + } + + try { + await Client.login( + server.publicUrl, + secondEmail, + password, + testOptions + ); + fail('Should have returned correct email for user to login'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(120); + expect(err.email).toBe(email); + + const res = await Client.login(server.publicUrl, err.email, password, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + expect(res).toBeTruthy(); + } + }); + + it('can change password', async () => { + let res = await Client.login(server.publicUrl, email, password, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + client = res; + + res = await client.changePassword( + newPassword, + undefined, + client.sessionToken + ); + expect(res).toBeTruthy(); + + res = await Client.login(server.publicUrl, email, newPassword, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + expect(res).toBeTruthy(); + }); + + it('can reset password', async () => { + client.email = secondEmail; + await client.forgotPassword(); + + const code = await server.mailbox.waitForCode(secondEmail); + expect(code).toBeTruthy(); + + const res = await resetPassword(client, code, newPassword, undefined, { + emailToHashWith: email, + }); + expect(res).toBeTruthy(); + + if (testOptions.version === 'V2') { + await Client.upgradeCredentials( + server.publicUrl, + email, + newPassword, + { + originalLoginEmail: secondEmail, + version: '', + keys: true, + } + ); + } + + const loginRes = await Client.login(server.publicUrl, email, newPassword, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + expect(loginRes).toBeTruthy(); + }); + + it('can delete account', async () => { + await client.destroyAccount(); + + try { + await Client.login(server.publicUrl, email, newPassword, { + originalLoginEmail: secondEmail, + ...testOptions, + }); + fail('Should not have been able to login after deleting account'); + } catch (err: any) { + expect(err.errno).toBe(102); + expect(err.email).toBe(secondEmail); + } + }); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/recovery_email_emails.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_email_emails.in.spec.ts new file mode 100644 index 00000000000..f69865c1c2e --- /dev/null +++ b/packages/fxa-auth-server/test/remote/recovery_email_emails.in.spec.ts @@ -0,0 +1,868 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import crypto from 'crypto'; + +const Client = require('../client')(); +const jwt = require('jsonwebtoken'); +const uuid = require('uuid'); +const tokens = require('../../lib/tokens')({ trace: function () {} }); +const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account'); +const { email: emailHelper } = require('fxa-shared'); +const baseConfig = require('../../config').default.getProperties(); + +const password = 'allyourbasearebelongtous'; + +async function generateMfaJwt(client: any) { + const sessionTokenHex = client.sessionToken; + const sessionToken = await tokens.SessionToken.fromHex(sessionTokenHex); + const sessionTokenId = sessionToken.id; + + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: client.uid, + scope: ['mfa:email'], + iat: now, + jti: uuid.v4(), + stid: sessionTokenId, + }; + + return jwt.sign(claims, baseConfig.mfa.jwt.secretKey, { + algorithm: 'HS256', + expiresIn: baseConfig.mfa.jwt.expiresInSec, + audience: baseConfig.mfa.jwt.audience, + issuer: baseConfig.mfa.jwt.issuer, + }); +} + +async function resetPassword( + client: any, + otpCode: string, + newPassword: string, + headers?: any, + options?: any +) { + const result = await client.verifyPasswordForgotOtp(otpCode, options); + await client.verifyPasswordResetCode(result.code, headers, options); + return client.resetPassword(newPassword, {}, options); +} + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: {} }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote emails', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + + beforeEach(async () => { + email = server.uniqueEmail(); + client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + expect(client.authAt).toBeTruthy(); + + const status = await client.emailStatus(); + expect(status.verified).toBe(true); + }); + + describe('should create and get additional email', () => { + it('can create', async () => { + const secondEmail = server.uniqueEmail(); + const mfaJwt = await generateMfaJwt(client); + + let res = await client.accountEmails(); + expect(res.length).toBe(1); + expect(res[0].email).toBe(email); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + + res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(1); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + }); + + it('can create account with an email that is an unverified secondary email on another account', async () => { + const secondEmail = server.uniqueEmail(); + const db = await setupAccountDatabase(baseConfig.database.mysql.auth); + try { + await db + .insertInto('emails') + .values({ + email: secondEmail, + normalizedEmail: emailHelper.helpers.normalizeEmail(secondEmail), + uid: Buffer.from(client.uid, 'hex'), + emailCode: Buffer.from(crypto.randomBytes(16).toString('hex'), 'hex'), + isVerified: 0, + isPrimary: 0, + createdAt: Date.now(), + }) + .execute(); + } finally { + await db.destroy(); + } + + let res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(false); + + const client2 = await Client.createAndVerify( + server.publicUrl, + secondEmail, + password, + server.mailbox, + testOptions + ); + expect(client2.email).toBe(secondEmail); + + res = await client.accountEmails(); + expect(res.length).toBe(1); + expect(res[0].email).toBe(client.email); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + }); + + it('can transfer an unverified secondary email from one account to another', async () => { + const clientEmail = server.uniqueEmail(); + const secondEmail = server.uniqueEmail(); + const db = await setupAccountDatabase(baseConfig.database.mysql.auth); + try { + await db + .insertInto('emails') + .values({ + email: secondEmail, + normalizedEmail: emailHelper.helpers.normalizeEmail(secondEmail), + uid: Buffer.from(client.uid, 'hex'), + emailCode: Buffer.from(crypto.randomBytes(16).toString('hex'), 'hex'), + isVerified: 0, + isPrimary: 0, + createdAt: Date.now(), + }) + .execute(); + } finally { + await db.destroy(); + } + + let res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(false); + + const client2 = await Client.createAndVerify( + server.publicUrl, + clientEmail, + password, + server.mailbox, + testOptions + ); + expect(client2.email).toBe(clientEmail); + + const client2Jwt = await generateMfaJwt(client2); + await client2.createEmail(client2Jwt, secondEmail); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + res = await client2.verifySecondaryEmailWithCode(client2Jwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(1); + expect(res[0].email).toBe(client.email); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + + res = await client2.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + }); + + it('fails create when email is user primary email', async () => { + const mfaJwt = await generateMfaJwt(client); + try { + await client.createEmail(mfaJwt, email); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(139); + expect(err.code).toBe(400); + expect(err.message).toBe( + 'Can not add secondary email that is same as your primary' + ); + } + }); + + it('fails create when email exists in user emails', async () => { + const secondEmail = server.uniqueEmail(); + const mfaJwt = await generateMfaJwt(client); + + await client.createEmail(mfaJwt, secondEmail); + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + const res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + try { + await client.createEmail(mfaJwt, secondEmail); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(189); + expect(err.code).toBe(400); + expect(err.message).toBe('This email already exists on your account'); + } + }); + + it('fails create when verified secondary email exists in other user account', async () => { + const anotherUserEmail = server.uniqueEmail(); + const anotherUserSecondEmail = server.uniqueEmail(); + + const anotherClient = await Client.createAndVerify( + server.publicUrl, + anotherUserEmail, + password, + server.mailbox, + testOptions + ); + + const anotherClientJwt = await generateMfaJwt(anotherClient); + await anotherClient.createEmail(anotherClientJwt, anotherUserSecondEmail); + + const emailData = await server.mailbox.waitForEmail(anotherUserSecondEmail); + const emailCode = emailData['headers']['x-verify-code']; + const res = await anotherClient.verifySecondaryEmailWithCode( + anotherClientJwt, + emailCode, + anotherUserSecondEmail + ); + expect(res).toBeTruthy(); + + const mfaJwt = await generateMfaJwt(client); + try { + await client.createEmail(mfaJwt, anotherUserSecondEmail); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(136); + expect(err.code).toBe(400); + expect(err.message).toBe('Email already exists'); + } + }); + + it('fails for unverified session', async () => { + const secondEmail = server.uniqueEmail(); + await client.login(); + + const res = await client.accountEmails(); + expect(res.length).toBe(1); + expect(res[0].email).toBe(email); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + + const mfaJwt = await generateMfaJwt(client); + try { + await client.createEmail(mfaJwt, secondEmail); + fail('Should not have created email'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(138); + } + }); + + it('fails create when email is another users verified primary', async () => { + const anotherUserEmail = server.uniqueEmail(); + await Client.createAndVerify( + server.publicUrl, + anotherUserEmail, + password, + server.mailbox, + testOptions + ); + + const mfaJwt = await generateMfaJwt(client); + try { + await client.createEmail(mfaJwt, anotherUserEmail); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(140); + expect(err.code).toBe(400); + expect(err.message).toBe('Email already exists'); + } + }); + }); + + describe('should delete additional email', () => { + let secondEmail: string; + let mfaJwt: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + mfaJwt = await generateMfaJwt(client); + + await client.createEmail(mfaJwt, secondEmail); + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const emailCode = emailData['headers']['x-verify-code']; + + let res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + + const postVerifyEmailData = await server.mailbox.waitForEmail(email); + expect(postVerifyEmailData['headers']['x-template-name']).toBe('postVerifySecondary'); + }); + + it('can delete', async () => { + let res = await client.deleteEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(1); + expect(res[0].email).toBe(email); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('postRemoveSecondary'); + }); + + it('resets account tokens when deleting an email', async () => { + await client.forgotPassword(); + const forgotEmailData = await server.mailbox.waitForEmail(secondEmail); + const otpCode = forgotEmailData.headers['x-password-forgot-otp']; + expect(otpCode).toBeTruthy(); + + let res = await client.deleteEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(1); + }); + + it('silent fail on delete non-existent email', async () => { + const res = await client.deleteEmail(mfaJwt, 'fill@yourboots.com'); + expect(res).toBeTruthy(); + }); + + it('fails on delete primary account email', async () => { + try { + await client.deleteEmail(mfaJwt, email); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(137); + expect(err.code).toBe(400); + expect(err.message).toBe('Can not delete primary email'); + } + }); + + it('fails for unverified session', async () => { + await client.login(); + + const res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + + const unverifiedJwt = await generateMfaJwt(client); + try { + await client.deleteEmail(unverifiedJwt, secondEmail); + fail('Should not have deleted email'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(138); + } + }); + }); + + describe('should receive emails on verified secondary emails', () => { + let secondEmail: string; + let thirdEmail: string; + let mfaJwt: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + thirdEmail = server.uniqueEmail(); + mfaJwt = await generateMfaJwt(client); + + let res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + let emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + + emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('postVerifySecondary'); + + // Create a third email but don't verify it (legacy unverified email) + const db = await setupAccountDatabase(baseConfig.database.mysql.auth); + try { + await db + .insertInto('emails') + .values({ + email: thirdEmail, + normalizedEmail: emailHelper.helpers.normalizeEmail(thirdEmail), + uid: Buffer.from(client.uid, 'hex'), + emailCode: Buffer.from(crypto.randomBytes(16).toString('hex'), 'hex'), + isVerified: 0, + isPrimary: 0, + createdAt: Date.now(), + }) + .execute(); + } finally { + await db.destroy(); + } + + res = await client.accountEmails(); + expect(res.length).toBe(3); + expect(res[2].email).toBe(thirdEmail); + expect(res[2].isPrimary).toBe(false); + expect(res[2].verified).toBe(false); + }); + + it('receives sign-in confirmation email', async () => { + const res = await client.login({ keys: true }); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('verifyLogin'); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + expect(emailData.cc.length).toBe(1); + expect(emailData.cc[0].address).toBe(secondEmail); + + await client.requestVerifyEmail(); + + const emailData2 = await server.mailbox.waitForEmail(email); + expect(emailData2['headers']['x-template-name']).toBe('verifyLogin'); + expect(emailData2['headers']['x-verify-code']).toBe(emailCode); + expect(emailData2.cc.length).toBe(1); + expect(emailData2.cc[0].address).toBe(secondEmail); + }); + + it('receives sign-in unblock email', async () => { + await client.sendUnblockCode(email); + + let emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('unblockCode'); + const unblockCode = emailData['headers']['x-unblock-code']; + expect(unblockCode).toBeTruthy(); + expect(emailData.cc.length).toBe(1); + expect(emailData.cc[0].address).toBe(secondEmail); + + await client.sendUnblockCode(email); + + emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('unblockCode'); + expect(emailData.cc.length).toBe(1); + expect(emailData.cc[0].address).toBe(secondEmail); + }); + + it('receives password reset email', async () => { + await client.forgotPassword(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('passwordForgotOtp'); + expect(emailData.cc.length).toBe(1); + expect(emailData.cc[0].address).toBe(secondEmail); + }); + + it('receives change password notification', async () => { + const res = await client.changePassword( + 'password1', + undefined, + client.sessionToken + ); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('passwordChanged'); + expect(emailData.cc.length).toBe(1); + expect(emailData.cc[0].address).toBe(secondEmail); + }); + + it('receives password reset notification', async () => { + await client.forgotPassword(); + + let emailData = await server.mailbox.waitForEmail(email); + const code = emailData.headers['x-password-forgot-otp']; + + const res = await resetPassword(client, code, 'password1', undefined, undefined); + expect(res).toBeTruthy(); + + emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('passwordReset'); + expect(emailData.cc.length).toBe(1); + expect(emailData.cc[0].address).toBe(secondEmail); + + if (testOptions.version === 'V2') { + client = await Client.upgradeCredentials( + server.publicUrl, + email, + 'password1', + { version: '', keys: true }, + server.mailbox + ); + } + + const loginClient = await client.login({ keys: true }); + client = loginClient; + + const accountEmails = await client.accountEmails(); + expect(accountEmails.length).toBe(3); + expect(accountEmails[1].email).toBe(secondEmail); + expect(accountEmails[1].isPrimary).toBe(false); + expect(accountEmails[1].verified).toBe(true); + expect(accountEmails[2].email).toBe(thirdEmail); + expect(accountEmails[2].isPrimary).toBe(false); + expect(accountEmails[2].verified).toBe(false); + }); + + it('receives secondary email removed notification', async () => { + const fourthEmail = server.uniqueEmail(); + + let res = await client.createEmail(mfaJwt, fourthEmail); + expect(res).toBeTruthy(); + + let emailData = await server.mailbox.waitForEmail(fourthEmail); + const emailCode = emailData['headers']['x-verify-code']; + + res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, fourthEmail); + expect(res).toBeTruthy(); + + // Clear email added template + await server.mailbox.waitForEmail(email); + + await client.deleteEmail(mfaJwt, fourthEmail); + + emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('postRemoveSecondary'); + expect(emailData.cc.length).toBe(1); + expect(emailData.cc[0].address).toBe(secondEmail); + }); + }); + + describe('should be able to initiate account reset from verified secondary email', () => { + let secondEmail: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + const mfaJwt = await generateMfaJwt(client); + + let res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + }); + + it('can initiate account reset with verified secondary email', async () => { + client.email = secondEmail; + await client.forgotPassword(); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData.headers['x-password-forgot-otp']).toBeTruthy(); + }); + }); + + describe("shouldn't be able to initiate account reset from secondary email", () => { + let secondEmail: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + const mfaJwt = await generateMfaJwt(client); + + const res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + await server.mailbox.waitForEmail(secondEmail); + }); + + it('fails to initiate account reset with unverified secondary email', async () => { + client.email = secondEmail; + try { + await client.forgotPassword(); + fail('should not have been able to initiate reset password'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(102); + } + }); + + it('returns account unknown error when using unknown email', async () => { + client.email = 'unknown@email.com'; + try { + await client.forgotPassword(); + fail('should not have been able to initiate reset password'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(102); + } + }); + }); + + describe("shouldn't be able to login with secondary email", () => { + let secondEmail: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + const mfaJwt = await generateMfaJwt(client); + + let res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + + await server.mailbox.waitForEmail(email); + }); + + it('fails to login', async () => { + try { + await Client.login( + server.publicUrl, + secondEmail, + password, + testOptions + ); + fail('should not have been able to login'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(142); + } + }); + }); + + describe('verified secondary email', () => { + let secondEmail: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + const mfaJwt = await generateMfaJwt(client); + + let res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + const emailCode = emailData['headers']['x-verify-code']; + expect(emailCode).toBeTruthy(); + + res = await client.verifySecondaryEmailWithCode(mfaJwt, emailCode, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + }); + + it('cannot be used to create a new account', async () => { + try { + await Client.create( + server.publicUrl, + secondEmail, + password, + testOptions + ); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(144); + expect(err.code).toBe(400); + } + }); + }); + + describe('verify secondary email with code', () => { + let secondEmail: string; + let mfaJwt: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + mfaJwt = await generateMfaJwt(client); + + const res = await client.createEmail(mfaJwt, secondEmail); + expect(res).toBeTruthy(); + }); + + it('can verify using a code', async () => { + let emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const code = emailData['headers']['x-verify-code']; + expect(code).toBeTruthy(); + + let res = await client.verifySecondaryEmailWithCode(mfaJwt, code, secondEmail); + expect(res).toBeTruthy(); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + + emailData = await server.mailbox.waitForEmail(email); + expect(emailData['headers']['x-template-name']).toBe('postVerifySecondary'); + }); + + it('does not verify on random email code', async () => { + try { + await client.verifySecondaryEmailWithCode(mfaJwt, '123123', secondEmail); + fail('should have failed'); + } catch (err: any) { + expect(err.errno).toBe(105); + expect(err.code).toBe(400); + } + }); + }); + + describe('(legacy) unverified secondary email', () => { + let secondEmail: string; + + beforeEach(async () => { + secondEmail = server.uniqueEmail(); + const db = await setupAccountDatabase(baseConfig.database.mysql.auth); + const emailCode = Buffer.from(crypto.randomBytes(16).toString('hex'), 'hex'); + try { + await db + .insertInto('emails') + .values({ + email: secondEmail, + normalizedEmail: emailHelper.helpers.normalizeEmail(secondEmail), + uid: Buffer.from(client.uid, 'hex'), + emailCode, + isVerified: 0, + isPrimary: 0, + createdAt: Date.now(), + }) + .execute(); + } finally { + await db.destroy(); + } + }); + + it('is deleted from the initial account if the email is verified on another account', async () => { + let res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(false); + + const client2 = await Client.createAndVerify( + server.publicUrl, + secondEmail, + password, + server.mailbox, + testOptions + ); + expect(client2.email).toBe(secondEmail); + + res = await client.accountEmails(); + expect(res.length).toBe(1); + expect(res[0].email).toBe(client.email); + expect(res[0].isPrimary).toBe(true); + expect(res[0].verified).toBe(true); + }); + + it('can resend verify email code', async () => { + const mfaJwt = await generateMfaJwt(client); + let res = await client.resendVerifySecondaryEmailWithCode( + mfaJwt, + secondEmail + ); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(secondEmail); + expect(emailData['headers']['x-template-name']).toBe('verifySecondaryCode'); + const resendEmailCode = emailData['headers']['x-verify-code']; + expect(resendEmailCode.length).toBe(6); + + await client.verifySecondaryEmailWithCode(mfaJwt, resendEmailCode, secondEmail); + + res = await client.accountEmails(); + expect(res.length).toBe(2); + expect(res[1].email).toBe(secondEmail); + expect(res[1].isPrimary).toBe(false); + expect(res[1].verified).toBe(true); + }); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/recovery_email_resend_code.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_email_resend_code.in.spec.ts new file mode 100644 index 00000000000..be5afd8f507 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/recovery_email_resend_code.in.spec.ts @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: { allowedRecency: 0 } }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote recovery email resend code', + ({ version, tag }) => { + const testOptions = { version }; + + it('sign-in verification resend email verify code', async () => { + const email = server.uniqueEmail(); + const password = 'something'; + const config = server.config as any; + const options = { + ...testOptions, + redirectTo: `https://sync.${config.smtp.redirectDomain}`, + service: 'sync', + resume: 'resumeToken', + keys: true, + }; + + let client = await Client.create(server.publicUrl, email, password, options); + + // Clear first account create email and login again + await server.mailbox.waitForEmail(email); + client = await Client.login(server.publicUrl, email, password, options); + + const verifyEmailCode = await server.mailbox.waitForCode(email); + await client.requestVerifyEmail(); + + const code = await server.mailbox.waitForCode(email); + expect(code).toBe(verifyEmailCode); + + await client.verifyEmail(code); + + const status = await client.emailStatus(); + expect(status.verified).toBe(true); + expect(status.emailVerified).toBe(true); + expect(status.sessionVerified).toBe(true); + }); + + it('sign-in verification resend login verify code', async () => { + const email = server.uniqueEmail(); + const password = 'something'; + const config = server.config as any; + const options = { + ...testOptions, + redirectTo: `https://sync.${config.smtp.redirectDomain}`, + service: 'sync', + resume: 'resumeToken', + keys: true, + }; + + await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + options + ); + + // Attempt to login from new location + let client2 = await Client.login(server.publicUrl, email, password, options); + + // Clears inbox of new signin email + await server.mailbox.waitForEmail(email); + + client2 = await client2.login(options); + + const verifyEmailCode = await server.mailbox.waitForCode(email); + await client2.requestVerifyEmail(); + + const code = await server.mailbox.waitForCode(email); + expect(code).toBe(verifyEmailCode); + + await client2.verifyEmail(code); + + const status = await client2.emailStatus(); + expect(status.verified).toBe(true); + expect(status.emailVerified).toBe(true); + expect(status.sessionVerified).toBe(true); + }); + + it('fail when resending verification email when not owned by account', async () => { + const email = server.uniqueEmail(); + const secondEmail = server.uniqueEmail(); + const password = 'something'; + const options = { ...testOptions, keys: true }; + + const [client] = await Promise.all([ + Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + options + ), + Client.create(server.publicUrl, secondEmail, password, options), + ]); + + client.options = { ...client.options, email: secondEmail }; + + try { + await client.requestVerifyEmail(); + fail('Should not have succeeded in sending verification code'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(150); + } + }); + + it('should be able to upgrade unverified session to verified session', async () => { + const email = server.uniqueEmail(); + const password = 'something'; + const options = { ...testOptions, keys: false }; + + let client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + options + ); + + // Create an unverified session + client = await client.login(); + + // Clear the verify account email + await server.mailbox.waitForCode(email); + + let result = await client.sessionStatus(); + expect(result.state).toBe('unverified'); + + // Set the type of code to receive + client.options.type = 'upgradeSession'; + await client.requestVerifyEmail(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('verifyPrimary'); + const code = emailData.headers['x-verify-code']; + expect(code).toBeTruthy(); + + await client.verifyEmail(code); + + result = await client.sessionStatus(); + expect(result.state).toBe('verified'); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/recovery_email_verify.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_email_verify.in.spec.ts new file mode 100644 index 00000000000..632f334debf --- /dev/null +++ b/packages/fxa-auth-server/test/remote/recovery_email_verify.in.spec.ts @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import url from 'url'; + +const Client = require('../client')(); + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote recovery email verify', + ({ version, tag }) => { + const testOptions = { version }; + + it('create account verify with incorrect code', async () => { + const email = server.uniqueEmail(); + const password = 'allyourbasearebelongtous'; + + const client = await Client.create( + server.publicUrl, + email, + password, + testOptions + ); + + let status = await client.emailStatus(); + expect(status.verified).toBe(false); + + try { + await client.verifyEmail('00000000000000000000000000000000'); + fail('verified email with bad code'); + } catch (err: any) { + expect(err.message.toString()).toBe('Invalid confirmation code'); + } + + status = await client.emailStatus(); + expect(status.verified).toBe(false); + }); + + it('verification email link', async () => { + const email = server.uniqueEmail(); + const password = 'something'; + const config = server.config as any; + const options = { + ...testOptions, + redirectTo: `https://sync.${config.smtp.redirectDomain}/`, + service: 'sync', + }; + + await Client.create(server.publicUrl, email, password, options); + + const emailData = await server.mailbox.waitForEmail(email); + const link = emailData.headers['x-link']; + const query = url.parse(link, true).query; + expect(query.uid).toBeTruthy(); + expect(query.code).toBeTruthy(); + expect(query.redirectTo).toBe(options.redirectTo); + expect(query.service).toBe(options.service); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/recovery_key.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_key.in.spec.ts new file mode 100644 index 00000000000..3b18517fa7a --- /dev/null +++ b/packages/fxa-auth-server/test/remote/recovery_key.in.spec.ts @@ -0,0 +1,319 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import crypto from 'crypto'; + +const Client = require('../client')(); + +function createMockRecoveryKey() { + const recoveryCode = crypto.randomBytes(16).toString('hex'); + const recoveryKeyId = crypto.randomBytes(16).toString('hex'); + const recoveryKey = crypto.randomBytes(16).toString('hex'); + const recoveryData = crypto.randomBytes(32).toString('hex'); + + return Promise.resolve({ + recoveryCode, + recoveryData, + recoveryKeyId, + recoveryKey, + }); +} + +async function getAccountResetToken(client: any, server: TestServerInstance, email: string) { + await client.forgotPassword(); + const otpCode = await server.mailbox.waitForCode(email); + const result = await client.verifyPasswordForgotOtp(otpCode); + await client.verifyPasswordResetCode( + result.code, + {}, + { accountResetWithRecoveryKey: true } + ); +} + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote recovery keys', + ({ version, tag }) => { + const testOptions = { version }; + const password = '(-.-)Zzz...'; + let client: any; + let email: string; + let recoveryKeyId: string; + let recoveryData: string; + let keys: any; + + beforeEach(async () => { + email = server.uniqueEmail(); + client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + expect(client.authAt).toBeTruthy(); + + keys = await client.keys(); + + const mockKey = await createMockRecoveryKey(); + recoveryKeyId = mockKey.recoveryKeyId; + recoveryData = mockKey.recoveryData; + + const res = await client.createRecoveryKey( + mockKey.recoveryKeyId, + mockKey.recoveryData + ); + expect(res).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('postAddAccountRecovery'); + }); + + it('should get account recovery key', async () => { + await getAccountResetToken(client, server, email); + const res = await client.getRecoveryKey(recoveryKeyId); + expect(res.recoveryData).toBe(recoveryData); + }); + + it('should fail to get unknown account recovery key', async () => { + await getAccountResetToken(client, server, email); + try { + await client.getRecoveryKey('abce1234567890'); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(159); + } + }); + + if (version === 'V2') { + const checkPayloadV2 = async (mutate: () => void, restore: () => void) => { + await getAccountResetToken(client, server, email); + await client.getRecoveryKey(recoveryKeyId); + let err: any; + try { + mutate(); + await client.api.accountResetWithRecoveryKeyV2( + client.accountResetToken, + client.authPW, + client.authPWVersion2, + client.wrapKb, + client.wrapKbVersion2, + client.clientSalt, + recoveryKeyId, + undefined, + {} + ); + } catch (error) { + err = error; + } finally { + restore(); + } + + expect(err).toBeDefined(); + expect(err.errno).toBe(107); + }; + + it('should fail if wrapKb is missing and authPWVersion2 is provided', async () => { + const temp = client.wrapKb; + await checkPayloadV2( + () => { + client.unwrapBKey = undefined; + client.wrapKb = undefined; + }, + () => { + client.wrapKb = temp; + } + ); + }); + + it('should fail if wrapKbVersion2 is missing and authPWVersion2 is provided', async () => { + const temp = client.wrapKbVersion2; + await checkPayloadV2( + () => { + client.wrapKbVersion2 = undefined; + }, + () => { + client.wrapKbVersion2 = temp; + } + ); + }); + + it('should fail if clientSalt is missing and authPWVersion2 is provided', async () => { + const temp = client.clientSalt; + await checkPayloadV2( + () => { + client.clientSalt = undefined; + }, + () => { + client.clientSalt = temp; + } + ); + }); + } + + if (version === '') { + it('should fail if recoveryKeyId is missing', async () => { + await getAccountResetToken(client, server, email); + const res = await client.getRecoveryKey(recoveryKeyId); + expect(res.recoveryData).toBe(recoveryData); + + try { + await client.resetAccountWithRecoveryKey( + 'newpass', + keys.kB, + undefined, + {}, + { keys: true } + ); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(107); + } + }); + + it('should fail if wrapKb is missing', async () => { + await getAccountResetToken(client, server, email); + const res = await client.getRecoveryKey(recoveryKeyId); + expect(res.recoveryData).toBe(recoveryData); + + try { + await client.resetAccountWithRecoveryKey( + 'newpass', + keys.kB, + recoveryKeyId, + {}, + { keys: true, undefinedWrapKb: true } + ); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(107); + } + }); + } + + it('should reset password while keeping kB', async () => { + await getAccountResetToken(client, server, email); + let res = await client.getRecoveryKey(recoveryKeyId); + expect(res.recoveryData).toBe(recoveryData); + + const profileBefore = await client.accountProfile(); + + res = await client.resetAccountWithRecoveryKey( + 'newpass', + keys.kB, + recoveryKeyId, + {}, + { keys: true } + ); + expect(res.uid).toBe(client.uid); + expect(res.sessionToken).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe( + 'passwordResetAccountRecovery' + ); + + res = await client.keys(); + expect(res.kA).toBe(keys.kA); + expect(res.kB).toBe(keys.kB); + + // Login with new password and check kB hasn't changed + const c = await Client.login(server.publicUrl, email, 'newpass', { + ...testOptions, + keys: true, + }); + expect(c.sessionToken).toBeTruthy(); + res = await c.keys(); + expect(res.kA).toBe(keys.kA); + expect(res.kB).toBe(keys.kB); + + const profileAfter = await client.accountProfile(); + expect(profileBefore['keysChangedAt']).toBe(profileAfter['keysChangedAt']); + }); + + it('should delete account recovery key', async () => { + const res = await client.deleteRecoveryKey(); + expect(res).toBeTruthy(); + + const result = await client.getRecoveryKeyExists(); + expect(result.exists).toBe(false); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe( + 'postRemoveAccountRecovery' + ); + }); + + it('should fail to create account recovery key when one already exists', async () => { + const mockKey = await createMockRecoveryKey(); + + try { + await client.createRecoveryKey(mockKey.recoveryKeyId, mockKey.recoveryData); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(161); + } + }); + + describe('check account recovery key status', () => { + describe('with sessionToken', () => { + it('should return true if account recovery key exists and enabled', async () => { + const res = await client.getRecoveryKeyExists(); + expect(res.exists).toBe(true); + }); + + it("should return false if account recovery key doesn't exist", async () => { + const newEmail = server.uniqueEmail(); + const newClient = await Client.createAndVerify( + server.publicUrl, + newEmail, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + const res = await newClient.getRecoveryKeyExists(); + expect(res.exists).toBe(false); + }); + + it('should return false if account recovery key exist but not enabled', async () => { + const email2 = server.uniqueEmail(); + const client2 = await Client.createAndVerify( + server.publicUrl, + email2, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + + const recoveryKeyMock = await createMockRecoveryKey(); + let res = await client2.createRecoveryKey( + recoveryKeyMock.recoveryKeyId, + recoveryKeyMock.recoveryData, + false + ); + expect(res).toEqual({}); + + res = await client2.getRecoveryKeyExists(); + expect(res.exists).toBe(false); + }); + }); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts new file mode 100644 index 00000000000..3026f8faf73 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts @@ -0,0 +1,396 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import crypto from 'crypto'; + +const Client = require('../client')(); +const otplib = require('otplib'); +const Redis = require('ioredis'); +const { setupAccountDatabase } = require('@fxa/shared/db/mysql/account'); +const { RECOVERY_PHONE_REDIS_PREFIX } = require('@fxa/accounts/recovery-phone'); +const baseConfig = require('../../config').default.getProperties(); + +const redis = new Redis({ + ...baseConfig.redis, + ...baseConfig.redis.recoveryPhone, +}); + +const redisUtil = { + async clearAllKey(keys: string) { + const result = await redis.keys(keys); + if (result.length > 0) { + await redis.del(result); + } + }, + recoveryPhone: { + async getCode(uid: string) { + const redisKey = `${RECOVERY_PHONE_REDIS_PREFIX}:${uid}:*`; + const result = await redis.keys(redisKey); + expect(result.length).toBe(1); + const parts = result[0].split(':'); + return parts[parts.length - 1]; + }, + async clearAll() { + await redisUtil.clearAllKey('recovery-phone:*'); + }, + }, + customs: { + async clearAll() { + await redisUtil.clearAllKey('customs:*'); + }, + }, +}; + +const isTwilioConfiguredForTest = + baseConfig.twilio.testAccountSid?.length >= 24 && + baseConfig.twilio.testAccountSid?.startsWith('AC') && + baseConfig.twilio.testAuthToken?.length >= 24 && + baseConfig.twilio.credentialMode === 'test'; + +const phoneNumber = '+14159929960'; +const password = 'password'; + +describe('#integration - recovery phone', () => { + let server: TestServerInstance; + let client: any; + let email: string; + let db: any; + + beforeAll(async () => { + if (!isTwilioConfiguredForTest) return; + server = await createTestServer({ + configOverrides: { + recoveryPhone: { enabled: true }, + twilio: { credentialMode: 'test' }, + securityHistory: { ipProfiling: { allowedRecency: 0 } }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + }, + }); + db = await setupAccountDatabase(baseConfig.database.mysql.auth); + }, 120000); + + async function cleanUp() { + if (!db) return; + await redisUtil.recoveryPhone.clearAll(); + await db.deleteFrom('accounts').execute(); + await db.deleteFrom('recoveryPhones').execute(); + await db.deleteFrom('sessionTokens').execute(); + await db.deleteFrom('recoveryCodes').execute(); + } + + beforeEach(async () => { + if (!server) return; + email = server.uniqueEmail(); + client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + { version: 'V2' } + ); + + // Add totp to account + client.totpAuthenticator = new otplib.authenticator.Authenticator(); + const totpResult = await client.createTotpToken(); + client.totpAuthenticator.options = { + secret: totpResult.secret, + crypto: crypto, + }; + await client.verifyTotpSetupCode(client.totpAuthenticator.generate()); + await client.completeTotpSetup(); + }); + + afterEach(async () => { + await cleanUp(); + }); + + afterAll(async () => { + if (server) await server.stop(); + if (db) await db.destroy(); + }); + + const skipIfNoTwilio = () => { + if (!isTwilioConfiguredForTest) { + return true; + } + return false; + }; + + it('sets up a recovery phone', async () => { + if (skipIfNoTwilio()) return; + + const createResp = await client.recoveryPhoneCreate(phoneNumber); + const codeSent = await redisUtil.recoveryPhone.getCode(client.uid); + const confirmResp = await client.recoveryPhoneConfirmSetup(codeSent); + const checkResp = await client.recoveryPhoneNumber(); + + expect(createResp.status).toBe('success'); + expect(codeSent).toBeDefined(); + expect(confirmResp.status).toBe('success'); + expect(checkResp.exists).toBe(true); + expect(checkResp.phoneNumber).toBe(phoneNumber); + }); + + it('can send, confirm code, verify session, and remove totp', async () => { + if (skipIfNoTwilio()) return; + + // Add recovery phone + await client.recoveryPhoneCreate(phoneNumber); + await client.recoveryPhoneConfirmSetup( + await redisUtil.recoveryPhone.getCode(client.uid) + ); + + // Log back, capture session status + await client.destroySession(); + client = await Client.login(server.publicUrl, email, password, { + version: 'V2', + }); + const sessionStatus1 = await client.sessionStatus(); + const totpExistsResp1 = await client.checkTotpTokenExists(); + + // Send recovery phone code and confirm + const sendResp = await client.recoveryPhoneSendCode(); + const confirmResp2 = await client.recoveryPhoneConfirmSignin( + await redisUtil.recoveryPhone.getCode(client.uid) + ); + const sessionStatus2 = await client.sessionStatus(); + + await client.deleteTotpToken(); + const totpExistsResp2 = await client.checkTotpTokenExists(); + + expect(sendResp.status).toBe('success'); + expect(confirmResp2.status).toBe('success'); + expect(sessionStatus2.state).toBe('verified'); + expect(sessionStatus1.state).toBe('unverified'); + expect(totpExistsResp1.exists).toBe(true); + expect(totpExistsResp2.exists).toBe(false); + }); + + it('can remove recovery phone', async () => { + if (skipIfNoTwilio()) return; + + await client.recoveryPhoneCreate(phoneNumber); + await client.recoveryPhoneConfirmSetup( + await redisUtil.recoveryPhone.getCode(client.uid) + ); + const checkResp = await client.recoveryPhoneNumber(); + const destroyResp = await client.recoveryPhoneDestroy(); + const checkResp2 = await client.recoveryPhoneNumber(); + + expect(checkResp.exists).toBe(true); + expect(destroyResp).toBeDefined(); + expect(checkResp2.exists).toBe(false); + }); + + it('fails to set up invalid phone number', async () => { + if (skipIfNoTwilio()) return; + + const invalidNumber = '+1234567890'; + try { + await client.recoveryPhoneCreate(invalidNumber); + fail('should have thrown'); + } catch (err: any) { + expect(err.message).toBe('Invalid phone number'); + } + }); + + it('can recreate recovery phone number', async () => { + if (skipIfNoTwilio()) return; + + await client.recoveryPhoneCreate(phoneNumber); + const createResp = await client.recoveryPhoneCreate(phoneNumber); + expect(createResp.status).toBe('success'); + }); + + it('fails to send a code to an unregistered phone number', async () => { + if (skipIfNoTwilio()) return; + + await client.recoveryPhoneCreate(phoneNumber); + + try { + await client.recoveryPhoneSendCode(); + fail('should have thrown'); + } catch (err: any) { + expect(err.message).toBe('Recovery phone number does not exist'); + } + }); + + it('fails to register the same phone number again', async () => { + if (skipIfNoTwilio()) return; + + await client.recoveryPhoneCreate(phoneNumber); + const code = await redisUtil.recoveryPhone.getCode(client.uid); + await client.recoveryPhoneConfirmSetup(code); + + try { + await client.recoveryPhoneCreate(phoneNumber); + const code2 = await redisUtil.recoveryPhone.getCode(client.uid); + await client.recoveryPhoneConfirmSetup(code2); + fail('should have thrown'); + } catch (err: any) { + expect(err.message).toBe('Recovery phone number already exists'); + } + }); + + it('fails to use the same code again', async () => { + if (skipIfNoTwilio()) return; + + await client.recoveryPhoneCreate(phoneNumber); + const code = await redisUtil.recoveryPhone.getCode(client.uid); + await client.recoveryPhoneConfirmSetup(code); + + try { + await client.recoveryPhoneConfirmSetup(code); + fail('should have thrown'); + } catch (err: any) { + expect(err.message).toBe('Invalid or expired confirmation code'); + } + }); +}); + +describe('#integration - recovery phone - feature flag check', () => { + let server: TestServerInstance; + + beforeAll(async () => { + if (!isTwilioConfiguredForTest) return; + server = await createTestServer({ + configOverrides: { + recoveryPhone: { enabled: false }, + twilio: { credentialMode: 'test' }, + securityHistory: { ipProfiling: { allowedRecency: 0 } }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + }, + }); + }, 120000); + + afterAll(async () => { + if (server) await server.stop(); + }); + + it('returns feature not enabled error', async () => { + if (!isTwilioConfiguredForTest) return; + + try { + const email = server.uniqueEmail(); + const client = await Client.createAndVerify( + server.publicUrl, + email, + 'topsecretz', + server.mailbox, + { version: 'V2' } + ); + client.totpAuthenticator = new otplib.authenticator.Authenticator(); + const totpResult = await client.createTotpToken(); + client.totpAuthenticator.options = { + secret: totpResult.secret, + crypto: crypto, + }; + await client.verifyTotpSetupCode(client.totpAuthenticator.generate()); + await client.completeTotpSetup(); + await client.recoveryPhoneCreate('+14159929960'); + fail('Should have received an error'); + } catch (err: any) { + expect(err.message).toBe('Feature not enabled'); + } + }); +}); + +describe('#integration - recovery phone - customs checks', () => { + let server: TestServerInstance; + let email: string; + let client: any; + + beforeAll(async () => { + if (!isTwilioConfiguredForTest) return; + server = await createTestServer({ + configOverrides: { + recoveryPhone: { enabled: true }, + securityHistory: { ipProfiling: { allowedRecency: 0 } }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + customsUrl: 'http://127.0.0.1:7000', + }, + }); + }, 120000); + + afterAll(async () => { + if (server) await server.stop(); + }); + + beforeEach(async () => { + if (!server) return; + email = server.uniqueEmail(); + client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + { version: 'V2' } + ); + }); + + afterEach(async () => { + await redisUtil.recoveryPhone.clearAll(); + await redisUtil.customs.clearAll(); + }); + + it('prevents excessive calls to /recovery_phone/create', async () => { + if (!isTwilioConfiguredForTest || !server) return; + + await client.recoveryPhoneCreate(phoneNumber); + + let error: any; + try { + for (let i = 0; i < 9; i++) { + await client.recoveryPhoneCreate(phoneNumber); + } + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect(error.message).toBe('Client has sent too many requests'); + }); + + it('prevents excessive calls to /recovery_phone/confirm', async () => { + if (!isTwilioConfiguredForTest || !server) return; + + await client.recoveryPhoneCreate(phoneNumber); + + for (let i = 0; i < 15; i++) { + try { + await client.recoveryPhoneConfirmSetup('000001'); + } catch {} + } + + let error: any; + try { + await client.recoveryPhoneConfirmSetup('000001'); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.message).toBe('Client has sent too many requests'); + }); + + it('prevents excessive calls to /recovery_phone/signin/send_code', async () => { + if (!isTwilioConfiguredForTest || !server) return; + + await client.recoveryPhoneCreate(phoneNumber); + const codeSent = await redisUtil.recoveryPhone.getCode(client.uid); + await client.recoveryPhoneConfirmSetup(codeSent); + + let error: any; + try { + for (let i = 0; i < 7; i++) { + await client.recoveryPhoneSendCode(); + } + } catch (err) { + error = err; + } + + expect(error).toBeDefined(); + expect(error.message).toBe('Client has sent too many requests'); + }); +}); diff --git a/packages/fxa-auth-server/test/remote/security_events.in.spec.ts b/packages/fxa-auth-server/test/remote/security_events.in.spec.ts new file mode 100644 index 00000000000..bf9ddb1e6de --- /dev/null +++ b/packages/fxa-auth-server/test/remote/security_events.in.spec.ts @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); + +function delay(seconds: number) { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +async function resetPassword(client: any, otpCode: string, newPassword: string, options?: any) { + const result = await client.verifyPasswordForgotOtp(otpCode); + await client.verifyPasswordResetCode(result.code); + return client.resetPassword(newPassword, {}, options); +} + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: { allowedRecency: 0 } }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote securityEvents', + ({ version, tag }) => { + const testOptions = { version }; + + it('returns securityEvents on creating and login into an account', async () => { + const email = server.uniqueEmail(); + const password = 'abcdef'; + + const client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + + // Login creates an unverified session + await client.login(); + + // Verify the login session to be able to call securityEvents endpoint + const code = await server.mailbox.waitForCode(email); + await client.verifyEmail(code); + + await delay(1); + const events = await client.securityEvents(); + + expect(events.length).toBe(2); + expect(events[0].name).toBe('account.login'); + expect(events[0].createdAt).toBeLessThan(new Date().getTime()); + + expect(events[1].name).toBe('account.create'); + expect(events[1].createdAt).toBeLessThan(new Date().getTime()); + expect(events[1].verified).toBe(true); + }); + + it('returns security events after account reset w/o keys, with sessionToken', async () => { + const email = server.uniqueEmail(); + const password = 'oldPassword'; + const newPassword = 'newPassword'; + + const client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + + await client.forgotPassword(); + const code = await server.mailbox.waitForCode(email); + + await expect(client.resetPassword(newPassword)).rejects.toBeDefined(); + const response = await resetPassword(client, code, newPassword); + + expect(response.sessionToken).toBeTruthy(); + expect(response.keyFetchToken).toBeFalsy(); + expect(response.emailVerified).toBe(true); + expect(response.sessionVerified).toBe(true); + + await delay(1); + const events = await client.securityEvents(); + + const resetEvent = events.find((e: any) => e.name === 'account.reset'); + const createEvent = events.find((e: any) => e.name === 'account.create'); + + expect(resetEvent).toBeTruthy(); + expect(resetEvent.createdAt).toBeLessThan(new Date().getTime()); + expect(resetEvent.verified).toBe(true); + + expect(createEvent).toBeTruthy(); + expect(createEvent.createdAt).toBeLessThan(new Date().getTime()); + expect(createEvent.verified).toBe(true); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/session_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/session_tests.in.spec.ts new file mode 100644 index 00000000000..6922afa118d --- /dev/null +++ b/packages/fxa-auth-server/test/remote/session_tests.in.spec.ts @@ -0,0 +1,342 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const Client = require('../client')(); + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + signinConfirmation: { + skipForNewAccounts: { enabled: false }, + }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +describe.each(testVersions)( + '#integration$tag - remote session', + ({ version, tag }) => { + const testOptions = { version }; + + describe('destroy', () => { + it('deletes a valid session', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + await client.sessionStatus(); + const sessionToken = client.sessionToken; + await client.destroySession(); + expect(client.sessionToken).toBeNull(); + + client.sessionToken = sessionToken; + try { + await client.sessionStatus(); + throw new Error('got status with destroyed session'); + } catch (err: any) { + expect(err.errno).toBe(110); + } + }); + + it('deletes a different custom token', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const sessionTokenCreate = client.sessionToken; + const sessions = await client.api.sessions(sessionTokenCreate); + const tokenId = sessions[0].id; + + const c = await client.login(); + const sessionTokenLogin = c.sessionToken; + + const status = await client.api.sessionStatus(sessionTokenCreate); + expect(status.uid).toBeTruthy(); + + await client.api.sessionDestroy(sessionTokenLogin, { + customSessionToken: tokenId, + }); + + try { + await client.api.sessionStatus(sessionTokenCreate); + throw new Error('got status with destroyed session'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('fails with a bad custom token', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + + const sessionTokenCreate = client.sessionToken; + const c = await client.login(); + const sessionTokenLogin = c.sessionToken; + + await client.api.sessionStatus(sessionTokenCreate); + + // In the original Mocha test, sessionDestroy may throw and the + // rejection propagates to the final .then(null, errHandler). + // With async/await we must catch errors from either call. + try { + await client.api.sessionDestroy(sessionTokenLogin, { + customSessionToken: + 'eff779f59ab974f800625264145306ce53185bb22ee01fe80280964ff2766504', + }); + await client.api.sessionStatus(sessionTokenCreate); + throw new Error('got status with destroyed session'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + expect(err.error).toBe('Unauthorized'); + expect(err.message).toBe('The authentication token could not be found'); + } + }); + }); + + describe('duplicate', () => { + it('duplicates a valid session into a new, independent session', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client1 = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + const client2 = await client1.duplicate(); + expect(client1.sessionToken).not.toBe(client2.sessionToken); + + await client1.api.sessionDestroy(client1.sessionToken); + + try { + await client1.sessionStatus(); + throw new Error('client1 session should have been destroyed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + + const status = await client2.sessionStatus(); + expect(status).toBeTruthy(); + + await client2.api.sessionDestroy(client2.sessionToken); + + try { + await client2.sessionStatus(); + throw new Error('client2 session should have been destroyed'); + } catch (err: any) { + expect(err.code).toBe(401); + expect(err.errno).toBe(110); + } + }); + + it('creates independent verification state for the new token', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client1 = await Client.create(server.publicUrl, email, password, testOptions); + const client2 = await client1.duplicate(); + + expect(client1.verified).toBeFalsy(); + expect(client2.verified).toBeFalsy(); + + const code = await server.mailbox.waitForCode(email); + await client1.verifyEmail(code); + + let status = await client1.sessionStatus(); + expect(status.state).toBe('verified'); + + status = await client2.sessionStatus(); + expect(status.state).toBe('unverified'); + + const client3 = await client2.duplicate(); + await client2.requestVerifyEmail(); + const code2 = await server.mailbox.waitForCode(email); + await client2.verifyEmail(code2); + + status = await client2.sessionStatus(); + expect(status.state).toBe('verified'); + + status = await client3.sessionStatus(); + expect(status.state).toBeTruthy(); + }); + }); + + describe('reauth', () => { + it('allocates a new keyFetchToken', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, + { ...testOptions, keys: true } + ); + + const keys = await client.keys(); + const kA = keys.kA; + const kB = keys.kB; + expect(client.getState().keyFetchToken).toBeNull(); + + await client.reauth({ keys: true }); + expect(client.getState().keyFetchToken).toBeTruthy(); + + const keys2 = await client.keys(); + expect(keys2.kA).toBe(kA); + expect(keys2.kB).toBe(kB); + expect(client.getState().keyFetchToken).toBeNull(); + }); + + it('rejects incorrect passwords', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + + await client.setupCredentials(email, 'fiibar'); + if (testOptions.version === 'V2') { + await client.setupCredentialsV2(email, 'fiibar'); + } + + try { + await client.reauth(); + throw new Error('password should have been rejected'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(103); + } + }); + + it('has sane account-verification behaviour', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client = await Client.create(server.publicUrl, email, password, testOptions); + expect(client.verified).toBeFalsy(); + + // Clear the verification email, without verifying. + await server.mailbox.waitForCode(email); + + await client.reauth(); + let status = await client.sessionStatus(); + expect(status.state).toBe('unverified'); + + // The reauth should have triggered a second email. + const code = await server.mailbox.waitForCode(email); + await client.verifyEmail(code); + + status = await client.sessionStatus(); + expect(status.state).toBe('verified'); + }); + + it('has sane session-verification behaviour', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, + { ...testOptions, keys: false } + ); + + const client = await Client.login(server.publicUrl, email, password, { + keys: false, + ...testOptions, + }); + + // Clears inbox of new signin email + await server.mailbox.waitForEmail(email); + + let status = await client.sessionStatus(); + expect(status.state).toBe('unverified'); + + let emailStatus = await client.emailStatus(); + expect(emailStatus.verified).toBe(true); + + await client.reauth({ keys: true }); + + status = await client.sessionStatus(); + expect(status.state).toBe('unverified'); + + emailStatus = await client.emailStatus(); + expect(emailStatus.verified).toBe(false); + + // The reauth should have triggered a verification email. + const code = await server.mailbox.waitForCode(email); + await client.verifyEmail(code); + + status = await client.sessionStatus(); + expect(status.state).toBe('verified'); + + emailStatus = await client.emailStatus(); + expect(emailStatus.verified).toBe(true); + }); + + it('does not send notification emails on verified sessions', async () => { + const email = server.uniqueEmail(); + const password = 'foobar'; + const client = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, + { ...testOptions, keys: true } + ); + + await client.reauth({ keys: true }); + + // Send some other type of email, and assert that it's the one we get back. + // If the above sent a "new login" notification, we would get that instead. + await client.forgotPassword(); + const msg = await server.mailbox.waitForEmail(email); + expect(msg.headers['x-password-forgot-otp']).toBeTruthy(); + }); + }); + + describe('status', () => { + it('succeeds with valid token', async () => { + const email = server.uniqueEmail(); + const password = 'testx'; + const c = await Client.createAndVerify( + server.publicUrl, email, password, server.mailbox, testOptions + ); + const uid = c.uid; + await c.login(); + const x = await c.api.sessionStatus(c.sessionToken); + + expect(x).toEqual({ + state: 'unverified', + uid, + details: { + accountEmailVerified: true, + sessionVerificationMeetsMinimumAAL: true, + sessionVerificationMethod: null, + sessionVerified: false, + verified: false, + }, + }); + }); + + it('errors with invalid token', async () => { + const client = new Client(server.publicUrl, testOptions); + try { + await client.api.sessionStatus( + '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF' + ); + throw new Error('should have failed'); + } catch (err: any) { + expect(err.errno).toBe(110); + } + }); + }); + } +); diff --git a/packages/fxa-auth-server/test/remote/sign_key_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/sign_key_tests.in.spec.ts new file mode 100644 index 00000000000..62432a211eb --- /dev/null +++ b/packages/fxa-auth-server/test/remote/sign_key_tests.in.spec.ts @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import path from 'path'; +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +const superagent = require('superagent'); + +let server: TestServerInstance; + +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + oldPublicKeyFile: path.resolve(__dirname, '../../config/public-key.json'), + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +describe('#integration - remote sign key', () => { + it('.well-known/browserid has keys', async () => { + const res = await superagent.get( + `${server.publicUrl}/.well-known/browserid` + ); + expect(res.statusCode).toBe(200); + const json = res.body; + expect(json.authentication).toBe( + '/.well-known/browserid/nonexistent.html' + ); + expect(json.keys.length).toBe(2); + }); +}); diff --git a/packages/fxa-auth-server/test/remote/totp.in.spec.ts b/packages/fxa-auth-server/test/remote/totp.in.spec.ts new file mode 100644 index 00000000000..48cb4411ed9 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/totp.in.spec.ts @@ -0,0 +1,433 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import crypto from 'crypto'; + +const Client = require('../client')(); +const otplib = require('otplib'); +const jwt = require('jsonwebtoken'); +const uuid = require('uuid'); +const { default: Container } = require('typedi'); +const { + PlaySubscriptions, +} = require('../../lib/payments/iap/google-play/subscriptions'); +const { + AppStoreSubscriptions, +} = require('../../lib/payments/iap/apple-app-store/subscriptions'); +const tokens = require('../../lib/tokens')({ trace: function () {} }); +const baseConfig = require('../../config').default.getProperties(); + +async function generateMfaJwt(client: any) { + const sessionTokenHex = client.sessionToken; + const sessionToken = await tokens.SessionToken.fromHex(sessionTokenHex); + const sessionTokenId = sessionToken.id; + + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: client.uid, + scope: ['mfa:2fa'], + iat: now, + jti: uuid.v4(), + stid: sessionTokenId, + }; + + return jwt.sign(claims, baseConfig.mfa.jwt.secretKey, { + algorithm: 'HS256', + expiresIn: baseConfig.mfa.jwt.expiresInSec, + audience: baseConfig.mfa.jwt.audience, + issuer: baseConfig.mfa.jwt.issuer, + }); +} + +let server: TestServerInstance; + +beforeAll(async () => { + Container.set(PlaySubscriptions, {}); + Container.set(AppStoreSubscriptions, {}); + + server = await createTestServer({ + configOverrides: { + securityHistory: { ipProfiling: {} }, + signinConfirmation: { skipForNewAccounts: { enabled: false } }, + }, + }); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +const testVersions = [ + { version: '', tag: '' }, + { version: 'V2', tag: 'V2' }, +]; + +const password = 'pssssst'; +const metricsContext = { + flowBeginTime: Date.now(), + flowId: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', +}; + +otplib.authenticator.options = { + crypto: crypto, + encoding: 'hex', + window: 10, +}; + +describe.each(testVersions)( + '#integration$tag - remote totp', + ({ version, tag }) => { + const testOptions = { version }; + let client: any; + let email: string; + let totpToken: any; + let authenticator: any; + + function verifyTOTP(c: any) { + return c + .createTotpToken({ metricsContext }) + .then((result: any) => { + authenticator = new otplib.authenticator.Authenticator(); + authenticator.options = Object.assign( + {}, + otplib.authenticator.options, + { secret: result.secret } + ); + totpToken = result; + + const code = authenticator.generate(); + return c.verifyTotpSetupCode(code); + }) + .then(() => { + return c.completeTotpSetup({ + metricsContext, + service: 'sync', + }); + }) + .then((response: any) => { + expect(response.success).toBe(true); + return server.mailbox.waitForEmail(email); + }) + .then((emailData: any) => { + expect(emailData.headers['x-template-name']).toBe( + 'postAddTwoStepAuthentication' + ); + }); + } + + beforeEach(async () => { + email = server.uniqueEmail(); + client = await Client.createAndVerify( + server.publicUrl, + email, + password, + server.mailbox, + testOptions + ); + expect(client.authAt).toBeTruthy(); + await verifyTOTP(client); + }); + + it('should create totp token', () => { + expect(totpToken).toBeTruthy(); + expect(totpToken.qrCodeUrl).toBeTruthy(); + }); + + it('should check if totp token exists for user', async () => { + const response = await client.checkTotpTokenExists(); + expect(response.exists).toBe(true); + }); + + it('should fail to create second totp token for same user', async () => { + try { + await client.createTotpToken(); + fail('should have thrown'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(154); + } + }); + + it('should not fail to delete unknown totp token', async () => { + const newEmail = server.uniqueEmail(); + const newClient = await Client.createAndVerify( + server.publicUrl, + newEmail, + password, + server.mailbox, + testOptions + ); + expect(newClient.authAt).toBeTruthy(); + const mfaJwt = await generateMfaJwt(newClient); + const result = await newClient.deleteTotpToken(mfaJwt); + expect(result).toBeTruthy(); + }); + + it('should delete totp token', async () => { + const mfaJwt = await generateMfaJwt(client); + const result = await client.deleteTotpToken(mfaJwt); + expect(result).toBeTruthy(); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe( + 'postRemoveTwoStepAuthentication' + ); + + const tokenResult = await client.checkTotpTokenExists(); + expect(tokenResult.exists).toBe(false); + }); + + it('should not allow unverified sessions before totp enabled to delete totp token', async () => { + const newEmail = server.uniqueEmail(); + const newClient = await Client.createAndVerify( + server.publicUrl, + newEmail, + password, + server.mailbox, + testOptions + ); + + const response = await newClient.login({ keys: true }); + expect(response.verificationMethod).toBe('email'); + expect(response.verificationReason).toBe('login'); + expect(response.sessionVerified).toBe(false); + + await server.mailbox.waitForEmail(newEmail); + + // Login with a new client and enable TOTP + const client2 = await Client.loginAndVerify( + server.publicUrl, + newEmail, + password, + server.mailbox, + { ...testOptions, keys: true } + ); + email = newEmail; + await verifyTOTP(client2); + + // Attempt to delete totp from original unverified session + const mfaJwt = await generateMfaJwt(newClient); + try { + await newClient.deleteTotpToken(mfaJwt); + fail('should have thrown'); + } catch (err: any) { + expect(err.errno).toBe(138); + } + }); + + it('should request `totp-2fa` on login if user has verified totp token', async () => { + const response = await Client.login(server.publicUrl, email, password, { + ...testOptions, + keys: true, + }); + expect(response.verificationMethod).toBe('totp-2fa'); + expect(response.verificationReason).toBe('login'); + }); + + it('should not have `totp-2fa` verification if user has unverified totp token', async () => { + const mfaJwt = await generateMfaJwt(client); + await client.deleteTotpToken(mfaJwt); + await client.createTotpToken(); + + const response = await Client.login(server.publicUrl, email, password, { + ...testOptions, + keys: true, + }); + expect(response.verificationMethod).not.toBe('totp-2fa'); + expect(response.verificationReason).toBe('login'); + }); + + it('should not bypass `totp-2fa` by resending sign-in confirmation code', async () => { + const response = await Client.login(server.publicUrl, email, password, { + ...testOptions, + keys: true, + }); + client = response; + expect(response.verificationMethod).toBe('totp-2fa'); + expect(response.verificationReason).toBe('login'); + + const res = await client.requestVerifyEmail(); + expect(res).toEqual({}); + }); + + it('should not bypass `totp-2fa` by when using session reauth', async () => { + const response = await Client.login( + server.publicUrl, + email, + password, + testOptions + ); + client = response; + expect(response.verificationMethod).toBe('totp-2fa'); + expect(response.verificationReason).toBe('login'); + + const reauthResponse = await client.reauth(); + expect(reauthResponse.verificationMethod).toBe('totp-2fa'); + expect(reauthResponse.verificationReason).toBe('login'); + }); + + it('should fail reset password without verifying totp', async () => { + const newPassword = 'anotherPassword'; + + const loginClient = await Client.login( + server.publicUrl, + email, + password, + { ...testOptions, keys: true } + ); + expect(loginClient.verificationMethod).toBe('totp-2fa'); + expect(loginClient.verificationReason).toBe('login'); + + await loginClient.forgotPassword(); + const otpCode = await server.mailbox.waitForCode(email); + const result = await loginClient.verifyPasswordForgotOtp(otpCode); + await loginClient.verifyPasswordResetCode(result.code); + + try { + await loginClient.resetPassword(newPassword, {}, { keys: true }); + fail('should not have succeeded'); + } catch (err: any) { + expect(err.errno).toBe(138); + } + }); + + it('should reset password after verifying totp', async () => { + const newPassword = 'anotherPassword'; + + const loginClient = await Client.login( + server.publicUrl, + email, + password, + { ...testOptions, keys: true } + ); + expect(loginClient.verificationMethod).toBe('totp-2fa'); + expect(loginClient.verificationReason).toBe('login'); + + await loginClient.forgotPassword(); + const otpCode = await server.mailbox.waitForCode(email); + const result = await loginClient.verifyPasswordForgotOtp(otpCode); + + const totpCode = authenticator.generate(); + await loginClient.verifyTotpCodeForPasswordReset(totpCode); + + await loginClient.verifyPasswordResetCode(result.code); + + const res = await loginClient.resetPassword( + newPassword, + {}, + { keys: true } + ); + expect(res.verificationMethod).toBeUndefined(); + expect(res.verificationReason).toBeUndefined(); + expect(res.emailVerified).toBe(true); + expect(res.sessionVerified).toBe(true); + expect(res.keyFetchToken).toBeTruthy(); + expect(res.sessionToken).toBeTruthy(); + expect(res.authAt).toBeTruthy(); + }); + + describe('totp code verification', () => { + beforeEach(async () => { + client = await Client.login( + server.publicUrl, + email, + password, + testOptions + ); + }); + + it('should fail to verify totp code', async () => { + const code = authenticator.generate(); + const incorrectCode = code === '123456' ? '123455' : '123456'; + const result = await client.verifyTotpCode(incorrectCode, { + metricsContext, + service: 'sync', + }); + expect(result.success).toBe(false); + }); + + it('should reject non-numeric codes', async () => { + try { + await client.verifyTotpCode('wrong', { + metricsContext, + service: 'sync', + }); + fail('should have thrown'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(107); + } + }); + + it('should fail to verify totp code that does not have totp token', async () => { + const newEmail = server.uniqueEmail(); + const newClient = await Client.createAndVerify( + server.publicUrl, + newEmail, + password, + server.mailbox, + testOptions + ); + expect(newClient.authAt).toBeTruthy(); + + try { + await newClient.verifyTotpCode('123456', { + metricsContext, + service: 'sync', + }); + fail('should have thrown'); + } catch (err: any) { + expect(err.code).toBe(400); + expect(err.errno).toBe(155); + } + }); + + it('should verify totp code', async () => { + const code = authenticator.generate(); + const response = await client.verifyTotpCode(code, { + metricsContext, + service: 'sync', + }); + expect(response.success).toBe(true); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('newDeviceLogin'); + }); + + it('should verify totp code from previous code window', async () => { + const futureAuthenticator = new otplib.authenticator.Authenticator(); + futureAuthenticator.options = Object.assign( + {}, + authenticator.options, + { epoch: Date.now() / 1000 - 30 } + ); + const code = futureAuthenticator.generate(); + const response = await client.verifyTotpCode(code, { + metricsContext, + service: 'sync', + }); + expect(response.success).toBe(true); + + const emailData = await server.mailbox.waitForEmail(email); + expect(emailData.headers['x-template-name']).toBe('newDeviceLogin'); + }); + + it('should not verify totp code from future code window', async () => { + const futureAuthenticator = new otplib.authenticator.Authenticator(); + futureAuthenticator.options = Object.assign( + {}, + authenticator.options, + { epoch: Date.now() / 1000 + 3000 } + ); + const code = futureAuthenticator.generate(); + const response = await client.verifyTotpCode(code, { + metricsContext, + service: 'sync', + }); + expect(response.success).toBe(false); + }); + }); + } +); diff --git a/packages/fxa-auth-server/test/support/helpers/mailbox.ts b/packages/fxa-auth-server/test/support/helpers/mailbox.ts index 1a6544dd4a5..89cd97d96a4 100644 --- a/packages/fxa-auth-server/test/support/helpers/mailbox.ts +++ b/packages/fxa-auth-server/test/support/helpers/mailbox.ts @@ -27,6 +27,7 @@ export interface EmailData { export interface Mailbox { waitForEmail: (email: string) => Promise; + waitForEmails: (email: string, count: number) => Promise; waitForCode: (email: string) => Promise; waitForMfaCode: (email: string) => Promise; waitForEmailByHeader: (email: string, headerName: string) => Promise; @@ -98,6 +99,27 @@ export function createMailbox( throw error; } + async function waitForEmails(email: string, count: number): Promise { + const username = email.split('@')[0]; + + for (let tries = MAX_RETRIES; tries > 0; tries--) { + log('mail status tries', tries, 'waiting for', count, 'emails'); + + const mail = await fetchMail(username); + + if (mail && mail.length >= count) { + await deleteMail(username); + return mail; + } + + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } + + const error = new Error(`Timeout waiting for ${count} emails: ${email}`); + eventEmitter.emit('email:error', email, error); + throw error; + } + async function waitForCode(email: string): Promise { const emailData = await waitForEmail(email); const code = @@ -150,6 +172,7 @@ export function createMailbox( return { waitForEmail, + waitForEmails, waitForCode, waitForMfaCode, waitForEmailByHeader, diff --git a/packages/fxa-auth-server/test/support/jest-global-setup.ts b/packages/fxa-auth-server/test/support/jest-global-setup.ts index 1460f7ffe48..1812792ff93 100644 --- a/packages/fxa-auth-server/test/support/jest-global-setup.ts +++ b/packages/fxa-auth-server/test/support/jest-global-setup.ts @@ -24,6 +24,8 @@ const AUTH_SERVER_ROOT = path.resolve(__dirname, '../..'); const TMP_DIR = path.join(AUTH_SERVER_ROOT, 'test', 'support', '.tmp'); const MAIL_HELPER_PID_FILE = path.join(TMP_DIR, 'mail_helper.pid'); const SHARED_SERVER_PID_FILE = path.join(TMP_DIR, 'shared_server.pid'); +const VERSION_JSON_PATH = path.join(AUTH_SERVER_ROOT, 'config', 'version.json'); +const VERSION_JSON_MARKER = path.join(TMP_DIR, 'version_json_created'); function generateKeysIfNeeded(): void { const genKeysScript = path.join(AUTH_SERVER_ROOT, 'scripts', 'gen_keys.js'); @@ -86,10 +88,35 @@ function killExistingProcess(pidFile: string, label: string): void { } } +function generateVersionJsonIfNeeded(): void { + // In git worktree environments, .git is a file (not a directory), which causes + // the server's fallback `git rev-parse HEAD` (with cwd set to .git) to fail. + // Generate config/version.json so the server can serve / and /__version__. + if (fs.existsSync(VERSION_JSON_PATH)) { + return; + } + try { + const hash = execSync('git rev-parse HEAD').toString().trim(); + let source = 'unknown'; + try { + source = execSync('git config --get remote.origin.url').toString().trim(); + } catch { /* ignore */ } + fs.writeFileSync( + VERSION_JSON_PATH, + JSON.stringify({ version: { hash, source } }) + ); + // Write a marker so teardown knows to clean it up + fs.writeFileSync(VERSION_JSON_MARKER, ''); + } catch { + // If git isn't available, skip — version endpoints will return errors + } +} + export default async function globalSetup(): Promise { const printLogs = process.env.MAIL_HELPER_LOGS === 'true'; generateKeysIfNeeded(); + generateVersionJsonIfNeeded(); // Kill stale auth servers from previous runs before starting new ones console.log('[Jest Global Setup] Cleaning up stale auth server processes...'); diff --git a/packages/fxa-auth-server/test/support/jest-global-teardown.ts b/packages/fxa-auth-server/test/support/jest-global-teardown.ts index 6011a011b06..5d1cc9e1669 100644 --- a/packages/fxa-auth-server/test/support/jest-global-teardown.ts +++ b/packages/fxa-auth-server/test/support/jest-global-teardown.ts @@ -10,6 +10,8 @@ const AUTH_SERVER_ROOT = path.resolve(__dirname, '../..'); const TMP_DIR = path.join(AUTH_SERVER_ROOT, 'test', 'support', '.tmp'); const MAIL_HELPER_PID_FILE = path.join(TMP_DIR, 'mail_helper.pid'); const SHARED_SERVER_PID_FILE = path.join(TMP_DIR, 'shared_server.pid'); +const VERSION_JSON_PATH = path.join(AUTH_SERVER_ROOT, 'config', 'version.json'); +const VERSION_JSON_MARKER = path.join(TMP_DIR, 'version_json_created'); interface NodeError extends Error { code?: string; @@ -40,6 +42,12 @@ function killProcessByPidFile(pidFile: string, label: string): void { } catch (err) { console.error(`[Jest Global Teardown] Error cleaning up ${label}:`, err); } + + // Clean up version.json if we created it + if (fs.existsSync(VERSION_JSON_MARKER)) { + try { fs.unlinkSync(VERSION_JSON_PATH); } catch { /* ignore */ } + try { fs.unlinkSync(VERSION_JSON_MARKER); } catch { /* ignore */ } + } } export default async function globalTeardown(): Promise {