Skip to content

Commit 82bb599

Browse files
committed
Token refresh endpoint + stsTokenManager implementation
1 parent fb35ff7 commit 82bb599

File tree

6 files changed

+192
-49
lines changed

6 files changed

+192
-49
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 endpoint = `${_ENDPOINT}?key=${mockAuth.config.apiKey}`;
32+
beforeEach(fetch.setUp);
33+
afterEach(fetch.tearDown);
34+
35+
it('should POST to the correct endpoint', async () => {
36+
const mock = fetch.mock(endpoint, {
37+
'access_token': 'new-access-token',
38+
'expires_in': '3600',
39+
'refresh_token': 'new-refresh-token',
40+
});
41+
42+
const response = await requestStsToken(mockAuth, 'old-refresh-token');
43+
expect(response.accessToken).to.eq('new-access-token');
44+
expect(response.expiresIn).to.eq('3600');
45+
expect(response.refreshToken).to.eq('new-refresh-token');
46+
const request = querystringDecode(`?${mock.calls[0].request}`);
47+
expect(request).to.eql({
48+
'grant_type': 'refresh_token',
49+
'refresh_token': 'old-refresh-token',
50+
});
51+
expect(mock.calls[0].method).to.eq('POST');
52+
expect(mock.calls[0].headers).to.eql({
53+
'Content-Type': 'application/x-www-form-urlencoded',
54+
'X-Client-Version': 'testSDK/0.0.0'
55+
});
56+
});
57+
58+
it('should handle errors', async () => {
59+
const mock = fetch.mock(
60+
endpoint,
61+
{
62+
error: {
63+
code: 400,
64+
message: ServerError.TOKEN_EXPIRED,
65+
errors: [
66+
{
67+
message: ServerError.TOKEN_EXPIRED
68+
}
69+
]
70+
}
71+
},
72+
400
73+
);
74+
75+
await expect(requestStsToken(mockAuth, 'old-token')).to.be.rejectedWith(
76+
FirebaseError,
77+
'Firebase: The user\'s credential is no longer valid. The user must sign in again. (auth/user-token-expired)'
78+
);
79+
const request = querystringDecode(`?${mock.calls[0].request}`);
80+
expect(request).to.eql({
81+
'grant_type': 'refresh_token',
82+
'refresh_token': 'old-token',
83+
});
84+
});
85+
});

packages-exp/auth-exp/src/api/authentication/token.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,32 +20,42 @@ import { querystring } from '@firebase/util';
2020
import { performFetchWithErrorHandling } from '../';
2121
import { Auth } from '../../model/auth';
2222

23-
const ENDPOINT = 'https://securetoken.googleapis.com/v1/token';
23+
export const _ENDPOINT = 'https://securetoken.googleapis.com/v1/token';
2424
const GRANT_TYPE = 'refresh_token';
2525

26+
enum ServerField {
27+
ACCESS_TOKEN = 'access_token',
28+
EXPIRES_IN = 'expires_in',
29+
REFRESH_TOKEN = 'refresh_token',
30+
}
31+
2632
export interface RequestStsTokenResponse {
27-
access_token?: string;
28-
expires_in?: string;
29-
token_type?: string;
30-
refresh_token?: string;
31-
id_token?: string;
32-
user_id?: string;
33-
project_id?: string;
33+
accessToken?: string;
34+
expiresIn?: string;
35+
refreshToken?: string;
3436
}
3537

