From 26d979276e2551c13074f700faf0588c0d383487 Mon Sep 17 00:00:00 2001 From: Joe Bottigliero <694253+jbottigliero@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:55:10 -0500 Subject: [PATCH 1/2] wip:feat: `AuthorizationManager` enhancements. feat: Adds `prompt` method to `AuthorizationManager` - allows prompting for consent **without** reseting configured authorization context. feat: Tokens managed by an `AuthorizationManager` will now be stored as a `StoredToken` type, which will include a `__metadata` property in addition to the raw `Token`. The `__metadata` will contain information about the creation and expiration time of the token allowing for improved refresh functionality. feat: Adds `isTokenExpired` method for determining whether or not a stored token is expired. --- .../AuthorizationManager.spec.ts | 38 +++++++- .../authorization/TokenLookup.spec.ts | 59 +++++++++++++ .../authorization/AuthorizationManager.ts | 21 +++-- src/core/authorization/TokenLookup.ts | 86 ++++++++++++++++--- src/services/auth/types.ts | 19 ++++ 5 files changed, 204 insertions(+), 19 deletions(-) diff --git a/src/core/__tests__/authorization/AuthorizationManager.spec.ts b/src/core/__tests__/authorization/AuthorizationManager.spec.ts index ac503a24..d9c89708 100644 --- a/src/core/__tests__/authorization/AuthorizationManager.spec.ts +++ b/src/core/__tests__/authorization/AuthorizationManager.spec.ts @@ -32,6 +32,36 @@ describe('AuthorizationManager', () => { }); if (RedirectTransport.supported) { + it('supports prompt', async () => { + const instance = new AuthorizationManager({ + client: 'client_id', + redirect: 'https://redirect_uri', + }); + await instance.prompt({ + scopes: 'some:scope:example', + }); + expect(window.location.assign).toHaveBeenCalledTimes(1); + + expect(window.location.assign).toHaveBeenCalledWith( + /** + * Ensure arbitrary `scope` parameters are passed. + */ + expect.stringContaining('scope=some%3Ascope%3Aexample'), + ); + expect(window.location.assign).toHaveBeenCalledWith( + /** + * Should contain the configured `client_id` + */ + expect.stringContaining('client_id=client_id'), + ); + expect(window.location.assign).toHaveBeenCalledWith( + /** + * Should contain the configured `redirect_uri` + */ + expect.stringContaining('redirect_uri=https%3A%2F%2Fredirect_uri'), + ); + }); + describe('RedirectTransport', () => { it('can be created without providing scopes', async () => { const instance = new AuthorizationManager({ @@ -162,7 +192,13 @@ describe('AuthorizationManager', () => { expect(instance.authenticated).toBe(true); expect(spy).toHaveBeenCalledWith({ isAuthenticated: true, - token: MOCK_TOKEN, + token: expect.objectContaining({ + ...MOCK_TOKEN, + __metadata: expect.objectContaining({ + created: expect.any(Number), + expires: expect.any(Number), + }), + }), }); expect(spy).toHaveBeenCalledTimes(1); }); diff --git a/src/core/__tests__/authorization/TokenLookup.spec.ts b/src/core/__tests__/authorization/TokenLookup.spec.ts index 62e9ccc5..e6f3f6f1 100644 --- a/src/core/__tests__/authorization/TokenLookup.spec.ts +++ b/src/core/__tests__/authorization/TokenLookup.spec.ts @@ -4,7 +4,13 @@ import { TokenLookup } from '../../authorization/TokenLookup'; import { RESOURCE_SERVERS } from '../../../services/auth/config'; +import type { Token } from '../../../services/auth/types'; + describe('TokenLookup', () => { + beforeEach(() => { + localStorage.clear(); + }); + const manager = new AuthorizationManager({ client: 'CLIENT_ID', redirect: 'REDIRECT_URI', @@ -38,6 +44,59 @@ describe('TokenLookup', () => { expect(lookup.compute).toBeNull(); }); + describe('isTokenExpired', () => { + it('processes a token without __metadata as expired', () => { + const TOKEN: Token = { + resource_server: RESOURCE_SERVERS.AUTH, + access_token: 'AUTH', + token_type: 'Bearer', + scope: 'openid', + expires_in: 1000, + }; + expect(TokenLookup.isTokenExpired(TOKEN)).toBe(true); + }); + + it('handles stored tokens', () => { + const TOKEN: Token = { + resource_server: RESOURCE_SERVERS.AUTH, + access_token: 'AUTH', + token_type: 'Bearer', + scope: 'openid', + expires_in: 1000, + }; + const EXPIRED_TOKEN = { + ...TOKEN, + resource_server: RESOURCE_SERVERS.FLOWS, + expires_in: 0, + }; + + lookup.add(TOKEN); + lookup.add(EXPIRED_TOKEN); + + expect(TokenLookup.isTokenExpired(lookup.auth)).toBe(false); + expect(TokenLookup.isTokenExpired(lookup.flows)).toBe(true); + /** + * `null` / Missing Token + */ + expect(TokenLookup.isTokenExpired(lookup.groups)).toBe(true); + }); + + it('supports time augments', () => { + const TOKEN: Token = { + resource_server: RESOURCE_SERVERS.AUTH, + access_token: 'AUTH', + token_type: 'Bearer', + scope: 'openid', + expires_in: 5, + }; + + lookup.add(TOKEN); + + expect(TokenLookup.isTokenExpired(lookup.auth, 4500)).toBe(false); + expect(TokenLookup.isTokenExpired(lookup.auth, 20 * 1000)).toBe(true); + }); + }); + describe('getAll', () => { it('should return all tokens when in storage', () => { const TOKENS = [ diff --git a/src/core/authorization/AuthorizationManager.ts b/src/core/authorization/AuthorizationManager.ts index f4a3c9d6..51e0c257 100644 --- a/src/core/authorization/AuthorizationManager.ts +++ b/src/core/authorization/AuthorizationManager.ts @@ -1,6 +1,7 @@ import { jwtDecode } from 'jwt-decode'; import { isGlobusAuthTokenResponse, isRefreshToken, oauth2 } from '../../services/auth/index.js'; +import { RESOURCE_SERVERS } from '../../services/auth/config.js'; import { createStorage, getStorage } from '../storage/index.js'; import { log } from '../logger.js'; @@ -268,7 +269,7 @@ export class AuthorizationManager { * Retrieve the Globus Auth token managed by the instance. */ getGlobusAuthToken() { - const entry = getStorage().get(`${this.storageKeyPrefix}auth.globus.org`); + const entry = getStorage().get(`${this.storageKeyPrefix}${RESOURCE_SERVERS.AUTH}`); return entry ? JSON.parse(entry) : null; } @@ -329,6 +330,10 @@ export class AuthorizationManager { /** * Initiate the login process by redirecting to the Globus Auth login page. + * + * **IMPORTANT**: This method will reset the instance state before initiating the login process, + * including clearing all tokens from storage. If you need to maintain the current state, + * use the `AuthorizationManager.prompt` method. */ async login(options = { additionalParams: {} }) { log('debug', 'AuthorizationManager.login'); @@ -340,6 +345,15 @@ export class AuthorizationManager { await transport.send(); } + /** + * Prompt the user to authenticate with Globus Auth. + */ + async prompt(options?: Partial) { + log('debug', 'AuthorizationManager.prompt'); + const transport = this.#buildTransport(options); + await transport.send(); + } + /** * This method will attempt to complete the PKCE protocol flow. */ @@ -471,10 +485,7 @@ export class AuthorizationManager { * consumers to add tokens to storage if necessary. */ addTokenResponse = (token: Token | TokenResponse) => { - getStorage().set(`${this.configuration.client}:${token.resource_server}`, token); - if ('other_tokens' in token) { - token.other_tokens?.forEach(this.addTokenResponse); - } + this.tokens.add(token); this.#checkAuthorizationState(); }; diff --git a/src/core/authorization/TokenLookup.ts b/src/core/authorization/TokenLookup.ts index 89338429..39a0a1b8 100644 --- a/src/core/authorization/TokenLookup.ts +++ b/src/core/authorization/TokenLookup.ts @@ -4,11 +4,31 @@ import { CONFIG, isToken } from '../../services/auth/index.js'; import { SERVICES, type Service } from '../global.js'; import { AuthorizationManager } from './AuthorizationManager.js'; -import type { Token } from '../../services/auth/types.js'; +import type { Token, TokenResponse } from '../../services/auth/types.js'; + +export type StoredToken = Token & { + /** + * Tokens stored before the introduction of the `__metadata` field will be missing this property. + * @since 4.3.0 + */ + __metadata?: { + /** + * The timestamp when the token was added to the storage as a number of milliseconds since the Unix epoch. + * + * **IMPORTANT**: This value might **not** represent the time when the token was created by the authorization server. + */ + created: number; + /** + * The timestamp when the token will expire as a number of milliseconds since the Unix epoch, based + * on the `expires_in` value from the token response and the time when the token was stored. + */ + expires: number | null; + }; +}; function getTokenFromStorage(key: string) { const raw = getStorage().get(key) || 'null'; - let token: Token | null = null; + let token: StoredToken | null = null; try { const parsed = JSON.parse(raw); if (isToken(parsed)) { @@ -20,6 +40,10 @@ function getTokenFromStorage(key: string) { return token; } +/** + * @todo In the next major version, we should consider renaming this class to `TokenManager`, + * since it's usage has expanded beyond just looking up tokens. + */ export class TokenLookup { #manager: AuthorizationManager; @@ -36,46 +60,46 @@ export class TokenLookup { return this.#getClientStorageEntry(resourceServer); } - get auth(): Token | null { + get auth(): StoredToken | null { return this.#getTokenForService(SERVICES.AUTH); } - get transfer(): Token | null { + get transfer(): StoredToken | null { return this.#getTokenForService(SERVICES.TRANSFER); } - get flows(): Token | null { + get flows(): StoredToken | null { return this.#getTokenForService(SERVICES.FLOWS); } - get groups(): Token | null { + get groups(): StoredToken | null { return this.#getTokenForService(SERVICES.GROUPS); } - get search(): Token | null { + get search(): StoredToken | null { return this.#getTokenForService(SERVICES.SEARCH); } - get timer(): Token | null { + get timer(): StoredToken | null { return this.#getTokenForService(SERVICES.TIMER); } - get compute(): Token | null { + get compute(): StoredToken | null { return this.#getTokenForService(SERVICES.COMPUTE); } - gcs(endpoint: string): Token | null { + gcs(endpoint: string): StoredToken | null { return this.getByResourceServer(endpoint); } - getByResourceServer(resourceServer: string): Token | null { + getByResourceServer(resourceServer: string): StoredToken | null { return this.#getClientStorageEntry(resourceServer); } - getAll(): Token[] { + getAll(): StoredToken[] { const entries = getStorage() .keys() - .reduce((acc: (Token | null)[], key) => { + .reduce((acc: (StoredToken | null)[], key) => { if (key.startsWith(this.#manager.storageKeyPrefix)) { acc.push(getTokenFromStorage(key)); } @@ -83,4 +107,40 @@ export class TokenLookup { }, []); return entries.filter(isToken); } + + /** + * Add a token to the storage. + */ + add(token: Token | TokenResponse) { + const created = Date.now(); + const expires = token.expires_in ? created + token.expires_in * 1000 : null; + getStorage().set(`${this.#manager.storageKeyPrefix}${token.resource_server}`, { + ...token, + /** + * Add metadata to the token to track when it was created and when it expires. + */ + __metadata: { + created, + expires, + }, + }); + if ('other_tokens' in token) { + token.other_tokens?.forEach((t) => { + this.add(t); + }); + } + } + + /** + * Determines whether or not a stored token is expired. + * @param token The token to check. + * @param augment An optional number of milliseconds to add to the current time when checking the expiration. + */ + static isTokenExpired(token: StoredToken | null, augment: number = 0) { + /* eslint-disable no-underscore-dangle */ + return token && token.__metadata?.expires + ? Date.now() + augment >= token.__metadata.expires + : true; + /* eslint-enable no-underscore-dangle */ + } } diff --git a/src/services/auth/types.ts b/src/services/auth/types.ts index d161bc96..b2f6f9ed 100644 --- a/src/services/auth/types.ts +++ b/src/services/auth/types.ts @@ -1,15 +1,34 @@ import type { JwtPayload } from 'jwt-decode'; export type Token = { + /** + * @see https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.12 + */ access_token: string; + /** + * @see https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.4 + */ scope: string; + /** + * The lifetime in seconds of the access token. + * @see https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.14 + */ expires_in: number; + /** + * @see https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.13 + */ token_type: string; resource_server: string; + /** + * @see https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.17 + */ refresh_token?: string; }; export type TokenWithRefresh = Token & { + /** + * @see https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.17 + */ refresh_token: string; }; From f69bad31725bee447dec5a6b8d77e7695123c895 Mon Sep 17 00:00:00 2001 From: Joe Bottigliero <694253+jbottigliero@users.noreply.github.com> Date: Tue, 24 Sep 2024 08:30:55 -0500 Subject: [PATCH 2/2] chore: updates isTokenExpired to return boolean | undefined --- .../__tests__/authorization/TokenLookup.spec.ts | 7 +++++-- src/core/authorization/TokenLookup.ts | 13 ++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/core/__tests__/authorization/TokenLookup.spec.ts b/src/core/__tests__/authorization/TokenLookup.spec.ts index e6f3f6f1..df22a473 100644 --- a/src/core/__tests__/authorization/TokenLookup.spec.ts +++ b/src/core/__tests__/authorization/TokenLookup.spec.ts @@ -53,7 +53,10 @@ describe('TokenLookup', () => { scope: 'openid', expires_in: 1000, }; - expect(TokenLookup.isTokenExpired(TOKEN)).toBe(true); + /** + * Expect raw token to be returned as `undefined`; Only includes relative `expires_in`. + */ + expect(TokenLookup.isTokenExpired(TOKEN)).toBe(undefined); }); it('handles stored tokens', () => { @@ -78,7 +81,7 @@ describe('TokenLookup', () => { /** * `null` / Missing Token */ - expect(TokenLookup.isTokenExpired(lookup.groups)).toBe(true); + expect(TokenLookup.isTokenExpired(lookup.groups)).toBe(undefined); }); it('supports time augments', () => { diff --git a/src/core/authorization/TokenLookup.ts b/src/core/authorization/TokenLookup.ts index 39a0a1b8..db8dc092 100644 --- a/src/core/authorization/TokenLookup.ts +++ b/src/core/authorization/TokenLookup.ts @@ -113,7 +113,7 @@ export class TokenLookup { */ add(token: Token | TokenResponse) { const created = Date.now(); - const expires = token.expires_in ? created + token.expires_in * 1000 : null; + const expires = created + token.expires_in * 1000; getStorage().set(`${this.#manager.storageKeyPrefix}${token.resource_server}`, { ...token, /** @@ -135,12 +135,15 @@ export class TokenLookup { * Determines whether or not a stored token is expired. * @param token The token to check. * @param augment An optional number of milliseconds to add to the current time when checking the expiration. + * @returns `true` if the token is expired, `false` if it is not expired, and `undefined` if the expiration status cannot be determined + * based on the token's metadata. This can happen if the token is missing the `__metadata` field or the `expires` field. */ - static isTokenExpired(token: StoredToken | null, augment: number = 0) { + static isTokenExpired(token: StoredToken | null, augment: number = 0): boolean | undefined { /* eslint-disable no-underscore-dangle */ - return token && token.__metadata?.expires - ? Date.now() + augment >= token.__metadata.expires - : true; + if (!token || !token.__metadata || typeof token.__metadata.expires !== 'number') { + return undefined; + } + return Date.now() + augment >= token.__metadata.expires; /* eslint-enable no-underscore-dangle */ } }