diff --git a/src/core/__tests__/authorization/AuthorizationManager.spec.ts b/src/core/__tests__/authorization/AuthorizationManager.spec.ts index ac503a2..d9c8970 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 62e9ccc..df22a47 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,62 @@ 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 raw token to be returned as `undefined`; Only includes relative `expires_in`. + */ + expect(TokenLookup.isTokenExpired(TOKEN)).toBe(undefined); + }); + + 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(undefined); + }); + + 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 f4a3c9d..51e0c25 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 8933842..db8dc09 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,43 @@ export class TokenLookup { }, []); return entries.filter(isToken); } + + /** + * Add a token to the storage. + */ + add(token: Token | TokenResponse) { + const created = Date.now(); + const expires = created + token.expires_in * 1000; + 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. + * @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): boolean | undefined { + /* eslint-disable no-underscore-dangle */ + if (!token || !token.__metadata || typeof token.__metadata.expires !== 'number') { + return undefined; + } + return Date.now() + augment >= token.__metadata.expires; + /* eslint-enable no-underscore-dangle */ + } } diff --git a/src/services/auth/types.ts b/src/services/auth/types.ts index d161bc9..b2f6f9e 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; };