diff --git a/packages-exp/auth-exp/src/core/user/token_manager.test.ts b/packages-exp/auth-exp/src/core/user/token_manager.test.ts new file mode 100644 index 00000000000..c6a943a62ed --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/token_manager.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { StsTokenManager, TOKEN_REFRESH_BUFFER_MS } from './token_manager'; +import { IdTokenResponse } from '../../model/id_token'; +import { createSandbox } from 'sinon'; + +use(chaiAsPromised); + +const sandbox = createSandbox(); + +describe('core/user/token_manager', () => { + let stsTokenManager: StsTokenManager; + let now: number; + + beforeEach(() => { + stsTokenManager = new StsTokenManager(); + now = Date.now(); + sandbox.stub(Date, 'now').returns(now); + }); + + afterEach(() => sandbox.restore()); + + describe('#isExpired', () => { + it('is true if past expiration time', () => { + stsTokenManager.expirationTime = 1; // Ancient history + expect(stsTokenManager.isExpired).to.eq(true); + }); + + it('is true if exp is in future but within buffer', () => { + stsTokenManager.expirationTime = now + (TOKEN_REFRESH_BUFFER_MS - 10); + expect(stsTokenManager.isExpired).to.eq(true); + }); + + it('is fals if exp is far enough in future', () => { + stsTokenManager.expirationTime = now + (TOKEN_REFRESH_BUFFER_MS + 10); + expect(stsTokenManager.isExpired).to.eq(false); + }); + }); + + describe('#updateFromServerResponse', () => { + it('sets all the fields correctly', () => { + stsTokenManager.updateFromServerResponse({ + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '60' // From the server this is 30s + } as IdTokenResponse); + + expect(stsTokenManager.expirationTime).to.eq(now + 60_000); + expect(stsTokenManager.accessToken).to.eq('id-token'); + expect(stsTokenManager.refreshToken).to.eq('refresh-token'); + }); + }); + + describe('#getToken', () => { + it('throws if forceRefresh is true', async () => { + Object.assign(stsTokenManager, { + accessToken: 'token', + expirationTime: now + 100_000 + }); + await expect(stsTokenManager.getToken(true)).to.be.rejectedWith( + Error, + 'StsTokenManager: token refresh not implemented' + ); + }); + + it('throws if token is expired', async () => { + Object.assign(stsTokenManager, { + accessToken: 'token', + expirationTime: now - 1 + }); + await expect(stsTokenManager.getToken()).to.be.rejectedWith( + Error, + 'StsTokenManager: token refresh not implemented' + ); + }); + + it('throws if access token is missing', async () => { + await expect(stsTokenManager.getToken()).to.be.rejectedWith( + Error, + 'StsTokenManager: token refresh not implemented' + ); + }); + + it('returns access token if not expired, not refreshing', async () => { + Object.assign(stsTokenManager, { + accessToken: 'token', + refreshToken: 'refresh', + expirationTime: now + 100_000 + }); + + const tokens = await stsTokenManager.getToken(); + expect(tokens.accessToken).to.eq('token'); + expect(tokens.refreshToken).to.eq('refresh'); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/token_manager.ts b/packages-exp/auth-exp/src/core/user/token_manager.ts new file mode 100644 index 00000000000..49bd349f03b --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/token_manager.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IdTokenResponse } from '../../model/id_token'; + +/** + * The number of milliseconds before the official expiration time of a token + * to refresh that token, to provide a buffer for RPCs to complete. + */ +export const TOKEN_REFRESH_BUFFER_MS = 30_000; + +export interface Tokens { + accessToken: string; + refreshToken: string | null; +} + +export class StsTokenManager { + refreshToken: string | null = null; + accessToken: string | null = null; + expirationTime: number | null = null; + + get isExpired(): boolean { + return ( + !this.expirationTime || + Date.now() > this.expirationTime - TOKEN_REFRESH_BUFFER_MS + ); + } + + updateFromServerResponse({ + idToken, + refreshToken, + expiresIn: expiresInSec + }: IdTokenResponse): void { + this.refreshToken = refreshToken; + this.accessToken = idToken; + this.expirationTime = Date.now() + Number(expiresInSec) * 1000; + } + + async getToken(forceRefresh = false): Promise { + if (!forceRefresh && this.accessToken && !this.isExpired) { + return { + accessToken: this.accessToken, + refreshToken: this.refreshToken + }; + } + + throw new Error('StsTokenManager: token refresh not implemented'); + } + + // TODO: There are a few more methods in here that need implemented: + // # toPlainObject + // # fromPlainObject + // # (private) performRefresh +} diff --git a/packages-exp/auth-exp/src/core/user/user_impl.test.ts b/packages-exp/auth-exp/src/core/user/user_impl.test.ts new file mode 100644 index 00000000000..4f3d9d0d616 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/user_impl.test.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { UserImpl } from './user_impl'; +import { mockAuth } from '../../../test/mock_auth'; +import { StsTokenManager } from './token_manager'; +import { IdTokenResponse } from '../../model/id_token'; + +use(chaiAsPromised); + +describe('core/user/user_impl', () => { + const auth = mockAuth('foo', 'i-am-the-api-key'); + let stsTokenManager: StsTokenManager; + + beforeEach(() => { + stsTokenManager = new StsTokenManager(); + }); + + describe('constructor', () => { + it('attaches required fields', () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + expect(user.auth).to.eq(auth); + expect(user.uid).to.eq('uid'); + }); + + it('attaches optional fields if provided', () => { + const user = new UserImpl({ + uid: 'uid', + auth, + stsTokenManager, + displayName: 'displayName', + email: 'email', + phoneNumber: 'phoneNumber', + photoURL: 'photoURL' + }); + + expect(user.displayName).to.eq('displayName'); + expect(user.email).to.eq('email'); + expect(user.phoneNumber).to.eq('phoneNumber'); + expect(user.photoURL).to.eq('photoURL'); + }); + + it('sets optional fields to null if not provided', () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + expect(user.displayName).to.eq(null); + expect(user.email).to.eq(null); + expect(user.phoneNumber).to.eq(null); + expect(user.photoURL).to.eq(null); + }); + }); + + describe('#getIdToken', () => { + it('returns the raw token if refresh tokens are in order', async () => { + stsTokenManager.updateFromServerResponse({ + idToken: 'id-token-string', + refreshToken: 'refresh-token-string', + expiresIn: '100000' + } as IdTokenResponse); + + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + const token = await user.getIdToken(); + expect(token).to.eq('id-token-string'); + expect(user.refreshToken).to.eq('refresh-token-string'); + }); + + it('throws if refresh is required', async () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + await expect(user.getIdToken()).to.be.rejectedWith(Error); + }); + }); + + describe('#getIdTokenResult', () => { + it('throws', async () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + await expect(user.getIdTokenResult()).to.be.rejectedWith(Error); + }); + }); + + describe('#reload', () => { + it('throws', () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + expect(() => user.reload()).to.throw(); + }); + }); + + describe('#delete', () => { + it('throws', () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + expect(() => user.delete()).to.throw(); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/user_impl.ts b/packages-exp/auth-exp/src/core/user/user_impl.ts new file mode 100644 index 00000000000..1e535299ca4 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/user_impl.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { User } from '../../model/user'; +import { Auth } from '../../model/auth'; +import { IdTokenResult } from '../../model/id_token'; +import { ProviderId } from '../providers'; +import { StsTokenManager } from './token_manager'; + +export interface UserParameters { + uid: string; + auth: Auth; + stsTokenManager: StsTokenManager; + + displayName?: string; + email?: string; + phoneNumber?: string; + photoURL?: string; +} + +export class UserImpl implements User { + // For the user object, provider is always Firebase. + readonly providerId = ProviderId.FIREBASE; + stsTokenManager: StsTokenManager; + refreshToken = ''; + + uid: string; + auth: Auth; + + // Optional fields from UserInfo + displayName: string | null; + email: string | null; + phoneNumber: string | null; + photoURL: string | null; + + constructor({ uid, auth, stsTokenManager, ...opt }: UserParameters) { + this.uid = uid; + this.auth = auth; + this.stsTokenManager = stsTokenManager; + this.displayName = opt.displayName || null; + this.email = opt.email || null; + this.phoneNumber = opt.phoneNumber || null; + this.photoURL = opt.photoURL || null; + } + + async getIdToken(forceRefresh?: boolean): Promise { + const { refreshToken, accessToken } = await this.stsTokenManager.getToken( + forceRefresh + ); + this.refreshToken = refreshToken || ''; + + // TODO: notify listeners at this point + return accessToken; + } + + async getIdTokenResult(forceRefresh?: boolean): Promise { + await this.getIdToken(forceRefresh); + // TODO: Parse token + throw new Error('Method not implemented'); + } + + reload(): Promise { + throw new Error('Method not implemented.'); + } + + delete(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages-exp/auth-exp/src/model/id_token.d.ts b/packages-exp/auth-exp/src/model/id_token.d.ts index 49d4db9c800..7cf492dfb1f 100644 --- a/packages-exp/auth-exp/src/model/id_token.d.ts +++ b/packages-exp/auth-exp/src/model/id_token.d.ts @@ -22,6 +22,26 @@ import { ProviderId } from '../core/providers/index'; */ export type IdToken = string; +/** + * Raw parsed JWT + */ +export interface ParsedIdToken { + iss: string; + aud: string; + exp: number; + sub: string; + iat: number; + email?: string; + verified: boolean; + providerId?: string; + tenantId?: string; + anonymous: boolean; + federatedId?: string; + displayName?: string; + photoURL?: string; + toString(): string; +} + /** * IdToken as returned by the API */ diff --git a/packages-exp/auth-exp/src/model/user.d.ts b/packages-exp/auth-exp/src/model/user.d.ts index 361ca3b6e68..d21eae226d8 100644 --- a/packages-exp/auth-exp/src/model/user.d.ts +++ b/packages-exp/auth-exp/src/model/user.d.ts @@ -15,14 +15,24 @@ * limitations under the License. */ +import { IdTokenResult } from './id_token'; +import { ProviderId } from '../core/providers'; + export interface UserInfo { readonly uid: string; -} - -export interface UserParameters { - uid: string; + readonly providerId: ProviderId; + readonly displayName: string | null; + readonly email: string | null; + readonly phoneNumber: string | null; + readonly photoURL: string | null; } export interface User extends UserInfo { - uid: string; + providerId: ProviderId.FIREBASE; + refreshToken: string; + + getIdToken(forceRefresh?: boolean): Promise; + getIdTokenResult(forceRefresh?: boolean): Promise; + reload(): Promise; + delete(): Promise; } diff --git a/packages-exp/auth-exp/test/mock_auth.ts b/packages-exp/auth-exp/test/mock_auth.ts new file mode 100644 index 00000000000..23a2b26de16 --- /dev/null +++ b/packages-exp/auth-exp/test/mock_auth.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppName, ApiKey, Auth } from '../src/model/auth'; + +export function mockAuth(name: AppName, apiKey: ApiKey): Auth { + return { + name, + config: { + apiKey + } + }; +} diff --git a/yarn.lock b/yarn.lock index f1c0e41c024..c2b89459a84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15668,4 +15668,4 @@ zip-stream@^2.1.2: dependencies: archiver-utils "^2.1.0" compress-commons "^2.1.1" - readable-stream "^3.4.0" + readable-stream "^3.4.0" \ No newline at end of file