Skip to content

Implement exchangeToken public api #9039

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 23, 2025
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