1+ /* eslint-disable no-underscore-dangle */
12import { CONFIG , isToken } from '../../services/auth/index.js' ;
23
34import { SERVICES , type Service } from '../global.js' ;
5+ import { log } from '../logger.js' ;
46import { AuthorizationManager } from './AuthorizationManager.js' ;
57
68import 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
2856export 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,47 +233,82 @@ 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
132- remove ( token : Token | TokenResponse ) {
133- this . #manager. storage . removeItem ( `${ this . #manager. storageKeyPrefix } ${ token . resource_server } ` ) ;
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+ } ;
134312 }
135313
136314 /**
@@ -141,11 +319,9 @@ export class TokenManager {
141319 * based on the token's metadata. This can happen if the token is missing the `__metadata` field or the `expires` field.
142320 */
143321 static isTokenExpired ( token : StoredToken | null , augment : number = 0 ) : boolean | undefined {
144- /* eslint-disable no-underscore-dangle */
145322 if ( ! token || ! token . __metadata || typeof token . __metadata . expires !== 'number' ) {
146323 return undefined ;
147324 }
148325 return Date . now ( ) + augment >= token . __metadata . expires ;
149- /* eslint-enable no-underscore-dangle */
150326 }
151327}
0 commit comments