/** * EncryptID Session Management * * Handles session tokens, cross-app SSO, and authentication levels. * This is Layer 4/5 of the EncryptID architecture. */ import { AuthenticationResult, bufferToBase64url } from './webauthn'; import { resetLinkedWalletStore } from './linked-wallets'; const ENCRYPTID_SERVER = 'https://auth.rspace.online'; // ============================================================================ // TYPES // ============================================================================ /** * Authentication security levels */ export enum AuthLevel { /** Session token only - read-only, public content */ BASIC = 1, /** Recent WebAuthn (within 15 min) - standard operations */ STANDARD = 2, /** Fresh WebAuthn (just authenticated) - sensitive operations */ ELEVATED = 3, /** Fresh WebAuthn + explicit consent - critical operations */ CRITICAL = 4, } /** * EncryptID session token claims */ export interface EncryptIDClaims { // Standard JWT claims iss: string; // Issuer: https://encryptid.online sub: string; // Subject: DID (did:key:z6Mk...) aud: string[]; // Audience: authorized apps iat: number; // Issued at exp: number; // Expiration (short-lived: 15 min) jti: string; // JWT ID (for revocation) // EncryptID-specific claims eid: { walletAddress?: string; // AA wallet address if deployed credentialId: string; // WebAuthn credential ID authLevel: AuthLevel; // Security level of this session authTime: number; // When WebAuthn was performed capabilities: { encrypt: boolean; // Has derived encryption key sign: boolean; // Has derived signing key wallet: boolean; // Can authorize wallet ops }; recoveryConfigured: boolean; // Has social recovery set up }; } /** * Session state stored locally */ export interface SessionState { accessToken: string; refreshToken: string; claims: EncryptIDClaims; lastAuthTime: number; } /** * Operation permission requirements */ export interface OperationPermission { minAuthLevel: AuthLevel; requiresCapability?: 'encrypt' | 'sign' | 'wallet'; maxAgeSeconds?: number; // Max time since last WebAuthn } // ============================================================================ // OPERATION PERMISSIONS // ============================================================================ /** * Permission requirements for various operations across r-apps */ export const OPERATION_PERMISSIONS: Record = { // rspace operations 'rspace:view-public': { minAuthLevel: AuthLevel.BASIC }, 'rspace:view-private': { minAuthLevel: AuthLevel.STANDARD }, 'rspace:edit-board': { minAuthLevel: AuthLevel.STANDARD }, 'rspace:create-board': { minAuthLevel: AuthLevel.STANDARD }, 'rspace:delete-board': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 }, 'rspace:encrypt-board': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'encrypt' }, // rwallet operations 'rwallet:view-balance': { minAuthLevel: AuthLevel.BASIC }, 'rwallet:view-history': { minAuthLevel: AuthLevel.STANDARD }, 'rwallet:send-small': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'wallet' }, 'rwallet:send-large': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 }, 'rwallet:add-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, 'rwallet:remove-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, 'rwallet:link-wallet': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 }, 'rwallet:unlink-wallet': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 }, 'rwallet:external-send': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 }, // rvote operations 'rvote:view-proposals': { minAuthLevel: AuthLevel.BASIC }, 'rvote:cast-vote': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'sign', maxAgeSeconds: 300 }, 'rvote:delegate': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet' }, // rfiles operations 'rfiles:list-files': { minAuthLevel: AuthLevel.STANDARD }, 'rfiles:download-own': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'encrypt' }, 'rfiles:upload': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'encrypt' }, 'rfiles:share': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'encrypt' }, 'rfiles:delete': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 }, 'rfiles:export-keys': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, // rmaps operations 'rmaps:view-public': { minAuthLevel: AuthLevel.BASIC }, 'rmaps:add-location': { minAuthLevel: AuthLevel.STANDARD }, 'rmaps:edit-location': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'sign' }, // Payment operations (x402 and Safe treasury) 'payment:x402': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'wallet' }, 'payment:safe-propose': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 }, 'payment:safe-execute': { minAuthLevel: AuthLevel.CRITICAL, requiresCapability: 'wallet', maxAgeSeconds: 60 }, // Account operations 'account:view-profile': { minAuthLevel: AuthLevel.STANDARD }, 'account:edit-profile': { minAuthLevel: AuthLevel.ELEVATED }, 'account:export-data': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, 'account:delete': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, }; // ============================================================================ // SESSION MANAGER // ============================================================================ const SESSION_STORAGE_KEY = 'encryptid_session'; const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes before expiry /** * EncryptID Session Manager * * Handles session state, token refresh, and permission checks. */ export class SessionManager { private session: SessionState | null = null; private refreshTimer: ReturnType | null = null; constructor() { // Try to restore session from storage this.restoreSession(); } /** * Initialize session from authentication result. * Exchanges the credential for a server-signed JWT via EncryptID. */ async createSession( authResult: AuthenticationResult, did: string, capabilities: EncryptIDClaims['eid']['capabilities'] ): Promise { const now = Math.floor(Date.now() / 1000); // Get a server-signed JWT by exchanging the credential via EncryptID let accessToken: string; try { // Step 1: Get a server challenge const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credentialId: authResult.credentialId }), }); if (!startRes.ok) throw new Error('Failed to start server auth'); const { options } = await startRes.json(); // Step 2: Complete auth with credentialId to get signed token const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: options.challenge, credential: { credentialId: authResult.credentialId }, }), }); if (!completeRes.ok) throw new Error('Server auth failed'); const data = await completeRes.json(); if (!data.token) throw new Error('No token in response'); accessToken = data.token; } catch (err) { console.warn('EncryptID: Server token exchange failed, using local token', err); // Fallback to unsigned token if server is unreachable accessToken = this.createUnsignedToken({ iss: 'https://auth.ridentity.online', sub: did, aud: ['rspace.online'], iat: now, exp: now + 15 * 60, jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), eid: { credentialId: authResult.credentialId, authLevel: AuthLevel.ELEVATED, authTime: now, capabilities, recoveryConfigured: false, }, }); } // Decode claims from the token (works for both signed and unsigned JWTs) let claims: EncryptIDClaims; try { const payloadB64 = accessToken.split('.')[1]; claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); } catch { // Fallback claims claims = { iss: 'https://auth.ridentity.online', sub: did, aud: ['rspace.online'], iat: now, exp: now + 15 * 60, jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), eid: { credentialId: authResult.credentialId, authLevel: AuthLevel.ELEVATED, authTime: now, capabilities, recoveryConfigured: false, }, }; } this.session = { accessToken, refreshToken: accessToken, // Server token serves as both claims, lastAuthTime: Date.now(), }; // Store session this.persistSession(); // Schedule token refresh this.scheduleRefresh(); console.log('EncryptID: Session created', { did: (claims.sub || did).slice(0, 30) + '...', authLevel: AuthLevel[claims.eid?.authLevel ?? AuthLevel.ELEVATED], signed: !accessToken.endsWith('.'), }); return this.session; } /** * Get current session */ getSession(): SessionState | null { return this.session; } /** * Get current DID */ getDID(): string | null { return this.session?.claims.sub ?? null; } /** * Get current auth level */ getAuthLevel(): AuthLevel { if (!this.session) return AuthLevel.BASIC; // Check if session is still valid const now = Math.floor(Date.now() / 1000); if (now >= this.session.claims.exp) { return AuthLevel.BASIC; } // Check auth age for level downgrade const authAge = now - this.session.claims.eid.authTime; if (authAge < 60) { return AuthLevel.ELEVATED; // Within 1 minute } else if (authAge < 15 * 60) { return AuthLevel.STANDARD; // Within 15 minutes } else { return AuthLevel.BASIC; } } /** * Check if user can perform an operation */ canPerform(operation: string): { allowed: boolean; reason?: string } { const permission = OPERATION_PERMISSIONS[operation]; if (!permission) { return { allowed: false, reason: 'Unknown operation' }; } if (!this.session) { return { allowed: false, reason: 'Not authenticated' }; } const currentLevel = this.getAuthLevel(); // Check auth level if (currentLevel < permission.minAuthLevel) { return { allowed: false, reason: `Requires ${AuthLevel[permission.minAuthLevel]} auth level (current: ${AuthLevel[currentLevel]})`, }; } // Check capability if (permission.requiresCapability) { const hasCapability = this.session.claims.eid.capabilities[permission.requiresCapability]; if (!hasCapability) { return { allowed: false, reason: `Requires ${permission.requiresCapability} capability`, }; } } // Check max age if (permission.maxAgeSeconds) { const authAge = Math.floor(Date.now() / 1000) - this.session.claims.eid.authTime; if (authAge > permission.maxAgeSeconds) { return { allowed: false, reason: `Authentication too old (${authAge}s > ${permission.maxAgeSeconds}s)`, }; } } return { allowed: true }; } /** * Require fresh authentication for an operation */ requiresFreshAuth(operation: string): boolean { const permission = OPERATION_PERMISSIONS[operation]; if (!permission) return true; if (permission.minAuthLevel >= AuthLevel.CRITICAL) return true; if (permission.maxAgeSeconds && permission.maxAgeSeconds <= 60) return true; return false; } /** * Upgrade auth level after fresh WebAuthn */ upgradeAuthLevel(level: AuthLevel = AuthLevel.ELEVATED): void { if (!this.session) return; this.session.claims.eid.authLevel = level; this.session.claims.eid.authTime = Math.floor(Date.now() / 1000); this.session.lastAuthTime = Date.now(); this.persistSession(); console.log('EncryptID: Auth level upgraded to', AuthLevel[level]); } /** * Clear session (logout) */ clearSession(): void { this.session = null; if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } // Clear linked wallet cache so it doesn't survive across user sessions resetLinkedWalletStore(); try { localStorage.removeItem(SESSION_STORAGE_KEY); } catch { // Ignore storage errors } console.log('EncryptID: Session cleared'); } /** * Check if session is valid */ isValid(): boolean { if (!this.session) return false; const now = Math.floor(Date.now() / 1000); return now < this.session.claims.exp; } // ========================================================================== // PRIVATE METHODS // ========================================================================== private createUnsignedToken(claims: EncryptIDClaims): string { // In production, this would be a proper JWT signed by the server // For the prototype, we create a base64-encoded JSON token const header = { alg: 'none', typ: 'JWT' }; const headerB64 = btoa(JSON.stringify(header)); const payloadB64 = btoa(JSON.stringify(claims)); return `${headerB64}.${payloadB64}.`; } private createRefreshToken(did: string): string { // In production, refresh tokens would be opaque server-issued tokens const payload = { sub: did, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), }; return btoa(JSON.stringify(payload)); } private persistSession(): void { if (!this.session) return; try { localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.session)); } catch (error) { console.warn('EncryptID: Failed to persist session', error); } } private restoreSession(): void { try { const stored = localStorage.getItem(SESSION_STORAGE_KEY); if (stored) { const session = JSON.parse(stored) as SessionState; // Verify session is not expired const now = Math.floor(Date.now() / 1000); if (now < session.claims.exp) { this.session = session; this.scheduleRefresh(); console.log('EncryptID: Session restored'); } else { // Session expired, clear it localStorage.removeItem(SESSION_STORAGE_KEY); } } } catch (error) { console.warn('EncryptID: Failed to restore session', error); } } private scheduleRefresh(): void { if (!this.session) return; // Clear existing timer if (this.refreshTimer) { clearTimeout(this.refreshTimer); } // Calculate time until refresh needed const expiresAt = this.session.claims.exp * 1000; const refreshAt = expiresAt - TOKEN_REFRESH_THRESHOLD; const delay = Math.max(refreshAt - Date.now(), 0); this.refreshTimer = setTimeout(() => { this.refreshTokens(); }, delay); } private async refreshTokens(): Promise { if (!this.session) return; try { // Call EncryptID server to refresh the token const res = await fetch(`${ENCRYPTID_SERVER}/api/session/refresh`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.session.accessToken}`, 'Content-Type': 'application/json', }, }); if (!res.ok) throw new Error(`Refresh failed: ${res.status}`); const data = await res.json(); if (!data.token) throw new Error('No token in refresh response'); // Decode new claims const payloadB64 = data.token.split('.')[1]; const claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); this.session.accessToken = data.token; this.session.refreshToken = data.token; this.session.claims = claims; this.persistSession(); this.scheduleRefresh(); console.log('EncryptID: Tokens refreshed (server-signed)'); } catch (err) { console.warn('EncryptID: Token refresh failed', err); // Session will expire naturally; user will need to re-authenticate } } } // ============================================================================ // SINGLETON INSTANCE // ============================================================================ let sessionManagerInstance: SessionManager | null = null; /** * Get the global session manager instance */ export function getSessionManager(): SessionManager { if (!sessionManagerInstance) { sessionManagerInstance = new SessionManager(); } return sessionManagerInstance; }