Skip to content

Commit 9c61b0b

Browse files
authored
Add refresh token endpoint + implementation to token manager (#2975)
1 parent 27910ee commit 9c61b0b

File tree

12 files changed

+352
-74
lines changed

12 files changed

+352
-74
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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 * as chaiAsPromised from 'chai-as-promised';
20+
21+
import { FirebaseError, querystringDecode } from '@firebase/util';
22+
23+
import { mockAuth } from '../../../test/mock_auth';
24+
import * as fetch from '../../../test/mock_fetch';
25+
import { ServerError } from '../errors';
26+
import { _ENDPOINT, requestStsToken } from './token';
27+
28+
use(chaiAsPromised);
29+
30+
describe('requestStsToken', () => {
31+
const { apiKey, tokenApiHost, apiScheme } = mockAuth.config;
32+
const endpoint = `${apiScheme}://${tokenApiHost}/${_ENDPOINT}?key=${apiKey}`;
33+
beforeEach(fetch.setUp);
34+
afterEach(fetch.tearDown);
35+
36+
it('should POST to the correct endpoint', async () => {
37+
const mock = fetch.mock(endpoint, {
38+
'access_token': 'new-access-token',
39+
'expires_in': '3600',
40+
'refresh_token': 'new-refresh-token'
41+
});
42+
43+
const response = await requestStsToken(mockAuth, 'old-refresh-token');
44+
expect(response.accessToken).to.eq('new-access-token');
45+
expect(response.expiresIn).to.eq('3600');
46+
expect(response.refreshToken).to.eq('new-refresh-token');
47+
const request = querystringDecode(`?${mock.calls[0].request}`);
48+
expect(request).to.eql({
49+
'grant_type': 'refresh_token',
50+
'refresh_token': 'old-refresh-token'
51+
});
52+
expect(mock.calls[0].method).to.eq('POST');
53+
expect(mock.calls[0].headers).to.eql({
54+
'Content-Type': 'application/x-www-form-urlencoded',
55+
'X-Client-Version': 'testSDK/0.0.0'
56+
});
57+
});
58+
59+
it('should handle errors', async () => {
60+
const mock = fetch.mock(
61+
endpoint,
62+
{
63+
error: {
64+
code: 400,
65+
message: ServerError.TOKEN_EXPIRED,
66+
errors: [
67+
{
68+
message: ServerError.TOKEN_EXPIRED
69+
}
70+
]
71+
}
72+
},
73+
400
74+
);
75+
76+
await expect(requestStsToken(mockAuth, 'old-token')).to.be.rejectedWith(
77+
FirebaseError,
78+
"Firebase: The user's credential is no longer valid. The user must sign in again. (auth/user-token-expired)"
79+
);
80+
const request = querystringDecode(`?${mock.calls[0].request}`);
81+
expect(request).to.eql({
82+
'grant_type': 'refresh_token',
83+
'refresh_token': 'old-token'
84+
});
85+
});
86+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
/* eslint-disable camelcase */
19+
20+
import { querystring } from '@firebase/util';
21+
22+
import { _performFetchWithErrorHandling, HttpMethod } from '../';
23+
import { Auth } from '../../model/auth';
24+
25+
export const _ENDPOINT = 'v1/token';
26+
const GRANT_TYPE = 'refresh_token';
27+
28+
/** The server responses with snake_case; we convert to camelCase */
29+
interface RequestStsTokenServerResponse {
30+
access_token?: string;
31+
expires_in?: string;
32+
refresh_token?: string;
33+
}
34+
35+
export interface RequestStsTokenResponse {
36+
accessToken?: string;
37+
expiresIn?: string;
38+
refreshToken?: string;
39+
}
40+
41+
export async function requestStsToken(
42+
auth: Auth,
43+
refreshToken: string
44+
): Promise<RequestStsTokenResponse> {
45+
const response = await _performFetchWithErrorHandling<
46+
RequestStsTokenServerResponse
47+
>(auth, {}, () => {
48+
const body = querystring({
49+
'grant_type': GRANT_TYPE,
50+
'refresh_token': refreshToken
51+
}).slice(1);
52+
const { apiScheme, tokenApiHost, apiKey, sdkClientVersion } = auth.config;
53+
const url = `${apiScheme}://${tokenApiHost}/${_ENDPOINT}`;
54+
55+
return fetch(`${url}?key=${apiKey}`, {
56+
method: HttpMethod.POST,
57+
headers: {
58+
'X-Client-Version': sdkClientVersion,
59+
'Content-Type': 'application/x-www-form-urlencoded'
60+
},
61+
body
62+
});
63+
});
64+
65+
// The response comes back in snake_case. Convert to camel:
66+
return {
67+
accessToken: response.access_token,
68+
expiresIn: response.expires_in,
69+
refreshToken: response.refresh_token
70+
};
71+
}

packages-exp/auth-exp/src/api/index.ts

+42-27
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,17 @@
1616
*/
1717

1818
import { FirebaseError, querystring } from '@firebase/util';
19-
import { AuthErrorCode, AUTH_ERROR_FACTORY } from '../core/errors';
19+
20+
import { AUTH_ERROR_FACTORY, AuthErrorCode } from '../core/errors';
21+
import { Delay } from '../core/util/delay';
2022
import { Auth } from '../model/auth';
2123
import { IdTokenResponse } from '../model/id_token';
2224
import {
2325
JsonError,
26+
SERVER_ERROR_MAP,
2427
ServerError,
25-
ServerErrorMap,
26-
SERVER_ERROR_MAP
28+
ServerErrorMap
2729
} from './errors';
28-
import { Delay } from '../core/util/delay';
2930

3031
export enum HttpMethod {
3132
POST = 'POST',
@@ -63,8 +64,7 @@ export async function _performApiRequest<T, V>(
6364
request?: T,
6465
customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
6566
): Promise<V> {
66-
const errorMap = { ...SERVER_ERROR_MAP, ...customErrorMap };
67-
try {
67+
return _performFetchWithErrorHandling(auth, customErrorMap, () => {
6868
let body = {};
6969
let params = {};
7070
if (request) {
@@ -82,28 +82,31 @@ export async function _performApiRequest<T, V>(
8282
...params
8383
}).slice(1);
8484

85+
return fetch(
86+
`${auth.config.apiScheme}://${auth.config.apiHost}${path}?${query}`,
87+
{
88+
method,
89+
headers: {
90+
'Content-Type': 'application/json',
91+
'X-Client-Version': auth.config.sdkClientVersion
92+
},
93+
referrerPolicy: 'no-referrer',
94+
...body
95+
}
96+
);
97+
});
98+
}
99+
100+
export async function _performFetchWithErrorHandling<V>(
101+
auth: Auth,
102+
customErrorMap: Partial<ServerErrorMap<ServerError>>,
103+
fetchFn: () => Promise<Response>
104+
): Promise<V> {
105+
const errorMap = { ...SERVER_ERROR_MAP, ...customErrorMap };
106+
try {
85107
const response: Response = await Promise.race<Promise<Response>>([
86-
fetch(
87-
`${auth.config.apiScheme}://${auth.config.apiHost}${path}?${query}`,
88-
{
89-
method,
90-
headers: {
91-
'Content-Type': 'application/json',
92-
'X-Client-Version': auth.config.sdkClientVersion
93-
},
94-
referrerPolicy: 'no-referrer',
95-
...body
96-
}
97-
),
98-
new Promise((_, reject) =>
99-
setTimeout(() => {
100-
return reject(
101-
AUTH_ERROR_FACTORY.create(AuthErrorCode.TIMEOUT, {
102-
appName: auth.name
103-
})
104-
);
105-
}, DEFAULT_API_TIMEOUT_MS.get())
106-
)
108+
fetchFn(),
109+
makeNetworkTimeout(auth.name)
107110
]);
108111
if (response.ok) {
109112
return response.json();
@@ -154,3 +157,15 @@ export async function _performSignInRequest<T, V extends IdTokenResponse>(
154157

155158
return serverResponse;
156159
}
160+
161+
function makeNetworkTimeout<T>(appName: string): Promise<T> {
162+
return new Promise((_, reject) =>
163+
setTimeout(() => {
164+
return reject(
165+
AUTH_ERROR_FACTORY.create(AuthErrorCode.TIMEOUT, {
166+
appName
167+
})
168+
);
169+
}, DEFAULT_API_TIMEOUT_MS.get())
170+
);
171+
}

packages-exp/auth-exp/src/core/auth/auth_impl.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ import { Persistence } from '../persistence';
2929
import { browserLocalPersistence } from '../persistence/browser';
3030
import { inMemoryPersistence } from '../persistence/in_memory';
3131
import { PersistenceUserManager } from '../persistence/persistence_user_manager';
32-
import { ClientPlatform, _getClientVersion } from '../util/version';
32+
import { _getClientVersion, ClientPlatform } from '../util/version';
3333
import {
3434
DEFAULT_API_HOST,
3535
DEFAULT_API_SCHEME,
36+
DEFAULT_TOKEN_API_HOST,
3637
initializeAuth
3738
} from './auth_impl';
3839

@@ -318,6 +319,7 @@ describe('core/auth/initializeAuth', () => {
318319
authDomain: FAKE_APP.options.authDomain,
319320
apiHost: DEFAULT_API_HOST,
320321
apiScheme: DEFAULT_API_SCHEME,
322+
tokenApiHost: DEFAULT_TOKEN_API_HOST,
321323
sdkClientVersion: _getClientVersion(ClientPlatform.BROWSER)
322324
});
323325
});

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

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface AsyncAction {
3939
(): Promise<void>;
4040
}
4141

42+
export const DEFAULT_TOKEN_API_HOST = 'securetoken.googleapis.com';
4243
export const DEFAULT_API_HOST = 'identitytoolkit.googleapis.com';
4344
export const DEFAULT_API_SCHEME = 'https';
4445

@@ -199,6 +200,7 @@ export function initializeAuth(
199200
apiKey,
200201
authDomain,
201202
apiHost: DEFAULT_API_HOST,
203+
tokenApiHost: DEFAULT_TOKEN_API_HOST,
202204
apiScheme: DEFAULT_API_SCHEME,
203205
sdkClientVersion: _getClientVersion(ClientPlatform.BROWSER)
204206
};

0 commit comments

Comments
 (0)