Skip to content

Commit f633e45

Browse files
committed
chore(Authorization)!: Updates Token Storage Structure
- Updates the structure of how tokens are stored on the configured storage system to allow retrieval by scope when necessary. - **Allows for multiple tokens, for the same resource server, with different scopes to be stored.**
1 parent 355b5dc commit f633e45

File tree

2 files changed

+232
-54
lines changed

2 files changed

+232
-54
lines changed

src/core/authorization/AuthorizationManager.ts

+12-14
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ export class AuthorizationManager {
159159
* @event AuthorizationManager.events#authenticated
160160
* @type {object}
161161
* @property {boolean} isAuthenticated - Whether the `AuthorizationManager` is authenticated.
162-
* @property {TokenResponse} [token] - The token response if the `AuthorizationManager` is authenticated.
163162
*/
164163
authenticated: new Event<
165164
'authenticated',
@@ -169,7 +168,6 @@ export class AuthorizationManager {
169168
* @see {@link AuthorizationManager.authenticated}
170169
*/
171170
isAuthenticated: boolean;
172-
token?: TokenResponse;
173171
}
174172
>('authenticated'),
175173
/**
@@ -239,8 +237,12 @@ export class AuthorizationManager {
239237
* @see {@link https://docs.globus.org/api/auth/reference/#oidc_userinfo_endpoint}
240238
*/
241239
get user() {
242-
const token = this.getGlobusAuthToken();
243-
return token && token.id_token ? jwtDecode<JwtUserInfo>(token.id_token) : null;
240+
const token = this.tokens
241+
.getAll()
242+
.find((t) => t.resource_server === RESOURCE_SERVERS.AUTH && t.scope.includes('openid'));
243+
return token && 'id_token' in token && token.id_token
244+
? jwtDecode<JwtUserInfo>(token.id_token)
245+
: null;
244246
}
245247

246248
/**
@@ -289,32 +291,28 @@ export class AuthorizationManager {
289291

290292
/**
291293
* Whether or not the instance has a reference to a Globus Auth token.
294+
* @deprecated Use `AuthorizationManager.tokens.auth` instead.
292295
*/
293296
hasGlobusAuthToken() {
294-
return this.getGlobusAuthToken() !== null;
297+
return Boolean(this.tokens.auth);
295298
}
296299

297300
/**
298301
* Retrieve the Globus Auth token managed by the instance.
302+
* @deprecated Use `AuthorizationManager.tokens.auth` instead.
299303
*/
300304
getGlobusAuthToken() {
301-
const entry = this.storage.getItem(`${this.storageKeyPrefix}${RESOURCE_SERVERS.AUTH}`);
302-
return entry ? JSON.parse(entry) : null;
305+
return this.tokens.auth;
303306
}
304307

305308
#checkAuthorizationState() {
306309
log('debug', 'AuthorizationManager.#checkAuthorizationState');
307-
if (this.hasGlobusAuthToken()) {
308-
this.authenticated = true;
309-
}
310+
this.authenticated = Boolean(this.tokens.auth);
310311
}
311312

312313
async #emitAuthenticatedState() {
313-
const isAuthenticated = this.authenticated;
314-
const token = this.getGlobusAuthToken() ?? undefined;
315314
await this.events.authenticated.dispatch({
316-
isAuthenticated,
317-
token,
315+
isAuthenticated: this.authenticated,
318316
});
319317
}
320318

src/core/authorization/TokenManager.ts

+220-40
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,39 @@
1+
/* eslint-disable no-underscore-dangle */
12
import { CONFIG, isToken } from '../../services/auth/index.js';
23

34
import { SERVICES, type Service } from '../global.js';
5+
import { log } from '../logger.js';
46
import { AuthorizationManager } from './AuthorizationManager.js';
57

68
import type { Token, TokenResponse } from '../../services/auth/types.js';
79

