Skip to content

Commit d181898

Browse files
wokkaflokkayuchenshi
authored andcommitted
Authentication Emulator Supports MFA for Simple Read/Write User Operations (Fixes firebase#3170) (firebase#3173)
* Authentication Emulator Supports MFA Info for Simple Read/Write User Operations (firebase#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 <[email protected]>
1 parent 930a310 commit d181898

15 files changed

+924
-17
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
- Fixes an issue where `ext:dev:init` failed when Typescript was chosen.
2+
- Add support for reading/writing mfaInfo field in Auth Emulator (#3173).

src/accountExporter.js

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ var api = require("./api");
88
var utils = require("./utils");
99
var { FirebaseError } = require("./error");
1010

11+
// TODO: support for MFA at runtime was added in PR #3173, but this exporter currently ignores `mfaInfo` and loses the data on export.
1112
var EXPORTED_JSON_KEYS = [
1213
"localId",
1314
"email",

src/accountImporter.js

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { logger } = require("./logger");
88
var utils = require("./utils");
99
var { FirebaseError } = require("./error");
1010

11+
// TODO: support for MFA at runtime was added in PR #3173, but this importer currently ignores `mfaInfo` and loses the data on import.
1112
var ALLOWED_JSON_KEYS = [
1213
"localId",
1314
"email",

src/emulator/auth/cloudFunctions.ts

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export class AuthCloudFunction {
7777
customClaims: JSON.parse(user.customAttributes || "{}"),
7878
providerData: user.providerUserInfo,
7979
tenantId: user.tenantId,
80+
mfaInfo: user.mfaInfo,
8081
};
8182
}
8283
}
@@ -99,4 +100,5 @@ type UserInfoPayload = {
99100
phoneNumber?: string;
100101
customClaims?: object;
101102
tenantId?: string;
103+
mfaInfo?: object;
102104
};

src/emulator/auth/operations.ts

+83-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
MakeRequired,
1414
isValidPhoneNumber,
1515
} from "./utils";
16-
import { NotImplementedError, assert, BadRequestError } from "./errors";
16+
import { NotImplementedError, assert, BadRequestError, InternalError } from "./errors";
1717
import { Emulators } from "../types";
1818
import { EmulatorLogger } from "../emulatorLogger";
1919
import {
@@ -28,9 +28,7 @@ import {
2828
PROVIDER_CUSTOM,
2929
OobRecord,
3030
} from "./state";
31-
32-
import * as schema from "./schema";
33-
export type Schemas = schema.components["schemas"];
31+
import { MfaEnrollments, CreateMfaEnrollmentsRequest, Schemas, MfaEnrollment } from "./types";
3432

3533
/**
3634
* Create a map from IDs to operations handlers suitable for exegesis.
@@ -166,6 +164,11 @@ function signUp(
166164
updates.passwordUpdatedAt = Date.now();
167165
updates.validSince = toUnixTimestamp(new Date()).toString();
168166
}
167+
if (reqBody.mfaInfo) {
168+
updates.mfaInfo = getMfaEnrollmentsFromRequest(state, reqBody.mfaInfo, {
169+
generateEnrollmentIds: true,
170+
});
171+
}
169172
let user: UserInfo | undefined;
170173
if (reqBody.idToken) {
171174
({ user } = parseIdToken(state, reqBody.idToken));
@@ -185,7 +188,6 @@ function signUp(
185188
return {
186189
kind: "identitytoolkit#SignupNewUserResponse",
187190
localId: user.localId,
188-
189191
displayName: user.displayName,
190192
email: user.email,
191193
...(provider ? issueTokens(state, user, provider) : {}),
@@ -974,6 +976,17 @@ export function setAccountInfoImpl(
974976
updates.validSince = toUnixTimestamp(new Date()).toString();
975977
}
976978

979+
// if the request specifies an `mfa` key and enrollments are present and non-empty, set the enrollments
980+
// as the current MFA state for the user. if the `mfa` key is specified and no enrollments are present,
981+
// clear any existing MFA data for the user. if no `mfa` key is specified, MFA is left unchanged.
982+
if (reqBody.mfa) {
983+
if (reqBody.mfa.enrollments && reqBody.mfa.enrollments.length > 0) {
984+
updates.mfaInfo = getMfaEnrollmentsFromRequest(state, reqBody.mfa.enrollments);
985+
} else {
986+
updates.mfaInfo = undefined;
987+
}
988+
}
989+
977990
// Copy profile properties to updates, if they're specified.
978991
const fieldsToCopy: (keyof typeof reqBody & keyof typeof updates)[] = [
979992
"displayName",
@@ -1203,6 +1216,11 @@ function signInWithCustomToken(
12031216
throw new Error(`Internal assertion error: trying to create duplicate localId: ${localId}`);
12041217
}
12051218
}
1219+
1220+
if (user.mfaInfo) {
1221+
throw new NotImplementedError("MFA Login not yet implemented.");
1222+
}
1223+
12061224
return {
12071225
kind: "identitytoolkit#VerifyCustomTokenResponse",
12081226
isNewUser,
@@ -1248,6 +1266,10 @@ function signInWithEmailLink(
12481266
user = state.updateUserByLocalId(user.localId, updates);
12491267
}
12501268

1269+
if (user.mfaInfo) {
1270+
throw new NotImplementedError("MFA Login not yet implemented.");
1271+
}
1272+
12511273
const tokens = issueTokens(state, user, PROVIDER_PASSWORD);
12521274
return {
12531275
kind: "identitytoolkit#EmailLinkSigninResponse",
@@ -1385,6 +1407,10 @@ function signInWithIdp(
13851407
);
13861408
}
13871409

1410+
if (user.mfaInfo) {
1411+
throw new NotImplementedError("MFA Login not yet implemented.");
1412+
}
1413+
13881414
if (user.email === response.email) {
13891415
response.emailVerified = user.emailVerified;
13901416
}
@@ -1414,6 +1440,10 @@ function signInWithPassword(
14141440
assert(user.passwordHash && user.salt, "INVALID_PASSWORD");
14151441
assert(user.passwordHash === hashPassword(reqBody.password, user.salt), "INVALID_PASSWORD");
14161442

1443+
if (user.mfaInfo) {
1444+
throw new NotImplementedError("MFA Login not yet implemented.");
1445+
}
1446+
14171447
const tokens = issueTokens(state, user, PROVIDER_PASSWORD);
14181448

14191449
return {
@@ -1477,6 +1507,10 @@ function signInWithPhoneNumber(
14771507
user = state.updateUserByLocalId(user.localId, updates);
14781508
}
14791509

1510+
if (user.mfaInfo) {
1511+
throw new NotImplementedError("MFA Login not yet implemented.");
1512+
}
1513+
14801514
const tokens = issueTokens(state, user, PROVIDER_PHONE);
14811515

14821516
return {
@@ -1767,6 +1801,50 @@ function validateCustomClaims(claims: unknown): asserts claims is Record<string,
17671801
}
17681802
}
17691803

1804+
// generates a new random ID, checking against an optional set of "existing ids" for
1805+
// uniqueness. if a unique ID cannot be generated in 10 tries, an internal error is
1806+
// thrown. the ID generated by this method is not added to the set provided to this
1807+
// method, callers must manage their own state.
1808+
function newRandomId(length: number, existingIds?: Set<string>): string {
1809+
for (let i = 0; i < 10; i++) {
1810+
const id = randomId(length);
1811+
if (!existingIds?.has(id)) {
1812+
return id;
1813+
}
1814+
}
1815+
throw new InternalError(
1816+
"INTERNAL_ERROR : Failed to generate a random ID after 10 attempts",
1817+
"INTERNAL"
1818+
);
1819+
}
1820+
1821+
function getMfaEnrollmentsFromRequest(
1822+
state: ProjectState,
1823+
request: MfaEnrollments,
1824+
options?: { generateEnrollmentIds: boolean }
1825+
): MfaEnrollments {
1826+
const enrollments: MfaEnrollments = [];
1827+
const phoneNumbers: Set<string> = new Set<string>();
1828+
const enrollmentIds: Set<string> = new Set<string>();
1829+
for (const enrollment of request) {
1830+
assert(
1831+
enrollment.phoneInfo && isValidPhoneNumber(enrollment.phoneInfo),
1832+
"INVALID_MFA_PHONE_NUMBER : Invalid format."
1833+
);
1834+
if (!phoneNumbers.has(enrollment.phoneInfo)) {
1835+
const mfaEnrollmentId = options?.generateEnrollmentIds
1836+
? newRandomId(28, enrollmentIds)
1837+
: enrollment.mfaEnrollmentId;
1838+
assert(mfaEnrollmentId, "INVALID_MFA_ENROLLMENT_ID : mfaEnrollmentId must be defined.");
1839+
assert(!enrollmentIds.has(mfaEnrollmentId), "DUPLICATE_MFA_ENROLLMENT_ID");
1840+
enrollments.push({ ...enrollment, mfaEnrollmentId });
1841+
phoneNumbers.add(enrollment.phoneInfo);
1842+
enrollmentIds.add(mfaEnrollmentId);
1843+
}
1844+
}
1845+
return state.validateMfaEnrollments(enrollments);
1846+
}
1847+
17701848
function getNormalizedUri(reqBody: {
17711849
requestUri?: string | undefined;
17721850
postBody?: string | undefined;

src/emulator/auth/state.ts

+54-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { randomBase64UrlStr, randomId, mirrorFieldTo, randomDigits } from "./utils";
1+
import {
2+
randomBase64UrlStr,
3+
randomId,
4+
mirrorFieldTo,
5+
randomDigits,
6+
isValidPhoneNumber,
7+
} from "./utils";
28
import { MakeRequired } from "./utils";
3-
4-
import * as schema from "./schema";
59
import { AuthCloudFunction } from "./cloudFunctions";
6-
type Schemas = schema.components["schemas"];
10+
import { assert } from "./errors";
11+
import { MfaEnrollments, Schemas } from "./types";
712

813
export const PROVIDER_PASSWORD = "password";
914
export const PROVIDER_PHONE = "phone";
@@ -131,6 +136,7 @@ export class ProjectState {
131136
}
132137
const oldEmail = user.email;
133138
const oldPhoneNumber = user.phoneNumber;
139+
134140
for (const field of Object.keys(fields) as (keyof typeof fields)[]) {
135141
mirrorFieldTo(user, field, fields);
136142
}
@@ -172,9 +178,53 @@ export class ProjectState {
172178
deleteProviders.push(PROVIDER_PHONE);
173179
}
174180

181+
// if MFA info is specified on the user, ensure MFA data is valid before returning.
182+
// callers are expected to have called `validateMfaEnrollments` prior to having called
183+
// this method.
184+
if (user.mfaInfo) {
185+
this.validateMfaEnrollments(user.mfaInfo);
186+
}
187+
175188
return this.updateUserProviderInfo(user, upsertProviders, deleteProviders);
176189
}
177190

191+
/**
192+
* Validates a collection of MFA Enrollments. If all data is valid, returns the data
193+
* unmodified to the caller.
194+
*
195+
* @param enrollments the MFA Enrollments to validate. each enrollment must have a valid and unique phone number, a non-null enrollment ID,
196+
* and the enrollment ID must be unique across all other enrollments in the array.
197+
* @returns the validated MFA Enrollments passed to this method
198+
* @throws BadRequestError if the phone number is absent or invalid
199+
* @throws BadRequestError if the MFA Enrollment ID is absent
200+
* @throws BadRequestError if the MFA Enrollment ID is duplicated in the provided array
201+
* @throws BadRequestError if any of the phone numbers are duplicated. callers should de-duplicate phone numbers
202+
* prior to calling this validation method, as the real API is lenient and removes duplicates from requests
203+
* for well-formed create/update requests.
204+
*/
205+
validateMfaEnrollments(enrollments: MfaEnrollments): MfaEnrollments {
206+
const phoneNumbers: Set<string> = new Set<string>();
207+
const enrollmentIds: Set<string> = new Set<string>();
208+
for (const enrollment of enrollments) {
209+
assert(
210+
enrollment.phoneInfo && isValidPhoneNumber(enrollment.phoneInfo),
211+
"INVALID_MFA_PHONE_NUMBER : Invalid format."
212+
);
213+
assert(
214+
enrollment.mfaEnrollmentId,
215+
"INVALID_MFA_ENROLLMENT_ID : mfaEnrollmentId must be defined."
216+
);
217+
assert(!enrollmentIds.has(enrollment.mfaEnrollmentId), "DUPLICATE_MFA_ENROLLMENT_ID");
218+
assert(
219+
!phoneNumbers.has(enrollment.phoneInfo),
220+
"INTERNAL_ERROR : MFA Enrollment Phone Numbers must be unique."
221+
);
222+
phoneNumbers.add(enrollment.phoneInfo);
223+
enrollmentIds.add(enrollment.mfaEnrollmentId);
224+
}
225+
return enrollments;
226+
}
227+
178228
private updateUserProviderInfo(
179229
user: UserInfo,
180230
upsertProviders: ProviderUserInfo[],

src/emulator/auth/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import * as schema from "./schema";
2+
export type Schemas = schema.components["schemas"];
3+
export type MfaEnrollment = Schemas["GoogleCloudIdentitytoolkitV1MfaEnrollment"];
4+
export type MfaEnrollments = MfaEnrollment[];
5+
export type CreateMfaEnrollmentsRequest = Schemas["GoogleCloudIdentitytoolkitV1MfaFactor"][];

src/test/emulators/auth/customToken.spec.ts

+22
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
getAccountInfoByIdToken,
99
updateAccountByLocalId,
1010
signInWithEmailLink,
11+
registerUser,
12+
TEST_MFA_INFO,
1113
} from "./helpers";
1214

1315
describeAuthEmulator("sign-in with custom token", ({ authApi }) => {
@@ -231,4 +233,24 @@ describeAuthEmulator("sign-in with custom token", ({ authApi }) => {
231233
expect(res.body.error).to.have.property("message").equal("USER_DISABLED");
232234
});
233235
});
236+
237+
it("should error if user has MFA", async () => {
238+
const user = {
239+
240+
password: "notasecret",
241+
mfaInfo: [TEST_MFA_INFO],
242+
};
243+
const { localId } = await registerUser(authApi(), user);
244+
245+
const claims = { abc: "def", ultimate: { answer: 42 } };
246+
const token = JSON.stringify({ uid: localId, claims });
247+
await authApi()
248+
.post("/identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken")
249+
.query({ key: "fake-api-key" })
250+
.send({ token })
251+
.then((res) => {
252+
expectStatusCode(501, res);
253+
expect(res.body.error.message).to.equal("MFA Login not yet implemented.");
254+
});
255+
});
234256
});

src/test/emulators/auth/emailLink.spec.ts

+21-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { expect } from "chai";
22
import { decode as decodeJwt, JwtHeader } from "jsonwebtoken";
33
import { FirebaseJwtPayload } from "../../../emulator/auth/operations";
4-
import { TEST_PHONE_NUMBER } from "./helpers";
54
import { describeAuthEmulator } from "./setup";
65
import {
76
expectStatusCode,
@@ -11,6 +10,8 @@ import {
1110
getSigninMethods,
1211
inspectOobs,
1312
createEmailSignInOob,
13+
TEST_PHONE_NUMBER,
14+
TEST_MFA_INFO,
1415
} from "./helpers";
1516

1617
describeAuthEmulator("email link sign-in", ({ authApi }) => {
@@ -201,4 +202,23 @@ describeAuthEmulator("email link sign-in", ({ authApi }) => {
201202
expect(res.body.error).to.have.property("message").equals("USER_DISABLED");
202203
});
203204
});
205+
206+
it("should error if user has MFA", async () => {
207+
const user = {
208+
209+
password: "notasecret",
210+
mfaInfo: [TEST_MFA_INFO],
211+
};
212+
const { idToken, email } = await registerUser(authApi(), user);
213+
const { oobCode } = await createEmailSignInOob(authApi(), email);
214+
215+
await authApi()
216+
.post("/identitytoolkit.googleapis.com/v1/accounts:signInWithEmailLink")
217+
.query({ key: "fake-api-key" })
218+
.send({ email, oobCode, idToken })
219+
.then((res) => {
220+
expectStatusCode(501, res);
221+
expect(res.body.error.message).to.equal("MFA Login not yet implemented.");
222+
});
223+
});
204224
});

src/test/emulators/auth/helpers.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ import { expect, AssertionError } from "chai";
55
import { IdpJwtPayload } from "../../../emulator/auth/operations";
66
import { OobRecord, PhoneVerificationRecord, UserInfo } from "../../../emulator/auth/state";
77
import { TestAgent, PROJECT_ID } from "./setup";
8+
import { MfaEnrollments } from "../../../emulator/auth/types";
89

910
export { PROJECT_ID };
1011
export const TEST_PHONE_NUMBER = "+15555550100";
12+
export const TEST_PHONE_NUMBER_2 = "+15555550101";
13+
export const TEST_PHONE_NUMBER_3 = "+15555550102";
14+
export const TEST_MFA_INFO = {
15+
displayName: "Cell Phone",
16+
phoneInfo: TEST_PHONE_NUMBER,
17+
};
18+
export const TEST_INVALID_PHONE_NUMBER = "5555550100"; /* no country code */
1119
export const FAKE_GOOGLE_ACCOUNT = {
1220
displayName: "Example User",
1321
@@ -79,7 +87,12 @@ export function fakeClaims(input: Partial<IdpJwtPayload> & { sub: string }): Idp
7987

8088
export function registerUser(
8189
testAgent: TestAgent,
82-
user: { email: string; password: string; displayName?: string }
90+
user: {
91+
email: string;
92+
password: string;
93+
displayName?: string;
94+
mfaInfo?: MfaEnrollments;
95+
}
8396
): Promise<{ idToken: string; localId: string; refreshToken: string; email: string }> {
8497
return testAgent
8598
.post("/identitytoolkit.googleapis.com/v1/accounts:signUp")

0 commit comments

Comments
 (0)