Skip to content

Commit

Permalink
Authentication Emulator Supports MFA for Simple Read/Write User Opera…
Browse files Browse the repository at this point in the history
…tions (Fixes #3170) (#3173)

* Authentication Emulator Supports MFA Info for Simple Read/Write User Operations (#3170)

* throw NotImplementedError if an MFA user attempts to login using the Authentication Emulator

* bring validation into operations layer and extend MFA related support to the `updateUser` path + add test cases for validation of `signUp` MFA flows

* 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

* match the SDK behavior for duplicated phone numbers and duplicated MFA Enrollment IDs on create and update

* change import for brevity

* update constants and IDs used in tests + simplify ID generation

* update variables in test

* Update CHANGELOG.md

Co-authored-by: Yuchen Shi <yuchenshi@google.com>
  • Loading branch information
wokkaflokka and yuchenshi committed Mar 18, 2021
1 parent 718e5a7 commit be0d2b6
Show file tree
Hide file tree
Showing 15 changed files with 924 additions and 17 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Fixes an issue where `ext:dev:init` failed when Typescript was chosen.
- Add support for reading/writing mfaInfo field in Auth Emulator (#3173).
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
2 changes: 2 additions & 0 deletions src/emulator/auth/cloudFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export class AuthCloudFunction {
customClaims: JSON.parse(user.customAttributes || "{}"),
providerData: user.providerUserInfo,
tenantId: user.tenantId,
mfaInfo: user.mfaInfo,
};
}
}
Expand All @@ -99,4 +100,5 @@ type UserInfoPayload = {
phoneNumber?: string;
customClaims?: object;
tenantId?: string;
mfaInfo?: object;
};
88 changes: 83 additions & 5 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 All @@ -28,9 +28,7 @@ import {
PROVIDER_CUSTOM,
OobRecord,
} from "./state";

import * as schema from "./schema";
export type Schemas = schema.components["schemas"];
import { MfaEnrollments, CreateMfaEnrollmentsRequest, Schemas, MfaEnrollment } from "./types";

/**
* Create a map from IDs to operations handlers suitable for exegesis.
Expand Down Expand Up @@ -166,6 +164,11 @@ function signUp(
updates.passwordUpdatedAt = Date.now();
updates.validSince = toUnixTimestamp(new Date()).toString();
}
if (reqBody.mfaInfo) {
updates.mfaInfo = getMfaEnrollmentsFromRequest(state, reqBody.mfaInfo, {
generateEnrollmentIds: true,
});
}
let user: UserInfo | undefined;
if (reqBody.idToken) {
({ user } = parseIdToken(state, reqBody.idToken));
Expand All @@ -185,7 +188,6 @@ function signUp(
return {
kind: "identitytoolkit#SignupNewUserResponse",
localId: user.localId,

displayName: user.displayName,
email: user.email,
...(provider ? issueTokens(state, user, provider) : {}),
Expand Down Expand Up @@ -974,6 +976,17 @@ export function setAccountInfoImpl(
updates.validSince = toUnixTimestamp(new Date()).toString();
}

// 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) {
updates.mfaInfo = getMfaEnrollmentsFromRequest(state, reqBody.mfa.enrollments);
} else {
updates.mfaInfo = undefined;
}
}

// Copy profile properties to updates, if they're specified.
const fieldsToCopy: (keyof typeof reqBody & keyof typeof updates)[] = [
"displayName",
Expand Down Expand Up @@ -1203,6 +1216,11 @@ function signInWithCustomToken(
throw new Error(`Internal assertion error: trying to create duplicate localId: ${localId}`);
}
}

if (user.mfaInfo) {
throw new NotImplementedError("MFA Login not yet implemented.");
}

return {
kind: "identitytoolkit#VerifyCustomTokenResponse",
isNewUser,
Expand Down Expand Up @@ -1248,6 +1266,10 @@ function signInWithEmailLink(
user = state.updateUserByLocalId(user.localId, updates);
}

if (user.mfaInfo) {
throw new NotImplementedError("MFA Login not yet implemented.");
}

const tokens = issueTokens(state, user, PROVIDER_PASSWORD);
return {
kind: "identitytoolkit#EmailLinkSigninResponse",
Expand Down Expand Up @@ -1385,6 +1407,10 @@ function signInWithIdp(
);
}

if (user.mfaInfo) {
throw new NotImplementedError("MFA Login not yet implemented.");
}

if (user.email === response.email) {
response.emailVerified = user.emailVerified;
}
Expand Down Expand Up @@ -1414,6 +1440,10 @@ function signInWithPassword(
assert(user.passwordHash && user.salt, "INVALID_PASSWORD");
assert(user.passwordHash === hashPassword(reqBody.password, user.salt), "INVALID_PASSWORD");

if (user.mfaInfo) {
throw new NotImplementedError("MFA Login not yet implemented.");
}

const tokens = issueTokens(state, user, PROVIDER_PASSWORD);

return {
Expand Down Expand Up @@ -1477,6 +1507,10 @@ function signInWithPhoneNumber(
user = state.updateUserByLocalId(user.localId, updates);
}

if (user.mfaInfo) {
throw new NotImplementedError("MFA Login not yet implemented.");
}

const tokens = issueTokens(state, user, PROVIDER_PHONE);

return {
Expand Down Expand Up @@ -1767,6 +1801,50 @@ 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 getMfaEnrollmentsFromRequest(
state: ProjectState,
request: MfaEnrollments,
options?: { generateEnrollmentIds: boolean }
): MfaEnrollments {
const enrollments: MfaEnrollments = [];
const phoneNumbers: Set<string> = new Set<string>();
const enrollmentIds: Set<string> = new Set<string>();
for (const enrollment of request) {
assert(
enrollment.phoneInfo && isValidPhoneNumber(enrollment.phoneInfo),
"INVALID_MFA_PHONE_NUMBER : Invalid format."
);
if (!phoneNumbers.has(enrollment.phoneInfo)) {
const mfaEnrollmentId = options?.generateEnrollmentIds
? newRandomId(28, enrollmentIds)
: enrollment.mfaEnrollmentId;
assert(mfaEnrollmentId, "INVALID_MFA_ENROLLMENT_ID : mfaEnrollmentId must be defined.");
assert(!enrollmentIds.has(mfaEnrollmentId), "DUPLICATE_MFA_ENROLLMENT_ID");
enrollments.push({ ...enrollment, mfaEnrollmentId });
phoneNumbers.add(enrollment.phoneInfo);
enrollmentIds.add(mfaEnrollmentId);
}
}
return state.validateMfaEnrollments(enrollments);
}

function getNormalizedUri(reqBody: {
requestUri?: string | undefined;
postBody?: string | undefined;
Expand Down
58 changes: 54 additions & 4 deletions src/emulator/auth/state.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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";
type Schemas = schema.components["schemas"];
import { assert } from "./errors";
import { MfaEnrollments, Schemas } from "./types";

export const PROVIDER_PASSWORD = "password";
export const PROVIDER_PHONE = "phone";
Expand Down Expand Up @@ -131,6 +136,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 @@ -172,9 +178,53 @@ export class ProjectState {
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 having called
// this method.
if (user.mfaInfo) {
this.validateMfaEnrollments(user.mfaInfo);
}

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

/**
* Validates a collection of MFA Enrollments. If all data is valid, returns the data
* unmodified to the caller.
*
* @param enrollments the MFA Enrollments to validate. each enrollment must have a valid and unique 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
* @throws BadRequestError if any of the phone numbers are duplicated. callers should de-duplicate phone numbers
* prior to calling this validation method, as the real API is lenient and removes duplicates from requests
* for well-formed create/update requests.
*/
validateMfaEnrollments(enrollments: MfaEnrollments): MfaEnrollments {
const phoneNumbers: Set<string> = new Set<string>();
const enrollmentIds: Set<string> = new Set<string>();
for (const enrollment of enrollments) {
assert(
enrollment.phoneInfo && isValidPhoneNumber(enrollment.phoneInfo),
"INVALID_MFA_PHONE_NUMBER : Invalid format."
);
assert(
enrollment.mfaEnrollmentId,
"INVALID_MFA_ENROLLMENT_ID : mfaEnrollmentId must be defined."
);
assert(!enrollmentIds.has(enrollment.mfaEnrollmentId), "DUPLICATE_MFA_ENROLLMENT_ID");
assert(
!phoneNumbers.has(enrollment.phoneInfo),
"INTERNAL_ERROR : MFA Enrollment Phone Numbers must be unique."
);
phoneNumbers.add(enrollment.phoneInfo);
enrollmentIds.add(enrollment.mfaEnrollmentId);
}
return enrollments;
}

private updateUserProviderInfo(
user: UserInfo,
upsertProviders: ProviderUserInfo[],
Expand Down
5 changes: 5 additions & 0 deletions src/emulator/auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as schema from "./schema";
export type Schemas = schema.components["schemas"];
export type MfaEnrollment = Schemas["GoogleCloudIdentitytoolkitV1MfaEnrollment"];
export type MfaEnrollments = MfaEnrollment[];
export type CreateMfaEnrollmentsRequest = Schemas["GoogleCloudIdentitytoolkitV1MfaFactor"][];
22 changes: 22 additions & 0 deletions src/test/emulators/auth/customToken.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
getAccountInfoByIdToken,
updateAccountByLocalId,
signInWithEmailLink,
registerUser,
TEST_MFA_INFO,
} from "./helpers";

describeAuthEmulator("sign-in with custom token", ({ authApi }) => {
Expand Down Expand Up @@ -231,4 +233,24 @@ describeAuthEmulator("sign-in with custom token", ({ authApi }) => {
expect(res.body.error).to.have.property("message").equal("USER_DISABLED");
});
});

it("should error if user has MFA", async () => {
const user = {
email: "alice@example.com",
password: "notasecret",
mfaInfo: [TEST_MFA_INFO],
};
const { localId } = await registerUser(authApi(), user);

const claims = { abc: "def", ultimate: { answer: 42 } };
const token = JSON.stringify({ uid: localId, claims });
await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken")
.query({ key: "fake-api-key" })
.send({ token })
.then((res) => {
expectStatusCode(501, res);
expect(res.body.error.message).to.equal("MFA Login not yet implemented.");
});
});
});
22 changes: 21 additions & 1 deletion 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 { 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 @@ -201,4 +202,23 @@ describeAuthEmulator("email link sign-in", ({ authApi }) => {
expect(res.body.error).to.have.property("message").equals("USER_DISABLED");
});
});

