From da366f1a6d2b52d92e7e06cc8dfa2eb70d0be282 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Thu, 26 Feb 2026 09:48:37 -0500 Subject: [PATCH] fxa-12622: Migrate 24 Mocha integration tests to Jest --- .../remote/attached_clients_tests.in.spec.ts | 232 ++++++++ .../test/remote/device_tests.in.spec.ts | 464 +++++++++++++++ .../device_tests_refresh_tokens.in.spec.ts | 364 ++++++++++++ .../test/remote/misc_tests.in.spec.ts | 258 +++++++++ ...oauth_session_token_scope_tests.in.spec.ts | 149 +++++ .../test/remote/oauth_tests.in.spec.ts | 535 ++++++++++++++++++ .../test/remote/push_db_tests.in.spec.ts | 132 +++++ .../test/remote/pushbox_db.in.spec.ts | 122 ++++ .../test/remote/session_tests.in.spec.ts | 342 +++++++++++ .../test/remote/sign_key_tests.in.spec.ts | 36 ++ .../test/support/jest-global-setup.ts | 27 + .../test/support/jest-global-teardown.ts | 8 + 12 files changed, 2669 insertions(+) create mode 100644 packages/fxa-auth-server/test/remote/attached_clients_tests.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/device_tests.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/device_tests_refresh_tokens.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/misc_tests.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/oauth_session_token_scope_tests.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/push_db_tests.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/session_tests.in.spec.ts create mode 100644 packages/fxa-auth-server/test/remote/sign_key_tests.in.spec.ts 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/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/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/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/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 {