533 lines
17 KiB
TypeScript
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;
|
|
}
|