Skip to content

Commit

Permalink
Merge pull request #18145 from mozilla/FXA-10380
Browse files Browse the repository at this point in the history
task(auth): Add GET /recovery-phone
  • Loading branch information
dschom authored Dec 21, 2024
2 parents 2eaddb1 + fbceaff commit 078339c
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 12 deletions.
2 changes: 1 addition & 1 deletion packages/fxa-auth-server/lib/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1402,7 +1402,7 @@ export class AccountHandler {
scope = { contains: () => true };
} else {
uid = auth.credentials.user;
scope = ScopeSet.fromArray(auth.credentials.scope);
scope = ScopeSet.fromArray(auth.credentials.scope || []);
}

const res: Record<string, any> = {};
Expand Down
44 changes: 40 additions & 4 deletions packages/fxa-auth-server/lib/routes/recovery-phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@fxa/accounts/recovery-phone';
import * as isA from 'joi';
import { GleanMetricsType } from '../metrics/glean';
import { AuthLogger, AuthRequest } from '../types';
import { AuthLogger, AuthRequest, SessionTokenAuthCredential } from '../types';
import { E164_NUMBER } from './validators';
import AppError from '../error';

Expand All @@ -32,7 +32,7 @@ class RecoveryPhoneHandler {
}

async sendCode(request: AuthRequest) {
const { uid } = request.auth.credentials as unknown as { uid: string };
const { uid } = request.auth.credentials as SessionTokenAuthCredential;

let status = false;
try {
Expand All @@ -56,7 +56,7 @@ class RecoveryPhoneHandler {
}

async setupPhoneNumber(request: AuthRequest) {
const { uid } = request.auth.credentials as unknown as { uid: string };
const { uid } = request.auth.credentials as SessionTokenAuthCredential;
const { phoneNumber } = request.payload as unknown as {
phoneNumber: string;
};
Expand Down Expand Up @@ -93,7 +93,7 @@ class RecoveryPhoneHandler {
}

async confirm(request: AuthRequest) {
const { uid } = request.auth.credentials as unknown as { uid: string };
const { uid } = request.auth.credentials as SessionTokenAuthCredential;
const { code } = request.payload as unknown as {
code: string;
};
Expand Down Expand Up @@ -168,6 +168,30 @@ class RecoveryPhoneHandler {
available,
};
}

async exists(request: AuthRequest) {
const { uid, emailVerified, mustVerify, tokenVerified } = request.auth
.credentials as SessionTokenAuthCredential;

// Short circuit if the account / session still needs verification.
// Note this is typically due to totp being required, but there are
// other states that could also result in an unverified session, such
// as a forced password change.
if (emailVerified || (mustVerify && !tokenVerified)) {
return {};
}

try {
return await this.recoveryPhoneService.hasConfirmed(uid);
} catch (error) {
throw AppError.backendServiceFailure(
'RecoveryPhoneService',
'destroy',
{ uid },
error
);
}
}
}

export const recoveryPhoneRoutes = (
Expand Down Expand Up @@ -247,6 +271,18 @@ export const recoveryPhoneRoutes = (
return recoveryPhoneHandler.destroy(request);
},
},
{
method: 'GET',
path: '/recovery-phone',
options: {
auth: {
strategy: 'sessionToken',
},
},
handler: function (request: AuthRequest) {
return recoveryPhoneHandler.exists(request);
},
},
];

return routes;
Expand Down
6 changes: 3 additions & 3 deletions packages/fxa-auth-server/lib/routes/subscriptions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function handleAuth(
auth: AuthRequest['auth'],
fetchEmail = false
) {
const scope = ScopeSet.fromArray(auth.credentials.scope);
const scope = ScopeSet.fromArray(auth.credentials.scope || []);
if (!scope.contains(OAUTH_SCOPE_SUBSCRIPTIONS)) {
throw error.invalidScopes();
}
Expand All @@ -37,15 +37,15 @@ export async function handleAuth(
}

export function handleUidAuth(auth: AuthRequest['auth']): string {
const scope = ScopeSet.fromArray(auth.credentials.scope);
const scope = ScopeSet.fromArray(auth.credentials.scope || []);
if (!scope.contains(OAUTH_SCOPE_SUBSCRIPTIONS)) {
throw error.invalidScopes();
}
return auth.credentials.user as string;
}

export function handleAuthScoped(auth: AuthRequest['auth'], scopes: string[]) {
const scope = ScopeSet.fromArray(auth.credentials.scope);
const scope = ScopeSet.fromArray(auth.credentials.scope || []);
for (const requiredScope of scopes) {
if (!scope.contains(requiredScope)) {
throw error.invalidScopes();
Expand Down
31 changes: 29 additions & 2 deletions packages/fxa-auth-server/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* 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 { Request, RequestApplicationState } from '@hapi/hapi';
import { AuthCredentials, Request, RequestApplicationState } from '@hapi/hapi';
import { Token } from 'typedi';
import { Logger } from 'mozlog';
import { ConfigType } from '../config';
Expand Down Expand Up @@ -44,11 +44,38 @@ export interface AuthApp extends RequestApplicationState {
};
}

// Type declaration for SessionToken found in lib/tokens/session_token.js
export interface SessionTokenAuthCredential {
uid: string;
lifetime: number;
createdAt: number;
email: string | null;
emailCode: string | null;
emailVerified: boolean;
verifierSetAt: number;
profileChangedAt: number;
keysChangedAt: number;
authAt: number;
locale: string | null;
mustVerify: boolean;
tokenVerificationId: string | null;
tokenVerified: boolean;
verificationMethod: number;
verificationMethodValue: string;
verifiedAt: number | null;
metricsOptOutAt: number | null;
providerId: string | null;
}

export type AuthCredentialsWithScope = AuthCredentials & {
scope: string[];
};

export interface AuthRequest extends Request {
auth: Request['auth'] & {
// AuthRequest will always have scopes present provided by
// the auth-oauth scheme
credentials: Request['auth']['credentials'] & { scope: string[] };
credentials: AuthCredentialsWithScope | SessionTokenAuthCredential;
};
// eslint-disable-next-line no-use-before-define
log: AuthLogger;
Expand Down
52 changes: 52 additions & 0 deletions packages/fxa-auth-server/test/local/routes/recovery-phone.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('/recovery-phone', () => {
setupPhoneNumber: sandbox.fake(),
confirmCode: sandbox.fake(),
removePhoneNumber: sandbox.fake(),
hasConfirmed: sandbox.fake(),
};
let routes = [];

Expand Down Expand Up @@ -335,4 +336,55 @@ describe('/recovery-phone', () => {
);
});
});

describe('GET /recovery-phone', async () => {
it('gets a recovery phone', async () => {
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({
exists: true,
phoneNumber,
});

const resp = await makeRequest({
method: 'GET',
path: '/recovery-phone',
credentials: { uid },
});

assert.isDefined(resp);
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 1);
assert.equal(
mockRecoveryPhoneService.removePhoneNumber.getCall(0).args[0],
uid
);
});

it('indicates service', async () => {
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns(
Promise.reject(new Error('BOOM'))
);
const promise = makeRequest({
method: 'GET',
path: '/recovery-phone',
credentials: { uid },
});

await assert.isRejected(promise, 'A backend service request failed.');
assert.equal(mockGlean.twoStepAuthPhoneRemove.success.callCount, 0);
});

it('returns empty response for unverified session', async () => {
mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({
exists: true,
phoneNumber,
});
const resp = await makeRequest({
method: 'GET',
path: '/recovery-phone',
credentials: { uid, mustVerify: true },
});
assert.isDefined(resp);
assert.isEmpty(resp);
assert.equal(mockRecoveryPhoneService.hasConfirmed.callCount, 0);
});
});
});
9 changes: 7 additions & 2 deletions packages/fxa-auth-server/test/remote/recovery_phone_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,17 @@ describe(`#integration - recovery phone`, function () {
});

it('confirms a recovery code', async () => {
// TODO: Setup test account / twilio emulator
// TODO: FXA-10913 Setup test account / twilio emulator
// TODO: Figure out how to emulate a test code
});

it('confirms a recovery code', async () => {
// TODO: Setup test account / twilio emulator
// TODO: FXA-10913 Setup test account / twilio emulator
// TODO: Figure out how to emulate a test code
});

it('checks if recovery phone exists', async () => {
// TODO: FXA-10913 Setup test account / twilio emulator
// TODO: Figure out how to emulate a test code
});
});

0 comments on commit 078339c

Please sign in to comment.