Skip to content

Instantly share code, notes, and snippets.

@visualjeff
Last active November 21, 2025 03:58
Show Gist options
  • Select an option

  • Save visualjeff/99aee53e5ac50cfb8698db27fdf0f1c5 to your computer and use it in GitHub Desktop.

Select an option

Save visualjeff/99aee53e5ac50cfb8698db27fdf0f1c5 to your computer and use it in GitHub Desktop.
tokenManager

This gives you:

•	getToken() that:
•	returns cached token if still valid
•	otherwise calls your REST endpoint with the existing token
•	Protection against the "thundering herd" of multiple callers triggering multiple renewals at once
•	invalidate() method for logout or when you need to clear the token
•	Support for seeding an initial token (e.g., from localStorage)
•	No external deps, MIT-licensed, TypeScript-native

If you want this to be slightly more opinionated (e.g., parse exp out of a JWT instead of using expiresInSeconds), we can tweak the TokenInfo / FetchTokenFn shape to match what you’re already getting back from the server.

/**
* 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;
}
}
import { TokenManager, TokenInfo, FetchTokenFn } from "./TokenManager";
interface RenewResponse {
token: string;
expiresInSeconds: number; // Note: Your API might return expires_in, expiresIn, or an ISO timestamp instead. Adjust the interface and parsing logic below to match your actual API response.
}
// Your custom callback to fetch a new token
const fetchToken: FetchTokenFn = async (currentToken) => {
const res = await fetch("https://your-service.example.com/api/token/renew", {
method: "POST",
headers: {
"Content-Type": "application/json",
// If your endpoint needs the current token to issue a new one:
...(currentToken ? { Authorization: `Bearer ${currentToken}` } : {}),
},
body: JSON.stringify({
// Or send currentToken in the body if that’s what your API expects:
currentToken,
}),
});
if (!res.ok) {
throw new Error(`Failed to renew token: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as RenewResponse;
const now = Date.now();
const expiresAt = now + data.expiresInSeconds * 1000;
const info: TokenInfo = {
token: data.token,
expiresAt,
};
return info;
};
export const tokenManager = new TokenManager(fetchToken, {
// Optional: custom skew
skewMs: 10_000,
// Optional: provide initial token from localStorage, login response, etc.
// initialToken: { token: "existing-token", expiresAt: Date.now() + 3600000 },
});
// Example: wrapping fetch to always use the current token.
export async function authedFetch(input: RequestInfo, init: RequestInit = {}) {
const token = await tokenManager.getToken();
const headers = new Headers(init.headers || {});
headers.set("Authorization", `Bearer ${token}`);
return fetch(input, { ...init, headers });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment