diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 27203e95f3a..ef28f329f56 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -364,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 diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index 088facef70f..4515bb46fb7 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -306,6 +306,8 @@ toc: 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 1f96bd23881..502cf53628e 100644 --- a/docs-devsite/auth.auth.md +++ b/docs-devsite/auth.auth.md @@ -31,8 +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 backend endpoint is used. | +| [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 @@ -123,7 +124,7 @@ 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 backend endpoint is used. +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: @@ -156,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 b80bd24af20..2f63a9c6509 100644 --- a/docs-devsite/auth.dependencies.md +++ b/docs-devsite/auth.dependencies.md @@ -29,7 +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 endpoint. It should not be set otherwise. | +| [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 @@ -65,7 +65,7 @@ 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 endpoint. It should not be set otherwise. +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: diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 6e87e5c1110..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. | @@ -138,6 +139,7 @@ Firebase Authentication | [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. | @@ -405,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.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 87f674807c0..614956abb4e 100644 --- a/packages/auth/src/api/index.test.ts +++ b/packages/auth/src/api/index.test.ts @@ -24,7 +24,10 @@ 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 { + mockEndpoint, + mockRegionalEndpointWithParent +} from '../../test/helpers/api/helper'; import { regionalTestAuth, testAuth, @@ -36,6 +39,7 @@ import { ConfigInternal } from '../model/auth'; import { _getFinalTarget, _performApiRequest, + _performRegionalApiRequest, DEFAULT_API_TIMEOUT_MS, Endpoint, RegionalEndpoint, @@ -604,12 +608,12 @@ describe('api/_performApiRequest', () => { }); context('throws Operation not allowed exception', () => { - it('when tenantConfig is not initialized and Regional Endpoint is used', async () => { + it('when tenantConfig is initialized and default Endpoint is used', async () => { await expect( _performApiRequest( - auth, + regionalAuth, HttpMethod.POST, - RegionalEndpoint.EXCHANGE_TOKEN, + Endpoint.SIGN_UP, request ) ).to.be.rejectedWith( @@ -617,13 +621,100 @@ describe('api/_performApiRequest', () => { 'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).' ); }); + }); +}); - it('when tenantConfig is initialized and default Endpoint is used', async () => { +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( - _performApiRequest( - regionalAuth, + _performRegionalApiRequest( + auth, HttpMethod.POST, - Endpoint.SIGN_UP, + RegionalEndpoint.EXCHANGE_TOKEN, request ) ).to.be.rejectedWith( diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index a1480803449..ec3c7662194 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -54,7 +54,7 @@ export const enum HttpHeader { X_FIREBASE_APP_CHECK = 'X-Firebase-AppCheck' } -export enum Endpoint { +export const enum Endpoint { CREATE_AUTH_URI = '/v1/accounts:createAuthUri', DELETE_ACCOUNT = '/v1/accounts:delete', RESET_PASSWORD = '/v1/accounts:resetPassword', @@ -81,8 +81,11 @@ export enum Endpoint { REVOKE_TOKEN = '/v2/accounts:revokeToken' } -export enum RegionalEndpoint { - EXCHANGE_TOKEN = 'v2/${body.parent}:exchangeOidcToken' +export const EXCHANGE_TOKEN_PARENT = + 'projects/${projectId}/locations/${location}/tenants/${tenantId}/idpConfigs/${idpConfigId}'; + +export const enum RegionalEndpoint { + EXCHANGE_TOKEN = ':exchangeOidcToken' } const CookieAuthProxiedEndpoints: string[] = [ @@ -141,14 +144,17 @@ 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 | RegionalEndpoint, + path: string, request?: T, customErrorMap: Partial> = {} ): Promise { - _assertValidEndpointForAuth(auth, path); return _performFetchWithErrorHandling(auth, customErrorMap, async () => { let body = {}; let params = {}; @@ -162,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'; @@ -193,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>, @@ -287,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 @@ -328,22 +374,6 @@ export function _parseEnforcementState( } } -function _assertValidEndpointForAuth( - auth: Auth, - path: Endpoint | RegionalEndpoint -): void { - if ( - !auth.tenantConfig && - Object.values(RegionalEndpoint).includes(path as RegionalEndpoint) - ) { - throw _operationNotSupportedForInitializedAuthInstance(auth); - } - - if (auth.tenantConfig && Object.values(Endpoint).includes(path as Endpoint)) { - throw _operationNotSupportedForInitializedAuthInstance(auth); - } -} - class NetworkTimeout { // Node timers and browser timers are fundamentally incompatible, but we // don't care about the value here diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index d21cfdd0214..5fcaf2eb1ca 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -37,7 +37,8 @@ import { NextFn, Unsubscribe, PasswordValidationStatus, - TenantConfig + TenantConfig, + TokenResponse } from '../../model/public_types'; import { createSubscribe, @@ -93,12 +94,13 @@ export const enum DefaultConfig { TOKEN_API_HOST = 'securetoken.googleapis.com', API_HOST = 'identitytoolkit.googleapis.com', API_SCHEME = 'https', - REGIONAL_API_HOST = 'identityplatform.googleapis.com' + 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; @@ -454,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( @@ -621,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/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/model/auth.ts b/packages/auth/src/model/auth.ts index 60faedef4e6..4928c17ad5a 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -24,6 +24,7 @@ import { PasswordValidationStatus, PopupRedirectResolver, TenantConfig, + TokenResponse, User } from './public_types'; import { ErrorFactory } from '@firebase/util'; @@ -66,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; @@ -75,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; diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index bd6c9cc2b8c..99fec4e9a5e 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -185,7 +185,7 @@ export interface Auth { readonly config: Config; /** * The {@link TenantConfig} used to initialize a Regional Auth. This is only present - * if regional auth is initialized and {@link DefaultConfig.REGIONAL_API_HOST} + * if regional auth is initialized and `DefaultConfig.REGIONAL_API_HOST` * backend endpoint is used. */ readonly tenantConfig?: TenantConfig; @@ -334,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; } /** @@ -966,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. * @@ -1269,7 +1289,7 @@ export interface Dependencies { /** * The {@link TenantConfig} to use. This dependency is only required * if you want to use regional auth which works with - * {@link DefaultConfig.REGIONAL_API_HOST} endpoint. It should not be set otherwise. + * `DefaultConfig.REGIONAL_API_HOST`` endpoint. It should not be set otherwise. */ tenantConfig?: TenantConfig; } 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 15c03dc42c1..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 };