/** * UNIFIED AUTH MODULE: Complete Token Acquisition, Storage, and Retrieval * * Standalone, drop-in authentication system for multi-provider scenarios * Handles: PocketBase (user + agent), Microsoft Graph (delegated + service principal) * * Features: * - Universal popup login interface (username/password + OAuth) * - Automatic token acquisition and storage * - Token refresh and expiration tracking * - Full support for 4 token types: pb-user, pb-agent, graph-user, graph-agent * - Zero dependencies except PocketBase SDK (optional for pb tokens) * * Usage: * await Auth.init('pb+graph', { * pbUrl: 'http://localhost:8090', * graphTenantId: '...' * }); * const pbToken = await Auth.getToken('pb-user'); * const graphToken = await Auth.getToken('graph-user'); */ // ============================================================================ // TOKEN TYPES & CONFIGURATION // ============================================================================ type TokenType = 'pb-user' | 'pb-agent' | 'graph-user' | 'graph-agent'; interface TokenInfo { value: string; expiresAt?: number; refreshToken?: string; acquiredAt: number; } interface AuthConfig { pbUrl?: string; graphTenantId?: string; graphClientId?: string; graphClientSecret?: string; redirectUri?: string; } interface AuthState { pattern: TokenType[]; config: AuthConfig; tokens: Map; pbInstance: any; popup: HTMLElement | null; agentCreds: { email: string; password: string }; } // Storage key definitions const STORAGE_KEYS: Record = { 'pb-user': 'auth:pb-user', 'pb-agent': 'auth:pb-agent', 'graph-user': 'auth:graph-user', 'graph-agent': 'auth:graph-agent' }; // ============================================================================ // AUTH MANAGER CLASS // ============================================================================ class UnifiedAuthManager { private state: AuthState = { pattern: [], config: {}, tokens: new Map(), pbInstance: null, popup: null, agentCreds: { email: '', password: '' } }; /** * Initialize auth module with pattern and configuration * Pattern: 'pb', 'graph', 'pb+graph', etc. (composable) * Config: Optional PocketBase URL, Graph tenant ID, etc. */ async init(pattern: string, config?: AuthConfig): Promise { this.state.config = config || {}; this.state.pattern = this.parsePattern(pattern); // Load persisted tokens from localStorage this.loadPersistedTokens(); // Initialize PocketBase if needed if (this.state.pattern.some(t => t.startsWith('pb'))) { await this.initPocketBase(); } // Create UI popup if not already present if (!document.getElementById('auth-unified-popup')) { this.createPopup(); } console.log('[Auth] Initialized with pattern:', this.state.pattern, 'config:', config); } /** * Parse pattern string into array of token types * Examples: 'pb' → ['pb-user', 'pb-agent'], 'graph' → ['graph-user', 'graph-agent'] * Composable: 'pb+graph' → all 4 types */ private parsePattern(patternStr: string): TokenType[] { const parts = patternStr.split('+').map(s => s.trim()); const types: TokenType[] = []; for (const part of parts) { if (part === 'pb') { types.push('pb-user', 'pb-agent'); } else if (part === 'graph') { types.push('graph-user', 'graph-agent'); } else if (['pb-user', 'pb-agent', 'graph-user', 'graph-agent'].includes(part)) { types.push(part as TokenType); } } return [...new Set(types)]; // Deduplicate } /** * Load persisted tokens from localStorage */ private loadPersistedTokens(): void { for (const [type, key] of Object.entries(STORAGE_KEYS)) { const stored = localStorage.getItem(key); if (stored) { try { const info = JSON.parse(stored); this.state.tokens.set(type as TokenType, info); } catch { // Ignore parse errors, treat as stale localStorage.removeItem(key); } } } } /** * Initialize PocketBase SDK */ private async initPocketBase(): Promise { if (this.state.pbInstance) return; const PocketBase = (window as any).PocketBase; if (!PocketBase) { throw new Error( 'PocketBase SDK not loaded. Include PocketBase script: ' + '' ); } const pbUrl = this.state.config.pbUrl || 'http://127.0.0.1:8090'; this.state.pbInstance = new PocketBase(pbUrl); console.log('[Auth] PocketBase initialized:', pbUrl); } /** * Get a token, prompting for auth if needed * Automatically handles refresh if expired */ async getToken(type: TokenType): Promise { // Check if token is cached and valid const cached = this.state.tokens.get(type); if (cached && cached.value) { if (!cached.expiresAt || cached.expiresAt > Date.now()) { return cached.value; } // Token expired, try refresh try { return await this.refreshToken(type); } catch (err) { console.warn(`[Auth] Token refresh failed for ${type}:`, err); // Fall through to re-acquire } } // Token not available, acquire it return await this.acquireToken(type); } /** * Acquire a token for the first time (shows login UI) */ private async acquireToken(type: TokenType): Promise { console.log('[Auth] Acquiring token:', type); if (type === 'pb-user') { return await this.acquirePocketBaseUser(); } else if (type === 'pb-agent') { return await this.acquirePocketBaseAgent(); } else if (type === 'graph-user') { return await this.acquireGraphUser(); } else if (type === 'graph-agent') { return await this.acquireGraphAgent(); } throw new Error(`Unknown token type: ${type}`); } /** * Acquire PocketBase user token via OAuth (Microsoft sign-in) * This is the PRIMARY user login flow */ private async acquirePocketBaseUser(): Promise { if (!this.state.pbInstance) { throw new Error('PocketBase not initialized'); } // Use PocketBase OAuth with Microsoft // This automatically includes Graph scopes from PocketBase config const authData = await this.state.pbInstance.collection('users').authWithOAuth2({ provider: 'microsoft' }); const token = authData.token; const meta = authData.meta || {}; // Store pb user token this.storeToken('pb-user', token, meta.expiresIn); // Extract and store Graph token from OAuth response // PocketBase can return Graph token in meta if configured const graphToken = this.extractGraphToken(meta); if (graphToken) { this.storeToken('graph-user', graphToken, meta.graphExpiresIn); } console.log('[Auth] PocketBase user token acquired via OAuth'); return token; } /** * Acquire PocketBase agent token (service account) * Shows popup for agent email/password */ private async acquirePocketBaseAgent(): Promise { if (!this.state.pbInstance) { throw new Error('PocketBase not initialized'); } // Prompt for agent credentials via popup await this.showAuthPopup('pb-agent'); const { email, password } = this.state.agentCreds; if (!email || !password) { throw new Error('Agent credentials required'); } // Authenticate as agent const authData = await this.state.pbInstance.collection('users').authWithPassword(email, password); const token = authData.token; this.storeToken('pb-agent', token, authData.meta?.expiresIn); console.log('[Auth] PocketBase agent token acquired'); return token; } /** * Acquire Microsoft Graph delegated token (on behalf of user) * Usually acquired during initial PocketBase OAuth, but can be re-acquired */ private async acquireGraphUser(): Promise { if (!this.state.pbInstance) { throw new Error('PocketBase not initialized'); } // Try OAuth again (same as PB user, but explicitly for Graph) const authData = await this.state.pbInstance.collection('users').authWithOAuth2({ provider: 'microsoft' }); const graphToken = this.extractGraphToken(authData.meta || {}); if (!graphToken) { throw new Error('Graph token not returned by OAuth provider'); } this.storeToken('graph-user', graphToken, authData.meta?.graphExpiresIn); console.log('[Auth] Graph delegated token acquired via OAuth'); return graphToken; } /** * Acquire Microsoft Graph service principal token (app-only) * For backend/service scenarios - not typically used in browser * This is a placeholder; real flow requires backend service */ private async acquireGraphAgent(): Promise { // In a real scenario, this would: // 1. Call a backend endpoint // 2. Backend uses client credentials flow with Azure AD // 3. Backend returns token // For now, show popup and simulate await this.showAuthPopup('graph-agent'); // This is a placeholder - real implementation needs backend const { email } = this.state.agentCreds; if (!email) { throw new Error('Graph agent credentials required'); } // Simulate token (real app would get from backend) const token = await this.fetchGraphAgentTokenFromBackend(email); this.storeToken('graph-agent', token); console.log('[Auth] Graph service principal token acquired'); return token; } /** * Placeholder for fetching Graph agent token from backend * Real implementation would call POST /api/auth/graph-agent-token */ private async fetchGraphAgentTokenFromBackend(email: string): Promise { // TODO: Implement backend call to get service principal token // For now, return a placeholder return 'PLACEHOLDER_GRAPH_AGENT_TOKEN_' + email; } /** * Extract Graph token from OAuth meta response * PocketBase returns it in various places depending on config */ private extractGraphToken(meta: any): string | null { return ( meta?.graphAccessToken || meta?.graph_token || meta?.graphToken || meta?.accessToken || meta?.access_token || meta?.token || meta?.rawToken || meta?.authData?.access_token || null ); } /** * Refresh a token if it's expired */ private async refreshToken(type: TokenType): Promise { console.log('[Auth] Refreshing token:', type); if (type === 'pb-user' && this.state.pbInstance) { try { const authData = await this.state.pbInstance.collection('users').authRefresh(); const token = authData.token; this.storeToken('pb-user', token, authData.meta?.expiresIn); return token; } catch (err) { throw new Error('PocketBase refresh failed'); } } if (type === 'graph-user' && this.state.pbInstance) { try { const authData = await this.state.pbInstance.collection('users').authRefresh(); const graphToken = this.extractGraphToken(authData.meta || {}); if (graphToken) { this.storeToken('graph-user', graphToken, authData.meta?.graphExpiresIn); return graphToken; } } catch (err) { throw new Error('Graph token refresh failed'); } } // For agents and other types, re-acquire return await this.acquireToken(type); } /** * Store token to memory and localStorage */ private storeToken(type: TokenType, value: string, expiresIn?: number): void { const expiresAt = expiresIn ? Date.now() + expiresIn * 1000 : undefined; const info: TokenInfo = { value, expiresAt, acquiredAt: Date.now() }; this.state.tokens.set(type, info); // Persist to localStorage const key = STORAGE_KEYS[type]; localStorage.setItem(key, JSON.stringify(info)); console.log('[Auth] Token stored:', type, 'expires at:', expiresAt ? new Date(expiresAt).toISOString() : 'never'); } /** * Clear a specific token */ clearToken(type: TokenType): void { this.state.tokens.delete(type); localStorage.removeItem(STORAGE_KEYS[type]); console.log('[Auth] Token cleared:', type); } /** * Clear all tokens */ clearAllTokens(): void { this.state.tokens.clear(); for (const key of Object.values(STORAGE_KEYS)) { localStorage.removeItem(key); } console.log('[Auth] All tokens cleared'); } /** * Get user info from PocketBase auth store */ getUserInfo(): { displayName: string; email: string } { const model = this.state.pbInstance?.authStore?.model; if (!model) { return { displayName: '', email: '' }; } return { displayName: model.display_name || model.name || model.email || model.username || 'Unknown', email: model.email || model.username || '' }; } /** * Check if module is initialized */ isInitialized(): boolean { return this.state.pattern.length > 0; } /** * Get enabled token types */ getEnabledTypes(): TokenType[] { return [...this.state.pattern]; } // ======================================================================== // UI POPUP MANAGEMENT // ======================================================================== /** * Create the auth popup UI element */ private createPopup(): void { const popup = document.createElement('div'); popup.id = 'auth-unified-popup'; popup.style.cssText = ` display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.5); z-index: 9999; align-items: center; justify-content: center; `; popup.innerHTML = `

