rspace-online/src/encryptid/session.ts

533 lines
17 KiB
TypeScript

/**
* 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<string, OperationPermission> = {
// 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<typeof setTimeout> | 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<SessionState> {
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<void> {
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;
}