Skip to content

Commit af1571a

Browse files
committed
Add token manager
1 parent 4a3a125 commit af1571a

File tree

6 files changed

+240
-22
lines changed

6 files changed

+240
-22
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
import { StsTokenManager, DATE_GENERATOR } from './token_manager';
21+
import { IdTokenResponse } from '../../model/id_token';
22+
import { createSandbox } from 'sinon';
23+
24+
use(chaiAsPromised);
25+
26+
const sandbox = createSandbox();
27+
28+
describe('core/user/token_manager', () => {
29+
let stsTokenManager: StsTokenManager;
30+
let now: number;
31+
32+
beforeEach(() => {
33+
stsTokenManager = new StsTokenManager();
34+
now = Date.now();
35+
sandbox.stub(DATE_GENERATOR, 'now').returns(now);
36+
});
37+
38+
afterEach(() => sandbox.restore());
39+
40+
describe('#isExpired', () => {
41+
it('is true if past expiration time', () => {
42+
stsTokenManager.expirationTime = 1; // Ancient history
43+
expect(stsTokenManager.isExpired).to.eq(true);
44+
});
45+
46+
it('is true if exp is in future but within buffer', () => {
47+
// Buffer is 30_000
48+
stsTokenManager.expirationTime = now + 20_000;
49+
expect(stsTokenManager.isExpired).to.eq(true);
50+
});
51+
52+
it('is fals if exp is far enough in future', () => {
53+
stsTokenManager.expirationTime = now + 40_000;
54+
expect(stsTokenManager.isExpired).to.eq(false);
55+
});
56+
});
57+
58+
describe('#updateFromServerResponse', () => {
59+
it('sets all the fields correctly', () => {
60+
stsTokenManager.updateFromServerResponse({
61+
idToken: 'id-token',
62+
refreshToken: 'refresh-token',
63+
expiresIn: '60', // From the server this is 30s
64+
} as IdTokenResponse);
65+
66+
expect(stsTokenManager.expirationTime).to.eq(now + 60_000);
67+
expect(stsTokenManager.accessToken).to.eq('id-token');
68+
expect(stsTokenManager.refreshToken).to.eq('refresh-token');
69+
});
70+
});
71+
72+
describe('#getToken', () => {
73+
it('throws if forceRefresh is true', async () => {
74+
Object.assign(stsTokenManager, {accessToken: 'token', expirationTime: now + 100_000});
75+
await expect(stsTokenManager.getToken(true)).to.be.rejectedWith(Error);
76+
});
77+
78+
it('throws if token is expired', async () => {
79+
Object.assign(stsTokenManager, {accessToken: 'token', expirationTime: now - 1});
80+
await expect(stsTokenManager.getToken()).to.be.rejectedWith(Error);
81+
});
82+
83+
it('throws if access token is missing', async () => {
84+
await expect(stsTokenManager.getToken()).to.be.rejectedWith(Error);
85+
});
86+
87+
it('returns access token if not expired, not refreshing', async () => {
88+
Object.assign(stsTokenManager, {
89+
accessToken: 'token',
90+
refreshToken: 'refresh',
91+
expirationTime: now + 100_000,
92+
});
93+
94+
const tokens = await stsTokenManager.getToken();
95+
expect(tokens.accessToken).to.eq('token');
96+
expect(tokens.refreshToken).to.eq('refresh');
97+
});
98+
});
99+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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 { IdTokenResponse } from '../../model/id_token';
19+
20+
/**
21+
* The number of milliseconds before the official expiration time of a token
22+
* to refresh that token, to provide a buffer for RPCs to complete.
23+
*/
24+
const TOKEN_REFRESH_BUFFER_MS = 30_000;
25+
26+
export interface Tokens {
27+
accessToken: string;
28+
refreshToken: string|null;
29+
}
30+
31+
export const DATE_GENERATOR = {
32+
now: () => Date.now(),
33+
};
34+
35+
export class StsTokenManager {
36+
refreshToken: string|null = null;
37+
accessToken: string|null = null;
38+
expirationTime: number|null = null;
39+
40+
get isExpired(): boolean {
41+
return !this.expirationTime ||
42+
DATE_GENERATOR.now() > this.expirationTime - TOKEN_REFRESH_BUFFER_MS;
43+
}
44+
45+
updateFromServerResponse({idToken, refreshToken, expiresIn}: IdTokenResponse): void {
46+
this.refreshToken = refreshToken;
47+
this.accessToken = idToken;
48+
this.expirationTime = DATE_GENERATOR.now() + (Number.parseInt(expiresIn, 10) * 1000);
49+
}
50+
51+
async getToken(forceRefresh = false): Promise<Tokens> {
52+
if (!forceRefresh && this.accessToken && !this.isExpired) {
53+
return {
54+
accessToken: this.accessToken,
55+
refreshToken: this.refreshToken,
56+
};
57+
}
58+
59+
throw new Error('StsTokenManager: token refresh not implemented');
60+
}
61+
62+
// TODO: There are a few more methods in here that need implemented
63+
}

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

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,26 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { expect } from 'chai';
18+
import { expect, use } from 'chai';
19+
import * as chaiAsPromised from 'chai-as-promised';
1920
import { UserImpl } from './user_impl';
20-
import { mockAuth } from '../../test/mock_auth';
21+
import { mockAuth } from '../../../test/mock_auth';
22+
import { StsTokenManager } from './token_manager';
23+
import { IdTokenResponse } from '../../model/id_token';
2124

22-
describe('core/user_impl', () => {
25+
use(chaiAsPromised);
26+
27+
describe('core/user/user_impl', () => {
2328
const auth = mockAuth('foo', 'i-am-the-api-key');
29+
let stsTokenManager: StsTokenManager;
30+
31+
beforeEach(() => {
32+
stsTokenManager = new StsTokenManager();
33+
});
2434

2535
describe('constructor', () => {
2636
it('attaches required fields', () => {
27-
const user = new UserImpl({ uid: 'uid', auth });
37+
const user = new UserImpl({ uid: 'uid', auth, stsTokenManager });
2838
expect(user.auth).to.eq(auth);
2939
expect(user.uid).to.eq('uid');
3040
});
@@ -33,6 +43,7 @@ describe('core/user_impl', () => {
3343
const user = new UserImpl({
3444
uid: 'uid',
3545
auth,
46+
stsTokenManager,
3647
displayName: 'displayName',
3748
email: 'email',
3849
phoneNumber: 'phoneNumber',
@@ -46,7 +57,7 @@ describe('core/user_impl', () => {
4657
});
4758

4859
it('sets optional fields to null if not provided', () => {
49-
const user = new UserImpl({ uid: 'uid', auth });
60+
const user = new UserImpl({ uid: 'uid', auth, stsTokenManager });
5061
expect(user.displayName).to.eq(null);
5162
expect(user.email).to.eq(null);
5263
expect(user.phoneNumber).to.eq(null);
@@ -55,29 +66,42 @@ describe('core/user_impl', () => {
5566
});
5667

5768
describe('#getIdToken', () => {
58-
it('throws', () => {
59-
const user = new UserImpl({ uid: 'uid', auth });
60-
expect(() => user.getIdToken()).to.throw();
69+
it('returns the raw token if refresh tokens are in order', async () => {
70+
stsTokenManager.updateFromServerResponse({
71+
idToken: 'id-token-string',
72+
refreshToken: 'refresh-token-string',
73+
expiresIn: '100000',
74+
} as IdTokenResponse);
75+
76+
const user = new UserImpl({uid: 'uid', auth, stsTokenManager});
77+
const token = await user.getIdToken();
78+
expect(token).to.eq('id-token-string');
79+
expect(user.refreshToken).to.eq('refresh-token-string');
80+
});
81+
82+
it('throws if refresh is required', async () => {
83+
const user = new UserImpl({ uid: 'uid', auth, stsTokenManager });
84+
await expect(user.getIdToken()).to.be.rejectedWith(Error);
6185
});
6286
});
6387

6488
describe('#getIdTokenResult', () => {
65-
it('throws', () => {
66-
const user = new UserImpl({ uid: 'uid', auth });
67-
expect(() => user.getIdTokenResult()).to.throw();
89+
it('throws', async () => {
90+
const user = new UserImpl({ uid: 'uid', auth, stsTokenManager });
91+
await expect(user.getIdTokenResult()).to.be.rejectedWith(Error);
6892
});
6993
});
7094

7195
describe('#reload', () => {
7296
it('throws', () => {
73-
const user = new UserImpl({ uid: 'uid', auth });
97+
const user = new UserImpl({ uid: 'uid', auth, stsTokenManager });
7498
expect(() => user.reload()).to.throw();
7599
});
76100
});
77101

78102
describe('#delete', () => {
79103
it('throws', () => {
80-
const user = new UserImpl({ uid: 'uid', auth });
104+
const user = new UserImpl({ uid: 'uid', auth, stsTokenManager });
81105
expect(() => user.delete()).to.throw();
82106
});
83107
});

packages-exp/auth-exp/src/core/user_impl.ts renamed to packages-exp/auth-exp/src/core/user/user_impl.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { User } from '../model/user';
19-
import { Auth } from '../model/auth';
20-
import { IdTokenResult } from '../model/id_token';
21-
import { ProviderId } from './providers';
18+
import { User } from '../../model/user';
19+
import { Auth } from '../../model/auth';
20+
import { IdTokenResult } from '../../model/id_token';
21+
import { ProviderId } from '../providers';
22+
import { StsTokenManager } from './token_manager';
2223

2324
export interface UserParameters {
2425
uid: string;
2526
auth: Auth;
27+
stsTokenManager: StsTokenManager;
2628

2729
displayName?: string;
2830
email?: string;
@@ -33,6 +35,8 @@ export interface UserParameters {
3335
export class UserImpl implements User {
3436
// For the user object, provider is always Firebase.
3537
readonly providerId = ProviderId.FIREBASE;
38+
stsTokenManager: StsTokenManager;
39+
refreshToken = '';
3640

3741
uid: string;
3842
auth: Auth;
@@ -43,21 +47,28 @@ export class UserImpl implements User {
4347
phoneNumber: string | null;
4448
photoURL: string | null;
4549

46-
constructor({ uid, auth, ...opt }: UserParameters) {
50+
constructor({ uid, auth, stsTokenManager, ...opt }: UserParameters) {
4751
this.uid = uid;
4852
this.auth = auth;
53+
this.stsTokenManager = stsTokenManager;
4954
this.displayName = opt.displayName || null;
5055
this.email = opt.email || null;
5156
this.phoneNumber = opt.phoneNumber || null;
5257
this.photoURL = opt.photoURL || null;
5358
}
5459

55-
getIdToken(forceRefresh?: boolean): Promise<string> {
56-
throw new Error(`Method not implemented. forceRefresh: ${forceRefresh}`);
60+
async getIdToken(forceRefresh?: boolean): Promise<string> {
61+
const {refreshToken, accessToken} = await this.stsTokenManager.getToken(forceRefresh);
62+
this.refreshToken = refreshToken || '';
63+
64+
// TODO: notify listeners at this point
65+
return accessToken;
5766
}
5867

59-
getIdTokenResult(forceRefresh?: boolean): Promise<IdTokenResult> {
60-
throw new Error(`Method not implemented. forceRefresh: ${forceRefresh}`);
68+
async getIdTokenResult(forceRefresh?: boolean): Promise<IdTokenResult> {
69+
await this.getIdToken(forceRefresh);
70+
// TODO: Parse token
71+
throw new Error('Method not implemented');
6172
}
6273

6374
reload(): Promise<void> {

packages-exp/auth-exp/src/model/id_token.d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ import { ProviderId } from '../core/providers/index';
2222
*/
2323
export type IdToken = string;
2424

25+
/**
26+
* Raw parsed JWT
27+
*/
28+
export interface ParsedIdToken {
29+
iss: string;
30+
aud: string;
31+
exp: number;
32+
sub: string;
33+
iat: number;
34+
email?: string;
35+
verified: boolean;
36+
providerId?: string;
37+
tenantId?: string;
38+
anonymous: boolean;
39+
federatedId?: string;
40+
displayName?: string;
41+
photoURL?: string;
42+
toString(): string;
43+
}
44+
2545
/**
2646
* IdToken as returned by the API
2747
*/

packages-exp/auth-exp/src/model/user.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface UserInfo {
2929

3030
export interface User extends UserInfo {
3131
providerId: ProviderId.FIREBASE;
32+
refreshToken: string;
3233

3334
getIdToken(forceRefresh?: boolean): Promise<string>;
3435
getIdTokenResult(forceRefresh?: boolean): Promise<IdTokenResult>;

0 commit comments

Comments
 (0)