Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248/** * Session Routes * * Endpoints for session conflict detection and resolution. * Express 5 automatically forwards async errors to the error handler. */
import { Router, Request, Response } from 'express';import { DatabaseService } from '../services/database.js';import { createGatewayToken, verifyGatewayToken } from '../utils/hmac.js';import { httpError } from '../utils/errors.js';import type { SessionResolution, SessionConflict } from '../types/index.js';
const SESSION_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for pre-token endpoints
function requireGatewayAuth(req: Request, db: DatabaseService, session: { did: string; app_id: string }): void { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { throw httpError.unauthorized('auth_required', 'Authorization header with gateway token required'); } const token = authHeader.slice(7); const app = db.getApp(session.app_id); if (!app) { throw httpError.notFound('app_not_found', 'Application not found'); } const payload = verifyGatewayToken(token, app.hmac_secret); if (!payload || payload.did !== session.did) { throw httpError.unauthorized('invalid_token', 'Invalid or mismatched gateway token'); }}
export function createSessionRoutes(db: DatabaseService): Router { const router = Router();
/** * POST /session/check-conflict * Check for existing active sessions */ router.post('/check-conflict', async (req: Request, res: Response) => { const { session_id, app_id } = req.body;
if (!session_id || typeof session_id !== 'string') { throw httpError.badRequest('missing_session_id', 'session_id is required'); }
if (!app_id || typeof app_id !== 'string') { throw httpError.badRequest('missing_app_id', 'app_id is required'); }
const pendingSession = db.getSession(session_id); if (!pendingSession) { throw httpError.notFound('session_not_found', 'Pending session not found or expired'); }
// Session age check — only allow conflict checks on recently created sessions if (Date.now() - pendingSession.created_at.getTime() > SESSION_MAX_AGE_MS) { throw httpError.badRequest('session_expired', 'Session is too old for conflict check'); }
const activeSessions = db.getActiveSessionsByDid(pendingSession.did, app_id); const otherSessions = activeSessions.filter((s) => s.id !== session_id);
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); const conflictingSessions = otherSessions.filter( (s) => s.connection_state === 'connected' || s.last_activity > fiveMinutesAgo );
const response: SessionConflict = { has_conflict: conflictingSessions.length > 0, existing_sessions: conflictingSessions.map((s) => ({ session_id: s.id, created_at: s.created_at.toISOString(), last_activity: s.last_activity.toISOString(), connection_state: s.connection_state, client_info: s.client_info, })), pending_session_id: session_id, };
res.json(response); });
/** * POST /session/resolve-conflict * Resolve a session conflict */ router.post('/resolve-conflict', async (req: Request, res: Response) => { const { session_id, app_id, resolution } = req.body;
if (!session_id || !app_id) { throw httpError.badRequest('missing_params', 'session_id and app_id are required'); }
const validResolutions: SessionResolution[] = ['transfer', 'cancel', 'close_others']; if (!resolution || !validResolutions.includes(resolution)) { throw httpError.badRequest('invalid_resolution', "resolution must be: 'transfer', 'cancel', or 'close_others'"); }
const session = db.getSession(session_id); if (!session) { throw httpError.notFound('session_not_found', 'Session not found or expired'); }
// Only allow resolution of pending sessions within the age window if (session.connection_state === 'connected') { throw httpError.badRequest('already_resolved', 'Session has already been resolved'); } if (Date.now() - session.created_at.getTime() > SESSION_MAX_AGE_MS) { throw httpError.badRequest('session_expired', 'Session is too old for conflict resolution'); }
const app = db.getApp(app_id); if (!app) { throw httpError.notFound('app_not_found', 'Application not found'); }
switch (resolution as SessionResolution) { case 'cancel': db.deleteSession(session_id); return res.json({ success: true, action: 'cancelled', message: 'Login cancelled', });
case 'close_others': case 'transfer': { const closedCount = db.deleteOtherSessions(session_id, session.did, app_id); db.updateSessionConnectionState(session_id, 'pending');
const mapping = db.getUserMapping(session.did, app_id); const userId = mapping?.user_id ?? session.user_id;
const token = createGatewayToken( { did: session.did, handle: session.handle, user_id: userId, app_id, }, app.hmac_secret, app.token_ttl_seconds );
return res.json({ success: true, action: resolution === 'transfer' ? 'transferred' : 'closed_others', closed_count: closedCount, token, session_id, did: session.did, handle: session.handle, user_id: userId, }); } } });
/** * POST /session/update-state * Update session connection state */ router.post('/update-state', async (req: Request, res: Response) => { const { session_id, state, client_info } = req.body;
if (!session_id) { throw httpError.badRequest('missing_session_id', 'session_id is required'); }
const validStates = ['connected', 'disconnected', 'pending']; if (!state || !validStates.includes(state)) { throw httpError.badRequest('invalid_state', "state must be: 'connected', 'disconnected', or 'pending'"); }
const session = db.getSession(session_id); if (!session) { throw httpError.notFound('session_not_found', 'Session not found'); }
requireGatewayAuth(req, db, session);
db.updateSessionConnectionState(session_id, state, client_info);
res.json({ success: true, session_id, state }); });
/** * POST /session/heartbeat * Update session last activity */ router.post('/heartbeat', async (req: Request, res: Response) => { const { session_id } = req.body;
if (!session_id) { throw httpError.badRequest('missing_session_id', 'session_id is required'); }
const session = db.getSession(session_id); if (!session) { throw httpError.notFound('session_not_found', 'Session not found or expired'); }
requireGatewayAuth(req, db, session);
db.updateSessionActivity(session_id);
res.json({ success: true, session_id }); });
/** * GET /session/active * List all active sessions for the user */ router.get('/active', async (req: Request, res: Response) => { const { session_id, app_id } = req.query;
if (!session_id || typeof session_id !== 'string') { throw httpError.badRequest('missing_session_id', 'session_id is required'); }
if (!app_id || typeof app_id !== 'string') { throw httpError.badRequest('missing_app_id', 'app_id is required'); }
const session = db.getSession(session_id); if (!session) { throw httpError.notFound('session_not_found', 'Session not found or expired'); }
requireGatewayAuth(req, db, session);
const activeSessions = db.getActiveSessionsByDid(session.did, app_id);
res.json({ sessions: activeSessions.map((s) => ({ session_id: s.id, is_current: s.id === session_id, created_at: s.created_at.toISOString(), last_activity: s.last_activity.toISOString(), connection_state: s.connection_state, client_info: s.client_info, })), }); });
return router;}