Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixes `auth:export` and `auth:import` dropping `mfaInfo` data for users with Multi-Factor Authentication enabled.
2 changes: 1 addition & 1 deletion firebase-vscode/src/utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function getSettings(): Settings {
firebaseBinaryKind = "firepit-global";
}

const extraEnv = config.get<Record<string,string>>("extraEnv", {})
const extraEnv = config.get<Record<string,string>>("extraEnv", {});
process.env = { ...process.env, ...extraEnv };

return {
Expand Down
53 changes: 45 additions & 8 deletions src/accountExporter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Expand All @@ -35,6 +39,7 @@ describe("accountExporter", () => {
displayName: string;
disabled: boolean;
customAttributes?: string;
mfaInfo?: unknown[];
providerUserInfo?: {
providerId: string;
rawId: string;
Expand All @@ -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: [
{
Expand All @@ -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,
});
}
Expand Down Expand Up @@ -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", {
Expand Down
2 changes: 1 addition & 1 deletion src/accountExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -25,6 +24,7 @@ const EXPORTED_JSON_KEYS = [
"phoneNumber",
"disabled",
"customAttributes",
"mfaInfo",
];
const EXPORTED_JSON_KEYS_RENAMING: Record<string, string> = {
lastLoginAt: "lastSignedInAt",
Expand Down
17 changes: 17 additions & 0 deletions src/accountImporter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/accountImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -25,6 +24,7 @@ const ALLOWED_JSON_KEYS = [
"phoneNumber",
"disabled",
"customAttributes",
"mfaInfo",
];
const ALLOWED_JSON_KEYS_RENAMING = {
lastSignedInAt: "lastLoginAt",
Expand Down