Skip to content

Commit ffec9ba

Browse files
authored
Implement exchangeToken public api (#9039)
* Throw Operation Not Allowed for Invalid Auth Endpoint (#9013) (#9019) * Throw Operation Not Allowed for Invalid Auth Endpoint (#9013) (#9019) * Implement exchangeToken public api * Using expiry_in returned by backend * Addressing review comments and adding some Unit test * Docgen * Addressing review comments and adding some Unit test * Typo * yarn docgen * Fixing typo * yarn run format
1 parent a3dc91a commit ffec9ba

File tree

15 files changed

+544
-46
lines changed

15 files changed

+544
-46
lines changed

common/api-review/auth.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,9 @@ export interface EmulatorConfig {
364364

365365
export { ErrorFn }
366366

367+
// @public (undocumented)
368+
export function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise<string>;
369+
367370
// Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.d.ts
368371
//
369372
// @public

docs-devsite/auth.auth.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface Auth
3131
| [languageCode](./auth.auth.md#authlanguagecode) | string \| null | The [Auth](./auth.auth.md#auth_interface) instance's language code. |
3232
| [name](./auth.auth.md#authname) | string | The name of the app associated with the <code>Auth</code> service instance. |
3333
| [settings](./auth.auth.md#authsettings) | [AuthSettings](./auth.authsettings.md#authsettings_interface) | The [Auth](./auth.auth.md#auth_interface) instance's settings. |
34-
| [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. |
34+
| [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. |
3535
| [tenantId](./auth.auth.md#authtenantid) | string \| null | The [Auth](./auth.auth.md#auth_interface) instance's tenant ID. |
3636

3737
## Methods
@@ -123,7 +123,7 @@ readonly settings: AuthSettings;
123123

124124
## Auth.tenantConfig
125125

126-
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.
126+
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.
127127

128128
<b>Signature:</b>
129129

docs-devsite/auth.dependencies.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface Dependencies
2929
| [errorMap](./auth.dependencies.md#dependencieserrormap) | [AuthErrorMap](./auth.autherrormap.md#autherrormap_interface) | Which [AuthErrorMap](./auth.autherrormap.md#autherrormap_interface) to use. |
3030
| [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)<!-- -->. |
3131
| [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. |
32-
| [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. |
32+
| [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. |
3333

3434
## Dependencies.errorMap
3535

@@ -65,7 +65,7 @@ popupRedirectResolver?: PopupRedirectResolver;
6565

6666
## Dependencies.tenantConfig
6767

68-
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.
68+
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.
6969

7070
<b>Signature:</b>
7171

docs-devsite/auth.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Firebase Authentication
2828
| [confirmPasswordReset(auth, oobCode, newPassword)](./auth.md#confirmpasswordreset_749dad8) | Completes the password reset process, given a confirmation code and new password. |
2929
| [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. |
3030
| [createUserWithEmailAndPassword(auth, email, password)](./auth.md#createuserwithemailandpassword_21ad33b) | Creates a new user account associated with the specified email address and password. |
31+
| [exchangeToken(auth, idpConfigId, customToken)](./auth.md#exchangetoken_b6b1871) | Asynchronously exchanges an OIDC provider's Authorization code or Id Token for a Firebase Token. |
3132
| [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. |
3233
| [getMultiFactorResolver(auth, error)](./auth.md#getmultifactorresolver_201ba61) | Provides a [MultiFactorResolver](./auth.multifactorresolver.md#multifactorresolver_interface) suitable for completion of a multi-factor flow. |
3334
| [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
405406

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

409+
### exchangeToken(auth, idpConfigId, customToken) {:#exchangetoken_b6b1871}
410+
411+
Asynchronously exchanges an OIDC provider's Authorization code or Id Token for a Firebase Token.
412+
413+
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.
414+
415+
Fails with an error if the token is invalid, expired, or not accepted by the Firebase Auth service.
416+
417+
<b>Signature:</b>
418+
419+
```typescript
420+
export declare function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise<string>;
421+
```
422+
423+
#### Parameters
424+
425+
| Parameter | Type | Description |
426+
| --- | --- | --- |
427+
| auth | [Auth](./auth.auth.md#auth_interface) | The [Auth](./auth.auth.md#auth_interface) instance. |
428+
| idpConfigId | string | The ExternalUserDirectoryId corresponding to the OIDC custom Token. |
429+
| customToken | string | The OIDC provider's Authorization code or Id Token to exchange. |
430+
431+
<b>Returns:</b>
432+
433+
Promise&lt;string&gt;
434+
435+
The firebase access token (JWT signed by Firebase Auth).
436+
408437
### fetchSignInMethodsForEmail(auth, email) {:#fetchsigninmethodsforemail_efb3887}
409438

410439
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.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect, use } from 'chai';
19+
import chaiAsPromised from 'chai-as-promised';
20+
21+
import {
22+
regionalTestAuth,
23+
testAuth,
24+
TestAuth
25+
} from '../../../test/helpers/mock_auth';
26+
import * as mockFetch from '../../../test/helpers/mock_fetch';
27+
import { mockRegionalEndpointWithParent } from '../../../test/helpers/api/helper';
28+
import { exchangeToken } from './exchange_token';
29+
import { HttpHeader, RegionalEndpoint } from '..';
30+
import { FirebaseError } from '@firebase/util';
31+
import { ServerError } from '../errors';
32+
33+
use(chaiAsPromised);
34+
35+
describe('api/authentication/exchange_token', () => {
36+
let auth: TestAuth;
37+
let regionalAuth: TestAuth;
38+
const request = {
39+
parent: 'test-parent',
40+
token: 'custom-token'
41+
};
42+
43+
beforeEach(async () => {
44+
auth = await testAuth();
45+
regionalAuth = await regionalTestAuth();
46+
mockFetch.setUp();
47+
});
48+
49+
afterEach(mockFetch.tearDown);
50+
51+
it('returns accesss token for Regional Auth', async () => {
52+
const mock = mockRegionalEndpointWithParent(
53+
RegionalEndpoint.EXCHANGE_TOKEN,
54+
'test-parent',
55+
{ accessToken: 'outbound-token', expiresIn: '1000' }
56+
);
57+
58+
const response = await exchangeToken(regionalAuth, request);
59+
expect(response.accessToken).equal('outbound-token');
60+
expect(response.expiresIn).equal('1000');
61+
expect(mock.calls[0].request).to.eql({
62+
parent: 'test-parent',
63+
token: 'custom-token'
64+
});
65+
expect(mock.calls[0].method).to.eq('POST');
66+
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
67+
'application/json'
68+
);
69+
});
70+
71+
it('throws exception for default Auth', async () => {
72+
await expect(exchangeToken(auth, request)).to.be.rejectedWith(
73+
FirebaseError,
74+
'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).'
75+
);
76+
});
77+
78+
it('should handle errors', async () => {
79+
const mock = mockRegionalEndpointWithParent(
80+
RegionalEndpoint.EXCHANGE_TOKEN,
81+
'test-parent',
82+
{
83+
error: {
84+
code: 400,
85+
message: ServerError.INVALID_CUSTOM_TOKEN,
86+
errors: [
87+
{
88+
message: ServerError.INVALID_CUSTOM_TOKEN
89+
}
90+
]
91+
}
92+
},
93+
400
94+
);
95+
96+
await expect(exchangeToken(regionalAuth, request)).to.be.rejectedWith(
97+
FirebaseError,
98+
'(auth/invalid-custom-token).'
99+
);
100+
expect(mock.calls[0].request).to.eql(request);
101+
});
102+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import {
18+
RegionalEndpoint,
19+
HttpMethod,
20+
_performRegionalApiRequest
21+
} from '../index';
22+
import { Auth } from '../../model/public_types';
23+
24+
export interface ExchangeTokenRequest {
25+
parent: string;
26+
token: string;
27+
}
28+
29+
export interface ExchangeTokenResponse {
30+
accessToken: string;
31+
expiresIn?: string;
32+
}
33+
34+
export async function exchangeToken(
35+
auth: Auth,
36+
request: ExchangeTokenRequest
37+
): Promise<ExchangeTokenResponse> {
38+
return _performRegionalApiRequest<
39+
ExchangeTokenRequest,
40+
ExchangeTokenResponse
41+
>(
42+
auth,
43+
HttpMethod.POST,
44+
RegionalEndpoint.EXCHANGE_TOKEN,
45+
request,
46+
{},
47+
request.parent
48+
);
49+
}

packages/auth/src/api/index.test.ts

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import sinonChai from 'sinon-chai';
2424
import { FirebaseError, getUA } from '@firebase/util';
2525
import * as utils from '@firebase/util';
2626

27-
import { mockEndpoint } from '../../test/helpers/api/helper';
27+
import {
28+
mockEndpoint,
29+
mockRegionalEndpointWithParent
30+
} from '../../test/helpers/api/helper';
2831
import {
2932
regionalTestAuth,
3033
testAuth,
@@ -36,6 +39,7 @@ import { ConfigInternal } from '../model/auth';
3639
import {
3740
_getFinalTarget,
3841
_performApiRequest,
42+
_performRegionalApiRequest,
3943
DEFAULT_API_TIMEOUT_MS,
4044
Endpoint,
4145
RegionalEndpoint,
@@ -604,26 +608,113 @@ describe('api/_performApiRequest', () => {
604608
});
605609

606610
context('throws Operation not allowed exception', () => {
607-
it('when tenantConfig is not initialized and Regional Endpoint is used', async () => {
611+
it('when tenantConfig is initialized and default Endpoint is used', async () => {
608612
await expect(
609613
_performApiRequest<typeof request, typeof serverResponse>(
610-
auth,
614+
regionalAuth,
611615
HttpMethod.POST,
612-
RegionalEndpoint.EXCHANGE_TOKEN,
616+
Endpoint.SIGN_UP,
613617
request
614618
)
615619
).to.be.rejectedWith(
616620
FirebaseError,
617621
'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).'
618622
);
619623
});
624+
});
625+
});
620626

621-
it('when tenantConfig is initialized and default Endpoint is used', async () => {
627+
describe('api/_performRegionalApiRequest', () => {
628+
const request = {
629+
requestKey: 'request-value'
630+
};
631+
632+
const serverResponse = {
633+
responseKey: 'response-value'
634+
};
635+
636+
let auth: TestAuth;
637+
let regionalAuth: TestAuth;
638+
639+
beforeEach(async () => {
640+
auth = await testAuth();
641+
regionalAuth = await regionalTestAuth();
642+
});
643+
644+
afterEach(() => {
645+
sinon.restore();
646+
});
647+
648+
context('with regular requests', () => {
649+
beforeEach(mockFetch.setUp);
650+
afterEach(mockFetch.tearDown);
651+
it('should set the correct request, method and HTTP Headers', async () => {
652+
const mock = mockRegionalEndpointWithParent(
653+
RegionalEndpoint.EXCHANGE_TOKEN,
654+
'test-parent',
655+
serverResponse
656+
);
657+
const response = await _performRegionalApiRequest<
658+
typeof request,
659+
typeof serverResponse
660+
>(
661+
regionalAuth,
662+
HttpMethod.POST,
663+
RegionalEndpoint.EXCHANGE_TOKEN,
664+
request,
665+
{},
666+
'test-parent'
667+
);
668+
expect(response).to.eql(serverResponse);
669+
expect(mock.calls.length).to.eq(1);
670+
expect(mock.calls[0].method).to.eq(HttpMethod.POST);
671+
expect(mock.calls[0].request).to.eql(request);
672+
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
673+
'application/json'
674+
);
675+
expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq(
676+
'testSDK/0.0.0'
677+
);
678+
expect(mock.calls[0].fullRequest?.credentials).to.be.undefined;
679+
});
680+
681+
it('should include whatever headers the auth impl attaches', async () => {
682+
sinon.stub(regionalAuth, '_getAdditionalHeaders').returns(
683+
Promise.resolve({
684+
'look-at-me-im-a-header': 'header-value',
685+
'anotherheader': 'header-value-2'
686+
})
687+
);
688+
689+
const mock = mockRegionalEndpointWithParent(
690+
RegionalEndpoint.EXCHANGE_TOKEN,
691+
'test-parent',
692+
serverResponse
693+
);
694+
await _performRegionalApiRequest<typeof request, typeof serverResponse>(
695+
regionalAuth,
696+
HttpMethod.POST,
697+
RegionalEndpoint.EXCHANGE_TOKEN,
698+
request,
699+
{},
700+
'test-parent'
701+
);
702+
expect(mock.calls[0].headers.get('look-at-me-im-a-header')).to.eq(
703+
'header-value'
704+
);
705+
expect(mock.calls[0].headers.get('anotherheader')).to.eq(
706+
'header-value-2'
707+
);
708+
});
709+
});
710+
711+
context('throws Operation not allowed exception', () => {
712+
it('when tenantConfig is not initialized and Regional Endpoint is used', async () => {
622713
await expect(
623-
_performApiRequest<typeof request, typeof serverResponse>(
624-
regionalAuth,
714+
_performRegionalApiRequest<typeof request, typeof serverResponse>(
715+
auth,
625716
HttpMethod.POST,
626-
Endpoint.SIGN_UP,
717+
RegionalEndpoint.EXCHANGE_TOKEN,
627718
request
628719
)
629720
).to.be.rejectedWith(

0 commit comments

Comments
 (0)