Skip to content
Merged
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
Expand Up @@ -4,3 +4,4 @@
- Updated the Firebase Data Connect local toolkit to v3.1.2, which contains the following changes: (#9882)
- Improved insecure operation warning messages and reduced the severity of existing insecure operation warnings to LOG_ONLY.
- Updated the Golang dependency version from 1.24.4 to 1.24.12.
- Fixes issue where auth emulator multi-tenant mode exports/imports only users tied to the default tenant (#5623)
163 changes: 163 additions & 0 deletions scripts/emulator-import-export-tests/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
function readConfig(): FrameworkOptions {
const filename = path.join(__dirname, "firebase.json");
const data = fs.readFileSync(filename, "utf8");
return JSON.parse(data);

Check warning on line 43 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

function logIncludes(msg: string) {
Expand Down Expand Up @@ -124,7 +124,7 @@

// Write some data to export
const config = readConfig();
const port = config.emulators!.database.port;

Check warning on line 127 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
const host = await localhost();
const aApp = admin.initializeApp(
{
Expand Down Expand Up @@ -214,11 +214,11 @@

// Confirm the data exported is as expected
const aPath = path.join(dbExportPath, "namespace-a.json");
const aData = JSON.parse(fs.readFileSync(aPath).toString());

Check warning on line 217 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(aData).to.deep.equal({ ns: "namespace-a" });

const bPath = path.join(dbExportPath, "namespace-b.json");
const bData = JSON.parse(fs.readFileSync(bPath).toString());

Check warning on line 221 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(bData).to.equal(null);
});

Expand All @@ -239,7 +239,7 @@

// Create some accounts to export:
const config = readConfig();
const port = config.emulators!.auth.port;

Check warning on line 242 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
try {
process.env.FIREBASE_AUTH_EMULATOR_HOST = `${await localhost()}:${port}`;
const adminApp = admin.initializeApp(
Expand Down Expand Up @@ -272,7 +272,7 @@

// Confirm the data is exported as expected
const configPath = path.join(exportPath, "auth_export", "config.json");
const configData = JSON.parse(fs.readFileSync(configPath).toString());

Check warning on line 275 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(configData).to.deep.equal({
signIn: {
allowDuplicateEmails: false,
Expand All @@ -283,9 +283,9 @@
});

const accountsPath = path.join(exportPath, "auth_export", "accounts.json");
const accountsData = JSON.parse(fs.readFileSync(accountsPath).toString());

Check warning on line 286 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
expect(accountsData.users).to.have.length(2);

Check warning on line 287 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .users on an `any` value
expect(accountsData.users[0]).to.deep.contain({

Check warning on line 288 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .users on an `any` value
localId: "123",
email: "foo@example.com",
emailVerified: false,
Expand All @@ -298,7 +298,7 @@
},
],
});
expect(accountsData.users[0].passwordHash).to.match(/:password=testing$/);

Check warning on line 301 in scripts/emulator-import-export-tests/tests.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .users on an `any` value
expect(accountsData.users[1]).to.deep.contain({
localId: "456",
email: "bar@example.com",
Expand Down Expand Up @@ -331,6 +331,169 @@
}
});

it("should be able to import/export multi-tenant auth data", async function (this) {
this.timeout(2 * TEST_SETUP_TIMEOUT);
await new Promise((resolve) => setTimeout(resolve, 2000));

// Start up emulator suite
const project = FIREBASE_PROJECT || "example";
const emulatorsCLI = new CLIProcess("1", __dirname);

await emulatorsCLI.start("emulators:start", project, ["--only", "auth"], (data: unknown) => {
if (typeof data !== "string" && !Buffer.isBuffer(data)) {
throw new Error(`data is not a string or buffer (${typeof data})`);
}
return data.includes(ALL_EMULATORS_STARTED_LOG);
});

// Create some accounts to export:
const config = readConfig();
const port = config.emulators!.auth.port;
try {
process.env.FIREBASE_AUTH_EMULATOR_HOST = `${await localhost()}:${port}`;
const adminApp = admin.initializeApp(
{
projectId: project,
credential: ADMIN_CREDENTIAL,
},
"admin-app-auth-mutli-tenant",
Copy link
Contributor

Choose a reason for hiding this comment

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

super-nit : multi

);

const defaultTenantAuth = adminApp.auth();
const secondTenantAuth = adminApp.auth().tenantManager().authForTenant("second-tenant");

await defaultTenantAuth.createUser({
uid: "123",
email: "foo@example.com",
password: "testing",
});
await defaultTenantAuth.createUser({
uid: "456",
email: "bar@example.com",
emailVerified: true,
});

await secondTenantAuth.createUser({
uid: "123",
email: "foo-second-tenant@example.com",
password: "testing",
});
await secondTenantAuth.createUser({
uid: "456",
email: "bar-second-tenant@example.com",
emailVerified: true,
});

// Ask for export
const exportCLI = new CLIProcess("2", __dirname);
const exportPath = fs.mkdtempSync(path.join(os.tmpdir(), "emulator-data"));
await exportCLI.start("emulators:export", project, [exportPath], (data: unknown) => {
if (typeof data !== "string" && !Buffer.isBuffer(data)) {
throw new Error(`data is not a string or buffer (${typeof data})`);
}
return data.includes("Export complete");
});
await exportCLI.stop();

// Stop the suite
await emulatorsCLI.stop();

// Confirm the data is exported as expected
const configPath = path.join(exportPath, "auth_export", "config.json");
const configData = JSON.parse(fs.readFileSync(configPath).toString());
expect(configData).to.deep.equal({
signIn: {
allowDuplicateEmails: false,
},
emailPrivacyConfig: {
enableImprovedEmailPrivacy: false,
},
});

const accountsDefaultTenantPath = path.join(exportPath, "auth_export", "accounts.json");
const accountsDefaultTenantData = JSON.parse(
fs.readFileSync(accountsDefaultTenantPath).toString(),
);
expect(accountsDefaultTenantData.users).to.have.length(2);
expect(accountsDefaultTenantData.users[0]).to.deep.contain({
localId: "123",
email: "foo@example.com",
emailVerified: false,
providerUserInfo: [
{
email: "foo@example.com",
federatedId: "foo@example.com",
providerId: "password",
rawId: "foo@example.com",
},
],
});
expect(accountsDefaultTenantData.users[0].passwordHash).to.match(/:password=testing$/);
expect(accountsDefaultTenantData.users[1]).to.deep.contain({
localId: "456",
email: "bar@example.com",
emailVerified: true,
});

const accountsSecondTenantPath = path.join(
exportPath,
"auth_export",
"accounts-second-tenant.json",
);
const accountsSecondTenantData = JSON.parse(
fs.readFileSync(accountsSecondTenantPath).toString(),
);
expect(accountsSecondTenantData.users).to.have.length(2);
expect(accountsSecondTenantData.users[0]).to.deep.contain({
localId: "123",
email: "foo-second-tenant@example.com",
emailVerified: false,
providerUserInfo: [
{
email: "foo-second-tenant@example.com",
federatedId: "foo-second-tenant@example.com",
providerId: "password",
rawId: "foo-second-tenant@example.com",
},
],
});
expect(accountsSecondTenantData.users[0].passwordHash).to.match(/:password=testing$/);
expect(accountsSecondTenantData.users[1]).to.deep.contain({
localId: "456",
email: "bar-second-tenant@example.com",
emailVerified: true,
});

// Attempt to import
const importCLI = new CLIProcess("3", __dirname);
await importCLI.start(
"emulators:start",
project,
["--only", "auth", "--import", exportPath],
(data: unknown) => {
if (typeof data !== "string" && !Buffer.isBuffer(data)) {
throw new Error(`data is not a string or buffer (${typeof data})`);
}
return data.includes(ALL_EMULATORS_STARTED_LOG);
},
);

// Check users are indeed imported correctly
const user1 = await defaultTenantAuth.getUserByEmail("foo@example.com");
expect(user1.passwordHash).to.match(/:password=testing$/);
const user2 = await defaultTenantAuth.getUser("456");
expect(user2.emailVerified).to.be.true;
const user3 = await secondTenantAuth.getUserByEmail("foo-second-tenant@example.com");
expect(user3.passwordHash).to.match(/:password=testing$/);
const user4 = await secondTenantAuth.getUser("456");
expect(user4.emailVerified).to.be.true;

await importCLI.stop();
} finally {
delete process.env.FIREBASE_AUTH_EMULATOR_HOST;
}
});

it("should be able to import/export auth data with many users", async function (this) {
this.timeout(2 * TEST_SETUP_TIMEOUT);
await new Promise((resolve) => setTimeout(resolve, 2000));
Expand Down
56 changes: 31 additions & 25 deletions src/emulator/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,32 +102,38 @@ export class AuthEmulator implements EmulatorInstance {
);
}

const accountsPath = path.join(authExportDir, "accounts.json");
const accountsStat = await stat(accountsPath);
if (accountsStat?.isFile()) {
logger.logLabeled("BULLET", "auth", `Importing accounts from ${accountsPath}`);

await importFromFile(
{
method: "POST",
host: utils.connectableHostname(host),
port,
path: `/identitytoolkit.googleapis.com/v1/projects/${projectId}/accounts:batchCreate`,
headers: {
Authorization: "Bearer owner",
"Content-Type": "application/json",
const accountFiles = fs
.readdirSync(authExportDir)
.filter((fileName) => fileName.includes("accounts"));
for (const accountFile of accountFiles) {
const accountsPath = path.join(authExportDir, accountFile);
const accountsStat = await stat(accountsPath);
const tenantId = accountFile.replace(/accounts(-|)|.json/gm, "");
if (accountsStat?.isFile()) {
logger.logLabeled("BULLET", "auth", `Importing accounts from ${accountsPath}`);

await importFromFile(
{
method: "POST",
host: utils.connectableHostname(host),
port,
path: `/identitytoolkit.googleapis.com/v1/projects/${projectId}/tenants/${tenantId}/accounts:batchCreate`,
headers: {
Authorization: "Bearer owner",
"Content-Type": "application/json",
},
},
},
accountsPath,
// Ignore the error when there are no users. No action needed.
{ ignoreErrors: ["MISSING_USER_ACCOUNT"] },
);
} else {
logger.logLabeled(
"WARN",
"auth",
`Skipped importing accounts because ${accountsPath} does not exist.`,
);
accountsPath,
// Ignore the error when there are no users. No action needed.
{ ignoreErrors: ["MISSING_USER_ACCOUNT"] },
);
} else {
logger.logLabeled(
"WARN",
"auth",
`Skipped importing accounts because ${accountsPath} does not exist.`,
);
}
}
}
}
Expand Down
21 changes: 16 additions & 5 deletions src/emulator/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,11 +559,22 @@ function batchGet(
): Schemas["GoogleCloudIdentitytoolkitV1DownloadAccountResponse"] {
assert(!state.disableAuth, "PROJECT_DISABLED");
const maxResults = Math.min(Math.floor(ctx.params.query.maxResults) || 20, 1000);

const users = state.queryUsers(
{},
{ sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken },
);
const queryTenantId = ctx.params.query.tenantId;
let users: UserInfo[];

// Get the accounts from the tenant when tenantId is specified in the query.
if (queryTenantId && state instanceof AgentProjectState) {
const tenant = state.getTenantProject(queryTenantId);
users = tenant.queryUsers(
{},
{ sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken },
);
} else {
users = state.queryUsers(
{},
{ sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken },
);
}
let newPageToken: string | undefined = undefined;

// As a non-standard behavior, passing in maxResults=-1 will return all users.
Expand Down
29 changes: 28 additions & 1 deletion src/emulator/hubExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { IMPORT_EXPORT_EMULATORS, Emulators, ALL_EMULATORS } from "./types";
import { EmulatorRegistry } from "./registry";
import { FirebaseError } from "../error";
import { EmulatorHub } from "./hub";
import { Tenant } from "./auth/state";
import { getDownloadDetails } from "./downloadableEmulators";
import { DatabaseEmulator } from "./databaseEmulator";
import { DataConnectEmulator } from "./dataconnectEmulator";
Expand Down Expand Up @@ -253,10 +254,36 @@ export class HubExport {
fs.mkdirSync(authExportPath);
}

const tenantsRes = await EmulatorRegistry.client(Emulators.AUTH).get<{
tenants: Array<Tenant>;
}>(`/identitytoolkit.googleapis.com/v2/projects/${this.projectId}/tenants`, {
headers: { Authorization: "Bearer owner" },
});
const tenants = tenantsRes.body.tenants.map((instance: Tenant) => instance.tenantId);

// Export accounts from other tenants.
for (const tenantId of tenants) {
const accountsFile = path.join(authExportPath, `accounts-${tenantId}.json`);
logger.debug(
`Exporting auth users in Project ${this.projectId} ${tenantId} tenant to ${accountsFile}`,
);
await fetchToFile(
{
host,
port,
path: `/identitytoolkit.googleapis.com/v1/projects/${this.projectId}/accounts:batchGet?maxResults=-1&tenantId=${tenantId}`,
headers: { Authorization: "Bearer owner" },
},
accountsFile,
);
}

// TODO: Shall we support exporting other projects too?

const accountsFile = path.join(authExportPath, "accounts.json");
logger.debug(`Exporting auth users in Project ${this.projectId} to ${accountsFile}`);
logger.debug(
`Exporting auth users in Project ${this.projectId} default tenant to ${accountsFile}`,
);
await fetchToFile(
{
host,
Expand Down
Loading