1
+ /* eslint-disable no-underscore-dangle */
1
2
import { CONFIG , isToken } from '../../services/auth/index.js' ;
2
3
3
4
import { SERVICES , type Service } from '../global.js' ;
5
+ import { log } from '../logger.js' ;
4
6
import { AuthorizationManager } from './AuthorizationManager.js' ;
5
7
6
8
import type { Token , TokenResponse } from '../../services/auth/types.js' ;
7
9
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 ) & {
9
37
/**
10
38
* Tokens stored before the introduction of the `__metadata` field will be missing this property.
11
39
* @since 4.3.0
@@ -26,36 +54,151 @@ export type StoredToken = Token & {
26
54
} ;
27
55
28
56
export class TokenManager {
57
+ /**
58
+ * The AuthorizationManager instance that the TokenManager is associated with.
59
+ */
29
60
#manager: AuthorizationManager ;
30
61
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
+
31
72
constructor ( options : { manager : AuthorizationManager } ) {
32
73
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( ) ;
33
80
}
34
81
35
82
/**
36
- * Retrieve and parse an item from the storage.
83
+ * Determines whether or not the TokenManager has a storage entry .
37
84
*/
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.' ) ;
48
96
}
49
- return token ;
97
+ return JSON . parse ( raw ) ;
50
98
}
51
99
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 {
53
169
const resourceServer = CONFIG . RESOURCE_SERVERS ?. [ service ] ;
54
170
return this . getByResourceServer ( resourceServer ) ;
55
171
}
56
172
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 ;
59
202
}
60
203
61
204
get auth ( ) : StoredToken | null {
@@ -90,45 +233,84 @@ export class TokenManager {
90
233
return this . getByResourceServer ( endpoint ) ;
91
234
}
92
235
236
+ /**
237
+ * Retrieve all tokens from the storage.
238
+ */
93
239
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 ) ;
104
241
}
105
242
106
243
/**
107
244
* Add a token to the storage.
108
245
*/
109
246
add ( token : Token | TokenResponse ) {
247
+ if ( ! isToken ( token ) ) {
248
+ throw new Error ( '@globus/sdk | Invalid token provided to TokenManager.add' ) ;
249
+ }
110
250
const created = Date . now ( ) ;
111
251
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
+ } ,
122
281
} ,
123
- } ) ,
124
- ) ;
282
+ } ,
283
+ } ;
125
284
if ( 'other_tokens' in token ) {
126
285
token . other_tokens ?. forEach ( ( t ) => {
127
286
this . add ( t ) ;
128
287
} ) ;
129
288
}
130
289
}
131
290
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
+
132
314
/**
133
315
* Determines whether or not a stored token is expired.
134
316
* @param token The token to check.
@@ -137,11 +319,9 @@ export class TokenManager {
137
319
* based on the token's metadata. This can happen if the token is missing the `__metadata` field or the `expires` field.
138
320
*/
139
321
static isTokenExpired ( token : StoredToken | null , augment : number = 0 ) : boolean | undefined {
140
- /* eslint-disable no-underscore-dangle */
141
322
if ( ! token || ! token . __metadata || typeof token . __metadata . expires !== 'number' ) {
142
323
return undefined ;
143
324
}
144
325
return Date . now ( ) + augment >= token . __metadata . expires ;
145
- /* eslint-enable no-underscore-dangle */
146
326
}
147
327
}
0 commit comments