Skip to content
3 changes: 3 additions & 0 deletions common/api-review/auth.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,9 @@ export interface EmulatorConfig {

export { ErrorFn }

// @public (undocumented)
export function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise<string>;

// Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.d.ts
//
// @public
Expand Down
4 changes: 2 additions & 2 deletions docs-devsite/auth.auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>Auth</code> 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 <code>DefaultConfig.REGIONAL_API_HOST</code> backend endpoint is used. |
| [tenantId](./auth.auth.md#authtenantid) | string \| null | The [Auth](./auth.auth.md#auth_interface) instance's tenant ID. |

## Methods
Expand Down Expand Up @@ -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.

<b>Signature:</b>

Expand Down
4 changes: 2 additions & 2 deletions docs-devsite/auth.dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>Persistence</code> that the device supports is used. The SDK searches for an existing account in order and, if one is found in a secondary <code>Persistence</code>, the account is moved to the primary <code>Persistence</code>.<!-- -->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 <code>DefaultConfig.REGIONAL_API_HOST</code> endpoint. It should not be set otherwise. |

## Dependencies.errorMap

Expand Down Expand Up @@ -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.

<b>Signature:</b>

Expand Down
29 changes: 29 additions & 0 deletions docs-devsite/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -405,6 +406,34 @@ export declare function createUserWithEmailAndPassword(auth: Auth, email: string

Promise&lt;[UserCredential](./auth.usercredential.md#usercredential_interface)<!-- -->&gt;

### 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.

<b>Signature:</b>

```typescript
export declare function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise<string>;
```

#### 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. |

<b>Returns:</b>

Promise&lt;string&gt;

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.
Expand Down
102 changes: 102 additions & 0 deletions packages/auth/src/api/authentication/exchange_token.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
49 changes: 49 additions & 0 deletions packages/auth/src/api/authentication/exchange_token.ts
Original file line number Diff line number Diff line change
@@ -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<ExchangeTokenResponse> {
return _performRegionalApiRequest<
ExchangeTokenRequest,
ExchangeTokenResponse
>(
auth,
HttpMethod.POST,
RegionalEndpoint.EXCHANGE_TOKEN,
request,
{},
request.parent
);
}
107 changes: 99 additions & 8 deletions packages/auth/src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,6 +39,7 @@ import { ConfigInternal } from '../model/auth';
import {
_getFinalTarget,
_performApiRequest,
_performRegionalApiRequest,
DEFAULT_API_TIMEOUT_MS,
Endpoint,
RegionalEndpoint,
Expand Down Expand Up @@ -604,26 +608,113 @@ 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<typeof request, typeof serverResponse>(
auth,
regionalAuth,
HttpMethod.POST,
RegionalEndpoint.EXCHANGE_TOKEN,
Endpoint.SIGN_UP,
request
)
).to.be.rejectedWith(
FirebaseError,
'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<typeof request, typeof serverResponse>(
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<typeof request, typeof serverResponse>(
regionalAuth,
_performRegionalApiRequest<typeof request, typeof serverResponse>(
auth,
HttpMethod.POST,
Endpoint.SIGN_UP,
RegionalEndpoint.EXCHANGE_TOKEN,
request
)
).to.be.rejectedWith(
Expand Down
Loading
Loading