Skip to content

Authentication Emulator Supports MFA for Simple Read/Write User Operations (Fixes #3170) #3173

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Mar 18, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
1. simplify state handling for MFA
2. match SDK behavior with respect to MFA ID uniqueness constraints
3. stylistic updates to test code
4. add TODOs regarding import/export MFA support
  • Loading branch information
wokkaflokka committed Mar 17, 2021
commit 0c592cfa78f2e248877e737f5ada5f248bbbbb86
1 change: 1 addition & 0 deletions src/accountExporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ var api = require("./api");
var utils = require("./utils");
var { FirebaseError } = require("./error");

// TODO: support for MFA at runtime was added in PR #3173, but this exporter currently ignores `mfaInfo` and loses the data on export.
var EXPORTED_JSON_KEYS = [
"localId",
"email",
Expand Down
1 change: 1 addition & 0 deletions src/accountImporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { logger } = require("./logger");
var utils = require("./utils");
var { FirebaseError } = require("./error");

// TODO: support for MFA at runtime was added in PR #3173, but this importer currently ignores `mfaInfo` and loses the data on import.
var ALLOWED_JSON_KEYS = [
"localId",
"email",
Expand Down
65 changes: 29 additions & 36 deletions src/emulator/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
MakeRequired,
isValidPhoneNumber,
} from "./utils";
import { NotImplementedError, assert, BadRequestError } from "./errors";
import { NotImplementedError, assert, BadRequestError, InternalError } from "./errors";
import { Emulators } from "../types";
import { EmulatorLogger } from "../emulatorLogger";
import {
Expand Down Expand Up @@ -168,28 +168,17 @@ function signUp(
}
if (reqBody.mfaInfo) {
const mfaInfo = [];
const enrollmentIds = new Set<string>();
for (const factor of reqBody.mfaInfo) {
// the node SDK validation asserts the absence of a uid on create requests prior to submitting any API requests.
// e.g., `Error: "uid" is not supported when adding second factors via "createUser()"`
assert(
!Object.prototype.hasOwnProperty.call(factor, "mfaEnrollmentId"),
"UNEXPECTED_PARAMETER : mfaEnrollmentId"
);
assert(
factor.phoneInfo && isValidPhoneNumber(factor.phoneInfo),
"INVALID_MFA_PHONE_NUMBER : Invalid format."
);
const mfaEnrollmentId = randomId(28);
assert(
state.validateMfaEnrollmentIdForCreate(mfaEnrollmentId),
"INVALID_MFA_ID : enrollment ID was not unique."
);
const mfaEnrollmentId = newRandomId(28, enrollmentIds);
mfaInfo.push({
...factor,
displayName: factor.displayName,
phoneInfo: factor.phoneInfo,
mfaEnrollmentId,
});
enrollmentIds.add(mfaEnrollmentId);
}
updates.mfaInfo = mfaInfo;
updates.mfaInfo = state.validateMfaEnrollments(mfaInfo);
}
let user: UserInfo | undefined;
if (reqBody.idToken) {
Expand Down Expand Up @@ -999,26 +988,13 @@ export function setAccountInfoImpl(
updates.validSince = toUnixTimestamp(new Date()).toString();
}

// if the user has MFA info, update it, otherwise if specified but empty, delete it...
// if the request specifies an `mfa` key and enrollments are present and non-empty, set the enrollments
// as the current MFA state for the user. if the `mfa` key is specified and no enrollments are present,
// clear any existing MFA data for the user. if no `mfa` key is specified, MFA is left unchanged.
if (reqBody.mfa) {
if (reqBody.mfa.enrollments && reqBody.mfa.enrollments.length > 0) {
const mfaInfo = [];
for (const enrolledFactor of reqBody.mfa.enrollments) {
assert(
enrolledFactor.phoneInfo && isValidPhoneNumber(enrolledFactor.phoneInfo),
"INVALID_MFA_PHONE_NUMBER : Invalid format."
);
assert(
enrolledFactor.mfaEnrollmentId,
"INVALID_MFA_ID : Must specify non-null mfaEnrollmentId on update."
);
assert(
state.validateMfaEnrollmentIdForUpdate(enrolledFactor.mfaEnrollmentId, user),
"INVALID_MFA_ID : enrollment ID was not unique."
);
mfaInfo.push({ ...enrolledFactor });
}
updates.mfaInfo = mfaInfo;
const mfaInfo = [...reqBody.mfa.enrollments];
updates.mfaInfo = state.validateMfaEnrollments(mfaInfo);
} else {
updates.mfaInfo = undefined;
}
Expand Down Expand Up @@ -1838,6 +1814,23 @@ function validateCustomClaims(claims: unknown): asserts claims is Record<string,
}
}

// generates a new random ID, checking against an optional set of "existing ids" for
// uniqueness. if a unique ID cannot be generated in 10 tries, an internal error is
// thrown. the ID generated by this method is not added to the set provided to this
// method, callers must manage their own state.
function newRandomId(length: number, existingIds?: Set<string>): string {
for (let i = 0; i < 10; i++) {
const id = randomId(length);
if (!existingIds?.has(id)) {
return id;
}
}
throw new InternalError(
"INTERNAL_ERROR : Failed to generate a random ID after 10 attempts",
"INTERNAL"
);
}

function getNormalizedUri(reqBody: {
requestUri?: string | undefined;
postBody?: string | undefined;
Expand Down
88 changes: 34 additions & 54 deletions src/emulator/auth/state.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { randomBase64UrlStr, randomId, mirrorFieldTo, randomDigits } from "./utils";
import {
randomBase64UrlStr,
randomId,
mirrorFieldTo,
randomDigits,
isValidPhoneNumber,
} from "./utils";
import { MakeRequired } from "./utils";

import * as schema from "./schema";
import { AuthCloudFunction } from "./cloudFunctions";
import { assert } from "./errors";
type Schemas = schema.components["schemas"];
type MfaEnrollment = Schemas["GoogleCloudIdentitytoolkitV1MfaEnrollment"];
type MfaEnrollments = MfaEnrollment[];

export const PROVIDER_PASSWORD = "password";
export const PROVIDER_PHONE = "phone";
Expand All @@ -21,8 +30,6 @@ export class ProjectState {
private userIdForProviderRawId: Map<string, Map<string, string>> = new Map();
private refreshTokens: Map<string, RefreshTokenRecord> = new Map();
private refreshTokensForLocalId: Map<string, Set<string>> = new Map();
private mfaEnrollmentIdsForLocalId: Map<string, Set<string>> = new Map();
private localIdForMfaEnrollmentId: Map<string, string> = new Map();
private oobs: Map<string, OobRecord> = new Map();
private verificationCodes: Map<string, PhoneVerificationRecord> = new Map();
private temporaryProofs: Map<string, TemporaryProofRecord> = new Map();
Expand Down Expand Up @@ -133,6 +140,7 @@ export class ProjectState {
}
const oldEmail = user.email;
const oldPhoneNumber = user.phoneNumber;

for (const field of Object.keys(fields) as (keyof typeof fields)[]) {
mirrorFieldTo(user, field, fields);
}
Expand Down Expand Up @@ -173,56 +181,39 @@ export class ProjectState {
} else {
deleteProviders.push(PROVIDER_PHONE);
}

// if MFA info is specified on the user, ensure MFA data is valid before returning.
// callers are expected to have called `validateMfaEnrollments` prior to creating
// or updating the user.
if (user.mfaInfo) {
this.updateMfaEnrollments(user, user.mfaInfo);
} else {
this.mfaEnrollmentIdsForLocalId.delete(user.localId);
this.validateMfaEnrollments(user.mfaInfo);
}

return this.updateUserProviderInfo(user, upsertProviders, deleteProviders);
}

/**
* Validates a new MFA enrollment ID, checking that the enrollment
* ID is not already in use for a different user.
*
* @param mfaEnrollmentId the proposed MFA enrollment ID
*/
validateMfaEnrollmentIdForCreate(mfaEnrollmentId: string): boolean {
const localId = this.localIdForMfaEnrollmentId.get(mfaEnrollmentId);
return !localId;
}

/**
* Validates an MFA enrollment ID being updated, checking either that
* the ID does not already exist, or that the ID exists and is being
* used by the current user.
* Validates a collection of MFA Enrollments. If all data is valid, returns the data
* unmodified to the caller.
*
* @param mfaEnrollmentId the proposed MFA enrollment ID
* @param user the user being updated
* @param enrollments the MFA Enrollments to validate. each enrollment must have a valid phone number, a non-null enrollment ID,
* and the enrollment ID must be unique across all other enrollments in the array.
* @returns the validated MFA Enrollments passed to this method
* @throws BadRequestError if the phone number is absent or invalid
* @throws BadRequestError if the MFA Enrollment ID is absent
* @throws BadRequestError if the MFA Enrollment ID is duplicated in the provided array
*/
validateMfaEnrollmentIdForUpdate(mfaEnrollmentId: string, user: UserInfo): boolean {
const localId = this.localIdForMfaEnrollmentId.get(mfaEnrollmentId);
// if the ID is in use for another user, it is invalid. otherwise, we'll allow an update
// even if the user is doing something sketchy like swapping the ID's on various
// existing enrolled factors.
return !localId || localId === user.localId;
}

private updateMfaEnrollments(
user: UserInfo,
mfaInfo: Schemas["GoogleCloudIdentitytoolkitV1MfaEnrollment"][]
): UserInfo {
for (const factor of mfaInfo) {
if (!factor.mfaEnrollmentId) {
throw new Error("MFA Factor Must Have an Enrollment ID");
}
const enrollments = this.mfaEnrollmentIdsForLocalId.get(user.localId) ?? new Set();
enrollments.add(factor.mfaEnrollmentId);
this.mfaEnrollmentIdsForLocalId.set(user.localId, enrollments);
this.localIdForMfaEnrollmentId.set(factor.mfaEnrollmentId, user.localId);
validateMfaEnrollments(enrollments: MfaEnrollments): MfaEnrollments {
const enrollmentIds = new Set();
for (const enrollment of enrollments) {
assert(
enrollment.phoneInfo && isValidPhoneNumber(enrollment.phoneInfo),
"INVALID_MFA_PHONE_NUMBER : Invalid format."
);
assert(enrollment.mfaEnrollmentId, "INVALID_MFA_ID : mfaEnrollmentId must be defined.");
assert(enrollmentIds.add(enrollment.mfaEnrollmentId), "DUPLICATE_MFA_ENROLLMENT_ID");
}
return user;
return enrollments;
}

private updateUserProviderInfo(
Expand Down Expand Up @@ -464,8 +455,6 @@ export class ProjectState {
this.userIdForProviderRawId.clear();
this.refreshTokens.clear();
this.refreshTokensForLocalId.clear();
this.mfaEnrollmentIdsForLocalId.clear();
this.localIdForMfaEnrollmentId.clear();

// We do not clear OOBs / phone verification codes since some of those may
// still be valid (e.g. email link / phone sign-in may still create a new
Expand Down Expand Up @@ -544,15 +533,6 @@ export class ProjectState {
this.localIdForPhoneNumber.delete(user.phoneNumber);
}

if (user.mfaInfo) {
for (const factor of user.mfaInfo) {
if (factor.mfaEnrollmentId) {
this.localIdForMfaEnrollmentId.delete(factor.mfaEnrollmentId);
}
}
this.mfaEnrollmentIdsForLocalId.delete(user.localId);
}

for (const info of user.providerUserInfo ?? []) {
this.userIdForProviderRawId.get(info.providerId)?.delete(info.rawId);
if (info.email) {
Expand Down
9 changes: 4 additions & 5 deletions src/test/emulators/auth/customToken.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
getAccountInfoByIdToken,
updateAccountByLocalId,
signInWithEmailLink,
registerMfaUser,
TEST_PHONE_NUMBER,
registerUser,
TEST_MFA_INFO,
} from "./helpers";

describeAuthEmulator("sign-in with custom token", ({ authApi }) => {
Expand Down Expand Up @@ -238,10 +238,9 @@ describeAuthEmulator("sign-in with custom token", ({ authApi }) => {
const user = {
email: "[email protected]",
password: "notasecret",
mfaInfo: [{ displayName: "Cell Phone", phoneInfo: TEST_PHONE_NUMBER }],
mfaInfo: [TEST_MFA_INFO],
};
const { localId } = await registerMfaUser(authApi(), user);
expect(localId).to.be.a("string").and.not.empty;
const { localId } = await registerUser(authApi(), user);

const claims = { abc: "def", ultimate: { answer: 42 } };
const token = JSON.stringify({ uid: localId, claims });
Expand Down
8 changes: 4 additions & 4 deletions src/test/emulators/auth/emailLink.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { expect } from "chai";
import { decode as decodeJwt, JwtHeader } from "jsonwebtoken";
import { FirebaseJwtPayload } from "../../../emulator/auth/operations";
import { registerMfaUser, TEST_PHONE_NUMBER } from "./helpers";
import { describeAuthEmulator } from "./setup";
import {
expectStatusCode,
Expand All @@ -11,6 +10,8 @@ import {
getSigninMethods,
inspectOobs,
createEmailSignInOob,
TEST_PHONE_NUMBER,
TEST_MFA_INFO,
} from "./helpers";

describeAuthEmulator("email link sign-in", ({ authApi }) => {
Expand Down Expand Up @@ -206,10 +207,9 @@ describeAuthEmulator("email link sign-in", ({ authApi }) => {
const user = {
email: "[email protected]",
password: "notasecret",
mfaInfo: [{ displayName: "Cell Phone", phoneInfo: TEST_PHONE_NUMBER }],
mfaInfo: [TEST_MFA_INFO],
};
const { localId, idToken, email } = await registerMfaUser(authApi(), user);
expect(localId).to.be.a("string").and.not.empty;
const { idToken, email } = await registerUser(authApi(), user);
const { oobCode } = await createEmailSignInOob(authApi(), email);

await authApi()
Expand Down
34 changes: 10 additions & 24 deletions src/test/emulators/auth/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { components } from "../../../emulator/auth/schema";

export { PROJECT_ID };
export const TEST_PHONE_NUMBER = "+15555550100";
export const TEST_MFA_INFO = {
displayName: "Cell Phone",
phoneInfo: TEST_PHONE_NUMBER,
};
export const FAKE_GOOGLE_ACCOUNT = {
displayName: "Example User",
email: "[email protected]",
Expand Down Expand Up @@ -80,7 +84,12 @@ export function fakeClaims(input: Partial<IdpJwtPayload> & { sub: string }): Idp

export function registerUser(
testAgent: TestAgent,
user: { email: string; password: string; displayName?: string }
user: {
email: string;
password: string;
displayName?: string;
mfaInfo?: components["schemas"]["GoogleCloudIdentitytoolkitV1MfaEnrollment"][];
}
): Promise<{ idToken: string; localId: string; refreshToken: string; email: string }> {
return testAgent
.post("/identitytoolkit.googleapis.com/v1/accounts:signUp")
Expand Down Expand Up @@ -114,29 +123,6 @@ export function registerAnonUser(
});
}

export function registerMfaUser(
testAgent: TestAgent,
user: {
email: string;
password: string;
mfaInfo: components["schemas"]["GoogleCloudIdentitytoolkitV1MfaEnrollment"][];
}
): Promise<{ idToken: string; localId: string; refreshToken: string; email: string }> {
return testAgent
.post("/identitytoolkit.googleapis.com/v1/accounts:signUp")
.send(user)
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(200, res);
return {
idToken: res.body.idToken,
localId: res.body.localId,
refreshToken: res.body.refreshToken,
email: res.body.email,
};
});
}

export async function signInWithEmailLink(
testAgent: TestAgent,
email: string,
Expand Down
Loading