8-
export type StoredToken = Token & {
10+
/**
11+
* The current version of the token storage format the `TokenManager` will
12+
* process.
13+
*/
14+
const TOKEN_STORAGE_VERSION = 0;
15+
16+
type TokenStorage = {
17+
/**
18+
* The version of the token storage format.
19+
*/
20+
version: typeof TOKEN_STORAGE_VERSION;
21+
/**
22+
* State held in the storage.
23+
*/
24+
state: Record<string, unknown>;
25+
};
26+
27+
type TokenStorageV0 = TokenStorage & {
28+
version: 0;
29+
state: {
30+
tokens: Record<StoredToken['access_token'], StoredToken>;
31+
};
32+
};
33+
34+
type ByScopeCache = Record<string, StoredToken['access_token']>;
35+
36+
export type StoredToken = (Token | TokenResponse) & {
937
/**
1038
* Tokens stored before the introduction of the `__metadata` field will be missing this property.
1139
* @since 4.3.0
@@ -26,36 +54,151 @@ export type StoredToken = Token & {
2654
};
2755

2856
export class TokenManager {
57+
/**
58+
* The AuthorizationManager instance that the TokenManager is associated with.
59+
*/
2960
#manager: AuthorizationManager;
3061

62+
/**
63+
* The key used to store the TokenStorage in the AuthorizationManager's storage provider.
64+
*/
65+
#storageKey: string;
66+
67+
/**
68+
* A cache of tokens by scope to allow for quick retrieval.
69+
*/
70+
#byScopeCache: ByScopeCache = {};
71+
3172
constructor(options: { manager: AuthorizationManager }) {
3273
this.#manager = options.manager;
74+
this.#storageKey = `${this.#manager.storageKeyPrefix}TokenManager`;
75+
/**
76+
* When the TokenManager is created, we need to check if there is a storage entry and migrate it if necessary.
77+
* This will ensure `this.#storage` is always the latest version.
78+
*/
79+
this.#migrate();
3380
}
3481

3582
/**
36-
* Retrieve and parse an item from the storage.
83+
* Determines whether or not the TokenManager has a storage entry.
3784
*/
38-
#getTokenFromStorage(key: string) {
39-
const raw = this.#manager.storage.getItem(key) || 'null';
40-
let token: StoredToken | null = null;
41-
try {
42-
const parsed = JSON.parse(raw);
43-
if (isToken(parsed)) {
44-
token = parsed;
45-
}
46-
} catch (e) {
47-
// no-op
85+
get #hasStorage() {
86+
return this.#manager.storage.getItem(this.#storageKey) !== null;
87+
}
88+
89+
/**
90+
* Retrieve the TokenStorage from the AuthorizationManager's storage provider.
91+
*/
92+
get #storage(): TokenStorageV0 {
93+
const raw = this.#manager.storage.getItem(this.#storageKey);
94+
if (!raw) {
95+
throw new Error('@globus/sdk | Unable to retrieve TokenStorage.');
4896
}
49-
return token;
97+
return JSON.parse(raw);
5098
}
5199

52-
#getTokenForService(service: Service) {
100+
/**
101+
* Store the TokenStorage in the AuthorizationManager's storage provider.
102+
*/
103+
set #storage(value: TokenStorageV0) {
104+
this.#manager.storage.setItem(this.#storageKey, JSON.stringify(value));
105+
/**
106+
* When the storage is update, we need to rebuild the cache of tokens by scope.
107+
*/
108+
this.#byScopeCache = Object.values(value.state.tokens).reduce((acc: ByScopeCache, token) => {
109+
token.scope.split(' ').forEach((scope) => {
110+
/**
111+
* If there isn't an existing token for the scope, add it to the cache.
112+
*/
113+
if (!acc[scope]) {
114+
acc[scope] = token.access_token;
115+
return;
116+
}
117+
/**
118+
* If there is an existing token for the scope, compare the expiration times and keep the token that expires later.
119+
*/
120+
const existing = value.state.tokens[acc[scope]];
121+
/**
122+
* If the existing token or the new token is missing the expiration metadata, skip the comparison.
123+
*/
124+
if (!existing.__metadata?.expires || !token.__metadata?.expires) {
125+
return;
126+
}
127+
if (existing.__metadata.expires < token.__metadata.expires) {
128+
acc[scope] = token.access_token;
129+
}
130+
});
131+
return acc;
132+
}, {});
133+
}
134+
135+
/**
136+
* Migrates the token storage to the latest version (if necessary).
137+
*/
138+
#migrate() {
139+
if (this.#hasStorage && this.#storage.version === TOKEN_STORAGE_VERSION) {
140+
/**
141+
* Storage entry exists and matches the current version.
142+
*/
143+
return;
144+
}
145+
/**
146+
* Migrate legacy token storage to the new format.
147+
*
148+
* Tokens were previously stored as individual items in the storage with keys that
149+
* included the resource server, e.g. `{client_id}:auth.globus.org`
150+
*/
151+
const tokens: TokenStorageV0['state']['tokens'] = {};
152+
Object.keys(this.#manager.storage).forEach((key) => {
153+
if (key.startsWith(this.#manager.storageKeyPrefix)) {
154+
const maybeToken = this.#manager.storage.getItem(key);
155+
if (isToken(maybeToken)) {
156+
tokens[maybeToken.access_token] = maybeToken;
157+
}
158+
}
159+
}, {});
160+
this.#storage = {
161+
version: TOKEN_STORAGE_VERSION,
162+
state: {
163+
tokens,
164+
},
165+
};
166+
}
167+
168+
#getTokenForService(service: Service): StoredToken | null {
53169
const resourceServer = CONFIG.RESOURCE_SERVERS?.[service];
54170
return this.getByResourceServer(resourceServer);
55171
}
56172

