From ebb206ec5408097d45b10b9c19bffd80f0279153 Mon Sep 17 00:00:00 2001 From: aalej Date: Fri, 4 Aug 2023 01:11:35 +0800 Subject: [PATCH 1/5] Fix batchGet endpoint to accept tenantId query --- src/emulator/auth/operations.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts index 128c4d98838..0c24844caa4 100644 --- a/src/emulator/auth/operations.ts +++ b/src/emulator/auth/operations.ts @@ -557,11 +557,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. From 2dfccb37b745252e5090da8127793237b0de624d Mon Sep 17 00:00:00 2001 From: aalej Date: Fri, 4 Aug 2023 01:15:04 +0800 Subject: [PATCH 2/5] Get either tenantId or tid query --- src/emulator/auth/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emulator/auth/handlers.ts b/src/emulator/auth/handlers.ts index 17ceb8482f9..979cc474f97 100644 --- a/src/emulator/auth/handlers.ts +++ b/src/emulator/auth/handlers.ts @@ -172,7 +172,7 @@ export function registerHandlers( res.set("Content-Type", "text/html; charset=utf-8"); const apiKey = req.query.apiKey as string | undefined; const providerId = req.query.providerId as string | undefined; - const tenantId = req.query.tenantId as string | undefined; + const tenantId = (req.query.tenantId || req.query.tid) as string | undefined; if (!apiKey || !providerId) { return res.status(400).json({ authEmulator: { From a4b9751610a7461fb05afdb90adfa8ca80830e48 Mon Sep 17 00:00:00 2001 From: aalej Date: Fri, 4 Aug 2023 01:15:52 +0800 Subject: [PATCH 3/5] Fix auth emulator multi-tenant import/export --- CHANGELOG.md | 1 + src/emulator/auth/index.ts | 56 +++++++++++++++++++++----------------- src/emulator/hubExport.ts | 29 +++++++++++++++++++- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8179f86f8..863e833d80d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ - Increase Next.js config bundle timeout to 60 seconds (#6214) +- Fixes issue where auth emulator multi-tenant mode exports/imports only users tied to the default tenant (#5623) diff --git a/src/emulator/auth/index.ts b/src/emulator/auth/index.ts index 7b34615220c..eb17c2b7b81 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/hubExport.ts b/src/emulator/hubExport.ts index 6065c63fbf9..4decb12ca68 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 * as rimraf from "rimraf"; @@ -228,10 +229,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, From bc00a1be2f66b3200d892fb6d172d91f2d7db3a9 Mon Sep 17 00:00:00 2001 From: aalej Date: Tue, 17 Sep 2024 22:54:03 +0800 Subject: [PATCH 4/5] ran formatter --- src/emulator/auth/index.ts | 4 ++-- src/emulator/auth/operations.ts | 4 ++-- src/emulator/hubExport.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/emulator/auth/index.ts b/src/emulator/auth/index.ts index 262cd5a541e..ca2ba1b3f08 100644 --- a/src/emulator/auth/index.ts +++ b/src/emulator/auth/index.ts @@ -125,13 +125,13 @@ export class AuthEmulator implements EmulatorInstance { }, accountsPath, // Ignore the error when there are no users. No action needed. - { ignoreErrors: ["MISSING_USER_ACCOUNT"] } + { ignoreErrors: ["MISSING_USER_ACCOUNT"] }, ); } else { logger.logLabeled( "WARN", "auth", - `Skipped importing accounts because ${accountsPath} does not exist.` + `Skipped importing accounts because ${accountsPath} does not exist.`, ); } } diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts index 0e1af947ea3..3cf2e6c5bf5 100644 --- a/src/emulator/auth/operations.ts +++ b/src/emulator/auth/operations.ts @@ -565,12 +565,12 @@ function batchGet( const tenant = state.getTenantProject(queryTenantId); users = tenant.queryUsers( {}, - { sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken } + { sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken }, ); } else { users = state.queryUsers( {}, - { sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken } + { sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken }, ); } let newPageToken: string | undefined = undefined; diff --git a/src/emulator/hubExport.ts b/src/emulator/hubExport.ts index 07fa3f68347..ba87110c735 100644 --- a/src/emulator/hubExport.ts +++ b/src/emulator/hubExport.ts @@ -243,7 +243,7 @@ export class HubExport { 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}` + `Exporting auth users in Project ${this.projectId} ${tenantId} tenant to ${accountsFile}`, ); await fetchToFile( { @@ -252,7 +252,7 @@ export class HubExport { path: `/identitytoolkit.googleapis.com/v1/projects/${this.projectId}/accounts:batchGet?maxResults=-1&tenantId=${tenantId}`, headers: { Authorization: "Bearer owner" }, }, - accountsFile + accountsFile, ); } @@ -260,7 +260,7 @@ export class HubExport { const accountsFile = path.join(authExportPath, "accounts.json"); logger.debug( - `Exporting auth users in Project ${this.projectId} default tenant to ${accountsFile}` + `Exporting auth users in Project ${this.projectId} default tenant to ${accountsFile}`, ); await fetchToFile( { From e05e071bcaa72e7b1690800d97e28fd27fadc51d Mon Sep 17 00:00:00 2001 From: aalej Date: Wed, 18 Sep 2024 01:26:17 +0800 Subject: [PATCH 5/5] Added integration test for auth multi-tenant import/export --- scripts/emulator-import-export-tests/tests.ts | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/scripts/emulator-import-export-tests/tests.ts b/scripts/emulator-import-export-tests/tests.ts index 6ed4133b637..2750f180e1b 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));