3638
export async function requestStsToken(auth: Auth, refreshToken: string): Promise<RequestStsTokenResponse> {
37-
return performFetchWithErrorHandling<RequestStsTokenResponse>(auth, {}, () => {
39+
const response = await performFetchWithErrorHandling<{[key: string]: string}>(auth, {}, () => {
3840
const body = querystring({
3941
'grant_type': GRANT_TYPE,
4042
'refresh_token': refreshToken,
4143
}).slice(1);
4244

43-
return fetch(`${ENDPOINT}?key=${auth.config.apiKey}`, {
45+
return fetch(`${_ENDPOINT}?key=${auth.config.apiKey}`, {
4446
method: 'POST',
4547
headers: {
4648
'X-Client-Version': auth.config.sdkClientVersion,
49+
'Content-Type': 'application/x-www-form-urlencoded',
4750
},
4851
body,
4952
});
5053
});
54+
55+
// The response comes back in snake_case. Convert to camel:
56+
return {
57+
accessToken: response[ServerField.ACCESS_TOKEN],
58+
expiresIn: response[ServerField.EXPIRES_IN],
59+
refreshToken: response[ServerField.REFRESH_TOKEN],
60+
};
5161
}

packages-exp/auth-exp/src/core/user/token_manager.test.ts

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,31 @@
1717

1818
import { expect, use } from 'chai';
1919
import * as chaiAsPromised from 'chai-as-promised';
20-
import { createSandbox } from 'sinon';
20+
import * as sinon from 'sinon';
21+
22+
import { FirebaseError } from '@firebase/util';
23+
24+
import { mockAuth } from '../../../test/mock_auth';
25+
import * as fetch from '../../../test/mock_fetch';
26+
import { _ENDPOINT } from '../../api/authentication/token';
2127
import { IdTokenResponse } from '../../model/id_token';
2228
import { StsTokenManager, TOKEN_REFRESH_BUFFER_MS } from './token_manager';
23-
import { FirebaseError } from '@firebase/util';
2429

2530
use(chaiAsPromised);
2631

27-
const sandbox = createSandbox();
28-
2932
describe('core/user/token_manager', () => {
3033
let stsTokenManager: StsTokenManager;
3134
let now: number;
3235

3336
beforeEach(() => {
3437
stsTokenManager = new StsTokenManager();
3538
now = Date.now();
36-
sandbox.stub(Date, 'now').returns(now);
39+
sinon.stub(Date, 'now').returns(now);
3740
});
3841

39-
afterEach(() => sandbox.restore());
42+
beforeEach(fetch.setUp);
43+
afterEach(fetch.tearDown);
44+
afterEach(() => sinon.restore());
4045

4146
describe('#isExpired', () => {
4247
it('is true if past expiration time', () => {
@@ -70,32 +75,71 @@ describe('core/user/token_manager', () => {
7075
});
7176

7277
describe('#getToken', () => {
73-
it('throws if forceRefresh is true', async () => {
74-
Object.assign(stsTokenManager, {
75-
accessToken: 'token',
76-
expirationTime: now + 100_000
78+
context('with endpoint setup', () => {
79+
let mock: fetch.Route;
80+
beforeEach(() => {
81+
const endpoint = `${_ENDPOINT}?key=${mockAuth.config.apiKey}`;
82+
mock = fetch.mock(endpoint, {
83+
'access_token': 'new-access-token',
84+
'refresh_token': 'new-refresh-token',
85+
'expires_in': '3600',
86+
});
87+
});
88+
89+
it('refreshes the token if forceRefresh is true', async () => {
90+
Object.assign(stsTokenManager, {
91+
accessToken: 'old-access-token',
92+
refreshToken: 'old-refresh-token',
93+
expirationTime: now + 100_000
94+
});
95+
96+
const tokens = await stsTokenManager.getToken(mockAuth, true);
97+
expect(mock.calls[0].request).to.contain('old-refresh-token');
98+
expect(stsTokenManager.accessToken).to.eq('new-access-token');
99+
expect(stsTokenManager.refreshToken).to.eq('new-refresh-token');
100+
expect(stsTokenManager.expirationTime).to.eq(now + 3_600_000);
101+
102+
expect(tokens).to.eql({
103+
accessToken: 'new-access-token',
104+
refreshToken: 'new-refresh-token',
105+
wasRefreshed: true,
106+
});
107+
});
108+
109+
it('refreshes the token if token is expired', async () => {
110+
Object.assign(stsTokenManager, {
111+
accessToken: 'old-access-token',
112+
refreshToken: 'old-refresh-token',
113+
expirationTime: now - 1
114+
});
115+
116+
const tokens = await stsTokenManager.getToken(mockAuth, false);
117+
expect(mock.calls[0].request).to.contain('old-refresh-token');
118+
expect(stsTokenManager.accessToken).to.eq('new-access-token');
119+
expect(stsTokenManager.refreshToken).to.eq('new-refresh-token');
120+
expect(stsTokenManager.expirationTime).to.eq(now + 3_600_000);
121+
122+
expect(tokens).to.eql({
123+
accessToken: 'new-access-token',
124+
refreshToken: 'new-refresh-token',
125+
wasRefreshed: true,
126+
});
77127
});
78-
await expect(stsTokenManager.getToken(true)).to.be.rejectedWith(
79-
Error,
80-
'StsTokenManager: token refresh not implemented'
81-
);
82128
});
83129

84-
it('throws if token is expired', async () => {
130+
it('returns null if the refresh token is missing', async () => {
131+
expect(await stsTokenManager.getToken(mockAuth)).to.be.null;
132+
});
133+
134+
it('throws an error if expired but refresh token is missing', async () => {
85135
Object.assign(stsTokenManager, {
86-
accessToken: 'token',
136+
accessToken: 'old-access-token',
87137
expirationTime: now - 1
88138
});
89-
await expect(stsTokenManager.getToken()).to.be.rejectedWith(
90-
Error,
91-
'StsTokenManager: token refresh not implemented'
92-
);
93-
});
94139

95-
it('throws if access token is missing', async () => {
96-
await expect(stsTokenManager.getToken()).to.be.rejectedWith(
97-
Error,
98-
'StsTokenManager: token refresh not implemented'
140+
await expect(stsTokenManager.getToken(mockAuth)).to.be.rejectedWith(
141+
FirebaseError,
142+
'Firebase: The user\'s credential is no longer valid. The user must sign in again. (auth/user-token-expired)'
99143
);
100144
});
101145

@@ -106,9 +150,12 @@ describe('core/user/token_manager', () => {
106150
expirationTime: now + 100_000
107151
});
108152

109-
const tokens = await stsTokenManager.getToken();
110-
expect(tokens.accessToken).to.eq('token');
111-
expect(tokens.refreshToken).to.eq('refresh');
153+
const tokens = (await stsTokenManager.getToken(mockAuth))!;
154+
expect(tokens).to.eql({
155+
accessToken: 'token',
156+
refreshToken: 'refresh',
157+
wasRefreshed: false,
158+
});
112159
});
113160
});
114161

packages-exp/auth-exp/src/core/user/token_manager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,12 @@ export class StsTokenManager {
8989
};
9090
}
9191

92-
private async refresh(auth: Auth, refreshToken: string) {
93-
const {access_token, refresh_token, expires_in} = await requestStsToken(auth, refreshToken);
94-
this.updateTokensAndExpiration(access_token, refresh_token, expires_in);
92+
private async refresh(auth: Auth, oldToken: string): Promise<void> {
93+
const {accessToken, refreshToken, expiresIn} = await requestStsToken(auth, oldToken);
94+
this.updateTokensAndExpiration(accessToken, refreshToken, expiresIn);
9595
}
9696

97-
private updateTokensAndExpiration(accessToken: string|undefined, refreshToken: string|undefined, expiresInSec: string|undefined) {
97+
private updateTokensAndExpiration(accessToken: string|undefined, refreshToken: string|undefined, expiresInSec: string|undefined): void {
9898
this.refreshToken = refreshToken || null;
9999
this.accessToken = accessToken || null;
100100
this.expirationTime = expiresInSec ? Date.now() + Number(expiresInSec) * 1000 : null;

packages-exp/auth-exp/src/core/user/user_impl.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
import { FirebaseError } from '@firebase/util';
1919
import { expect, use } from 'chai';
2020
import * as chaiAsPromised from 'chai-as-promised';
21+
22+
import { FirebaseError } from '@firebase/util';
23+
2124
import { mockAuth } from '../../../test/mock_auth';
2225
import { IdTokenResponse } from '../../model/id_token';
2326
import { StsTokenManager } from './token_manager';
@@ -79,11 +82,6 @@ describe('core/user/user_impl', () => {
7982
expect(token).to.eq('id-token-string');
8083
expect(user.refreshToken).to.eq('refresh-token-string');
8184
});
82-
83-
it('throws if refresh is required', async () => {
84-
const user = new UserImpl({ uid: 'uid', auth, stsTokenManager });
85-
await expect(user.getIdToken()).to.be.rejectedWith(Error);
86-
});
8785
});
8886

8987
describe('#getIdTokenResult', () => {

packages-exp/auth-exp/test/mock_fetch.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import { SinonStub, stub } from 'sinon';
1919

2020
export interface Call {
21-
request?: object;
21+
request?: object|string;
2222
method?: string;
2323
headers?: HeadersInit;
2424
}
@@ -45,8 +45,11 @@ const fakeFetch: typeof fetch = (input: RequestInfo, request?: RequestInit) => {
4545
// Bang-assertion is fine since we check for routes.has() above
4646
const { response, status, calls } = routes.get(input)!;
4747

48+
const requestBody = request?.body && (request?.headers as any)?.['Content-Type'] === 'application/json' ?
49+
JSON.parse(request.body as string) : request?.body;
50+
4851
calls.push({
49-
request: request?.body ? JSON.parse(request.body as string) : undefined,
52+
request: requestBody,
5053
method: request?.method,
5154
headers: request?.headers
5255
});

0 commit comments

Comments
 (0)