Files
2026-01-10 19:23:47 +00:00

592 lines
18 KiB
TypeScript

/**
* 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<TokenType, TokenInfo>;
pbInstance: any;
popup: HTMLElement | null;
agentCreds: { email: string; password: string };
}
// Storage key definitions
const STORAGE_KEYS: Record<TokenType, string> = {
'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<void> {
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<void> {
if (this.state.pbInstance) return;
const PocketBase = (window as any).PocketBase;
if (!PocketBase) {
throw new Error(
'PocketBase SDK not loaded. Include PocketBase script: ' +
'<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></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<string> {
// 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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
// 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<string> {
// 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<string> {
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 = `
<div style="
background: white;
padding: 2em 2.5em;
border-radius: 1em;
box-shadow: 0 2px 24px rgba(0, 0, 0, 0.15);
text-align: center;
max-width: 400px;
width: 90%;
">
<h2 style="font-size: 1.3em; margin-bottom: 1.5em; color: #1f2937;">Authentication Required</h2>
<div id="auth-popup-form" style="margin-bottom: 1.5em;"></div>
<div id="auth-popup-error" style="color: #dc2626; margin-top: 1em; display: none; font-size: 0.9em;"></div>
</div>
`;
document.body.appendChild(popup);
this.state.popup = popup;
}
/**
* Show auth popup and wait for user to complete auth
*/
private async showAuthPopup(type: TokenType): Promise<void> {
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 = `
<div style="text-align: left;">
<input id="auth-agent-email" type="email" placeholder="Agent Email"
style="width: 100%; padding: 0.75em; margin-bottom: 0.75em; border: 1px solid #d1d5db; border-radius: 0.5em; font-size: 1em;">
<input id="auth-agent-password" type="password" placeholder="Agent Password"
style="width: 100%; padding: 0.75em; margin-bottom: 0.75em; border: 1px solid #d1d5db; border-radius: 0.5em; font-size: 1em;">
</div>
<button id="auth-popup-submit" style="
background: #059669;
color: white;
padding: 0.75em 2em;
border: none;
border-radius: 0.5em;
font-size: 1em;
cursor: pointer;
width: 100%;
font-weight: 600;
">Sign in as Agent</button>
`;
} else if (type === 'graph-agent') {
formDiv.innerHTML = `
<div style="text-align: left;">
<input id="auth-agent-email" type="email" placeholder="Service Principal Email"
style="width: 100%; padding: 0.75em; margin-bottom: 0.75em; border: 1px solid #d1d5db; border-radius: 0.5em; font-size: 1em;">
<input id="auth-agent-password" type="password" placeholder="Service Principal Password"
style="width: 100%; padding: 0.75em; margin-bottom: 0.75em; border: 1px solid #d1d5db; border-radius: 0.5em; font-size: 1em;">
</div>
<button id="auth-popup-submit" style="
background: #2563eb;
color: white;
padding: 0.75em 2em;
border: none;
border-radius: 0.5em;
font-size: 1em;
cursor: pointer;
width: 100%;
font-weight: 600;
">Sign in as Service Principal</button>
`;
}
// 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 };