Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions packages/fxa-auth-server/lib/routes/passwordless.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@

import crypto from 'crypto';
import { createMock } from '@golevelup/ts-jest';
import { Container } from 'typedi';
import { StatsD } from 'hot-shots';
import { AppError as error } from '@fxa/accounts/errors';
import { AuthLogger } from '../types';
import { FxaMailer } from '../senders/fxa-mailer';
import {
installMockFxaMailer,
uninstallMockFxaMailer,
} from '../../test/fixtures/fxa-mailer';

const mocks = require('../../test/mocks');
const { getRoute } = require('../../test/routes_helpers');
const authConfig = require('../../config').default.getProperties();

function hexString(bytes: number) {
return crypto.randomBytes(bytes).toString('hex');
Expand Down Expand Up @@ -94,6 +97,10 @@ jest.mock('./utils/account', () => {
};
});

// The real metrics context module is used below to exercise the HMAC check.
// Its constructor opens a Redis connection, which a unit test must not do.
jest.mock('../metricsCache', () => ({ MetricsRedis: jest.fn() }));

jest.mock('fxa-shared/db/models/auth', () => ({
EmailBlocklist: {
findMatchingRegex: jest.fn().mockResolvedValue(null),
Expand Down Expand Up @@ -2361,3 +2368,154 @@ describe('existing passwordless accounts bypass flag and allowlist', () => {
});
});
});

// ---------------------------------------------------------------------------
// metricsContext validation
// ---------------------------------------------------------------------------

describe('passwordless metricsContext validation', () => {
// The real validate() runs here so the tests cover the HMAC check itself
// rather than a stub of it. It never throws: on failure it deletes flowId
// and flowBeginTime from the payload and returns false.
const realValidate = require('../metrics/context')(
createMock<AuthLogger>(),
authConfig
).validate;

const DEVICE_ID = 'b'.repeat(32);
const FLOW_ID_RANDOM_HALF = 'a'.repeat(32);
const FORGED_FLOW_ID = 'c'.repeat(64);

/** Sign a flowId the way the content server does, so validate() accepts it. */
function signedMetricsContext(flowBeginTime: number) {
const signature = crypto
.createHmac('sha256', authConfig.metrics.flow_id_key)
.update([FLOW_ID_RANDOM_HALF, flowBeginTime.toString(16)].join('\n'))
.digest('hex')
.substr(0, 32);
return {
deviceId: DEVICE_ID,
flowId: FLOW_ID_RANDOM_HALF + signature,
flowBeginTime,
};
}

// validate() requires an age greater than zero, so a fresh context is
// backdated by a second. Both timestamps are relative to the real clock
// because validate() compares them against Date.now().
function freshFlowBeginTime() {
return Date.now() - 1000;
}

function expiredFlowBeginTime() {
return Date.now() - authConfig.metrics.flow_id_expiry - 1000;
}

const CONFIRM_CODE_PATH = '/account/passwordless/confirm_code';

function setup(path: string, metricsContext: any) {
const mockLog = createMock<AuthLogger>();
const mockDB = mocks.mockDB({
uid: 'f9416ce3703e4916a4cd6b1e665a3f1a',
email: TEST_EMAIL,
emailVerified: true,
verifierSetAt: 0,
});
const request = mocks.mockRequest({
log: mockLog,
metricsContext: mocks.mockMetricsContext({ validate: realValidate }),
payload: {
email: TEST_EMAIL,
clientId: 'test-client-id',
metricsContext,
...(path === CONFIRM_CODE_PATH ? { code: '123456' } : {}),
},
});

const routes = makeRoutes({
log: mockLog,
db: mockDB,
customs: {
check: jest.fn(() => Promise.resolve()),
v2Enabled: () => true,
},
config: {
passwordlessOtp: {
enabled: true,
ttl: 300,
digits: 6,
allowedClientServices: {
'test-client-id': { allowedServices: ['*'] },
},
},
},
});
return { route: getRoute(routes, path, 'POST'), request };
}

afterEach(() => {
uninstallMockFxaMailer();
});

describe.each([
'/account/passwordless/send_code',
CONFIRM_CODE_PATH,
'/account/passwordless/resend_code',
])('%s', (path) => {
it('keeps a validly signed flowId', async () => {
const metricsContext = signedMetricsContext(freshFlowBeginTime());
// The handler mutates the payload copy, so assert against a separate one.
const { route, request } = setup(path, { ...metricsContext });

await route.handler(request);

expect(await request.app.metricsContext).toEqual(metricsContext);
});

it('strips a flowId with a forged signature', async () => {
const { route, request } = setup(path, {
deviceId: DEVICE_ID,
flowId: FORGED_FLOW_ID,
flowBeginTime: freshFlowBeginTime(),
});

await route.handler(request);

expect(await request.app.metricsContext).toEqual({
deviceId: DEVICE_ID,
});
});

it('strips a flowId with an expired flowBeginTime', async () => {
const { route, request } = setup(
path,
signedMetricsContext(expiredFlowBeginTime())
);

await route.handler(request);

expect(await request.app.metricsContext).toEqual({
deviceId: DEVICE_ID,
});
});
});

it('does not send a forged flowId to the OTP email', async () => {
const { route, request } = setup('/account/passwordless/send_code', {
deviceId: DEVICE_ID,
flowId: FORGED_FLOW_ID,
flowBeginTime: freshFlowBeginTime(),
});
const mailer = Container.get(FxaMailer);

await route.handler(request);

expect(mailer.sendPasswordlessSigninOtpEmail).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: DEVICE_ID,
flowId: undefined,
flowBeginTime: undefined,
})
);
});
});
7 changes: 4 additions & 3 deletions packages/fxa-auth-server/lib/routes/passwordless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ class PasswordlessHandler {

async sendCode(request: AuthRequest) {
this.log.begin('Passwordless.sendCode', request);
request.validateMetricsContext();

const { email, clientId, service } = request.payload as {
email: string;
Expand Down Expand Up @@ -213,9 +214,7 @@ class PasswordlessHandler {
await this.customs.check(
request,
email,
isNewAccount
? PASSWORDLESS_SEND_OTP_SIGNUP
: PASSWORDLESS_SEND_OTP_SIGNIN
isNewAccount ? PASSWORDLESS_SEND_OTP_SIGNUP : PASSWORDLESS_SEND_OTP_SIGNIN
);
}

Expand All @@ -226,6 +225,7 @@ class PasswordlessHandler {

async confirmCode(request: AuthRequest) {
this.log.begin('Passwordless.confirmCode', request);
request.validateMetricsContext();

const { email, code, clientId, service } = request.payload as {
email: string;
Expand Down Expand Up @@ -395,6 +395,7 @@ class PasswordlessHandler {

async resendCode(request: AuthRequest) {
this.log.begin('Passwordless.resendCode', request);
request.validateMetricsContext();

const { email, clientId, service } = request.payload as {
email: string;
Expand Down