592 lines
18 KiB
TypeScript
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 };
|