|
/** |
|
* MIT License |
|
* |
|
* Copyright (c) 2025 <Your Name or Company> |
|
* |
|
* Permission is hereby granted, free of charge, to any person obtaining a copy |
|
* of this software and associated documentation files (the "Software"), to deal |
|
* in the Software without restriction, including without limitation the rights |
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
* copies of the Software, and to permit persons to whom the Software is |
|
* furnished to do so, subject to the following conditions: |
|
* |
|
* The above copyright notice and this permission notice shall be included in |
|
* all copies or substantial portions of the Software. |
|
* |
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
|
* THE SOFTWARE. |
|
*/ |
|
|
|
export interface TokenInfo { |
|
token: string; |
|
expiresAt: number; // Epoch time in milliseconds when the token should be considered expired. |
|
} |
|
|
|
export type FetchTokenFn = (currentToken: string | null) => Promise<TokenInfo>; |
|
|
|
export interface TokenManagerOptions { |
|
skewMs?: number; // Milliseconds to subtract from expiresAt to account for clock skew, slow networks, etc. Default: 5_000 ms. |
|
initialToken?: TokenInfo; // Optional initial token (e.g., from localStorage or a login response). |
|
} |
|
|
|
/** |
|
* Bare-bones in-memory token manager. |
|
* |
|
* - Caches token in memory |
|
* - Refreshes on demand when expired |
|
* - Deduplicates concurrent refreshes |
|
*/ |
|
export class TokenManager { |
|
private current: TokenInfo | null = null; |
|
private refreshPromise: Promise<TokenInfo> | null = null; |
|
private readonly fetchToken: FetchTokenFn; |
|
private readonly skewMs: number; |
|
|
|
constructor(fetchToken: FetchTokenFn, options: TokenManagerOptions = {}) { |
|
this.fetchToken = fetchToken; |
|
this.skewMs = options.skewMs ?? 5_000; |
|
this.current = options.initialToken ?? null; |
|
} |
|
|
|
/** |
|
* Gets a valid token, refreshing if necessary. |
|
*/ |
|
async getToken(): Promise<string> { |
|
const current = this.current; |
|
if (this.isTokenValid(current)) { |
|
return current.token; |
|
} |
|
|
|
// If another caller already kicked off a refresh, await that. |
|
if (this.refreshPromise) { |
|
const info = await this.refreshPromise; |
|
return info.token; |
|
} |
|
|
|
// Kick off a new refresh. |
|
const p = this.fetchToken(this.current?.token ?? null) |
|
.then((info) => { |
|
// CRITICAL CHECK: Only update if this promise is still the active one. |
|
// If invalidate() or forceRefresh() ran, this.refreshPromise will be null or different. |
|
if (this.refreshPromise === p) { |
|
this.current = info; |
|
} |
|
return info; |
|
}) |
|
.finally(() => { |
|
// Only clear if we are still the active promise |
|
if (this.refreshPromise === p) { |
|
this.refreshPromise = null; |
|
} |
|
}); |
|
|
|
this.refreshPromise = p; |
|
|
|
const info = await this.refreshPromise; |
|
return info.token; |
|
} |
|
|
|
/** |
|
* Optional: force a refresh, ignoring current validity. |
|
*/ |
|
async forceRefresh(): Promise<string> { |
|
this.current = null; |
|
this.refreshPromise = null; |
|
return this.getToken(); |
|
} |
|
|
|
/** |
|
* Invalidates the current token and any in-flight refresh. |
|
* Useful for logout or when you know the token is bad. |
|
*/ |
|
invalidate(): void { |
|
this.current = null; |
|
this.refreshPromise = null; |
|
} |
|
|
|
private isTokenValid(info: TokenInfo | null): boolean { |
|
if (!info) return false; |
|
const now = Date.now(); |
|
return info.expiresAt - this.skewMs > now; |
|
} |
|
} |