diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 0c9625a90e9..ef28f329f56 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -95,6 +95,7 @@ export interface Auth { setPersistence(persistence: Persistence): Promise; readonly settings: AuthSettings; signOut(): Promise; + readonly tenantConfig?: TenantConfig; tenantId: string | null; updateCurrentUser(user: User | null): Promise; useDeviceLanguage(): void; @@ -316,6 +317,7 @@ export interface Dependencies { errorMap?: AuthErrorMap; persistence?: Persistence | Persistence[]; popupRedirectResolver?: PopupRedirectResolver; + tenantConfig?: TenantConfig; } // @public @@ -362,6 +364,9 @@ export interface EmulatorConfig { export { ErrorFn } +// @public (undocumented) +export function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise; + // Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.d.ts // // @public @@ -795,6 +800,12 @@ export function signInWithRedirect(auth: Auth, provider: AuthProvider, resolver? // @public export function signOut(auth: Auth): Promise; +// @public +export interface TenantConfig { + location: string; + tenantId: string; +} + // @public export interface TotpMultiFactorAssertion extends MultiFactorAssertion { } diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index b77a6b5910e..4515bb46fb7 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -304,6 +304,10 @@ toc: path: /docs/reference/js/auth.recaptchaverifier.md - title: SAMLAuthProvider path: /docs/reference/js/auth.samlauthprovider.md + - title: TenantConfig + path: /docs/reference/js/auth.tenantconfig.md + - title: TokenResponse + path: /docs/reference/js/auth.tokenresponse.md - title: TotpMultiFactorAssertion path: /docs/reference/js/auth.totpmultifactorassertion.md - title: TotpMultiFactorGenerator diff --git a/docs-devsite/auth.auth.md b/docs-devsite/auth.auth.md index cbbc7a9ceb0..502cf53628e 100644 --- a/docs-devsite/auth.auth.md +++ b/docs-devsite/auth.auth.md @@ -31,7 +31,9 @@ export interface Auth | [languageCode](./auth.auth.md#authlanguagecode) | string \| null | The [Auth](./auth.auth.md#auth_interface) instance's language code. | | [name](./auth.auth.md#authname) | string | The name of the app associated with the Auth service instance. | | [settings](./auth.auth.md#authsettings) | [AuthSettings](./auth.authsettings.md#authsettings_interface) | The [Auth](./auth.auth.md#auth_interface) instance's settings. | +| [tenantConfig](./auth.auth.md#authtenantconfig) | [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) | The [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) used to initialize a Regional Auth. This is only present if regional auth is initialized and DefaultConfig.REGIONAL_API_HOST backend endpoint is used. | | [tenantId](./auth.auth.md#authtenantid) | string \| null | The [Auth](./auth.auth.md#auth_interface) instance's tenant ID. | +| [tokenResponse](./auth.auth.md#authtokenresponse) | [TokenResponse](./auth.tokenresponse.md#tokenresponse_interface) \| null | The token response initialized via [exchangeToken()](./auth.md#exchangetoken_b6b1871) endpoint. | ## Methods @@ -120,6 +122,16 @@ This is used to edit/read configuration related options such as app verification readonly settings: AuthSettings; ``` +## Auth.tenantConfig + +The [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) used to initialize a Regional Auth. This is only present if regional auth is initialized and `DefaultConfig.REGIONAL_API_HOST` backend endpoint is used. + +Signature: + +```typescript +readonly tenantConfig?: TenantConfig; +``` + ## Auth.tenantId The [Auth](./auth.auth.md#auth_interface) instance's tenant ID. @@ -145,6 +157,18 @@ const result = await signInWithEmailAndPassword(auth, email, password); ``` +## Auth.tokenResponse + +The token response initialized via [exchangeToken()](./auth.md#exchangetoken_b6b1871) endpoint. + +This field is only supported for [Auth](./auth.auth.md#auth_interface) instance that have defined [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface). + +Signature: + +```typescript +readonly tokenResponse: TokenResponse | null; +``` + ## Auth.authStateReady() returns a promise that resolves immediately when the initial auth state is settled. When the promise resolves, the current user might be a valid user or `null` if the user signed out. diff --git a/docs-devsite/auth.dependencies.md b/docs-devsite/auth.dependencies.md index 8d212b7aa1d..2f63a9c6509 100644 --- a/docs-devsite/auth.dependencies.md +++ b/docs-devsite/auth.dependencies.md @@ -29,6 +29,7 @@ export interface Dependencies | [errorMap](./auth.dependencies.md#dependencieserrormap) | [AuthErrorMap](./auth.autherrormap.md#autherrormap_interface) | Which [AuthErrorMap](./auth.autherrormap.md#autherrormap_interface) to use. | | [persistence](./auth.dependencies.md#dependenciespersistence) | [Persistence](./auth.persistence.md#persistence_interface) \| [Persistence](./auth.persistence.md#persistence_interface)\[\] | Which [Persistence](./auth.persistence.md#persistence_interface) to use. If this is an array, the first Persistence that the device supports is used. The SDK searches for an existing account in order and, if one is found in a secondary Persistence, the account is moved to the primary Persistence.If no persistence is provided, the SDK falls back on [inMemoryPersistence](./auth.md#inmemorypersistence). | | [popupRedirectResolver](./auth.dependencies.md#dependenciespopupredirectresolver) | [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) | The [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) to use. This value depends on the platform. Options are [browserPopupRedirectResolver](./auth.md#browserpopupredirectresolver) and [cordovaPopupRedirectResolver](./auth.md#cordovapopupredirectresolver). This field is optional if neither [signInWithPopup()](./auth.md#signinwithpopup_770f816) or [signInWithRedirect()](./auth.md#signinwithredirect_770f816) are being used. | +| [tenantConfig](./auth.dependencies.md#dependenciestenantconfig) | [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) | The [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) to use. This dependency is only required if you want to use regional auth which works with DefaultConfig.REGIONAL_API_HOST\` endpoint. It should not be set otherwise. | ## Dependencies.errorMap @@ -61,3 +62,13 @@ The [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolve ```typescript popupRedirectResolver?: PopupRedirectResolver; ``` + +## Dependencies.tenantConfig + +The [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) to use. This dependency is only required if you want to use regional auth which works with `DefaultConfig.REGIONAL_API_HOST` endpoint. It should not be set otherwise. + +Signature: + +```typescript +tenantConfig?: TenantConfig; +``` diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 1b3938ef4eb..4e357662166 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -28,6 +28,7 @@ Firebase Authentication | [confirmPasswordReset(auth, oobCode, newPassword)](./auth.md#confirmpasswordreset_749dad8) | Completes the password reset process, given a confirmation code and new password. | | [connectAuthEmulator(auth, url, options)](./auth.md#connectauthemulator_657c7e5) | Changes the [Auth](./auth.auth.md#auth_interface) instance to communicate with the Firebase Auth Emulator, instead of production Firebase Auth services. | | [createUserWithEmailAndPassword(auth, email, password)](./auth.md#createuserwithemailandpassword_21ad33b) | Creates a new user account associated with the specified email address and password. | +| [exchangeToken(auth, idpConfigId, customToken)](./auth.md#exchangetoken_b6b1871) | Asynchronously exchanges an OIDC provider's Authorization code or Id Token for an OidcToken i.e. Outbound Access Token. | | [fetchSignInMethodsForEmail(auth, email)](./auth.md#fetchsigninmethodsforemail_efb3887) | Gets the list of possible sign in methods for the given email address. This method returns an empty list when [Email Enumeration Protection](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) is enabled, irrespective of the number of authentication methods available for the given email. | | [getMultiFactorResolver(auth, error)](./auth.md#getmultifactorresolver_201ba61) | Provides a [MultiFactorResolver](./auth.multifactorresolver.md#multifactorresolver_interface) suitable for completion of a multi-factor flow. | | [getRedirectResult(auth, resolver)](./auth.md#getredirectresult_c35dc1f) | Returns a [UserCredential](./auth.usercredential.md#usercredential_interface) from the redirect-based sign-in flow. | @@ -137,6 +138,8 @@ Firebase Authentication | [PopupRedirectResolver](./auth.popupredirectresolver.md#popupredirectresolver_interface) | A resolver used for handling DOM specific operations like [signInWithPopup()](./auth.md#signinwithpopup_770f816) or [signInWithRedirect()](./auth.md#signinwithredirect_770f816). | | [ReactNativeAsyncStorage](./auth.reactnativeasyncstorage.md#reactnativeasyncstorage_interface) | Interface for a supplied AsyncStorage. | | [RecaptchaParameters](./auth.recaptchaparameters.md#recaptchaparameters_interface) | Interface representing reCAPTCHA parameters.See the [reCAPTCHA docs](https://developers.google.com/recaptcha/docs/display#render_param) for the list of accepted parameters. All parameters are accepted except for sitekey: Firebase Auth provisions a reCAPTCHA for each project and will configure the site key upon rendering.For an invisible reCAPTCHA, set the size key to invisible. | +| [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) | The tenant config that can be used to initialize a Regional [Auth](./auth.auth.md#auth_interface) instance. | +| [TokenResponse](./auth.tokenresponse.md#tokenresponse_interface) | Interface for TokenRespone returned via [exchangeToken()](./auth.md#exchangetoken_b6b1871) endpoint. This is expected to be returned only if [Auth](./auth.auth.md#auth_interface) object initialized has defined [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface). | | [TotpMultiFactorAssertion](./auth.totpmultifactorassertion.md#totpmultifactorassertion_interface) | The class for asserting ownership of a TOTP second factor. Provided by [TotpMultiFactorGenerator.assertionForEnrollment()](./auth.totpmultifactorgenerator.md#totpmultifactorgeneratorassertionforenrollment) and [TotpMultiFactorGenerator.assertionForSignIn()](./auth.totpmultifactorgenerator.md#totpmultifactorgeneratorassertionforsignin). | | [TotpMultiFactorInfo](./auth.totpmultifactorinfo.md#totpmultifactorinfo_interface) | The subclass of the [MultiFactorInfo](./auth.multifactorinfo.md#multifactorinfo_interface) interface for TOTP second factors. The factorId of this second factor is [FactorId](./auth.md#factorid).TOTP. | | [User](./auth.user.md#user_interface) | A user account. | @@ -404,6 +407,34 @@ export declare function createUserWithEmailAndPassword(auth: Auth, email: string Promise<[UserCredential](./auth.usercredential.md#usercredential_interface)> +### exchangeToken(auth, idpConfigId, customToken) {:#exchangetoken_b6b1871} + +Asynchronously exchanges an OIDC provider's Authorization code or Id Token for an OidcToken i.e. Outbound Access Token. + +This method is implemented only for `DefaultConfig.REGIONAL_API_HOST` and requires [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface) to be configured in the [Auth](./auth.auth.md#auth_interface) instance used. + +Fails with an error if the token is invalid, expired, or not accepted by the Firebase Auth service. + +Signature: + +```typescript +export declare function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise; +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| auth | [Auth](./auth.auth.md#auth_interface) | The [Auth](./auth.auth.md#auth_interface) instance. | +| idpConfigId | string | The ExternalUserDirectoryId corresponding to the OIDC custom Token. | +| customToken | string | The OIDC provider's Authorization code or Id Token to exchange. | + +Returns: + +Promise<string> + +The firebase access token (JWT signed by Firebase Auth). + ### fetchSignInMethodsForEmail(auth, email) {:#fetchsigninmethodsforemail_efb3887} Gets the list of possible sign in methods for the given email address. This method returns an empty list when [Email Enumeration Protection](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) is enabled, irrespective of the number of authentication methods available for the given email. diff --git a/docs-devsite/auth.tenantconfig.md b/docs-devsite/auth.tenantconfig.md new file mode 100644 index 00000000000..8ac9d0c7c51 --- /dev/null +++ b/docs-devsite/auth.tenantconfig.md @@ -0,0 +1,46 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# TenantConfig interface +The tenant config that can be used to initialize a Regional [Auth](./auth.auth.md#auth_interface) instance. + +Signature: + +```typescript +export interface TenantConfig +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [location](./auth.tenantconfig.md#tenantconfiglocation) | string | Which location to use. | +| [tenantId](./auth.tenantconfig.md#tenantconfigtenantid) | string | The tenant Id being used. | + +## TenantConfig.location + +Which location to use. + +Signature: + +```typescript +location: string; +``` + +## TenantConfig.tenantId + +The tenant Id being used. + +Signature: + +```typescript +tenantId: string; +``` diff --git a/docs-devsite/auth.tokenresponse.md b/docs-devsite/auth.tokenresponse.md new file mode 100644 index 00000000000..540b529cd7d --- /dev/null +++ b/docs-devsite/auth.tokenresponse.md @@ -0,0 +1,42 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# TokenResponse interface +Interface for TokenRespone returned via [exchangeToken()](./auth.md#exchangetoken_b6b1871) endpoint. This is expected to be returned only if [Auth](./auth.auth.md#auth_interface) object initialized has defined [TenantConfig](./auth.tenantconfig.md#tenantconfig_interface). + +Signature: + +```typescript +export interface TokenResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [expiresIn](./auth.tokenresponse.md#tokenresponseexpiresin) | string | | +| [token](./auth.tokenresponse.md#tokenresponsetoken) | string | | + +## TokenResponse.expiresIn + +Signature: + +```typescript +readonly expiresIn: string; +``` + +## TokenResponse.token + +Signature: + +```typescript +readonly token: string; +``` diff --git a/packages/auth/src/api/authentication/exchange_token.test.ts b/packages/auth/src/api/authentication/exchange_token.test.ts new file mode 100644 index 00000000000..6a3b1b366e8 --- /dev/null +++ b/packages/auth/src/api/authentication/exchange_token.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { + regionalTestAuth, + testAuth, + TestAuth +} from '../../../test/helpers/mock_auth'; +import * as mockFetch from '../../../test/helpers/mock_fetch'; +import { mockRegionalEndpointWithParent } from '../../../test/helpers/api/helper'; +import { exchangeToken } from './exchange_token'; +import { HttpHeader, RegionalEndpoint } from '..'; +import { FirebaseError } from '@firebase/util'; +import { ServerError } from '../errors'; + +use(chaiAsPromised); + +describe('api/authentication/exchange_token', () => { + let auth: TestAuth; + let regionalAuth: TestAuth; + const request = { + parent: 'test-parent', + token: 'custom-token' + }; + + beforeEach(async () => { + auth = await testAuth(); + regionalAuth = await regionalTestAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('returns accesss token for Regional Auth', async () => { + const mock = mockRegionalEndpointWithParent( + RegionalEndpoint.EXCHANGE_TOKEN, + 'test-parent', + { accessToken: 'outbound-token', expiresIn: '1000' } + ); + + const response = await exchangeToken(regionalAuth, request); + expect(response.accessToken).equal('outbound-token'); + expect(response.expiresIn).equal('1000'); + expect(mock.calls[0].request).to.eql({ + parent: 'test-parent', + token: 'custom-token' + }); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + }); + + it('throws exception for default Auth', async () => { + await expect(exchangeToken(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).' + ); + }); + + it('should handle errors', async () => { + const mock = mockRegionalEndpointWithParent( + RegionalEndpoint.EXCHANGE_TOKEN, + 'test-parent', + { + error: { + code: 400, + message: ServerError.INVALID_CUSTOM_TOKEN, + errors: [ + { + message: ServerError.INVALID_CUSTOM_TOKEN + } + ] + } + }, + 400 + ); + + await expect(exchangeToken(regionalAuth, request)).to.be.rejectedWith( + FirebaseError, + '(auth/invalid-custom-token).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages/auth/src/api/authentication/exchange_token.ts b/packages/auth/src/api/authentication/exchange_token.ts new file mode 100644 index 00000000000..88fc32af54a --- /dev/null +++ b/packages/auth/src/api/authentication/exchange_token.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + RegionalEndpoint, + HttpMethod, + _performRegionalApiRequest +} from '../index'; +import { Auth } from '../../model/public_types'; + +export interface ExchangeTokenRequest { + parent: string; + token: string; +} + +export interface ExchangeTokenRespose { + accessToken: string; + expiresInSec: string; +} + +export async function exchangeToken( + auth: Auth, + request: ExchangeTokenRequest +): Promise { + return _performRegionalApiRequest( + auth, + HttpMethod.POST, + RegionalEndpoint.EXCHANGE_TOKEN, + request, + {}, + request.parent + ); +} diff --git a/packages/auth/src/api/index.test.ts b/packages/auth/src/api/index.test.ts index 02042fce429..614956abb4e 100644 --- a/packages/auth/src/api/index.test.ts +++ b/packages/auth/src/api/index.test.ts @@ -24,16 +24,25 @@ import sinonChai from 'sinon-chai'; import { FirebaseError, getUA } from '@firebase/util'; import * as utils from '@firebase/util'; -import { mockEndpoint } from '../../test/helpers/api/helper'; -import { testAuth, TestAuth } from '../../test/helpers/mock_auth'; +import { + mockEndpoint, + mockRegionalEndpointWithParent +} from '../../test/helpers/api/helper'; +import { + regionalTestAuth, + testAuth, + TestAuth +} from '../../test/helpers/mock_auth'; import * as mockFetch from '../../test/helpers/mock_fetch'; import { AuthErrorCode } from '../core/errors'; import { ConfigInternal } from '../model/auth'; import { _getFinalTarget, _performApiRequest, + _performRegionalApiRequest, DEFAULT_API_TIMEOUT_MS, Endpoint, + RegionalEndpoint, HttpHeader, HttpMethod, _addTidIfNecessary @@ -55,9 +64,11 @@ describe('api/_performApiRequest', () => { }; let auth: TestAuth; + let regionalAuth: TestAuth; beforeEach(async () => { auth = await testAuth(); + regionalAuth = await regionalTestAuth(); }); afterEach(() => { @@ -595,4 +606,121 @@ describe('api/_performApiRequest', () => { .and.not.have.property('tenantId'); }); }); + + context('throws Operation not allowed exception', () => { + it('when tenantConfig is initialized and default Endpoint is used', async () => { + await expect( + _performApiRequest( + regionalAuth, + HttpMethod.POST, + Endpoint.SIGN_UP, + request + ) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).' + ); + }); + }); +}); + +describe('api/_performRegionalApiRequest', () => { + const request = { + requestKey: 'request-value' + }; + + const serverResponse = { + responseKey: 'response-value' + }; + + let auth: TestAuth; + let regionalAuth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + regionalAuth = await regionalTestAuth(); + }); + + afterEach(() => { + sinon.restore(); + }); + + context('with regular requests', () => { + beforeEach(mockFetch.setUp); + afterEach(mockFetch.tearDown); + it('should set the correct request, method and HTTP Headers', async () => { + const mock = mockRegionalEndpointWithParent( + RegionalEndpoint.EXCHANGE_TOKEN, + 'test-parent', + serverResponse + ); + const response = await _performRegionalApiRequest< + typeof request, + typeof serverResponse + >( + regionalAuth, + HttpMethod.POST, + RegionalEndpoint.EXCHANGE_TOKEN, + request, + {}, + 'test-parent' + ); + expect(response).to.eql(serverResponse); + expect(mock.calls.length).to.eq(1); + expect(mock.calls[0].method).to.eq(HttpMethod.POST); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq( + 'testSDK/0.0.0' + ); + expect(mock.calls[0].fullRequest?.credentials).to.be.undefined; + }); + + it('should include whatever headers the auth impl attaches', async () => { + sinon.stub(regionalAuth, '_getAdditionalHeaders').returns( + Promise.resolve({ + 'look-at-me-im-a-header': 'header-value', + 'anotherheader': 'header-value-2' + }) + ); + + const mock = mockRegionalEndpointWithParent( + RegionalEndpoint.EXCHANGE_TOKEN, + 'test-parent', + serverResponse + ); + await _performRegionalApiRequest( + regionalAuth, + HttpMethod.POST, + RegionalEndpoint.EXCHANGE_TOKEN, + request, + {}, + 'test-parent' + ); + expect(mock.calls[0].headers.get('look-at-me-im-a-header')).to.eq( + 'header-value' + ); + expect(mock.calls[0].headers.get('anotherheader')).to.eq( + 'header-value-2' + ); + }); + }); + + context('throws Operation not allowed exception', () => { + it('when tenantConfig is not initialized and Regional Endpoint is used', async () => { + await expect( + _performRegionalApiRequest( + auth, + HttpMethod.POST, + RegionalEndpoint.EXCHANGE_TOKEN, + request + ) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).' + ); + }); + }); }); diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index af9b3c63bf1..ec3c7662194 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -26,6 +26,7 @@ import { AuthErrorCode, NamedErrorParams } from '../core/errors'; import { _createError, _errorWithCustomMessage, + _operationNotSupportedForInitializedAuthInstance, _fail } from '../core/util/assert'; import { Delay } from '../core/util/delay'; @@ -80,6 +81,13 @@ export const enum Endpoint { REVOKE_TOKEN = '/v2/accounts:revokeToken' } +export const EXCHANGE_TOKEN_PARENT = + 'projects/${projectId}/locations/${location}/tenants/${tenantId}/idpConfigs/${idpConfigId}'; + +export const enum RegionalEndpoint { + EXCHANGE_TOKEN = ':exchangeOidcToken' +} + const CookieAuthProxiedEndpoints: string[] = [ Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN, Endpoint.SIGN_IN_WITH_EMAIL_LINK, @@ -136,10 +144,14 @@ export function _addTidIfNecessary( return request; } -export async function _performApiRequest( +function isRegionalAuthInitialized(auth: Auth): boolean { + return !!auth.tenantConfig; +} + +async function performApiRequest( auth: Auth, method: HttpMethod, - path: Endpoint, + path: string, request?: T, customErrorMap: Partial> = {} ): Promise { @@ -156,10 +168,17 @@ export async function _performApiRequest( } } - const query = querystring({ - key: auth.config.apiKey, - ...params - }).slice(1); + let queryParamString: string; + if (isRegionalAuthInitialized(auth)) { + queryParamString = querystring({ + ...params + }).slice(1); + } else { + queryParamString = querystring({ + key: auth.config.apiKey, + ...params + }).slice(1); + } const headers = await (auth as AuthInternal)._getAdditionalHeaders(); headers[HttpHeader.CONTENT_TYPE] = 'application/json'; @@ -187,12 +206,45 @@ export async function _performApiRequest( } return FetchProvider.fetch()( - await _getFinalTarget(auth, auth.config.apiHost, path, query), + await _getFinalTarget(auth, auth.config.apiHost, path, queryParamString), fetchArgs ); }); } +export async function _performRegionalApiRequest( + auth: Auth, + method: HttpMethod, + path: RegionalEndpoint, + request?: T, + customErrorMap: Partial> = {}, + parent?: string +): Promise { + if (!isRegionalAuthInitialized(auth)) { + throw _operationNotSupportedForInitializedAuthInstance(auth); + } + return performApiRequest( + auth, + method, + `${parent}${path}`, + request, + customErrorMap + ); +} + +export async function _performApiRequest( + auth: Auth, + method: HttpMethod, + path: Endpoint, + request?: T, + customErrorMap: Partial> = {} +): Promise { + if (isRegionalAuthInitialized(auth)) { + throw _operationNotSupportedForInitializedAuthInstance(auth); + } + return performApiRequest(auth, method, `${path}`, request, customErrorMap); +} + export async function _performFetchWithErrorHandling( auth: Auth, customErrorMap: Partial>, @@ -281,9 +333,9 @@ export async function _getFinalTarget( auth: Auth, host: string, path: string, - query: string + query?: string ): Promise { - const base = `${host}${path}?${query}`; + const base = query ? `${host}${path}?${query}` : `${host}${path}`; const authInternal = auth as AuthInternal; const finalTarget = authInternal.config.emulator diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index 4a718702110..5fcaf2eb1ca 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -36,7 +36,9 @@ import { ErrorFn, NextFn, Unsubscribe, - PasswordValidationStatus + PasswordValidationStatus, + TenantConfig, + TokenResponse } from '../../model/public_types'; import { createSubscribe, @@ -91,12 +93,14 @@ interface AsyncAction { export const enum DefaultConfig { TOKEN_API_HOST = 'securetoken.googleapis.com', API_HOST = 'identitytoolkit.googleapis.com', - API_SCHEME = 'https' + API_SCHEME = 'https', + REGIONAL_API_HOST = 'identityplatform.googleapis.com/v2alpha/' } export class AuthImpl implements AuthInternal, _FirebaseService { currentUser: User | null = null; emulatorConfig: EmulatorConfig | null = null; + tokenResponse: TokenResponse | null = null; private operations = Promise.resolve(); private persistenceManager?: PersistenceUserManager; private redirectPersistenceManager?: PersistenceUserManager; @@ -125,6 +129,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { | undefined = undefined; _persistenceManagerAvailable: Promise; readonly name: string; + readonly tenantConfig?: TenantConfig; // Tracks the last notified UID for state change listeners to prevent // repeated calls to the callbacks. Undefined means it's never been @@ -139,7 +144,8 @@ export class AuthImpl implements AuthInternal, _FirebaseService { public readonly app: FirebaseApp, private readonly heartbeatServiceProvider: Provider<'heartbeat'>, private readonly appCheckServiceProvider: Provider, - public readonly config: ConfigInternal + public readonly config: ConfigInternal, + tenantConfig?: TenantConfig ) { this.name = app.name; this.clientVersion = config.sdkClientVersion; @@ -148,6 +154,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { this._persistenceManagerAvailable = new Promise( resolve => (this._resolvePersistenceManagerAvailable = resolve) ); + this.tenantConfig = tenantConfig; } _initializeWithPersistence( @@ -449,6 +456,12 @@ export class AuthImpl implements AuthInternal, _FirebaseService { }); } + async _updateTokenResponse(tokenResponse: TokenResponse): Promise { + if (tokenResponse) { + this.tokenResponse = tokenResponse; + } + } + async signOut(): Promise { if (_isFirebaseServerApp(this.app)) { return Promise.reject( @@ -616,6 +629,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } } + async getTokenForRegionalAuth(): + Promise { + if (Date.now() > this.tokenResponse?.expiresIn) + } + toJSON(): object { return { apiKey: this.config.apiKey, diff --git a/packages/auth/src/core/auth/firebase_internal.ts b/packages/auth/src/core/auth/firebase_internal.ts index 4fad0754375..9c267d9cc33 100644 --- a/packages/auth/src/core/auth/firebase_internal.ts +++ b/packages/auth/src/core/auth/firebase_internal.ts @@ -43,6 +43,9 @@ export class AuthInterop implements FirebaseAuthInternal { ): Promise<{ accessToken: string } | null> { this.assertAuthConfigured(); await this.auth._initializationPromise; + if (this.auth.tenantConfig) { + return this.getTokenRegionalAuth(); + } if (!this.auth.currentUser) { return null; } @@ -51,6 +54,23 @@ export class AuthInterop implements FirebaseAuthInternal { return { accessToken }; } + async getTokenRegionalAuth() : + Promise<{ accessToken: string } | null> { + this.assertRegionalAuthConfigured(); + if (!this.auth.tokenResponse) { + return null; + } + + if (!this.auth.tokenResponse.expirationTime || + Date.now() > this.auth.tokenResponse.expirationTime) { + await this.auth._updateTokenResponse(null); + return null; + } + + const accessToken = await this.auth.tokenResponse.token; + return { accessToken }; + } + addAuthTokenListener(listener: TokenListener): void { this.assertAuthConfigured(); if (this.internalListeners.has(listener)) { @@ -85,6 +105,10 @@ export class AuthInterop implements FirebaseAuthInternal { ); } + private assertRegionalAuthConfigured(): void { + _assert(this.auth.tenantConfig, AuthErrorCode.OPERATION_NOT_ALLOWED); + } + private updateProactiveRefresh(): void { if (this.internalListeners.size > 0) { this.auth._startProactiveRefresh(); diff --git a/packages/auth/src/core/auth/initialize.test.ts b/packages/auth/src/core/auth/initialize.test.ts index f2d4d24c887..a09efd8f46c 100644 --- a/packages/auth/src/core/auth/initialize.test.ts +++ b/packages/auth/src/core/auth/initialize.test.ts @@ -26,6 +26,7 @@ import { AuthProvider, Persistence as PersistencePublic, PopupRedirectResolver, + TenantConfig, UserCredential } from '../../model/public_types'; import { isNode } from '@firebase/util'; @@ -51,6 +52,7 @@ import { ClientPlatform, _getClientVersion } from '../util/version'; import { initializeAuth } from './initialize'; import { registerAuth } from './register'; import { debugErrorMap, prodErrorMap } from '../errors'; +import { DefaultConfig } from './auth_impl'; describe('core/auth/initialize', () => { let fakeApp: FirebaseApp; @@ -132,6 +134,11 @@ describe('core/auth/initialize', () => { const fakePopupRedirectResolver: PopupRedirectResolver = FakePopupRedirectResolver; + const fakeTenantConfig: TenantConfig = { + 'location': 'us', + 'tenantId': 'tenant-1' + }; + before(() => { registerAuth(ClientPlatform.BROWSER); }); @@ -202,6 +209,15 @@ describe('core/auth/initialize', () => { ); }); + it('should set TenantConfig', async () => { + const auth = initializeAuth(fakeApp, { + tenantConfig: fakeTenantConfig + }) as AuthInternal; + await auth._initializationPromise; + + expect(auth.config.apiHost).equal(DefaultConfig.REGIONAL_API_HOST); + }); + it('should abort initialization if deleted synchronously', async () => { const auth = initializeAuth(fakeApp, { popupRedirectResolver: fakePopupRedirectResolver @@ -221,13 +237,15 @@ describe('core/auth/initialize', () => { const auth = initializeAuth(fakeApp, { errorMap: prodErrorMap, persistence: fakeSessionPersistence, - popupRedirectResolver: fakePopupRedirectResolver + popupRedirectResolver: fakePopupRedirectResolver, + tenantConfig: fakeTenantConfig }); expect( initializeAuth(fakeApp, { errorMap: prodErrorMap, persistence: fakeSessionPersistence, - popupRedirectResolver: fakePopupRedirectResolver + popupRedirectResolver: fakePopupRedirectResolver, + tenantConfig: fakeTenantConfig }) ).to.equal(auth); }); @@ -264,5 +282,16 @@ describe('core/auth/initialize', () => { }) ).to.throw(); }); + + it('should throw if called again with different params (TenantConfig)', () => { + initializeAuth(fakeApp, { + tenantConfig: fakeTenantConfig + }); + expect(() => + initializeAuth(fakeApp, { + tenantConfig: undefined + }) + ).to.throw(); + }); }); }); diff --git a/packages/auth/src/core/auth/register.ts b/packages/auth/src/core/auth/register.ts index 9d0d6b4559d..efe1d63ab16 100644 --- a/packages/auth/src/core/auth/register.ts +++ b/packages/auth/src/core/auth/register.ts @@ -68,6 +68,7 @@ export function registerAuth(clientPlatform: ClientPlatform): void { const appCheckServiceProvider = container.getProvider<'app-check-internal'>('app-check-internal'); const { apiKey, authDomain } = app.options; + const tenantConfig = deps?.tenantConfig; _assert( apiKey && !apiKey.includes(':'), @@ -79,7 +80,9 @@ export function registerAuth(clientPlatform: ClientPlatform): void { apiKey, authDomain, clientPlatform, - apiHost: DefaultConfig.API_HOST, + apiHost: tenantConfig?.location + ? DefaultConfig.REGIONAL_API_HOST + : DefaultConfig.API_HOST, tokenApiHost: DefaultConfig.TOKEN_API_HOST, apiScheme: DefaultConfig.API_SCHEME, sdkClientVersion: _getClientVersion(clientPlatform) @@ -89,7 +92,8 @@ export function registerAuth(clientPlatform: ClientPlatform): void { app, heartbeatServiceProvider, appCheckServiceProvider, - config + config, + tenantConfig ); _initializeAuthInstance(authInstance, deps); diff --git a/packages/auth/src/core/index.ts b/packages/auth/src/core/index.ts index 43b1adb4bb9..e3b0e3b55a1 100644 --- a/packages/auth/src/core/index.ts +++ b/packages/auth/src/core/index.ts @@ -315,6 +315,7 @@ export { sendEmailVerification, verifyBeforeUpdateEmail } from './strategies/email'; +export { exchangeToken } from './strategies/exhange_token'; // core export { ActionCodeURL, parseActionCodeURL } from './action_code_url'; diff --git a/packages/auth/src/core/strategies/exchange_token.test.ts b/packages/auth/src/core/strategies/exchange_token.test.ts new file mode 100644 index 00000000000..1548735de2d --- /dev/null +++ b/packages/auth/src/core/strategies/exchange_token.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { mockRegionalEndpointWithParent } from '../../../test/helpers/api/helper'; +import { + regionalTestAuth, + testAuth, + TestAuth +} from '../../../test/helpers/mock_auth'; +import * as mockFetch from '../../../test/helpers/mock_fetch'; +import { HttpHeader, RegionalEndpoint } from '../../api'; +import { exchangeToken } from './exhange_token'; +import { FirebaseError } from '@firebase/util'; +import { ServerError } from '../../api/errors'; + +use(chaiAsPromised); + +describe('core/strategies/exchangeToken', () => { + let auth: TestAuth; + let regionalAuth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + regionalAuth = await regionalTestAuth(); + mockFetch.setUp(); + }); + afterEach(mockFetch.tearDown); + + it('should return a valid access token for Regional Auth', async () => { + const mock = mockRegionalEndpointWithParent( + RegionalEndpoint.EXCHANGE_TOKEN, + 'projects/test-project-id/locations/us/tenants/tenant-1/idpConfigs/idp-config', + { accessToken: 'outbound-token', expiresIn: '1000' } + ); + + const accessToken = await exchangeToken( + regionalAuth, + 'idp-config', + 'custom-token' + ); + expect(accessToken).to.eq('outbound-token'); + expect(mock.calls[0].request).to.eql({ + parent: + 'projects/test-project-id/locations/us/tenants/tenant-1/idpConfigs/idp-config', + token: 'custom-token' + }); + expect(regionalAuth.tokenResponse?.token).to.equal('outbound-token'); + expect(regionalAuth.tokenResponse?.expiresIn).to.equal('1000'); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + }); + + it('throws exception for default Auth', async () => { + await expect( + exchangeToken(auth, 'idp-config', 'custom-token') + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).' + ); + }); + + it('should handle errors', async () => { + const mock = mockRegionalEndpointWithParent( + RegionalEndpoint.EXCHANGE_TOKEN, + 'projects/test-project-id/locations/us/tenants/tenant-1/idpConfigs/idp-config', + { + error: { + code: 400, + message: ServerError.INVALID_CUSTOM_TOKEN, + errors: [ + { + message: ServerError.INVALID_CUSTOM_TOKEN + } + ] + } + }, + 400 + ); + + await expect( + exchangeToken(regionalAuth, 'idp-config', 'custom-token') + ).to.be.rejectedWith(FirebaseError, '(auth/invalid-custom-token).'); + expect(mock.calls[0].request).to.eql({ + parent: + 'projects/test-project-id/locations/us/tenants/tenant-1/idpConfigs/idp-config', + token: 'custom-token' + }); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + }); +}); diff --git a/packages/auth/src/core/strategies/exhange_token.ts b/packages/auth/src/core/strategies/exhange_token.ts new file mode 100644 index 00000000000..c5fb88816a2 --- /dev/null +++ b/packages/auth/src/core/strategies/exhange_token.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Auth } from '../../model/public_types'; +import { _isFirebaseServerApp } from '@firebase/app'; +import { exchangeToken as getToken } from '../../api/authentication/exchange_token'; +import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; +import { EXCHANGE_TOKEN_PARENT } from '../../api'; +import { _castAuth } from '../auth/auth_impl'; + +/** + * Asynchronously exchanges an OIDC provider's Authorization code or Id Token + * for an OidcToken i.e. Outbound Access Token. + * + * @remarks + * This method is implemented only for `DefaultConfig.REGIONAL_API_HOST` and + * requires {@link TenantConfig} to be configured in the {@link Auth} instance used. + * + * Fails with an error if the token is invalid, expired, or not accepted by the Firebase Auth service. + * + * @param auth - The {@link Auth} instance. + * @param idpConfigId - The ExternalUserDirectoryId corresponding to the OIDC custom Token. + * @param customToken - The OIDC provider's Authorization code or Id Token to exchange. + * @returns The firebase access token (JWT signed by Firebase Auth). + * + * @public + */ +export async function exchangeToken( + auth: Auth, + idpConfigId: string, + customToken: string +): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } + const authInternal = _castAuth(auth); + const token = await getToken(authInternal, { + parent: buildParent(auth, idpConfigId), + token: customToken + }); + if (token) { + await authInternal._updateTokenResponse({ + token: token.accessToken, + expirationTime: Date.now() + Number(token.expiresInSec) * 1000 + }); + } + return token.accessToken; +} + +function buildParent(auth: Auth, idpConfigId: string): string { + return EXCHANGE_TOKEN_PARENT.replace( + '${projectId}', + auth.app.options.projectId ?? '' + ) + .replace('${location}', auth.tenantConfig?.location ?? '') + .replace('${tenantId}', auth.tenantConfig?.tenantId ?? '') + .replace('${idpConfigId}', idpConfigId); +} diff --git a/packages/auth/src/core/util/assert.ts b/packages/auth/src/core/util/assert.ts index 51dff0793e2..eb497fba8aa 100644 --- a/packages/auth/src/core/util/assert.ts +++ b/packages/auth/src/core/util/assert.ts @@ -112,6 +112,16 @@ export function _serverAppCurrentUserOperationNotSupportedError( ); } +export function _operationNotSupportedForInitializedAuthInstance( + auth: Auth +): FirebaseError { + return _errorWithCustomMessage( + auth, + AuthErrorCode.OPERATION_NOT_ALLOWED, + 'Operations not allowed for the auth object initialized.' + ); +} + export function _assertInstanceOf( auth: Auth, object: object, diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts index a88430fd5df..4928c17ad5a 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -23,6 +23,8 @@ import { PasswordPolicy, PasswordValidationStatus, PopupRedirectResolver, + TenantConfig, + TokenResponse, User } from './public_types'; import { ErrorFactory } from '@firebase/util'; @@ -65,6 +67,7 @@ export interface ConfigInternal extends Config { export interface AuthInternal extends Auth { currentUser: User | null; emulatorConfig: EmulatorConfig | null; + tokenResponse: TokenResponse | null; _agentRecaptchaConfig: RecaptchaConfig | null; _tenantRecaptchaConfigs: Record; _projectPasswordPolicy: PasswordPolicy | null; @@ -74,6 +77,7 @@ export interface AuthInternal extends Auth { _initializationPromise: Promise | null; _persistenceManagerAvailable: Promise; _updateCurrentUser(user: UserInternal | null): Promise; + _updateTokenResponse(tokenResponse: TokenResponse | null): Promise; _onStorageEvent(): void; @@ -100,6 +104,7 @@ export interface AuthInternal extends Auth { readonly name: AppName; readonly config: ConfigInternal; + readonly tenantConfig?: TenantConfig; languageCode: string | null; tenantId: string | null; readonly settings: AuthSettings; diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index 43942b93d92..99fec4e9a5e 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -183,6 +183,12 @@ export interface Auth { readonly name: string; /** The {@link Config} used to initialize this instance. */ readonly config: Config; + /** + * The {@link TenantConfig} used to initialize a Regional Auth. This is only present + * if regional auth is initialized and `DefaultConfig.REGIONAL_API_HOST` + * backend endpoint is used. + */ + readonly tenantConfig?: TenantConfig; /** * Changes the type of persistence on the `Auth` instance. * @@ -328,6 +334,14 @@ export interface Auth { * {@link @firebase/app#FirebaseServerApp}. */ signOut(): Promise; + /** + * The token response initialized via {@link exchangeToken} endpoint. + * + * @remarks + * This field is only supported for {@link Auth} instance that have defined + * {@link TenantConfig}. + */ + readonly tokenResponse: TokenResponse | null; } /** @@ -960,6 +974,18 @@ export interface ReactNativeAsyncStorage { removeItem(key: string): Promise; } +/** + * Interface for TokenRespone returned via {@link exchangeToken} endpoint. + * This is expected to be returned only if {@link Auth} object initialized + * has defined {@link TenantConfig}. + */ +export interface TokenResponse { + // The firebase access token (JWT signed by Firebase Auth). + readonly token: string; + // The time when the access token expires. + readonly expirationTime: number; +} + /** * A user account. * @@ -1260,6 +1286,28 @@ export interface Dependencies { * Which {@link AuthErrorMap} to use. */ errorMap?: AuthErrorMap; + /** + * The {@link TenantConfig} to use. This dependency is only required + * if you want to use regional auth which works with + * `DefaultConfig.REGIONAL_API_HOST`` endpoint. It should not be set otherwise. + */ + tenantConfig?: TenantConfig; +} + +/** + * The tenant config that can be used to initialize a Regional {@link Auth} instance. + * + * @public + */ +export interface TenantConfig { + /** + * Which location to use. + */ + location: string; + /** + * The tenant Id being used. + */ + tenantId: string; } /** diff --git a/packages/auth/test/helpers/api/helper.ts b/packages/auth/test/helpers/api/helper.ts index 638310b139e..00680be3341 100644 --- a/packages/auth/test/helpers/api/helper.ts +++ b/packages/auth/test/helpers/api/helper.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Endpoint } from '../../../src/api'; +import { Endpoint, RegionalEndpoint } from '../../../src/api'; import { TEST_HOST, TEST_KEY, TEST_SCHEME } from '../mock_auth'; import { mock, Route } from '../mock_fetch'; @@ -55,3 +55,13 @@ export function mockEndpointWithParams( ): Route { return mock(endpointUrlWithParams(endpoint, params), response, status); } + +export function mockRegionalEndpointWithParent( + endpoint: RegionalEndpoint, + parent: string, + response: object, + status = 200 +): Route { + const url = `${TEST_SCHEME}://${TEST_HOST}${parent}${endpoint}`; + return mock(url, response, status); +} diff --git a/packages/auth/test/helpers/mock_auth.ts b/packages/auth/test/helpers/mock_auth.ts index e5e30fa1384..68e155a88f4 100644 --- a/packages/auth/test/helpers/mock_auth.ts +++ b/packages/auth/test/helpers/mock_auth.ts @@ -42,7 +42,9 @@ export interface TestAuth extends AuthImpl { const FAKE_APP: FirebaseApp = { name: 'test-app', - options: {}, + options: { + projectId: 'test-project-id' + }, automaticDataCollectionEnabled: false }; @@ -116,6 +118,26 @@ export async function testAuth( return auth; } +export async function regionalTestAuth(): Promise { + const tenantConfig = { 'location': 'us', 'tenantId': 'tenant-1' }; + const auth: TestAuth = new AuthImpl( + FAKE_APP, + FAKE_HEARTBEAT_CONTROLLER_PROVIDER, + FAKE_APP_CHECK_CONTROLLER_PROVIDER, + { + apiKey: TEST_KEY, + authDomain: TEST_AUTH_DOMAIN, + apiHost: TEST_HOST, + apiScheme: TEST_SCHEME, + tokenApiHost: TEST_TOKEN_HOST, + clientPlatform: ClientPlatform.BROWSER, + sdkClientVersion: 'testSDK/0.0.0' + }, + tenantConfig + ) as TestAuth; + return auth; +} + export function testUser( auth: AuthInternal, uid: string,