it("should error if user has MFA", async () => {
const user = {
email: "alice@example.com",
password: "notasecret",
mfaInfo: [TEST_MFA_INFO],
};
const { idToken, email } = await registerUser(authApi(), user);
const { oobCode } = await createEmailSignInOob(authApi(), email);

await authApi()
.post("/identitytoolkit.googleapis.com/v1/accounts:signInWithEmailLink")
.query({ key: "fake-api-key" })
.send({ email, oobCode, idToken })
.then((res) => {
expectStatusCode(501, res);
expect(res.body.error.message).to.equal("MFA Login not yet implemented.");
});
});
});
15 changes: 14 additions & 1 deletion src/test/emulators/auth/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import { expect, AssertionError } from "chai";
import { IdpJwtPayload } from "../../../emulator/auth/operations";
import { OobRecord, PhoneVerificationRecord, UserInfo } from "../../../emulator/auth/state";
import { TestAgent, PROJECT_ID } from "./setup";
import { MfaEnrollments } from "../../../emulator/auth/types";

export { PROJECT_ID };
export const TEST_PHONE_NUMBER = "+15555550100";
export const TEST_PHONE_NUMBER_2 = "+15555550101";
export const TEST_PHONE_NUMBER_3 = "+15555550102";
export const TEST_MFA_INFO = {
displayName: "Cell Phone",
phoneInfo: TEST_PHONE_NUMBER,
};
export const TEST_INVALID_PHONE_NUMBER = "5555550100"; /* no country code */
export const FAKE_GOOGLE_ACCOUNT = {
displayName: "Example User",
email: "example@gmail.com",
Expand Down Expand Up @@ -79,7 +87,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?: MfaEnrollments;
}
): Promise<{ idToken: string; localId: string; refreshToken: string; email: string }> {
return testAgent
.post("/identitytoolkit.googleapis.com/v1/accounts:signUp")
Expand Down
Loading

0 comments on commit be0d2b6

Please sign in to comment.