Skip to content

Commit 6e7d522

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 6e7d522

File tree

2 files changed

+195
-54
lines changed

2 files changed

+195
-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

+183-40
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
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+
const TOKEN_STORAGE_VERSION = 0;
11+
12+
type TokenStorage = {
13+
version: typeof TOKEN_STORAGE_VERSION;
14+
state: {
15+
tokens: Record<StoredToken['access_token'], StoredToken>;
16+
};
17+
};
18+
19+
type ByScopeCache = Record<string, StoredToken['access_token']>;
20+
21+
export type StoredToken = (Token | TokenResponse) & {
922
/**
1023
* Tokens stored before the introduction of the `__metadata` field will be missing this property.
1124
* @since 4.3.0
@@ -28,34 +41,127 @@ export type StoredToken = Token & {
2841
export class TokenManager {
2942
#manager: AuthorizationManager;
3043

44+
#storageKey: string;
45+
46+
#byScopeCache: ByScopeCache = {};
47+
3148
constructor(options: { manager: AuthorizationManager }) {
3249
this.#manager = options.manager;
50+
this.#storageKey = `${this.#manager.storageKeyPrefix}TokenManager`;
51+
this.#migrate();
52+
}
53+
54+
get #hasStorage() {
55+
return this.#manager.storage.getItem(this.#storageKey) !== null;
56+
}
57+
58+
get #storage(): TokenStorage {
59+
const raw = this.#manager.storage.getItem(this.#storageKey);
60+
if (!raw) {
61+
throw new Error('@globus/sdk | Unable to retrieve TokenStorage.');
62+
}
63+
return JSON.parse(raw);
64+
}
65+
66+
set #storage(value: TokenStorage) {
67+
this.#manager.storage.setItem(this.#storageKey, JSON.stringify(value));
68+
/**
69+
* When the storage is update, we need to rebuild the cache of tokens by scope.
70+
*/
71+
this.#byScopeCache = Object.values(value.state.tokens).reduce((acc: ByScopeCache, token) => {
72+
token.scope.split(' ').forEach((scope) => {
73+
/**
74+
* If there isn't an existing token for the scope, add it to the cache.
75+
*/
76+
if (!acc[scope]) {
77+
acc[scope] = token.access_token;
78+
return;
79+
}
80+
/**
81+
* If there is an existing token for the scope, compare the expiration times and keep the token that expires later.
82+
*/
83+
const existing = value.state.tokens[acc[scope]];
84+
/**
85+
* If the existing token or the new token is missing the expiration metadata, skip the comparison.
86+
*/
87+
if (!existing.__metadata?.expires || !token.__metadata?.expires) {
88+
return;
89+
}
90+
if (existing.__metadata.expires < token.__metadata.expires) {
91+
acc[scope] = token.access_token;
92+
}
93+
});
94+
return acc;
95+
}, {});
3396
}
3497

3598
/**
36-
* Retrieve and parse an item from the storage.
99+
* Migrates the token storage to the latest version (if necessary).
37100
*/
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
101+
#migrate() {
102+
if (this.#hasStorage && this.#storage.version === TOKEN_STORAGE_VERSION) {
103+
/**
104+
* Storage entry exists and matches the current version.
105+
*/
106+
return;
48107
}
49-
return token;
108+
/**
109+
* Migrate legacy token storage to the new format.
110+
*
111+
* Tokens were previously stored as individual items in the storage with keys that
112+
* included the resource server, e.g. `{client_id}:auth.globus.org`
113+
*/
114+
const tokens: TokenStorage['state']['tokens'] = {};
115+
Object.keys(this.#manager.storage).forEach((key) => {
116+
if (key.startsWith(this.#manager.storageKeyPrefix)) {
117+
const maybeToken = this.#manager.storage.getItem(key);
118+
if (isToken(maybeToken)) {
119+
tokens[maybeToken.access_token] = maybeToken;
120+
}
121+
}
122+
}, {});
123+
this.#storage = {
124+
version: TOKEN_STORAGE_VERSION,
125+
state: {
126+
tokens,
127+
},
128+
};
50129
}
51130

52-
#getTokenForService(service: Service) {
131+
#getTokenForService(service: Service): StoredToken | null {
53132
const resourceServer = CONFIG.RESOURCE_SERVERS?.[service];
54133
return this.getByResourceServer(resourceServer);
55134
}
56135

