diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 8e979d1aed..0a1f5b438b 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -126,14 +126,15 @@ class AuthResourceUrlBuilder { /** * The resource URL builder constructor. * - * @param projectId - The resource project ID. + * @param app - The app for this URL builder. * @param version - The endpoint API version. + * @param emulatorHost - Optional emulator host captured at init time. * @constructor */ - constructor(protected app: App, protected version: string = 'v1') { - if (useEmulator()) { + constructor(protected app: App, protected version: string = 'v1', emHost?: string) { + if (emHost) { this.urlFormat = utils.formatString(FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT, { - host: emulatorHost() + host: emHost }); } else { this.urlFormat = FIREBASE_AUTH_BASE_URL_FORMAT; @@ -190,16 +191,17 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder { /** * The tenant aware resource URL builder constructor. * - * @param projectId - The resource project ID. + * @param app - The app for this URL builder. * @param version - The endpoint API version. * @param tenantId - The tenant ID. + * @param emHost - Optional emulator host captured at init time. * @constructor */ - constructor(protected app: App, protected version: string, protected tenantId: string) { - super(app, version); - if (useEmulator()) { + constructor(protected app: App, protected version: string, protected tenantId: string, emHost?: string) { + super(app, version, emHost); + if (emHost) { this.urlFormat = utils.formatString(FIREBASE_AUTH_EMULATOR_TENANT_URL_FORMAT, { - host: emulatorHost() + host: emHost }); } else { this.urlFormat = FIREBASE_AUTH_TENANT_URL_FORMAT; @@ -228,8 +230,15 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder { */ class AuthHttpClient extends AuthorizedHttpClient { + private readonly isEmulator: boolean; + + constructor(app: FirebaseApp, isEmulator: boolean) { + super(app); + this.isEmulator = isEmulator; + } + protected getToken(): Promise { - if (useEmulator()) { + if (this.isEmulator) { return Promise.resolve('owner'); } @@ -1013,6 +1022,7 @@ const LIST_INBOUND_SAML_CONFIGS = new ApiSettings('/inboundSamlConfigs', 'GET') export abstract class AbstractAuthRequestHandler { protected readonly httpClient: AuthorizedHttpClient; + public readonly emulatorHostValue: string | undefined; private authUrlBuilder: AuthResourceUrlBuilder; private projectConfigUrlBuilder: AuthResourceUrlBuilder; @@ -1067,9 +1077,12 @@ export abstract class AbstractAuthRequestHandler { /** * @param app - The app used to fetch access tokens to sign API requests. + * @param emHost - Optional emulator host override. When provided (including + * null for explicitly no emulator), this value is used instead of reading + * from the FIREBASE_AUTH_EMULATOR_HOST environment variable. * @constructor */ - constructor(protected readonly app: App) { + constructor(protected readonly app: App, emHost?: string | null) { if (typeof app !== 'object' || app === null || !('options' in app)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -1077,7 +1090,8 @@ export abstract class AbstractAuthRequestHandler { ); } - this.httpClient = new AuthHttpClient(app as FirebaseApp); + this.emulatorHostValue = emHost !== undefined ? (emHost || undefined) : emulatorHost(); + this.httpClient = new AuthHttpClient(app as FirebaseApp, !!this.emulatorHostValue); } /** @@ -2088,21 +2102,21 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { */ constructor(app: App) { super(app); - this.authResourceUrlBuilder = new AuthResourceUrlBuilder(app, 'v2'); + this.authResourceUrlBuilder = new AuthResourceUrlBuilder(app, 'v2', this.emulatorHostValue); } /** * @returns A new Auth user management resource URL builder instance. */ protected newAuthUrlBuilder(): AuthResourceUrlBuilder { - return new AuthResourceUrlBuilder(this.app, 'v1'); + return new AuthResourceUrlBuilder(this.app, 'v1', this.emulatorHostValue); } /** * @returns A new project config resource URL builder instance. */ protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder { - return new AuthResourceUrlBuilder(this.app, 'v2'); + return new AuthResourceUrlBuilder(this.app, 'v2', this.emulatorHostValue); } /** @@ -2259,24 +2273,25 @@ export class TenantAwareAuthRequestHandler extends AbstractAuthRequestHandler { * * @param app - The app used to fetch access tokens to sign API requests. * @param tenantId - The request handler's tenant ID. + * @param emHost - Optional emulator host override captured at init time. * @constructor */ - constructor(app: App, private readonly tenantId: string) { - super(app); + constructor(app: App, private readonly tenantId: string, emHost?: string | null) { + super(app, emHost); } /** * @returns A new Auth user management resource URL builder instance. */ protected newAuthUrlBuilder(): AuthResourceUrlBuilder { - return new TenantAwareAuthResourceUrlBuilder(this.app, 'v1', this.tenantId); + return new TenantAwareAuthResourceUrlBuilder(this.app, 'v1', this.tenantId, this.emulatorHostValue); } /** * @returns A new project config resource URL builder instance. */ protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder { - return new TenantAwareAuthResourceUrlBuilder(this.app, 'v2', this.tenantId); + return new TenantAwareAuthResourceUrlBuilder(this.app, 'v2', this.tenantId, this.emulatorHostValue); } /** diff --git a/src/auth/base-auth.ts b/src/auth/base-auth.ts index 25e1a3db2b..04e335f7da 100644 --- a/src/auth/base-auth.ts +++ b/src/auth/base-auth.ts @@ -116,9 +116,10 @@ export interface SessionCookieOptions { * @internal */ export function createFirebaseTokenGenerator(app: App, - tenantId?: string): FirebaseTokenGenerator { + tenantId?: string, isEmulator?: boolean): FirebaseTokenGenerator { try { - const signer = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app); + const shouldEmulate = isEmulator !== undefined ? isEmulator : useEmulator(); + const signer = shouldEmulate ? new EmulatedSigner() : cryptoSignerFromApp(app); return new FirebaseTokenGenerator(signer, tenantId); } catch (err) { throw handleCryptoSignerError(err); @@ -138,6 +139,7 @@ export abstract class BaseAuth { protected readonly authBlockingTokenVerifier: FirebaseTokenVerifier; /** @internal */ protected readonly sessionCookieVerifier: FirebaseTokenVerifier; + private readonly emulatorMode: boolean; /** * The BaseAuth class constructor. @@ -154,6 +156,8 @@ export abstract class BaseAuth { app: App, /** @internal */ protected readonly authRequestHandler: AbstractAuthRequestHandler, tokenGenerator?: FirebaseTokenGenerator) { + this.emulatorMode = !!this.authRequestHandler.emulatorHostValue; + if (tokenGenerator) { this.tokenGenerator = tokenGenerator; } else { @@ -210,7 +214,7 @@ export abstract class BaseAuth { * promise. */ public verifyIdToken(idToken: string, checkRevoked = false): Promise { - const isEmulator = useEmulator(); + const isEmulator = this.emulatorMode; return this.idTokenVerifier.verifyJWT(idToken, isEmulator) .then((decodedIdToken: DecodedIdToken) => { // Whether to check if the token was revoked. @@ -716,7 +720,7 @@ export abstract class BaseAuth { */ public verifySessionCookie( sessionCookie: string, checkRevoked = false): Promise { - const isEmulator = useEmulator(); + const isEmulator = this.emulatorMode; return this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator) .then((decodedIdToken: DecodedIdToken) => { // Whether to check if the token was revoked. @@ -1094,10 +1098,10 @@ export abstract class BaseAuth { /** @alpha */ // eslint-disable-next-line @typescript-eslint/naming-convention public _verifyAuthBlockingToken( - token: string, + token: string, audience?: string ): Promise { - const isEmulator = useEmulator(); + const isEmulator = this.emulatorMode; return this.authBlockingTokenVerifier._verifyAuthBlockingToken(token, isEmulator, audience) .then((decodedAuthBlockingToken: DecodedAuthBlockingToken) => { return decodedAuthBlockingToken; diff --git a/src/auth/tenant-manager.ts b/src/auth/tenant-manager.ts index 19da5b4c83..0c3048ec0e 100644 --- a/src/auth/tenant-manager.ts +++ b/src/auth/tenant-manager.ts @@ -76,12 +76,15 @@ export class TenantAwareAuth extends BaseAuth { * * @param app - The app that created this tenant. * @param tenantId - The corresponding tenant ID. + * @param emHost - Optional emulator host captured at init time. * @constructor * @internal */ - constructor(app: App, tenantId: string) { + constructor(app: App, tenantId: string, emHost?: string | null) { + const emIsSet = emHost !== undefined; super(app, new TenantAwareAuthRequestHandler( - app, tenantId), createFirebaseTokenGenerator(app, tenantId)); + app, tenantId, emHost), createFirebaseTokenGenerator( + app, tenantId, emIsSet ? !!emHost : undefined)); utils.addReadonlyGetter(this, 'tenantId', tenantId); } @@ -148,6 +151,7 @@ export class TenantAwareAuth extends BaseAuth { export class TenantManager { private readonly authRequestHandler: AuthRequestHandler; private readonly tenantsMap: {[key: string]: TenantAwareAuth}; + private readonly emulatorHost: string | undefined; /** * Initializes a TenantManager instance for a specified FirebaseApp. @@ -159,6 +163,7 @@ export class TenantManager { */ constructor(private readonly app: App) { this.authRequestHandler = new AuthRequestHandler(app); + this.emulatorHost = this.authRequestHandler.emulatorHostValue; this.tenantsMap = {}; } @@ -174,7 +179,7 @@ export class TenantManager { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); } if (typeof this.tenantsMap[tenantId] === 'undefined') { - this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId); + this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId, this.emulatorHost ?? null); } return this.tenantsMap[tenantId]; } diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 5f8e45b458..bbba298700 100644 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -959,6 +959,50 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); }); + + it('should keep using emulator after env var is deleted', () => { + const emulatorHost = 'localhost:9099'; + process.env.FIREBASE_AUTH_EMULATOR_HOST = emulatorHost; + + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + + return requestHandler.getAccountInfoByUid('uid') + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method, + url: `http://${emulatorHost}/identitytoolkit.googleapis.com${path}`, + data, + headers: expectedHeadersEmulator, + timeout, + }); + }); + }); + + it('should not use emulator when env var is set after initialization', () => { + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099'; + + return requestHandler.getAccountInfoByUid('uid') + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method, + url: `https://${host}${path}`, + data, + headers: expectedHeaders, + timeout, + }); + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + }); + }); }); describe('createSessionCookie', () => { diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index b9675e720b..33bda26e61 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -375,6 +375,46 @@ AUTH_CONFIGS.forEach((testConfig) => { expect(tenantManager1).to.equal(tenantManager2); }); }); + + describe('authForTenant() emulator isolation', () => { + afterEach(() => { + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + }); + + it('should not use emulator for tenant auth when env var set after Auth init', async () => { + // Initialize Auth WITHOUT the emulator env var. + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + const noEmulatorAuth = new Auth(mocks.app()); + + // Set the env var AFTER Auth initialization. + process.env.FIREBASE_AUTH_EMULATOR_HOST = '127.0.0.1:9099'; + + // Lazily create tenant auth — should inherit non-emulator mode. + const tenantAuth = noEmulatorAuth.tenantManager().authForTenant('tenant1'); + const token = await tenantAuth.createCustomToken('uid1'); + const decoded = jwt.decode(token, { complete: true }); + + // Token should be signed (not emulator-style unsigned). + expect(decoded).to.have.property('header').that.has.property('alg').that.does.not.equal('none'); + }); + + it('should use emulator for tenant auth when env var set before Auth init', async () => { + // Initialize Auth WITH the emulator env var. + process.env.FIREBASE_AUTH_EMULATOR_HOST = '127.0.0.1:9099'; + const emulatorAuth = new Auth(mocks.app()); + + // Delete the env var AFTER Auth initialization. + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + + // Lazily create tenant auth — should inherit emulator mode. + const tenantAuth = emulatorAuth.tenantManager().authForTenant('tenant1'); + const token = await tenantAuth.createCustomToken('uid1'); + const decoded = jwt.decode(token, { complete: true }); + + // Token should be unsigned (emulator-style). + expect(decoded).to.have.property('header').that.has.property('alg', 'none'); + }); + }); } describe('createCustomToken()', () => { @@ -3973,6 +4013,63 @@ AUTH_CONFIGS.forEach((testConfig) => { await expect(mockAuth.verifyIdToken(unsignedToken)) .to.be.rejectedWith(errorMessage); }); + + it('verifyIdToken() should still work after env var is deleted', () => { + const uid = userRecord.uid; + const oneSecBeforeValidSince = Math.floor(validSince.getTime() / 1000 - 1); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(userRecord); + stubs.push(getUserStub); + + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none', + subject: uid, + header: {}, + }, { + iat: oneSecBeforeValidSince, + auth_time: oneSecBeforeValidSince, + }, 'secret'); + + // Delete the env var after init; emulator mode should persist. + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + + return mockAuth.verifyIdToken(unsignedToken, false) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Should still behave as emulator (force revocation check). + expect(error).to.have.property('code', 'auth/id-token-revoked'); + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + }); + }); + + it('verifySessionCookie() should still work after env var is deleted', () => { + const uid = userRecord.uid; + const oneSecBeforeValidSince = Math.floor(validSince.getTime() / 1000 - 1); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(userRecord); + stubs.push(getUserStub); + + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none', + subject: uid, + issuer: 'https://session.firebase.google.com/' + mocks.projectId, + }, { + iat: oneSecBeforeValidSince, + auth_time: oneSecBeforeValidSince, + }, 'secret'); + + // Delete the env var after init; emulator mode should persist. + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + + return mockAuth.verifySessionCookie(unsignedToken, false) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.have.property('code', 'auth/session-cookie-revoked'); + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + }); + }); }); }); });