import type { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens, StorageAdapter, } from './config.js'; export type { StorageAdapter }; /** * Internal token storage format with absolute expiry timestamp */ interface StoredTokens extends Omit { /** * Absolute timestamp (milliseconds) when the token expires * This is derived from expires_in when tokens are saved */ expires_at?: number; } /** * Calculate expires_in from expires_at timestamp */ function calculateExpiresIn(expiresAt: number | undefined): number | undefined { if (!expiresAt) { return undefined; } const now = Date.now(); const expiresInMs = expiresAt - now; const expiresInSeconds = Math.floor(expiresInMs / 1000); // Return 0 if already expired, otherwise return remaining seconds return Math.max(0, expiresInSeconds); } /** * Calculate expires_at from expires_in */ function calculateExpiresAt(expiresIn: number | undefined): number { return Date.now() + (expiresIn || 0) * 1000; } /** * In-memory storage adapter for OAuth data * Suitable for development and testing, but data is lost when process exits */ export class MemoryStorage implements StorageAdapter { private data = new Map(); async get(key: string): Promise { return this.data.get(key); } async set(key: string, value: string): Promise { this.data.set(key, value); } async delete(key: string): Promise { this.data.delete(key); } /** * Clear all data (useful for testing) */ clear(): void { this.data.clear(); } } /** * File-based storage adapter for OAuth data * Persists data to the filesystem for longer-term storage */ export class FileStorage implements StorageAdapter { private basePath: string; constructor(basePath = './oauth-data') { this.basePath = basePath; } private getFilePath(key: string): string { // Sanitize key for filename const sanitized = key.replace(/[^a-zA-Z0-9-_]/g, '_'); return `${this.basePath}/${sanitized}.json`; } private async ensureDirectory(): Promise { try { await Bun.write(`${this.basePath}/.gitkeep`, ''); } catch { // Directory creation will happen automatically with Bun.write } } async get(key: string): Promise { try { const file = Bun.file(this.getFilePath(key)); const exists = await file.exists(); if (!exists) { return undefined; } return await file.text(); } catch { return undefined; } } async set(key: string, value: string): Promise { await this.ensureDirectory(); await Bun.write(this.getFilePath(key), value); } async delete(key: string): Promise { try { await Bun.$`rm -f ${this.getFilePath(key)}`; } catch { // File might not exist, ignore error } } /** * Clear all data (useful for testing) */ async clear(): Promise { try { await Bun.$`rm -rf ${this.basePath}`; } catch { // Directory might not exist, ignore error } } } /** * Create a storage adapter based on the provided configuration */ export function createStorageAdapter( type?: 'memory' | 'file', options?: { path?: string } ): StorageAdapter { switch (type) { case 'file': return new FileStorage(options?.path); case 'memory': default: return new MemoryStorage(); } } /** * Options for initializing OAuthStorage */ export interface OAuthStorageOptions { /** * Static client information (from config) * If provided, will ALWAYS be returned (takes precedence over storage) * This is because client credentials are like API keys - they don't change */ staticClientInfo?: OAuthClientInformation; /** * Initial tokens (from config) * If provided, will be stored ONCE on first access if storage is empty * After that, storage takes precedence (tokens change over time) */ initialTokens?: OAuthTokens & { expires_at?: number | undefined; }; } /** * Helper class to manage OAuth data with the simplified storage adapter * Handles initialization from config and provides a unified storage interface */ export class OAuthStorage { private initialized = false; constructor( private storage: StorageAdapter, private sessionId: string, private options?: OAuthStorageOptions ) {} /** * Initialize storage with config values if provided * This ensures config tokens are written to storage on first use */ private async initialize(): Promise { if (this.initialized) { return; } this.initialized = true; // Initialize tokens if provided and not already in storage // (tokens are one-time initialization, storage takes over after that) if (this.options?.initialTokens) { const existing = await this.storage.get(`tokens:${this.sessionId}`); if (!existing) { // Convert expires_in to expires_at before storing const storedTokens: StoredTokens = { access_token: this.options.initialTokens.access_token, token_type: this.options.initialTokens.token_type, refresh_token: this.options.initialTokens.refresh_token, scope: this.options.initialTokens.scope, expires_at: this.options.initialTokens.expires_at, }; // Only set expires_at if expires_in is provided if (this.options.initialTokens.expires_in !== undefined) { storedTokens.expires_at = calculateExpiresAt( this.options.initialTokens.expires_in ); } await this.storage.set( `tokens:${this.sessionId}`, JSON.stringify(storedTokens) ); } } } async saveTokens(tokens: OAuthTokens): Promise { await this.initialize(); // Convert expires_in to expires_at for storage const storedTokens: StoredTokens = { access_token: tokens.access_token, token_type: tokens.token_type, refresh_token: tokens.refresh_token, scope: tokens.scope, }; // Only set expires_at if expires_in is provided if (tokens.expires_in !== undefined) { storedTokens.expires_at = calculateExpiresAt(tokens.expires_in); } await this.storage.set( `tokens:${this.sessionId}`, JSON.stringify(storedTokens) ); } async getTokens(): Promise { await this.initialize(); const data = await this.storage.get(`tokens:${this.sessionId}`); if (!data) { return undefined; } const storedTokens: StoredTokens = JSON.parse(data); // Convert expires_at back to expires_in for the OAuthTokens interface const tokens: OAuthTokens = { access_token: storedTokens.access_token, token_type: storedTokens.token_type, }; // Only include optional fields if they exist if (storedTokens.refresh_token !== undefined) { tokens.refresh_token = storedTokens.refresh_token; } if (storedTokens.scope !== undefined) { tokens.scope = storedTokens.scope; } if (storedTokens.expires_at !== undefined) { tokens.expires_in = calculateExpiresIn(storedTokens.expires_at); } return tokens; } async clearTokens(): Promise { await this.initialize(); await this.storage.delete(`tokens:${this.sessionId}`); } async saveClientInfo(clientInfo: OAuthClientInformationFull): Promise { await this.initialize(); await this.storage.set('client_info', JSON.stringify(clientInfo)); } async getClientInfo(): Promise { await this.initialize(); // Static client info from config ALWAYS takes precedence // (client credentials are like API keys - they don't change) if (this.options?.staticClientInfo?.client_id) { return this.options.staticClientInfo; } const data = await this.storage.get('client_info'); return data ? JSON.parse(data) : undefined; } async clearClientInfo(): Promise { await this.initialize(); await this.storage.delete('client_info'); } async saveCodeVerifier(verifier: string): Promise { await this.initialize(); await this.storage.set(`verifier:${this.sessionId}`, verifier); } async getCodeVerifier(): Promise { await this.initialize(); return this.storage.get(`verifier:${this.sessionId}`); } async clearCodeVerifier(): Promise { await this.initialize(); await this.storage.delete(`verifier:${this.sessionId}`); } async clearSession(): Promise { await this.initialize(); await Promise.all([this.clearTokens(), this.clearCodeVerifier()]); } async clearAll(): Promise { await this.initialize(); await Promise.all([ this.clearTokens(), this.clearCodeVerifier(), this.clearClientInfo(), ]); } }