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/auth.auth.md b/docs-devsite/auth.auth.md index 1f96bd23881..170447bc43c 100644 --- a/docs-devsite/auth.auth.md +++ b/docs-devsite/auth.auth.md @@ -31,7 +31,7 @@ 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. | ## Methods @@ -123,7 +123,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: diff --git a/docs-devsite/auth.dependencies.md b/docs-devsite/auth.dependencies.md index b80bd24af20..d5b098cbedd 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..9b8a294ede4 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 a Firebase 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. | @@ -405,6 +406,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 a Firebase 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/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..751e51c4c2c --- /dev/null +++ b/packages/auth/src/api/authentication/exchange_token.ts @@ -0,0 +1,49 @@ +/** + * @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 ExchangeTokenResponse { + accessToken: string; + expiresIn?: string; +} + +export async function exchangeToken( + auth: Auth, + request: ExchangeTokenRequest +): Promise { + return _performRegionalApiRequest< + ExchangeTokenRequest, + ExchangeTokenResponse + >( + 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..7e18b7d44e0 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -93,7 +93,8 @@ export const enum DefaultConfig { TOKEN_API_HOST = 'securetoken.googleapis.com', API_HOST = 'identitytoolkit.googleapis.com', API_SCHEME = 'https', - REGIONAL_API_HOST = 'identityplatform.googleapis.com' + // TODO(sammansi): Update the endpoint before BYO-CIAM Private Preview Release. + REGIONAL_API_HOST = 'identityplatform.googleapis.com/v2alpha/' } export class AuthImpl implements AuthInternal, _FirebaseService { 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..60166034b64 --- /dev/null +++ b/packages/auth/src/core/strategies/exchange_token.test.ts @@ -0,0 +1,110 @@ +/** + * @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(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..6b4c11b8017 --- /dev/null +++ b/packages/auth/src/core/strategies/exhange_token.ts @@ -0,0 +1,69 @@ +/** + * @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 a Firebase 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 + }); + // TODO(sammansi): Write token to the Auth object passed. + 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/public_types.ts b/packages/auth/src/model/public_types.ts index bd6c9cc2b8c..353064af467 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; @@ -1269,7 +1269,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..1877371bf2e 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,14 @@ 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}`; + console.log('here ', url); + 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 };