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

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
match the SDK behavior for duplicated phone numbers and duplicated MF…
…A Enrollment IDs on create and update
  • Loading branch information
wokkaflokka committed Mar 17, 2021
commit d77f6f5e8878b3dd21d26c6aaafecb339d0f574b
57 changes: 39 additions & 18 deletions src/emulator/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -167,18 +165,10 @@ function signUp(
updates.validSince = toUnixTimestamp(new Date()).toString();
}
if (reqBody.mfaInfo) {
const mfaInfo = [];
const enrollmentIds = new Set<string>();
for (const factor of reqBody.mfaInfo) {
const mfaEnrollmentId = newRandomId(28, enrollmentIds);
mfaInfo.push({
displayName: factor.displayName,
phoneInfo: factor.phoneInfo,
mfaEnrollmentId,
});
enrollmentIds.add(mfaEnrollmentId);
}
updates.mfaInfo = state.validateMfaEnrollments(mfaInfo);
// on create, supply a function that creates a new enrollment ID per unique factor
updates.mfaInfo = getMfaEnrollmentsFromRequest(state, reqBody.mfaInfo, (_, ids) =>
newRandomId(28, ids)
);
}
let user: UserInfo | undefined;
if (reqBody.idToken) {
Expand All @@ -199,7 +189,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 @@ -993,8 +982,16 @@ export function setAccountInfoImpl(
// 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 = [...reqBody.mfa.enrollments];
updates.mfaInfo = state.validateMfaEnrollments(mfaInfo);
// on update, MFA enrollment ID must be specified, and we use the uid defined on each factor
// in our ID generating function.
updates.mfaInfo = getMfaEnrollmentsFromRequest(
state,
reqBody.mfa.enrollments,
({ mfaEnrollmentId }) => {
assert(mfaEnrollmentId, "INVALID_MFA_ENROLLMENT_ID : mfaEnrollmentId must be defined.");
return mfaEnrollmentId;
}
);
} else {
updates.mfaInfo = undefined;
}
Expand Down Expand Up @@ -1831,6 +1828,30 @@ function newRandomId(length: number, existingIds?: Set<string>): string {
);
}

function getMfaEnrollmentsFromRequest(
state: ProjectState,
request: MfaEnrollments,
getEnrollmentId: (enrollment: MfaEnrollment, enrollmentIds: Set<string>) => string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd actually suggest just options: {generateEnrollmentIds: boolean} instead. Less code, less abstractions and more readable.

): 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 = getEnrollmentId(enrollment, enrollmentIds);
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
31 changes: 20 additions & 11 deletions src/emulator/auth/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,9 @@ import {
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[];
import { MfaEnrollments, Schemas } from "./types";

export const PROVIDER_PASSWORD = "password";
export const PROVIDER_PHONE = "phone";
Expand Down Expand Up @@ -183,8 +179,8 @@ export class ProjectState {
}

// 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.
// callers are expected to have called `validateMfaEnrollments` prior to having called
// this method.
if (user.mfaInfo) {
this.validateMfaEnrollments(user.mfaInfo);
}
Expand All @@ -196,22 +192,35 @@ export class ProjectState {
* 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 phone number, a non-null enrollment ID,
* @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 enrollmentIds = new Set();
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_ID : mfaEnrollmentId must be defined.");
assert(enrollmentIds.add(enrollment.mfaEnrollmentId), "DUPLICATE_MFA_ENROLLMENT_ID");
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;
}
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"][];
Loading