Authentication Required

`; document.body.appendChild(popup); this.state.popup = popup; } /** * Show auth popup and wait for user to complete auth */ private async showAuthPopup(type: TokenType): Promise { if (!this.state.popup) return; const formDiv = document.getElementById('auth-popup-form')!; const errorDiv = document.getElementById('auth-popup-error')!; // Set form based on type if (type === 'pb-agent') { formDiv.innerHTML = `
`; } else if (type === 'graph-agent') { formDiv.innerHTML = `
`; } // Show popup this.state.popup.style.display = 'flex'; errorDiv.style.display = 'none'; // Wait for form submission return new Promise((resolve, reject) => { const submitBtn = document.getElementById('auth-popup-submit')!; submitBtn.onclick = () => { try { const emailInput = document.getElementById('auth-agent-email') as HTMLInputElement; const passwordInput = document.getElementById('auth-agent-password') as HTMLInputElement; this.state.agentCreds.email = emailInput.value; this.state.agentCreds.password = passwordInput.value; if (!this.state.agentCreds.email || !this.state.agentCreds.password) { throw new Error('Email and password required'); } this.state.popup!.style.display = 'none'; resolve(); } catch (err) { errorDiv.textContent = (err as Error).message || 'Authentication failed'; errorDiv.style.display = ''; } }; // Allow Enter key const inputs = formDiv.querySelectorAll('input'); inputs.forEach(input => { input.onkeypress = (e) => { if (e.key === 'Enter') submitBtn.click(); }; }); }); } } // ============================================================================ // SINGLETON INSTANCE & EXPORTS // ============================================================================ const Auth = new UnifiedAuthManager(); // Expose globally for browser if (typeof window !== 'undefined') { (window as any).Auth = Auth; } export default Auth; export { UnifiedAuthManager, TokenType, AuthConfig, TokenInfo };