57-
getByResourceServer(resourceServer: string): StoredToken | null {
58-
return this.#getTokenFromStorage(`${this.#manager.storageKeyPrefix}${resourceServer}`);
136+
/**
137+
* Retrieve a token by the `resource_server` and optional `scope`. If a `scope` is provided, the token will be retrieved by the scope.
138+
* This is useful when your application needs to manage multiple tokens for the same `resource_server`, but with different scopes.
139+
*
140+
* **IMPORTANT**: If multiple tokens are found for the same `resource_server` (and no `scope` is provided), the first identified token will be returned.
141+
* 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).
142+
* 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`
143+
* and manage them as needed.
144+
*/
145+
getByResourceServer(resourceServer: string, scope?: string) {
146+
if (scope) {
147+
return this.getByScope(scope);
148+
}
149+
const tokens = this.getAllByResourceServer(resourceServer);
150+
if (tokens.length > 1) {
151+
log(
152+
'warn',
153+
`TokenManager.getByResource | Multiple tokens found for resource server, narrow your token selection by providing a "scope" parameter. | resource_server=${resourceServer}`,
154+
);
155+
}
156+
return tokens.length ? tokens[0] : null;
157+
}
158+
159+
getAllByResourceServer(resourceServer: string): StoredToken[] {
160+
return this.getAll().filter((token) => token.resource_server === resourceServer);
161+
}
162+
163+
getByScope(scope: string): StoredToken | null {
164+
return this.#storage.state.tokens[this.#byScopeCache[scope]] || null;
59165
}
60166

61167
get auth(): StoredToken | null {
@@ -90,45 +196,84 @@ export class TokenManager {
90196
return this.getByResourceServer(endpoint);
91197
}
92198

199+
/**
200+
* Retrieve all tokens from the storage.
201+
*/
93202
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);
203+
return Object.values(this.#storage?.state.tokens);
104204
}
105205

106206
/**
107207
* Add a token to the storage.
108208
*/
109209
add(token: Token | TokenResponse) {
210+
if (!isToken(token)) {
211+
throw new Error('@globus/sdk | Invalid token provided to TokenManager.add');
212+
}
110213
const created = Date.now();
111214
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,
215+
const storage = this.#storage;
216+
/**
217+
* When adding a token, we **replace** any existing tokens with the same `resource_server` and `scope`
218+
* by filtering them out of the storage before adding the new token.
219+
*/
220+
const tokens = Object.entries(storage.state.tokens).reduce((acc, [key, value]) => {
221+
if (value.resource_server === token.resource_server && value.scope === token.scope) {
222+
return acc;
223+
}
224+
return {
225+
...acc,
226+
[key]: value,
227+
};
228+
}, {});
229+
this.#storage = {
230+
...storage,
231+
state: {
232+
tokens: {
233+
...tokens,
234+
[token.access_token]: {
235+
...token,
236+
/**
237+
* Add metadata to the token to track when it was created and when it expires.
238+
*/
239+
__metadata: {
240+
created,
241+
expires,
242+
},
243+
},
122244
},
123-
}),
124-
);
245+
},
246+
};
125247
if ('other_tokens' in token) {
126248
token.other_tokens?.forEach((t) => {
127249
this.add(t);
128250
});
129251
}
130252
}
131253

254+
remove(token: Token) {
255+
const storage = this.#storage;
256+
if (!storage) {
257+
return;
258+
}
259+
delete storage.state.tokens[token.access_token];
260+
this.#storage = {
261+
...storage,
262+
state: {
263+
tokens: storage.state.tokens,
264+
},
265+
};
266+
}
267+
268+
clear() {
269+
this.#storage = {
270+
version: TOKEN_STORAGE_VERSION,
271+
state: {
272+
tokens: {},
273+
},
274+
};
275+
}
276+
132277
/**
133278
* Determines whether or not a stored token is expired.
134279
* @param token The token to check.
@@ -137,11 +282,9 @@ export class TokenManager {
137282
* based on the token's metadata. This can happen if the token is missing the `__metadata` field or the `expires` field.
138283
*/
139284
static isTokenExpired(token: StoredToken | null, augment: number = 0): boolean | undefined {
140-
/* eslint-disable no-underscore-dangle */
141285
if (!token || !token.__metadata || typeof token.__metadata.expires !== 'number') {
142286
return undefined;
143287
}
144288
return Date.now() + augment >= token.__metadata.expires;
145-
/* eslint-enable no-underscore-dangle */
146289
}
147290
}

0 commit comments

Comments
 (0)