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
+ 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 ) & {
9
22
/**
10
23
* Tokens stored before the introduction of the `__metadata` field will be missing this property.
11
24
* @since 4.3.0
@@ -28,34 +41,127 @@ export type StoredToken = Token & {
28
41
export class TokenManager {
29
42
#manager: AuthorizationManager ;
30
43
44
+ #storageKey: string ;
45
+
46
+ #byScopeCache: ByScopeCache = { } ;
47
+
31
48
constructor ( options : { manager : AuthorizationManager } ) {
32
49
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
+ } , { } ) ;
33
96
}
34
97
35
98
/**
36
- * Retrieve and parse an item from the storage .
99
+ * Migrates the token storage to the latest version (if necessary) .
37
100
*/
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 ;
48
107
}
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
+ } ;
50
129
}
51
130
52
- #getTokenForService( service : Service ) {
131
+ #getTokenForService( service : Service ) : StoredToken | null {
53
132
const resourceServer = CONFIG . RESOURCE_SERVERS ?. [ service ] ;
54
133
return this . getByResourceServer ( resourceServer ) ;
55
134
}
56
135
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 ;
59
165
}
60
166
61
167
get auth ( ) : StoredToken | null {
@@ -90,45 +196,84 @@ export class TokenManager {
90
196
return this . getByResourceServer ( endpoint ) ;
91
197
}
92
198
199
+ /**
200
+ * Retrieve all tokens from the storage.
201
+ */
93
202
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 ) ;
104
204
}
105
205
106
206
/**
107
207
* Add a token to the storage.
108
208
*/
109
209
add ( token : Token | TokenResponse ) {
210
+ if ( ! isToken ( token ) ) {
211
+ throw new Error ( '@globus/sdk | Invalid token provided to TokenManager.add' ) ;
212
+ }
110
213
const created = Date . now ( ) ;
111
214
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
+ } ,
122
244
} ,
123
- } ) ,
124
- ) ;
245
+ } ,
246
+ } ;
125
247
if ( 'other_tokens' in token ) {
126
248
token . other_tokens ?. forEach ( ( t ) => {
127
249
this . add ( t ) ;
128
250
} ) ;
129
251
}
130
252
}
131
253
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
+
132
277
/**
133
278
* Determines whether or not a stored token is expired.
134
279
* @param token The token to check.
@@ -137,11 +282,9 @@ export class TokenManager {
137
282
* based on the token's metadata. This can happen if the token is missing the `__metadata` field or the `expires` field.
138
283
*/
139
284
static isTokenExpired ( token : StoredToken | null , augment : number = 0 ) : boolean | undefined {
140
- /* eslint-disable no-underscore-dangle */
141
285
if ( ! token || ! token . __metadata || typeof token . __metadata . expires !== 'number' ) {
142
286
return undefined ;
143
287
}
144
288
return Date . now ( ) + augment >= token . __metadata . expires ;
145
- /* eslint-enable no-underscore-dangle */
146
289
}
147
290
}
0 commit comments