Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Authorization): AuthorizationManager enhancements. #310

Merged
merged 2 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/core/__tests__/authorization/AuthorizationManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
});
Expand Down
62 changes: 62 additions & 0 deletions src/core/__tests__/authorization/TokenLookup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 = [
Expand Down
21 changes: 16 additions & 5 deletions src/core/authorization/AuthorizationManager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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');
Expand All @@ -340,6 +345,15 @@ export class AuthorizationManager {
await transport.send();
}

/**
* Prompt the user to authenticate with Globus Auth.
*/
async prompt(options?: Partial<RedirectTransportOptions>) {
log('debug', 'AuthorizationManager.prompt');
const transport = this.#buildTransport(options);
await transport.send();
}

/**
* This method will attempt to complete the PKCE protocol flow.
*/
Expand Down Expand Up @@ -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();
};

Expand Down
89 changes: 76 additions & 13 deletions src/core/authorization/TokenLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;

Expand All @@ -36,51 +60,90 @@ 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));
}
return acc;
}, []);
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 */
}
}
19 changes: 19 additions & 0 deletions src/services/auth/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};

Expand Down
Loading