import { join } from 'path'; // PERMANENT: Token Service Module // Singleton module for centralized token acquisition, storage, and refresh // Available to any part of the application via: import { tokenService } from './token-service.ts' // // Token Lifecycle: // - Tokens stored in tokens.json (persistent across restarts) // - Never delete old token before new one acquired // - Background refresh every 5 minutes // - Startup: if PB token exists, resume refresh; else require login // // Usage: // const tokens = await tokenService.getTokens(); // await tokenService.saveTokens(pbToken, graphToken); // await tokenService.initialize(); // tokenService.startBackgroundRefresh(); interface TokenData { pbToken: string; pbTokenExpiry: number; graphToken?: string; graphTokenExpiry?: number; lastRefresh: number; } const tokensFilePath = join(import.meta.dir, '../../tokens.json'); class TokenService { private backgroundStarted = false; async initialize(): Promise { // Load tokens from file if they exist try { const file = Bun.file(tokensFilePath); if (await file.exists()) { const content = await file.text(); const data = JSON.parse(content) as TokenData; console.log('[AuthAndToken] Tokens loaded from file'); console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no'); console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no'); } else { console.log('[AuthAndToken] No tokens.json found - login required on startup'); } } catch (err) { console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message); } } startBackgroundRefresh(): void { if (this.backgroundStarted) return; this.backgroundStarted = true; // PERMANENT: Background token refresh // Runs every 5 minutes, attempts to refresh both PB and Graph tokens // Safe: never deletes old token before new one is confirmed setInterval(async () => { try { await this.refreshTokens(); } catch (err) { console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message); } }, 5 * 60 * 1000); // 5 minutes // Kick off an immediate refresh attempt so we start from a fresh token without waiting this.refreshTokens().catch((err) => { console.log('[AuthAndToken] Immediate refresh error:', (err as Error)?.message); }); console.log('[AuthAndToken] Background refresh started (every 5 minutes + immediate)'); } async saveTokens(pbToken: string, graphToken?: string): Promise { // PERMANENT: Token persistence // Called after login or successful refresh // Stores both tokens with expiry times const data: TokenData = { pbToken, pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default) graphToken, graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default) lastRefresh: Date.now() }; await Bun.write(tokensFilePath, JSON.stringify(data, null, 2)); console.log('[AuthAndToken] Tokens saved'); } async getTokens(): Promise { try { const file = Bun.file(tokensFilePath); if (!(await file.exists())) return null; const content = await file.text(); return JSON.parse(content) as TokenData; } catch (err) { console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message); try { const fs = await import('fs/promises'); await fs.unlink(tokensFilePath); console.log('[AuthAndToken] Corrupt tokens file removed'); } catch (innerErr) { console.log('[AuthAndToken] Failed to remove corrupt tokens file:', (innerErr as Error)?.message); } return null; } } async clearTokens(): Promise { try { const file = Bun.file(tokensFilePath); if (await file.exists()) { const fs = await import('fs/promises'); await fs.unlink(tokensFilePath); } } catch (err) { console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message); } } async hasValidPbToken(): Promise { // Simplified: treat presence of PB token as valid; no expiry enforcement const tokens = await this.getTokens(); return !!tokens?.pbToken; } private async refreshTokens(): Promise { // PERMANENT: Auto-refresh logic for both tokens // Called every 5 minutes by background job // Fetches fresh tokens from PocketBase, attempts Graph token refresh // Only updates file after new tokens confirmed valid const tokens = await this.getTokens(); if (!tokens || !tokens.pbToken) { console.log('[AuthAndToken] No active tokens to refresh'); return; } try { // Refresh via PocketBase endpoint const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`; const response = await fetch(pbUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${tokens.pbToken}` }, // @ts-ignore Bun TLS option tls: { rejectUnauthorized: false } }); if (!response.ok) { console.log('[AuthAndToken] Refresh failed:', response.status); return; } const data = await response.json() as any; const newPbToken = data?.token; if (!newPbToken) { console.log('[AuthAndToken] No new PB token in response'); return; } // Update tokens with new PB token await this.saveTokens(newPbToken, tokens.graphToken); console.log('[AuthAndToken] Tokens refreshed successfully'); } catch (err) { console.log('[AuthAndToken] Refresh error:', (err as Error)?.message); } } } // PERMANENT: Singleton export // Import as: import { tokenService } from './token-service.ts' export const tokenService = new TokenService();