57-
getByResourceServer(resourceServer: string): StoredToken | null {
58-
return this.#getTokenFromStorage(`${this.#manager.storageKeyPrefix}${resourceServer}`);
173+
/**
174+
* Retrieve a token by the `resource_server` and optional `scope`. If a `scope` is provided, the token will be retrieved by the scope.
175+
* This is useful when your application needs to manage multiple tokens for the same `resource_server`, but with different scopes.
176+
*
177+
* **IMPORTANT**: If multiple tokens are found for the same `resource_server` (and no `scope` is provided), the first identified token will be returned.
178+
* If your application requires multiple tokens for the same `resource_server` this might lead to unexpected behavior (e.g. using the wrong token for requests).
179+
* In this case, you can use the `scope` parameter to retrieve the token you need, or use the `getAllByResourceServer` method to retrieve all tokens for a `resource_server`
180+
* and manage them as needed.
181+
*/
182+
getByResourceServer(resourceServer: string, scope?: string) {
183+
if (scope) {
184+
return this.getByScope(scope);
185+
}
186+
const tokens = this.getAllByResourceServer(resourceServer);
187+
if (tokens.length > 1) {
188+
log(
189+
'warn',
190+
`TokenManager.getByResource | Multiple tokens found for resource server, narrow your token selection by providing a "scope" parameter. | resource_server=${resourceServer}`,
191+
);
192+
}
193+
return tokens.length ? tokens[0] : null;
194+
}
195+
196+
getAllByResourceServer(resourceServer: string): StoredToken[] {
197+
return this.getAll().filter((token) => token.resource_server === resourceServer);
198+
}
199+
200+
getByScope(scope: string): StoredToken | null {
201+
return this.#storage.state.tokens[this.#byScopeCache[scope]] || null;
59202
}
60203

61204
get auth(): StoredToken | null {
@@ -90,45 +233,84 @@ export class TokenManager {
90233
return this.getByResourceServer(endpoint);
91234
}
92235

236+
/**
237+
* Retrieve all tokens from the storage.
238+
*/
93239
getAll(): StoredToken[] {
94-
const entries = Object.keys(this.#manager.storage).reduce(
95-
(acc: (StoredToken | null)[], key) => {
96-
if (key.startsWith(this.#manager.storageKeyPrefix)) {
97-
acc.push(this.#getTokenFromStorage(key));
98-
}
99-
return acc;
100-
},
101-
[],
102-
);
103-
return entries.filter(isToken);
240+
return Object.values(this.#storage?.state.tokens);
104241
}
105242

106243
/**
107244
* Add a token to the storage.
108245
*/
109246
add(token: Token | TokenResponse) {
247+
if (!isToken(token)) {
248+
throw new Error('@globus/sdk | Invalid token provided to TokenManager.add');
249+
}
110250
const created = Date.now();
111251
const expires = created + token.expires_in * 1000;
112-
this.#manager.storage.setItem(
113-
`${this.#manager.storageKeyPrefix}${token.resource_server}`,
114-
JSON.stringify({
115-
...token,
116-
/**
117-
* Add metadata to the token to track when it was created and when it expires.
118-
*/
119-
__metadata: {
120-
created,
121-
expires,
252+
const storage = this.#storage;
253+
/**
254+
* When adding a token, we **replace** any existing tokens with the same `resource_server` and `scope`
255+
* by filtering them out of the storage before adding the new token.
256+
*/
257+
const tokens = Object.entries(storage.state.tokens).reduce((acc, [key, value]) => {
258+
if (value.resource_server === token.resource_server && value.scope === token.scope) {
259+
return acc;
260+
}
261+
return {
262+
...acc,
263+
[key]: value,
264+
};
265+
}, {});
266+
this.#storage = {
267+
...storage,
268+
state: {
269+
tokens: {
270+
...tokens,
271+
[token.access_token]: {
272+
...token,
273+
/**
274+
* Add metadata to the token to track when it was created and when it expires.
275+
*/
276+
__metadata: {
277+
created,
278+
expires,
279+
},
280+
},
122281
},
123-
}),
124-
);
282+
},
283+
};
125284
if ('other_tokens' in token) {
126285
token.other_tokens?.forEach((t) => {
127286
this.add(t);
128287
});
129288
}
130289
}
131290

291+
remove(token: Token) {
292+
const storage = this.#storage;
293+
if (!storage) {
294+
return;
295+
}
296+
delete storage.state.tokens[token.access_token];
297+
this.#storage = {
298+
...storage,
299+
state: {
300+
tokens: storage.state.tokens,
301+
},
302+
};
303+
}
304+
305+
clear() {
306+
this.#storage = {
307+
version: TOKEN_STORAGE_VERSION,
308+
state: {
309+
tokens: {},
310+
},
311+
};
312+
}
313+
132314
/**
133315
* Determines whether or not a stored token is expired.
134316
* @param token The token to check.
@@ -137,11 +319,9 @@ export class TokenManager {
137319
* based on the token's metadata. This can happen if the token is missing the `__metadata` field or the `expires` field.
138320
*/
139321
static isTokenExpired(token: StoredToken | null, augment: number = 0): boolean | undefined {
140-
/* eslint-disable no-underscore-dangle */
141322
if (!token || !token.__metadata || typeof token.__metadata.expires !== 'number') {
142323
return undefined;
143324
}
144325
return Date.now() + augment >= token.__metadata.expires;
145-
/* eslint-enable no-underscore-dangle */
146326
}
147327
}

0 commit comments

Comments
 (0)