diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa4dfa0291..1f21dd98eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/scripts/emulator-import-export-tests/tests.ts b/scripts/emulator-import-export-tests/tests.ts index 55c6f8b86bf..a7f7e7acf96 100644 --- a/scripts/emulator-import-export-tests/tests.ts +++ b/scripts/emulator-import-export-tests/tests.ts @@ -331,6 +331,169 @@ describe("import/export end to end", () => { } }); + 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", + ); + + 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)); diff --git a/src/emulator/auth/index.ts b/src/emulator/auth/index.ts index fd293b3e92a..ca2ba1b3f08 100644 --- a/src/emulator/auth/index.ts +++ b/src/emulator/auth/index.ts @@ -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.`, + ); + } } } } diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts index d37de9c26cc..22f8e0c1847 100644 --- a/src/emulator/auth/operations.ts +++ b/src/emulator/auth/operations.ts @@ -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. diff --git a/src/emulator/hubExport.ts b/src/emulator/hubExport.ts index 07cc6a38e57..33c22e18e5e 100644 --- a/src/emulator/hubExport.ts +++ b/src/emulator/hubExport.ts @@ -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"; @@ -253,10 +254,36 @@ export class HubExport { fs.mkdirSync(authExportPath); } + const tenantsRes = await EmulatorRegistry.client(Emulators.AUTH).get<{ + tenants: Array; + }>(`/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,