Skip to content

Commit f46d09d

Browse files
committed
Implement exchangeToken public api
1 parent 7713281 commit f46d09d

File tree

12 files changed

+432
-47
lines changed

12 files changed

+432
-47
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.md

Lines changed: 21 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) | |
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,26 @@ 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+
<b>Signature:</b>
412+
413+
```typescript
414+
export declare function exchangeToken(auth: Auth, idpConfigId: string, customToken: string): Promise<string>;
415+
```
416+
417+
#### Parameters
418+
419+
| Parameter | Type | Description |
420+
| --- | --- | --- |
421+
| auth | [Auth](./auth.auth.md#auth_interface) | |
422+
| idpConfigId | string | |
423+
| customToken | string | |
424+
425+
<b>Returns:</b>
426+
427+
Promise&lt;string&gt;
428+
408429
### fetchSignInMethodsForEmail(auth, email) {:#fetchsigninmethodsforemail_efb3887}
409430

410431
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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* @license
3+
* Copyright 2020 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 { regionalTestAuth, testAuth, TestAuth } from '../../../test/helpers/mock_auth';
22+
import * as mockFetch from '../../../test/helpers/mock_fetch';
23+
import { mockRegionalEndpointWithParent } from '../../../test/helpers/api/helper';
24+
import { exchangeToken } from './exchange_token';
25+
import { HttpHeader, RegionalEndpoint } from '..';
26+
import { FirebaseError } from '@firebase/util';
27+
import { ServerError } from '../errors';
28+
29+
use(chaiAsPromised);
30+
31+
describe('api/authentication/exchange_token', () => {
32+
let auth: TestAuth;
33+
let regionalAuth: TestAuth;
34+
const request = {
35+
parent: "test-parent",
36+
token: "custom-token"
37+
};
38+
39+
beforeEach(async () => {
40+
auth = await testAuth();
41+
regionalAuth = await regionalTestAuth();
42+
mockFetch.setUp();
43+
});
44+
45+
afterEach(mockFetch.tearDown);
46+
47+
it('returns accesss token for Regional Auth', async() => {
48+
const mock = mockRegionalEndpointWithParent(RegionalEndpoint.EXCHANGE_TOKEN,
49+
"test-parent",
50+
{accessToken: "outbound-token"}
51+
);
52+
53+
const response = await exchangeToken(regionalAuth, request);
54+
expect(response.accessToken).equal("outbound-token");
55+
expect(mock.calls[0].request).to.eql({parent: "test-parent", token: "custom-token"});
56+
expect(mock.calls[0].method).to.eq('POST');
57+
expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq(
58+
'application/json');
59+
});
60+
61+
it('throws exception for default Auth', async() => {
62+
await expect(exchangeToken(auth, request)).to.be.rejectedWith(
63+
FirebaseError,
64+
'Firebase: Operations not allowed for the auth object initialized. (auth/operation-not-allowed).'
65+
);
66+
});
67+
68+
it('should handle errors', async () => {
69+
const mock = mockRegionalEndpointWithParent(
70+
RegionalEndpoint.EXCHANGE_TOKEN,
71+
"test-parent",
72+
{
73+
error: {
74+
code: 400,
75+
message: ServerError.INVALID_CUSTOM_TOKEN,
76+
errors: [
77+
{
78+
message: ServerError.INVALID_CUSTOM_TOKEN
79+
}
80+
]
81+
}
82+
},
83+
400
84+
);
85+
86+
await expect(exchangeToken(regionalAuth, request)).to.be.rejectedWith(
87+
FirebaseError,
88+
'(auth/invalid-custom-token).'
89+
);
90+
expect(mock.calls[0].request).to.eql(request);
91+
});
92+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* @license
3+
* Copyright 2020 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 ExchangeTokenRespose {
30+
accessToken: string;
31+
}
32+
33+
export async function exchangeToken(
34+
auth: Auth,
35+
request: ExchangeTokenRequest
36+
): Promise<ExchangeTokenRespose> {
37+
return _performRegionalApiRequest<
38+
ExchangeTokenRequest,
39+
ExchangeTokenRespose
40+
>(
41+
auth,
42+
HttpMethod.POST,
43+
RegionalEndpoint.EXCHANGE_TOKEN,
44+
request,
45+
{},
46+
request.parent
47+
);
48+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { ConfigInternal } from '../model/auth';
3636
import {
3737
_getFinalTarget,
3838
_performApiRequest,
39+
_performRegionalApiRequest,
3940
DEFAULT_API_TIMEOUT_MS,
4041
Endpoint,
4142
RegionalEndpoint,
@@ -606,7 +607,7 @@ describe('api/_performApiRequest', () => {
606607
context('throws Operation not allowed exception', () => {
607608
it('when tenantConfig is not initialized and Regional Endpoint is used', async () => {
608609
await expect(
609-
_performApiRequest<typeof request, typeof serverResponse>(
610+
_performRegionalApiRequest<typeof request, typeof serverResponse>(
610611
auth,
611612
HttpMethod.POST,
612613
RegionalEndpoint.EXCHANGE_TOKEN,

packages/auth/src/api/index.ts

Lines changed: 87 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,11 @@ export enum Endpoint {
8181
REVOKE_TOKEN = '/v2/accounts:revokeToken'
8282
}
8383

84+
export const EXCHANGE_TOKEN_PARENT =
85+
'projects/${projectId}/locations/${location}/tenants/${tenantId}/idpConfigs/${idpConfigId}';
86+
8487
export enum RegionalEndpoint {
85-
EXCHANGE_TOKEN = 'v2/${body.parent}:exchangeOidcToken'
88+
EXCHANGE_TOKEN = ':exchangeOidcToken'
8689
}
8790

8891
const CookieAuthProxiedEndpoints: string[] = [
@@ -141,24 +144,91 @@ export function _addTidIfNecessary<T extends { tenantId?: string }>(
141144
return request;
142145
}
143146

147+
function isRegionalAuthInitialized(auth: Auth): boolean {
148+
return !!auth.tenantConfig;
149+
}
150+
151+
async function createFetchArgs<T>(
152+
auth: Auth,
153+
method: HttpMethod,
154+
request?: T
155+
): Promise<RequestInit> {
156+
let body = {};
157+
let params = {};
158+
if (request) {
159+
if (method === HttpMethod.GET) {
160+
params = request;
161+
} else {
162+
body = {
163+
body: JSON.stringify(request)
164+
};
165+
}
166+
}
167+
168+
const headers = await (auth as AuthInternal)._getAdditionalHeaders();
169+
headers[HttpHeader.CONTENT_TYPE] = 'application/json';
170+
171+
if (auth.languageCode) {
172+
headers[HttpHeader.X_FIREBASE_LOCALE] = auth.languageCode;
173+
}
174+
175+
const fetchArgs: RequestInit = {
176+
method,
177+
headers,
178+
...body
179+
};
180+
181+
/* Security-conscious server-side frameworks tend to have built in mitigations for referrer
182+
problems". See the Cloudflare GitHub issue #487: Error: The 'referrerPolicy' field on
183+
'RequestInitializerDict' is not implemented."
184+
https://github.com/cloudflare/next-on-pages/issues/487 */
185+
if (!isCloudflareWorker()) {
186+
fetchArgs.referrerPolicy = 'no-referrer';
187+
}
188+
189+
if (auth.emulatorConfig && isCloudWorkstation(auth.emulatorConfig.host)) {
190+
fetchArgs.credentials = 'include';
191+
}
192+
return fetchArgs;
193+
}
194+
195+
export async function _performRegionalApiRequest<T, V>(
196+
auth: Auth,
197+
method: HttpMethod,
198+
path: RegionalEndpoint,
199+
request?: T,
200+
customErrorMap: Partial<ServerErrorMap<ServerError>> = {},
201+
parent?: string
202+
): Promise<V> {
203+
if (!isRegionalAuthInitialized(auth)) {
204+
throw _operationNotSupportedForInitializedAuthInstance(auth);
205+
}
206+
return _performFetchWithErrorHandling(auth, customErrorMap, async () => {
207+
let fullyQualifiedPath = `${parent}${path}`;
208+
const fetchArgs = await createFetchArgs<T>(auth, method, request);
209+
return FetchProvider.fetch()(
210+
`${auth.config.apiScheme}://${auth.config.apiHost}${fullyQualifiedPath}`,
211+
fetchArgs
212+
);
213+
});
214+
}
215+
144216
export async function _performApiRequest<T, V>(
145217
auth: Auth,
146218
method: HttpMethod,
147-
path: Endpoint | RegionalEndpoint,
219+
path: Endpoint,
148220
request?: T,
149221
customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
150222
): Promise<V> {
151-
_assertValidEndpointForAuth(auth, path);
223+
if (isRegionalAuthInitialized(auth)) {
224+
throw _operationNotSupportedForInitializedAuthInstance(auth);
225+
}
152226
return _performFetchWithErrorHandling(auth, customErrorMap, async () => {
153-
let body = {};
227+
const fetchArgs = await createFetchArgs<T>(auth, method, request);
154228
let params = {};
155229
if (request) {
156230
if (method === HttpMethod.GET) {
157231
params = request;
158-
} else {
159-
body = {
160-
body: JSON.stringify(request)
161-
};
162232
}
163233
}
164234

@@ -167,31 +237,6 @@ export async function _performApiRequest<T, V>(
167237
...params
168238
}).slice(1);
169239

170-
const headers = await (auth as AuthInternal)._getAdditionalHeaders();
171-
headers[HttpHeader.CONTENT_TYPE] = 'application/json';
172-
173-
if (auth.languageCode) {
174-
headers[HttpHeader.X_FIREBASE_LOCALE] = auth.languageCode;
175-
}
176-
177-
const fetchArgs: RequestInit = {
178-
method,
179-
headers,
180-
...body
181-
};
182-
183-
/* Security-conscious server-side frameworks tend to have built in mitigations for referrer
184-
problems". See the Cloudflare GitHub issue #487: Error: The 'referrerPolicy' field on
185-
'RequestInitializerDict' is not implemented."
186-
https://github.com/cloudflare/next-on-pages/issues/487 */
187-
if (!isCloudflareWorker()) {
188-
fetchArgs.referrerPolicy = 'no-referrer';
189-
}
190-
191-
if (auth.emulatorConfig && isCloudWorkstation(auth.emulatorConfig.host)) {
192-
fetchArgs.credentials = 'include';
193-
}
194-
195240
return FetchProvider.fetch()(
196241
await _getFinalTarget(auth, auth.config.apiHost, path, query),
197242
fetchArgs
@@ -287,7 +332,7 @@ export async function _getFinalTarget(
287332
auth: Auth,
288333
host: string,
289334
path: string,
290-
query: string
335+
query?: string
291336
): Promise<string> {
292337
const base = `${host}${path}?${query}`;
293338

@@ -332,15 +377,14 @@ function _assertValidEndpointForAuth(
332377
auth: Auth,
333378
path: Endpoint | RegionalEndpoint
334379
): void {
335-
if (
336-
!auth.tenantConfig &&
337-
Object.values(RegionalEndpoint).includes(path as RegionalEndpoint)
338-
) {
339-
throw _operationNotSupportedForInitializedAuthInstance(auth);
340-
}
341-
342-
if (auth.tenantConfig && Object.values(Endpoint).includes(path as Endpoint)) {
343-
throw _operationNotSupportedForInitializedAuthInstance(auth);
380+
if (isRegionalAuthInitialized(auth)) {
381+
if (Object.values(Endpoint).includes(path as Endpoint)) {
382+
throw _operationNotSupportedForInitializedAuthInstance(auth);
383+
}
384+
} else {
385+
if (Object.values(RegionalEndpoint).includes(path as RegionalEndpoint)) {
386+
throw _operationNotSupportedForInitializedAuthInstance(auth);
387+
}
344388
}
345389
}
346390

packages/auth/src/core/auth/auth_impl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export const enum DefaultConfig {
9393
TOKEN_API_HOST = 'securetoken.googleapis.com',
9494
API_HOST = 'identitytoolkit.googleapis.com',
9595
API_SCHEME = 'https',
96-
REGIONAL_API_HOST = 'identityplatform.googleapis.com'
96+
REGIONAL_API_HOST = 'identityplatform.googleapis.com/v2alpha/'
9797
}
9898

9999
export class AuthImpl implements AuthInternal, _FirebaseService {

packages/auth/src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ export {
315315
sendEmailVerification,
316316
verifyBeforeUpdateEmail
317317
} from './strategies/email';
318+
export { exchangeToken } from './strategies/exhange_token';
318319

319320
// core
320321
export { ActionCodeURL, parseActionCodeURL } from './action_code_url';

0 commit comments

Comments
 (0)