managarten/packages/shared-auth/src/core/tokenManager.ts
Wuesteon d36b321d9d style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
2025-11-27 18:33:16 +01:00

473 lines
12 KiB
TypeScript

import type {
TokenState,
TokenStateObserver,
QueuedRequest,
InternalTokenRefreshResult,
} from '../types';
import { TokenState as TokenStateEnum } from '../types';
import { isDeviceConnected, hasStableConnection } from '../adapters/network';
import type { AuthService } from './authService';
/**
* Configuration for the token manager
*/
export interface TokenManagerConfig {
maxQueueSize?: number;
queueTimeoutMs?: number;
maxRefreshAttempts?: number;
refreshCooldownMs?: number;
}
/**
* Create a token manager instance
*/
export function createTokenManager(authService: AuthService, config?: TokenManagerConfig) {
// Configuration
const MAX_QUEUE_SIZE = config?.maxQueueSize ?? 50;
const QUEUE_TIMEOUT_MS = config?.queueTimeoutMs ?? 30000;
const MAX_REFRESH_ATTEMPTS = config?.maxRefreshAttempts ?? 3;
const REFRESH_COOLDOWN_MS = config?.refreshCooldownMs ?? 5000;
// State
let state: TokenState = TokenStateEnum.IDLE;
let refreshPromise: Promise<InternalTokenRefreshResult> | null = null;
let requestQueue: QueuedRequest[] = [];
const observers = new Set<TokenStateObserver>();
let refreshAttempts = 0;
let lastRefreshTime = 0;
// Internal functions
function notifyObservers(newState: TokenState, token?: string): void {
observers.forEach((observer) => {
try {
observer(newState, token);
} catch (error) {
console.debug('Error in token state observer:', error);
}
});
}
function setState(newState: TokenState, token?: string): void {
if (state !== newState) {
console.debug(`TokenManager: State transition ${state} -> ${newState}`);
state = newState;
notifyObservers(newState, token);
}
}
function removeFromQueue(requestId: string): void {
const index = requestQueue.findIndex((item) => item.id === requestId);
if (index !== -1) {
requestQueue.splice(index, 1);
}
}
function isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const networkErrors = [
'network',
'Network',
'fetch',
'connection',
'timeout',
'Failed to fetch',
'NetworkError',
'TypeError',
'ERR_NETWORK',
'ERR_INTERNET_DISCONNECTED',
'ECONNREFUSED',
'ENOTFOUND',
'ETIMEDOUT',
'Unable to resolve host',
'Request failed',
];
const authErrors = [
'401',
'403',
'Unauthorized',
'Forbidden',
'Invalid token',
'Token expired',
'jwt expired',
'jwt malformed',
];
const errorString = `${error.message} ${error.name}`.toLowerCase();
const isNetworkError = networkErrors.some((keyword) =>
errorString.includes(keyword.toLowerCase())
);
const isAuthError = authErrors.some((keyword) => errorString.includes(keyword.toLowerCase()));
return isNetworkError && !isAuthError;
}
async function handleRefreshFailure(): Promise<void> {
console.debug('TokenManager: Handling permanent refresh failure');
try {
await authService.clearAuthStorage();
setState(TokenStateEnum.EXPIRED);
} catch (error) {
console.debug('Error in handleRefreshFailure:', error);
}
}
async function performTokenRefresh(): Promise<InternalTokenRefreshResult> {
try {
console.debug('TokenManager: Starting token refresh');
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Device offline, skipping refresh');
const currentToken = await authService.getAppToken();
if (currentToken) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
}
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
const isStable = await hasStableConnection();
if (!isStable) {
console.debug('TokenManager: Connection not stable yet, will retry');
return { success: false, error: 'unstable_connection' };
}
const refreshToken = await authService.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const refreshResult = await authService.refreshTokens(refreshToken);
const { appToken } = refreshResult;
console.debug('TokenManager: Token refresh successful');
return { success: true, token: appToken };
} catch (error) {
console.debug('TokenManager: Token refresh failed:', error);
const isRecoverable = isRecoverableError(error);
if (!isRecoverable) {
await handleRefreshFailure();
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown refresh error',
};
}
}
async function performTokenRefreshWithRetry(): Promise<InternalTokenRefreshResult> {
const retryDelays = [0, 1000, 2000, 5000];
let lastError: unknown = null;
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
try {
if (retryDelays[attempt] > 0) {
console.debug(
`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
);
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
}
const result = await performTokenRefresh();
if (result.success) {
return result;
}
// Non-retryable errors
if (
result.error === 'invalid_token' ||
result.error === 'token_expired' ||
result.error?.includes('Device ID has changed')
) {
return result;
}
if (result.error === 'offline') {
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
if (result.error === 'unstable_connection') {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
lastError = new Error(result.error || 'Token refresh failed');
if (attempt === retryDelays.length - 1) break;
} catch (error) {
lastError = error;
const isRecoverable = isRecoverableError(error);
if (!isRecoverable || attempt === retryDelays.length - 1) {
break;
}
}
}
return {
success: false,
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
};
}
async function processQueuedRequests(token: string): Promise<void> {
console.debug(`TokenManager: Processing ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
try {
const response = await retryRequestWithToken(request.input, request.init, token);
request.resolve(response);
} catch (error) {
request.reject(error);
}
}
}
async function rejectQueuedRequests(error: string): Promise<void> {
console.debug(`TokenManager: Rejecting ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error(error));
}
}
async function retryRequestWithToken(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string
): Promise<Response> {
const headers = new Headers(init?.headers || {});
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers,
});
}
// Public API
const manager = {
/**
* Subscribe to token state changes
*/
subscribe(observer: TokenStateObserver): () => void {
observers.add(observer);
return () => observers.delete(observer);
},
/**
* Get current token state
*/
getState(): TokenState {
return state;
},
/**
* Get a valid token, refreshing if necessary
*/
async getValidToken(): Promise<string | null> {
const currentToken = await authService.getAppToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
setState(TokenStateEnum.VALID, currentToken);
return currentToken;
}
if (!currentToken) {
console.debug('TokenManager: No token available, skipping refresh');
setState(TokenStateEnum.EXPIRED);
return null;
}
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Token expired while offline');
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return refreshResult.token;
}
if (refreshResult.shouldPreserveAuth) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
return null;
},
/**
* Handle 401 response
*/
async handle401Response(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (state === TokenStateEnum.REFRESHING && refreshPromise) {
return manager.queueRequest(input, init);
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return retryRequestWithToken(input, init, refreshResult.token);
}
throw new Error(refreshResult.error || 'Token refresh failed');
},
/**
* Queue a request during token refresh
*/
async queueRequest(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
if (requestQueue.length >= MAX_QUEUE_SIZE) {
reject(new Error('Request queue full'));
return;
}
const queueItem: QueuedRequest = {
id: Math.random().toString(36).substring(2, 11),
input,
init,
resolve,
reject,
timestamp: Date.now(),
};
requestQueue.push(queueItem);
setTimeout(() => {
removeFromQueue(queueItem.id);
reject(new Error('Queued request timeout'));
}, QUEUE_TIMEOUT_MS);
});
},
/**
* Refresh the authentication token
*/
async refreshToken(): Promise<InternalTokenRefreshResult> {
const now = Date.now();
if (now - lastRefreshTime < REFRESH_COOLDOWN_MS) {
return { success: false, error: 'Refresh cooldown active' };
}
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
await handleRefreshFailure();
return { success: false, error: 'Max refresh attempts reached' };
}
if (refreshPromise) {
return refreshPromise;
}
setState(TokenStateEnum.REFRESHING);
lastRefreshTime = now;
refreshPromise = performTokenRefreshWithRetry();
try {
const result = await refreshPromise;
if (result.success) {
refreshAttempts = 0;
setState(TokenStateEnum.VALID, result.token);
await processQueuedRequests(result.token!);
} else {
refreshAttempts++;
setState(TokenStateEnum.EXPIRED);
await rejectQueuedRequests(result.error || 'Token refresh failed');
}
return result;
} finally {
refreshPromise = null;
}
},
/**
* Reset the token manager state
*/
reset(): void {
state = TokenStateEnum.IDLE;
refreshPromise = null;
refreshAttempts = 0;
lastRefreshTime = 0;
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error('Token manager reset'));
}
},
/**
* Clear tokens and reset state
*/
async clearTokens(): Promise<void> {
try {
await authService.clearAuthStorage();
manager.reset();
} catch (error) {
console.debug('Error clearing tokens:', error);
manager.reset();
}
},
/**
* Get queue status for debugging
*/
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
return {
size: requestQueue.length,
state,
refreshAttempts,
};
},
/**
* Check initial token state
*/
async checkInitialState(): Promise<void> {
try {
const token = await authService.getAppToken();
if (!token) {
setState(TokenStateEnum.EXPIRED);
return;
}
if (authService.isTokenValidLocally(token)) {
setState(TokenStateEnum.VALID, token);
} else {
setState(TokenStateEnum.EXPIRED);
}
} catch (error) {
console.debug('Error checking initial token state:', error);
setState(TokenStateEnum.EXPIRED);
}
},
};
// Initialize
manager.checkInitialState();
return manager;
}
/**
* Type for the token manager instance
*/
export type TokenManager = ReturnType<typeof createTokenManager>;