diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..b8678174f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Fixes `auth:export` and `auth:import` dropping `mfaInfo` data for users with Multi-Factor Authentication enabled. diff --git a/firebase-vscode/src/utils/settings.ts b/firebase-vscode/src/utils/settings.ts index 216a2d085c4..af0c5c711eb 100644 --- a/firebase-vscode/src/utils/settings.ts +++ b/firebase-vscode/src/utils/settings.ts @@ -33,7 +33,7 @@ export function getSettings(): Settings { firebaseBinaryKind = "firepit-global"; } - const extraEnv = config.get>("extraEnv", {}) + const extraEnv = config.get>("extraEnv", {}); process.env = { ...process.env, ...extraEnv }; return { diff --git a/src/accountExporter.spec.ts b/src/accountExporter.spec.ts index 4d25b00b92e..ed3518974d8 100644 --- a/src/accountExporter.spec.ts +++ b/src/accountExporter.spec.ts @@ -9,20 +9,24 @@ import { validateOptions, serialExportUsers } from "./accountExporter"; describe("accountExporter", () => { describe("validateOptions", () => { it("should reject when no format provided", () => { - expect(() => validateOptions({}, "output_file")).to.throw(); + expect(() => { + validateOptions({}, "output_file"); + }).to.throw(); }); it("should reject when format is not csv or json", () => { - expect(() => validateOptions({ format: "txt" }, "output_file")).to.throw(); + expect(() => { + validateOptions({ format: "txt" }, "output_file"); + }).to.throw(); }); it("should ignore format param when implicitly specified in file name", () => { - const ret = validateOptions({ format: "JSON" }, "output_file.csv"); + const ret = validateOptions({ format: "JSON" }, "output_file.csv") as { format: string }; expect(ret.format).to.eq("csv"); }); it("should use format param when not implicitly specified in file name", () => { - const ret = validateOptions({ format: "JSON" }, "output_file"); + const ret = validateOptions({ format: "JSON" }, "output_file") as { format: string }; expect(ret.format).to.eq("json"); }); }); @@ -35,6 +39,7 @@ describe("accountExporter", () => { displayName: string; disabled: boolean; customAttributes?: string; + mfaInfo?: unknown[]; providerUserInfo?: { providerId: string; rawId: string; @@ -56,8 +61,8 @@ describe("accountExporter", () => { if (i === 6) { userList.push({ localId: i.toString(), - email: "test" + i + "@test.org", - displayName: "John Tester" + i, + email: `test${i}@test.org`, + displayName: `John Tester${i}`, disabled: i % 2 === 0, providerUserInfo: [ { @@ -71,8 +76,8 @@ describe("accountExporter", () => { } else { userList.push({ localId: i.toString(), - email: "test" + i + "@test.org", - displayName: "John Tester" + i, + email: `test${i}@test.org`, + displayName: `John Tester${i}`, disabled: i % 2 === 0, }); } @@ -311,6 +316,38 @@ describe("accountExporter", () => { expect(nock.isDone()).to.be.true; }); + it("should export a user's mfaInfo for JSON formats", async () => { + userList[0].mfaInfo = [ + { + mfaEnrollmentId: "enrollment-id-1", + displayName: "My SMS MFA", + phoneInfo: "+11111111111", + enrolledAt: "2026-06-24T00:00:00Z", + }, + ]; + nock("https://www.googleapis.com") + .post("/identitytoolkit/v3/relyingparty/downloadAccount", { + maxResults: 3, + targetProjectId: "test-project-id", + }) + .reply(200, { + users: userList.slice(0, 3), + }); + await serialExportUsers("test-project-id", { + format: "JSON", + batchSize: 3, + writeStream: writeStream, + }); + expect(spyWrite.getCall(0).args[0]).to.eq(JSON.stringify(userList[0], null, 2)); + expect(spyWrite.getCall(1).args[0]).to.eq( + "," + os.EOL + JSON.stringify(userList[1], null, 2), + ); + expect(spyWrite.getCall(2).args[0]).to.eq( + "," + os.EOL + JSON.stringify(userList[2], null, 2), + ); + expect(nock.isDone()).to.be.true; + }); + function mockAllUsersRequests(): void { nock("https://www.googleapis.com") .post("/identitytoolkit/v3/relyingparty/downloadAccount", { diff --git a/src/accountExporter.ts b/src/accountExporter.ts index 91965bf778d..22c07a7e307 100644 --- a/src/accountExporter.ts +++ b/src/accountExporter.ts @@ -11,7 +11,6 @@ const apiClient = new Client({ urlPrefix: googleOrigin(), }); -// TODO: support for MFA at runtime was added in PR #3173, but this exporter currently ignores `mfaInfo` and loses the data on export. const EXPORTED_JSON_KEYS = [ "localId", "email", @@ -25,6 +24,7 @@ const EXPORTED_JSON_KEYS = [ "phoneNumber", "disabled", "customAttributes", + "mfaInfo", ]; const EXPORTED_JSON_KEYS_RENAMING: Record = { lastLoginAt: "lastSignedInAt", diff --git a/src/accountImporter.spec.ts b/src/accountImporter.spec.ts index 2965278499e..fa20befc181 100644 --- a/src/accountImporter.spec.ts +++ b/src/accountImporter.spec.ts @@ -148,6 +148,23 @@ describe("accountImporter", () => { }), ).to.not.have.property("error"); }); + + it("should not reject when mfaInfo is present in user json", () => { + expect( + validateUserJson({ + localId: "123", + email: "test@test.org", + mfaInfo: [ + { + mfaEnrollmentId: "enrollment-id-1", + displayName: "My SMS MFA", + phoneInfo: "+11111111111", + enrolledAt: "2026-06-24T00:00:00Z", + }, + ], + }), + ).to.not.have.property("error"); + }); }); describe("serialImportUsers", () => { diff --git a/src/accountImporter.ts b/src/accountImporter.ts index 39a9e741387..16515e3427b 100644 --- a/src/accountImporter.ts +++ b/src/accountImporter.ts @@ -10,7 +10,6 @@ const apiClient = new Client({ urlPrefix: googleOrigin(), }); -// TODO: support for MFA at runtime was added in PR #3173, but this importer currently ignores `mfaInfo` and loses the data on import. const ALLOWED_JSON_KEYS = [ "localId", "email", @@ -25,6 +24,7 @@ const ALLOWED_JSON_KEYS = [ "phoneNumber", "disabled", "customAttributes", + "mfaInfo", ]; const ALLOWED_JSON_KEYS_RENAMING = { lastSignedInAt: "lastLoginAt",