Skip to content

Commit d5d8a1f

Browse files
authored
Merge pull request #19996 from mozilla/fxa-12559-v2
Add auth server jest setup and initial unit tests
2 parents 918411b + 44a3542 commit d5d8a1f

23 files changed

Lines changed: 2063 additions & 3 deletions

packages/fxa-auth-server/.eslintrc.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,16 @@
2525
"dist",
2626
"fxa-*",
2727
"vendor"
28+
],
29+
"overrides": [
30+
{
31+
"files": ["**/*.spec.ts"],
32+
"env": {
33+
"jest": true
34+
},
35+
"rules": {
36+
"fxa/async-crypto-random": "off"
37+
}
38+
}
2839
]
2940
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
describe('Config', () => {
6+
describe('NODE_ENV=prod', () => {
7+
let originalEnv: Record<string, string | undefined>;
8+
9+
function mockEnv(key: string, value: string) {
10+
originalEnv[key] = process.env[key];
11+
process.env[key] = value;
12+
}
13+
14+
beforeEach(() => {
15+
originalEnv = {};
16+
jest.resetModules();
17+
mockEnv('NODE_ENV', 'prod');
18+
});
19+
20+
afterEach(() => {
21+
for (const key in originalEnv) {
22+
if (originalEnv[key] === undefined) {
23+
delete process.env[key];
24+
} else {
25+
process.env[key] = originalEnv[key];
26+
}
27+
}
28+
});
29+
30+
it('errors when secret settings have their default values', () => {
31+
expect(() => {
32+
require('./index');
33+
}).toThrow(/Config '[a-zA-Z._]+' must be set in production/);
34+
});
35+
36+
it('succeeds when secret settings have all been configured', () => {
37+
mockEnv('FLOW_ID_KEY', 'production secret here');
38+
mockEnv('OAUTH_SERVER_SECRET_KEY', 'production secret here');
39+
mockEnv(
40+
'PROFILE_SERVER_AUTH_SECRET_BEARER_TOKEN',
41+
'production secret here'
42+
);
43+
expect(() => {
44+
require('./index');
45+
}).not.toThrow();
46+
});
47+
});
48+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
module.exports = {
6+
preset: 'ts-jest',
7+
testEnvironment: 'node',
8+
rootDir: '.',
9+
testMatch: [
10+
'<rootDir>/lib/**/*.spec.ts',
11+
'<rootDir>/config/**/*.spec.ts',
12+
],
13+
moduleFileExtensions: ['ts', 'js', 'json'],
14+
transform: {
15+
'^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: { isolatedModules: true } }],
16+
},
17+
transformIgnorePatterns: [
18+
'/node_modules/(?!(@fxa|fxa-shared)/)',
19+
],
20+
moduleNameMapper: {
21+
'^@fxa/shared/(.*)$': '<rootDir>/../../libs/shared/$1/src',
22+
'^@fxa/accounts/(.*)$': '<rootDir>/../../libs/accounts/$1/src',
23+
'^@fxa/payments/(.*)$': '<rootDir>/../../libs/payments/$1/src',
24+
'^@fxa/profile/(.*)$': '<rootDir>/../../libs/profile/$1/src',
25+
'^fxa-shared/(.*)$': '<rootDir>/../fxa-shared/$1',
26+
},
27+
testTimeout: 10000,
28+
clearMocks: true,
29+
setupFiles: ['<rootDir>/jest.setup.js'],
30+
testPathIgnorePatterns: ['/node_modules/'],
31+
// Coverage configuration (enabled via --coverage flag)
32+
collectCoverageFrom: [
33+
'lib/**/*.{ts,js}',
34+
'config/**/*.{ts,js}',
35+
'!lib/**/*.spec.{ts,js}',
36+
'!config/**/*.spec.{ts,js}',
37+
'!**/node_modules/**',
38+
],
39+
coverageDirectory: '../../artifacts/coverage/fxa-auth-server-jest',
40+
coverageReporters: ['text', 'lcov', 'html'],
41+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* Jest global setup - runs before each test file.
7+
*
8+
* The OAuth keys exist in config/key.json but the config module's path
9+
* resolution can behave differently under Jest's module transformation.
10+
* This sets the env var to allow tests to run without requiring the
11+
* OAuth key validation (which is a runtime concern, not a unit test concern).
12+
*/
13+
14+
process.env.FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY = 'true';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import sinon from 'sinon';
6+
import { AppError as error } from '@fxa/accounts/errors';
7+
import * as authMethods from './authMethods';
8+
9+
const MOCK_ACCOUNT = {
10+
uid: 'abcdef123456',
11+
};
12+
13+
function mockDB() {
14+
return {
15+
totpToken: sinon.stub(),
16+
// Add other DB methods as needed
17+
};
18+
}
19+
20+
describe('availableAuthenticationMethods', () => {
21+
let mockDbInstance: ReturnType<typeof mockDB>;
22+
23+
beforeEach(() => {
24+
mockDbInstance = mockDB();
25+
});
26+
27+
it('returns [`pwd`,`email`] for non-TOTP-enabled accounts', async () => {
28+
mockDbInstance.totpToken = sinon.stub().rejects(error.totpTokenNotFound());
29+
const amr = await authMethods.availableAuthenticationMethods(
30+
mockDbInstance as any,
31+
MOCK_ACCOUNT as any
32+
);
33+
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
34+
expect(Array.from(amr).sort()).toEqual(['email', 'pwd']);
35+
});
36+
37+
it('returns [`pwd`,`email`,`otp`] for TOTP-enabled accounts', async () => {
38+
mockDbInstance.totpToken = sinon.stub().resolves({
39+
verified: true,
40+
enabled: true,
41+
sharedSecret: 'secret!',
42+
});
43+
const amr = await authMethods.availableAuthenticationMethods(
44+
mockDbInstance as any,
45+
MOCK_ACCOUNT as any
46+
);
47+
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
48+
expect(Array.from(amr).sort()).toEqual(['email', 'otp', 'pwd']);
49+
});
50+
51+
it('returns [`pwd`,`email`] when TOTP token is not yet enabled', async () => {
52+
mockDbInstance.totpToken = sinon.stub().resolves({
53+
verified: true,
54+
enabled: false,
55+
sharedSecret: 'secret!',
56+
});
57+
const amr = await authMethods.availableAuthenticationMethods(
58+
mockDbInstance as any,
59+
MOCK_ACCOUNT as any
60+
);
61+
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
62+
expect(Array.from(amr).sort()).toEqual(['email', 'pwd']);
63+
});
64+
65+
it('rethrows unexpected DB errors', async () => {
66+
mockDbInstance.totpToken = sinon.stub().rejects(error.serviceUnavailable());
67+
try {
68+
await authMethods.availableAuthenticationMethods(
69+
mockDbInstance as any,
70+
MOCK_ACCOUNT as any
71+
);
72+
throw new Error('error should have been re-thrown');
73+
} catch (err: any) {
74+
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
75+
expect(err.errno).toBe(error.ERRNO.SERVER_BUSY);
76+
}
77+
});
78+
});
79+
80+
describe('verificationMethodToAMR', () => {
81+
it('maps `email` to `email`', () => {
82+
expect(authMethods.verificationMethodToAMR('email')).toBe('email');
83+
});
84+
85+
it('maps `email-captcha` to `email`', () => {
86+
expect(authMethods.verificationMethodToAMR('email-captcha')).toBe('email');
87+
});
88+
89+
it('maps `email-2fa` to `email`', () => {
90+
expect(authMethods.verificationMethodToAMR('email-2fa')).toBe('email');
91+
});
92+
93+
it('maps `totp-2fa` to `otp`', () => {
94+
expect(authMethods.verificationMethodToAMR('totp-2fa')).toBe('otp');
95+
});
96+
97+
it('maps `recovery-code` to `otp`', () => {
98+
expect(authMethods.verificationMethodToAMR('recovery-code')).toBe('otp');
99+
});
100+
101+
it('throws when given an unknown verification method', () => {
102+
expect(() => {
103+
authMethods.verificationMethodToAMR('email-gotcha' as any);
104+
}).toThrow(/unknown verificationMethod/);
105+
});
106+
});
107+
108+
describe('maximumAssuranceLevel', () => {
109+
it('returns 0 when no authentication methods are used', () => {
110+
expect(authMethods.maximumAssuranceLevel([])).toBe(0);
111+
expect(authMethods.maximumAssuranceLevel(new Set())).toBe(0);
112+
});
113+
114+
it('returns 1 when only `pwd` auth is used', () => {
115+
expect(authMethods.maximumAssuranceLevel(['pwd'])).toBe(1);
116+
});
117+
118+
it('returns 1 when only `email` auth is used', () => {
119+
expect(authMethods.maximumAssuranceLevel(['email'])).toBe(1);
120+
});
121+
122+
it('returns 1 when only `otp` auth is used', () => {
123+
expect(authMethods.maximumAssuranceLevel(['otp'])).toBe(1);
124+
});
125+
126+
it('returns 1 when only things-you-know auth mechanisms are used', () => {
127+
expect(authMethods.maximumAssuranceLevel(['email', 'pwd'])).toBe(1);
128+
});
129+
130+
it('returns 2 when both `pwd` and `otp` methods are used', () => {
131+
expect(authMethods.maximumAssuranceLevel(['pwd', 'otp'])).toBe(2);
132+
});
133+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import * as butil from './butil';
6+
7+
describe('butil', () => {
8+
describe('.buffersAreEqual', () => {
9+
it('returns false if lengths are different', () => {
10+
expect(butil.buffersAreEqual(Buffer.alloc(2), Buffer.alloc(4))).toBe(
11+
false
12+
);
13+
});
14+
15+
it('returns true if buffers have same bytes', () => {
16+
const b1 = Buffer.from('abcd', 'hex');
17+
const b2 = Buffer.from('abcd', 'hex');
18+
expect(butil.buffersAreEqual(b1, b2)).toBe(true);
19+
});
20+
});
21+
22+
describe('.xorBuffers', () => {
23+
it('throws an Error if lengths are different', () => {
24+
expect(() => {
25+
butil.xorBuffers(Buffer.alloc(2), Buffer.alloc(4));
26+
}).toThrow();
27+
});
28+
29+
it('should return a Buffer with bits ORed', () => {
30+
const b1 = Buffer.from('e5', 'hex');
31+
const b2 = Buffer.from('5e', 'hex');
32+
expect(butil.xorBuffers(b1, b2)).toEqual(Buffer.from('bb', 'hex'));
33+
});
34+
});
35+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import hkdf from './hkdf';
6+
7+
describe('hkdf', () => {
8+
it('should extract', async () => {
9+
const stretchedPw =
10+
'c16d46c31bee242cb31f916e9e38d60b76431d3f5304549cc75ae4bc20c7108c';
11+
const stretchedPwBuffer = Buffer.from(stretchedPw, 'hex');
12+
const info = 'mainKDF';
13+
const salt = Buffer.from(
14+
'00f000000000000000000000000000000000000000000000000000000000034d',
15+
'hex'
16+
);
17+
const lengthHkdf = 2 * 32;
18+
19+
const hkdfResult = await hkdf(stretchedPwBuffer, info, salt, lengthHkdf);
20+
const hkdfStr = hkdfResult.toString('hex');
21+
22+
expect(hkdfStr.substring(0, 64)).toBe(
23+
'00f9b71800ab5337d51177d8fbc682a3653fa6dae5b87628eeec43a18af59a9d'
24+
);
25+
expect(hkdfStr.substring(64, 128)).toBe(
26+
'6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a'
27+
);
28+
expect(salt.toString('hex')).toBe(
29+
'00f000000000000000000000000000000000000000000000000000000000034d'
30+
);
31+
});
32+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
export {};
6+
7+
const mockLog = {};
8+
const mockConfig = {};
9+
const Password = require('./password')(mockLog, mockConfig);
10+
11+
describe('Password', () => {
12+
it('password version zero', async () => {
13+
const pwd = Buffer.from('aaaaaaaaaaaaaaaa');
14+
const salt = Buffer.from('bbbbbbbbbbbbbbbb');
15+
const p1 = new Password(pwd, salt, 0);
16+
expect(p1.version).toBe(0);
17+
const p2 = new Password(pwd, salt, 0);
18+
expect(p2.version).toBe(0);
19+
const hash = await p1.verifyHash();
20+
const matched = await p2.matches(hash);
21+
expect(matched).toBe(true);
22+
});
23+
24+
it('password version one', async () => {
25+
const pwd = Buffer.from('aaaaaaaaaaaaaaaa');
26+
const salt = Buffer.from('bbbbbbbbbbbbbbbb');
27+
const p1 = new Password(pwd, salt, 1);
28+
expect(p1.version).toBe(1);
29+
const p2 = new Password(pwd, salt, 1);
30+
expect(p2.version).toBe(1);
31+
const hash = await p1.verifyHash();
32+
const matched = await p2.matches(hash);
33+
expect(matched).toBe(true);
34+
});
35+
36+
it('passwords of different versions should not match', async () => {
37+
const pwd = Buffer.from('aaaaaaaaaaaaaaaa');
38+
const salt = Buffer.from('bbbbbbbbbbbbbbbb');
39+
const p1 = new Password(pwd, salt, 0);
40+
const p2 = new Password(pwd, salt, 1);
41+
const hash = await p1.verifyHash();
42+
const matched = await p2.matches(hash);
43+
expect(matched).toBe(false);
44+
});
45+
46+
it('scrypt queue stats can be reported', () => {
47+
const stat = Password.stat();
48+
expect(stat.stat).toBe('scrypt');
49+
expect(stat).toHaveProperty('numPending');
50+
expect(stat).toHaveProperty('numPendingHWM');
51+
});
52+
});

0 commit comments

Comments
 (0)