import type { Context, Next } from 'hono' import { getCookie } from 'hono/cookie' import { validateApiKey } from './api-key' import { verifyWorkerJwt } from './jwt' import { resolveToken } from './token' import { resolveSessionToken } from './session' import { getDb } from '../db/sqlite' import { SYSTEM_USER_ID } from './constants' const WS_AUTH_PROTOCOL_PREFIX = 'rcs.auth.' /** Encode a bearer token for WebSocket clients that cannot send auth headers. */ export function encodeWebSocketAuthProtocol(token: string): string { return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, 'utf8').toString('base64url')}` } function decodeWebSocketAuthProtocol( protocolHeader: string | undefined, ): string | undefined { if (!protocolHeader) { return undefined } for (const protocol of protocolHeader.split(',')) { const trimmed = protocol.trim() if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) { continue } const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length) if (!encoded) { return undefined } try { const token = Buffer.from(encoded, 'base64url').toString('utf8') return token.length > 0 ? token : undefined } catch { return undefined } } return undefined } /** Extract a Bearer token from the Authorization header only. */ export function extractBearerToken(c: Context): string | undefined { const authHeader = c.req.header('Authorization') return authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined } /** Extract auth for WebSocket upgrades without putting secrets in query strings. */ export function extractWebSocketAuthToken(c: Context): string | undefined { return ( extractBearerToken(c) ?? decodeWebSocketAuthProtocol(c.req.header('Sec-WebSocket-Protocol')) ) } /** * Unified authentication middleware — supports two modes: * * 1. **Token mode** (Web UI): Bearer token resolved via server-side lookup → userId injected * 2. **API Key mode** (CLI bridge): Valid API key + X-Username header → userId injected */ export async function apiKeyAuth(c: Context, next: Next) { const token = extractBearerToken(c) // Try token authentication (Web UI) const tokenUsername = resolveToken(token) if (tokenUsername) { c.set('userId', tokenUsername) await next() return } // Try API Key authentication (CLI bridge) if (validateApiKey(token)) { // Extract username from X-Username header or ?username= query param const username = c.req.header('X-Username') || c.req.query('username') if (username) { c.set('userId', username) } await next() return } return c.json( { error: { type: 'unauthorized', message: 'Invalid or missing auth token' }, }, 401, ) } /** * Session ingress authentication — accepts both API key and worker JWT. * * Used for SSE stream, CCR worker events, and WebSocket ingress endpoints. * On JWT validation, stores the decoded payload in c.set("jwtPayload") for * downstream handlers to inspect session_id if needed. */ export async function sessionIngressAuth(c: Context, next: Next) { const token = extractWebSocketAuthToken(c) if (!token) { return c.json( { error: { type: 'unauthorized', message: 'Missing auth token' } }, 401, ) } // Try API key first (backward compatible) if (validateApiKey(token)) { await next() return } // Try JWT verification — validate session_id matches route param const payload = verifyWorkerJwt(token) if (payload) { const routeSessionId = c.req.param('id') || c.req.param('sessionId') if (routeSessionId && payload.session_id !== routeSessionId) { return c.json( { error: { type: 'forbidden', message: 'JWT session_id does not match target session', }, }, 403, ) } c.set('jwtPayload', payload) await next() return } return c.json( { error: { type: 'unauthorized', message: 'Invalid API key or JWT' } }, 401, ) } /** Accept CLI headers but don't validate them */ export async function acceptCliHeaders(c: Context, next: Next) { await next() } /** * Session-based authentication (Phase 1+). * * Priority: * 1. Admin API key bypass (Bearer token matching RCS_API_KEYS) → SYSTEM_USER_ID * (operations via API key are attributed to the system, not a specific admin) * If DB is unavailable, returns 503 to avoid silent misattribution. * 2. Cookie access token (rcs_access) — in-memory or SQLite resolution * 3. Bearer session token — in-memory or SQLite resolution * 4. 401 if no valid credentials * * Injects `userId` (string) and `isAdmin` (boolean) into context. */ export async function sessionAuth(c: Context, next: Next) { const bearer = extractBearerToken(c) // Admin API key bypass — attribute to __system__ identity. // Verify DB connectivity first: if the DB is unreachable we cannot // reliably serve requests, so return 503 rather than silently // impersonating a system identity without audit trail. if (bearer && validateApiKey(bearer)) { try { const db = getDb() // Verify the DB is reachable with a simple query db.query('SELECT 1').get() } catch (err) { console.warn('[sessionAuth] DB unavailable for API key auth:', err) return c.json( { error: { type: 'service_unavailable', message: 'Database unavailable — cannot process API key request', }, }, 503, ) } c.set('userId', SYSTEM_USER_ID) c.set('isAdmin', true) await next() return } // Try cookie-based token const cookieToken = getCookie(c, 'rcs_access') const tokenToResolve = cookieToken || bearer if (tokenToResolve) { // Try in-memory token resolution (existing issueToken flow) const username = resolveToken(tokenToResolve) if (username) { c.set('userId', username) c.set('isAdmin', false) await next() return } // Try SQLite-backed session token resolution try { const db = getDb() const userId = resolveSessionToken(tokenToResolve, db) if (userId) { // Look up role from DB to set isAdmin const userRow = db .query('SELECT role FROM users WHERE id = $userId') .get({ $userId: userId }) as { role: string } | null c.set('userId', userId) c.set('isAdmin', userRow?.role === 'admin') await next() return } } catch (err) { console.warn('[sessionAuth] DB resolution failed:', err) return c.json( { error: { type: 'service_unavailable', message: 'Database unavailable — cannot resolve session token', }, }, 503, ) } } return c.json( { error: { type: 'unauthorized', message: 'Invalid or missing credentials', }, }, 401, ) }