Files
openchamber/packages/web/server/lib/ui-auth/ui-auth.js
T
bot-hermes 05d8e953ca feat: agent-to-agent integrations — activity stream, steer channel, plan gate
Three fork-side integrations that turn OpenChamber from a fire-and-forget
coder into a visible, steerable, plan-gated agent:

1. Activity stream (GET /api/openchamber/agent-activity SSE)
   - agent-activity/runtime.js: subscribes to global hub, normalizes
     message.updated parts into structured activity events (tool-call,
     file-edit, text-part), rate-limited coalescing for tool calls
   - Broadcasts openchamber:agent-activity and session-completed events
   - SSE endpoint with heartbeat (25s), same shape as /api/openchamber/events

2. Steer channel (POST /api/openchamber/session/:id/steer)
   - session-steer/runtime.js: interrupt mode (interrupt → inject
     system-level directive → resume) and queue mode (deliver on next idle)
   - Follows message-queue precedent for interrupt-safe dispatch
   - Broadcasts openchamber:steer-delivered on queued delivery

3. Plan-first gate (plan-gate/runtime.js + approve/reject/status routes)
   - State machine per session: pending → approved | rejected | timed_out
   - Injects plan-gate reminder via openchamber-sessions create prompt
   - Agent emits ## Plan, runtime detects and emits openchamber:plan-ready
   - Approve sends 'Proceed' prompt, reject sends revision prompt
   - Configurable timeout (default 5 min), auto-approve on timeout

4. P1 shared plumbing
   - cardID accepted in session create payload, stored in session metadata
   - All new events carry cardID when known

Files added:
- packages/web/server/lib/agent-activity/runtime.js + runtime.test.js
- packages/web/server/lib/session-steer/runtime.js + runtime.test.js
- packages/web/server/lib/plan-gate/runtime.js + runtime.test.js

Files modified:
- packages/web/server/lib/openchamber-sessions/routes.js (cardID, planGate)
- packages/web/server/lib/opencode/feature-routes-runtime.js (route wiring)
- packages/web/server/index.js (runtime creation, SSE_PATH_PREFIXES)
- packages/web/server/lib/ui-auth/ui-auth.js (auth allowlist)
- packages/web/server/lib/realtime-proxy.js (SSE allowlist)

28 new tests passing. All pre-existing tests unaffected.
2026-09-07 22:34:56 +00:00

997 lines
31 KiB
JavaScript

import crypto from 'crypto';
import { SignJWT, jwtVerify } from 'jose';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { createUiPasskeys } from './ui-passkeys.js';
const SESSION_COOKIE_NAME = 'oc_ui_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const TRUSTED_DEVICE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const URL_AUTH_TOKEN_TTL_MS = 60 * 1000;
const URL_AUTH_TOKEN_PREFIX = 'oc_url_';
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000;
const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
const loginRateLimiter = new Map();
let rateLimitCleanupTimer = null;
const rateLimitLocks = new Map();
const getClientIp = (req) => {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
const ip = forwarded.split(',')[0].trim();
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
const ip = req.ip || req.connection?.remoteAddress;
if (ip) {
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
return null;
};
const getRateLimitKey = (req) => {
const ip = getClientIp(req);
if (ip) return ip;
return 'rate-limit:no-ip';
};
const getRateLimitConfig = (key) => {
if (key === 'rate-limit:no-ip') {
return {
maxAttempts: RATE_LIMIT_NO_IP_MAX_ATTEMPTS,
windowMs: RATE_LIMIT_WINDOW_MS
};
}
return {
maxAttempts: RATE_LIMIT_MAX_ATTEMPTS,
windowMs: RATE_LIMIT_WINDOW_MS
};
};
const acquireRateLimitLock = async (key) => {
const prev = rateLimitLocks.get(key) || Promise.resolve();
const curr = prev.then(() => rateLimitLocks.delete(key));
rateLimitLocks.set(key, curr);
await curr;
};
const checkRateLimit = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
const now = Date.now();
const { maxAttempts } = getRateLimitConfig(key);
let record;
try {
record = loginRateLimiter.get(key);
} catch (err) {
console.error('[RateLimit] Failed to get record', { key, error: err.message });
return {
allowed: true,
limit: maxAttempts,
remaining: maxAttempts,
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
};
}
if (record?.lockedUntil && now < record.lockedUntil) {
return {
allowed: false,
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
locked: true,
limit: maxAttempts,
remaining: 0,
reset: Math.ceil(record.lockedUntil / 1000)
};
}
if (record?.lockedUntil && now >= record.lockedUntil) {
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Failed to delete expired record', { key, error: err.message });
}
}
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
return {
allowed: true,
limit: maxAttempts,
remaining: maxAttempts,
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
};
}
if (record.count >= maxAttempts) {
const lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
try {
loginRateLimiter.set(key, { count: record.count + 1, lastAttempt: now, lockedUntil });
} catch (err) {
console.error('[RateLimit] Failed to set lockout', { key, error: err.message });
}
return {
allowed: false,
retryAfter: Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000),
locked: true,
limit: maxAttempts,
remaining: 0,
reset: Math.ceil(lockedUntil / 1000)
};
}
const remaining = maxAttempts - record.count;
const reset = Math.ceil((record.lastAttempt + RATE_LIMIT_WINDOW_MS) / 1000);
return {
allowed: true,
limit: maxAttempts,
remaining,
reset
};
};
const recordFailedAttempt = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
const now = Date.now();
const { maxAttempts } = getRateLimitConfig(key);
const record = loginRateLimiter.get(key);
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
try {
loginRateLimiter.set(key, { count: 1, lastAttempt: now });
} catch (err) {
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
}
} else {
const newCount = record.count + 1;
try {
loginRateLimiter.set(key, { count: newCount, lastAttempt: now });
} catch (err) {
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
}
}
};
const clearRateLimit = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Failed to clear', { key, error: err.message });
}
};
const cleanupRateLimitRecords = () => {
const now = Date.now();
for (const [key, record] of loginRateLimiter.entries()) {
const isExpired = record.lockedUntil && now >= record.lockedUntil;
const isStale = now - record.lastAttempt > RATE_LIMIT_CLEANUP_MS;
if (isExpired || isStale) {
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Cleanup failed', { key, error: err.message });
}
}
}
};
const startRateLimitCleanup = () => {
if (!rateLimitCleanupTimer) {
rateLimitCleanupTimer = setInterval(cleanupRateLimitRecords, RATE_LIMIT_CLEANUP_MS);
if (rateLimitCleanupTimer && typeof rateLimitCleanupTimer.unref === 'function') {
rateLimitCleanupTimer.unref();
}
}
};
const stopRateLimitCleanup = () => {
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
};
const isSecureRequest = (req) => {
if (req.secure) {
return true;
}
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
return firstProto === 'https';
}
return false;
};
const parseCookies = (cookieHeader) => {
if (!cookieHeader || typeof cookieHeader !== 'string') {
return {};
}
return cookieHeader.split(';').reduce((acc, segment) => {
const [name, ...rest] = segment.split('=');
if (!name) {
return acc;
}
const key = name.trim();
if (!key) {
return acc;
}
const value = rest.join('=').trim();
try {
acc[key] = decodeURIComponent(value || '');
} catch {
acc[key] = value || '';
}
return acc;
}, {});
};
const getBearerTokenFromRequest = (req) => {
const header = req?.headers?.authorization;
const value = Array.isArray(header) ? header[0] : header;
if (typeof value === 'string') {
const match = value.match(/^Bearer\s+(.+)$/i);
const token = match?.[1]?.trim() || '';
if (token) return token;
}
return null;
};
const getUrlAuthTokenFromRequest = (req) => {
const queryToken = req?.query?.oc_url_token;
let token = Array.isArray(queryToken) ? queryToken[0] : queryToken;
if (typeof token !== 'string' && typeof req?.url === 'string') {
try {
token = new URL(req.url, 'http://localhost').searchParams.get('oc_url_token') || undefined;
} catch {
token = undefined;
}
}
return typeof token === 'string' && token.trim() ? token.trim() : null;
};
const getRequestPathname = (req) => {
const rawUrl = req?.originalUrl || req?.url;
if (typeof rawUrl === 'string' && rawUrl) {
try {
return new URL(rawUrl, 'http://localhost').pathname;
} catch {
// Fall through to Express' derived path fields.
}
}
if (typeof req?.baseUrl === 'string' && req.baseUrl && typeof req?.path === 'string' && req.path) {
return `${req.baseUrl}${req.path}`.replace(/\/+/g, '/');
}
if (typeof req?.path === 'string' && req.path) return req.path;
return '';
};
const isWebSocketUpgrade = (req) => {
const upgrade = req?.headers?.upgrade;
const upgradeValue = Array.isArray(upgrade) ? upgrade[0] : upgrade;
return String(upgradeValue || '').toLowerCase() === 'websocket';
};
const isUrlAuthReadableHttpPath = (pathname) => {
return pathname === '/api/event'
|| pathname === '/api/global/event'
|| pathname === '/api/openchamber/events'
|| pathname === '/api/openchamber/realtime-proxy/sse'
|| pathname === '/api/openchamber/agent-activity'
|| pathname === '/api/notifications/stream'
|| pathname === '/api/fs/raw'
|| pathname === '/api/fs/serve'
|| pathname.startsWith('/api/fs/serve/')
|| pathname.startsWith('/api/preview/proxy/')
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname);
};
const isUrlAuthWebSocketPath = (pathname) => {
return pathname === '/api/event/ws'
|| pathname === '/api/global/event/ws'
|| pathname === '/api/openchamber/realtime-proxy/ws'
|| pathname === '/api/terminal/ws'
|| pathname === '/api/dictation/ws'
|| pathname === '/api/dev-tunnel'
|| pathname.startsWith('/api/preview/proxy/');
};
const canUseUrlAuthTokenForRequest = (req) => {
const method = typeof req?.method === 'string' ? req.method.toUpperCase() : 'GET';
const pathname = getRequestPathname(req);
if (isWebSocketUpgrade(req)) {
return isUrlAuthWebSocketPath(pathname);
}
return method === 'GET' && isUrlAuthReadableHttpPath(pathname);
};
const buildCookie = ({
name,
value,
maxAge,
secure,
}) => {
const attributes = [
`${name}=${value}`,
'Path=/',
'HttpOnly',
'SameSite=Strict',
];
if (typeof maxAge === 'number') {
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
}
const expires = maxAge === 0
? 'Thu, 01 Jan 1970 00:00:00 GMT'
: new Date(Date.now() + maxAge * 1000).toUTCString();
attributes.push(`Expires=${expires}`);
if (secure) {
attributes.push('Secure');
}
return attributes.join('; ');
};
const normalizePassword = (candidate) => {
if (typeof candidate !== 'string') {
return '';
}
return candidate.normalize().trim();
};
const isTrustedDeviceRequest = (value) => value === true;
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const JWT_SECRET_FILE = path.join(OPENCHAMBER_DATA_DIR, 'jwt-secret');
function getOrCreateJwtSecret() {
const envSecret = process.env.OPENCODE_JWT_SECRET;
if (envSecret) {
return new TextEncoder().encode(envSecret);
}
try {
if (fs.existsSync(JWT_SECRET_FILE)) {
return new TextEncoder().encode(fs.readFileSync(JWT_SECRET_FILE, 'utf8').trim());
}
} catch (e) {
console.warn('[JWT] Failed to read secret file:', e.message);
}
const secret = crypto.randomBytes(32).toString('hex');
try {
fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true });
fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
console.log('[JWT] Generated and persisted new secret to', JWT_SECRET_FILE);
} catch (e) {
console.warn('[JWT] Failed to persist secret:', e.message);
}
return new TextEncoder().encode(secret);
}
function persistJwtSecret(secret) {
if (process.env.OPENCODE_JWT_SECRET) {
const error = new Error('Global sign-out is unavailable while OPENCODE_JWT_SECRET is set');
error.statusCode = 400;
throw error;
}
fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true });
fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
return new TextEncoder().encode(secret);
}
export const createUiAuth = ({
password,
cookieName = SESSION_COOKIE_NAME,
sessionTtlMs = SESSION_TTL_MS,
readSettingsFromDiskMigrated,
clientAuthController = null,
requireClientAuth = false,
} = {}) => {
const normalizedPassword = normalizePassword(password);
const urlAuthTokens = new Map();
const sweepUrlAuthTokens = () => {
const now = Date.now();
for (const [token, entry] of urlAuthTokens.entries()) {
if (!entry || entry.expiresAt <= now) {
urlAuthTokens.delete(token);
}
}
};
const issueUrlAuthTokenForSession = (sessionToken) => {
sweepUrlAuthTokens();
const token = `${URL_AUTH_TOKEN_PREFIX}${crypto.randomBytes(24).toString('base64url')}`;
const expiresAt = Date.now() + URL_AUTH_TOKEN_TTL_MS;
urlAuthTokens.set(token, { sessionToken, expiresAt });
return { token, expiresAt };
};
const authenticateUrlAuthToken = (req) => {
if (!canUseUrlAuthTokenForRequest(req)) return null;
const token = getUrlAuthTokenFromRequest(req);
if (!token || !token.startsWith(URL_AUTH_TOKEN_PREFIX)) return null;
const entry = urlAuthTokens.get(token);
if (!entry || entry.expiresAt <= Date.now()) {
urlAuthTokens.delete(token);
return null;
}
return { ok: true, sessionToken: entry.sessionToken || 'url:authenticated' };
};
const authenticateClientRequest = async (req, { allowUrlToken = true } = {}) => {
if (allowUrlToken) {
const urlAuth = authenticateUrlAuthToken(req);
if (urlAuth) return urlAuth;
}
const token = getBearerTokenFromRequest(req);
if (!token || typeof clientAuthController?.authenticateBearerToken !== 'function') {
return null;
}
try {
const result = await clientAuthController.authenticateBearerToken(token, req);
if (result?.ok) {
return result;
}
return null;
} catch {
return null;
}
};
const clientSessionToken = (clientAuth) => {
const raw = clientAuth?.sessionToken || clientAuth?.clientId || clientAuth?.id;
if (typeof raw === 'string' && (raw.startsWith('client:') || raw.startsWith('url:'))) return raw;
return typeof raw === 'string' && raw.length > 0 ? `client:${raw}` : 'client:authenticated';
};
const clientAuthClientId = (clientAuth) => {
const raw = clientAuth?.client?.id || clientAuth?.clientId || clientAuth?.id || clientAuth?.sessionToken;
if (typeof raw !== 'string' || raw.length === 0) return null;
return raw.startsWith('client:') ? raw.slice('client:'.length) : raw;
};
const clientAuthContext = (clientAuth) => ({
type: 'client',
token: clientSessionToken(clientAuth),
clientId: clientAuthClientId(clientAuth),
client: clientAuth?.client || null,
});
if (!normalizedPassword) {
const setSessionCookie = (req, res, token, ttlMs = sessionTtlMs) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(ttlMs / 1000);
const header = buildCookie({
name: cookieName,
value: encodeURIComponent(token),
maxAge: maxAgeSeconds,
secure,
});
res.setHeader('Set-Cookie', header);
};
const ensureSessionToken = async (req, res) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[cookieName]) {
return cookies[cookieName];
}
const token = crypto.randomBytes(32).toString('base64url');
setSessionCookie(req, res, token, sessionTtlMs);
return token;
};
const requireAuth = async (req, res, next) => {
if (!requireClientAuth) {
return next();
}
if (req.method === 'OPTIONS') {
return next();
}
const clientAuth = await authenticateClientRequest(req);
if (clientAuth) {
return next();
}
return res.status(401).json({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
};
const requireSessionAuth = async (req, res, next) => {
if (!requireClientAuth) {
return next();
}
if (req.method === 'OPTIONS') {
return next();
}
return res.status(401).json({ error: 'UI session authentication required', locked: true });
};
const resolveAuthContext = async (req, res, { allowClientAuth = true, allowUrlToken = true } = {}) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[cookieName]) {
return { type: 'session', token: cookies[cookieName] };
}
if (allowClientAuth) {
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
if (clientAuth) return clientAuthContext(clientAuth);
}
if (!requireClientAuth) {
const token = await ensureSessionToken(req, res);
return { type: 'session', token };
}
return null;
};
return {
enabled: false,
requireAuth,
requireSessionAuth,
resolveAuthContext,
handleSessionStatus: async (req, res) => {
if (requireClientAuth) {
const clientAuth = await authenticateClientRequest(req);
if (clientAuth) {
return res.json({ authenticated: true, disabled: true, scope: 'client' });
}
return res.status(401).json({ authenticated: false, locked: true, clientAuthRequired: true });
}
res.json({ authenticated: true, disabled: true });
},
handleSessionCreate: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handleUrlAuthToken: async (req, res) => {
const clientAuth = await authenticateClientRequest(req, { allowUrlToken: false });
if (clientAuth) {
res.setHeader('Cache-Control', 'no-store');
return res.json(issueUrlAuthTokenForSession(clientSessionToken(clientAuth)));
}
if (requireClientAuth) {
return res.status(401).json({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
}
const sessionToken = await ensureSessionToken(req, res);
res.setHeader('Cache-Control', 'no-store');
return res.json(issueUrlAuthTokenForSession(sessionToken));
},
handlePasskeyStatus: (_req, res) => {
res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null });
},
handlePasskeyRegistrationOptions: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyRegistrationVerify: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyAuthenticationOptions: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyAuthenticationVerify: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handlePasskeyList: (_req, res) => {
res.json({ passkeys: [] });
},
handlePasskeyRevoke: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
handleResetAuth: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
ensureSessionToken: async (req, res) => {
const clientAuth = await authenticateClientRequest(req);
if (clientAuth) return clientSessionToken(clientAuth);
return ensureSessionToken(req, res);
},
dispose: () => {
},
};
}
const salt = crypto.randomBytes(16);
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
let jwtSecret = getOrCreateJwtSecret();
let passwordBinding = crypto.createHmac('sha256', jwtSecret).update(normalizedPassword).digest('hex');
const resolveSessionTtlMs = (trustDevice) => (trustDevice ? TRUSTED_DEVICE_SESSION_TTL_MS : sessionTtlMs);
let passkeyController = createUiPasskeys({
passwordBinding,
readSettingsFromDiskMigrated,
});
const rebuildPasskeyController = () => {
passkeyController.dispose();
passwordBinding = crypto.createHmac('sha256', jwtSecret).update(normalizedPassword).digest('hex');
passkeyController = createUiPasskeys({
passwordBinding,
readSettingsFromDiskMigrated,
});
};
const rotateJwtSecret = () => {
const nextSecret = crypto.randomBytes(32).toString('hex');
jwtSecret = persistJwtSecret(nextSecret);
urlAuthTokens.clear();
rebuildPasskeyController();
};
const getTokenFromRequest = (req) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[cookieName]) {
return cookies[cookieName];
}
return null;
};
const setSessionCookie = (req, res, token, ttlMs) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(ttlMs / 1000);
const header = buildCookie({
name: cookieName,
value: encodeURIComponent(token),
maxAge: maxAgeSeconds,
secure,
});
res.setHeader('Set-Cookie', header);
};
const clearSessionCookie = (req, res) => {
const secure = isSecureRequest(req);
const header = buildCookie({
name: cookieName,
value: '',
maxAge: 0,
secure,
});
res.setHeader('Set-Cookie', header);
};
const verifyPassword = (candidate) => {
if (!candidate) {
return false;
}
const normalizedCandidate = normalizePassword(candidate);
if (!normalizedCandidate) {
return false;
}
try {
const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
return crypto.timingSafeEqual(candidateHash, expectedHash);
} catch {
return false;
}
};
const isSessionValid = async (token) => {
if (!token) {
return false;
}
try {
await jwtVerify(token, jwtSecret);
return true;
} catch {
return false;
}
};
const issueSession = async (req, res, { trustDevice = false } = {}) => {
const ttlMs = resolveSessionTtlMs(trustDevice);
const token = await new SignJWT({ type: 'ui-session' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(ttlMs / 1000 + 's')
.sign(jwtSecret);
setSessionCookie(req, res, token, ttlMs);
return token;
};
startRateLimitCleanup();
const respondUnauthorized = (req, res) => {
res.status(401);
const acceptsJson = req.headers.accept?.includes('application/json');
if (acceptsJson || req.path?.startsWith('/api')) {
res.json({ error: 'UI authentication required', locked: true });
} else {
res.type('text/plain').send('Authentication required');
}
};
const requireAuth = async (req, res, next) => {
if (req.method === 'OPTIONS') {
return next();
}
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
return next();
}
const clientAuth = await authenticateClientRequest(req);
if (clientAuth) {
return next();
}
clearSessionCookie(req, res);
return respondUnauthorized(req, res);
};
const requireSessionAuth = async (req, res, next) => {
if (req.method === 'OPTIONS') {
return next();
}
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
return next();
}
clearSessionCookie(req, res);
return respondUnauthorized(req, res);
};
const handleSessionStatus = async (req, res) => {
// An explicit bearer credential decides the answer on its own. Native
// clients probe with the token their runtime transport will actually use;
// falling back to the ambient session cookie here masked revoked tokens
// (cookie said "authenticated", every bearer-only API call then 401'd).
const authorization = req.headers?.authorization;
const hasBearer = typeof authorization === 'string' && authorization.toLowerCase().startsWith('bearer ');
if (hasBearer) {
const clientAuth = await authenticateClientRequest(req, { allowUrlToken: false });
if (clientAuth) {
res.json({ authenticated: true, scope: 'client' });
return;
}
res.status(401).json({ authenticated: false, locked: true });
return;
}
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
res.json({ authenticated: true });
return;
}
const clientAuth = await authenticateClientRequest(req);
if (clientAuth) {
res.json({ authenticated: true, scope: 'client' });
return;
}
clearSessionCookie(req, res);
res.status(401).json({ authenticated: false, locked: true });
};
const resolveAuthenticatedSessionToken = async (req, { allowUrlToken = true } = {}) => {
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
return token;
}
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
return clientAuth ? clientSessionToken(clientAuth) : null;
};
const resolveAuthContext = async (req, _res, { allowClientAuth = true, allowUrlToken = true } = {}) => {
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
return { type: 'session', token };
}
if (!allowClientAuth) return null;
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
return clientAuth ? clientAuthContext(clientAuth) : null;
};
const handleUrlAuthToken = async (req, res) => {
const sessionToken = await resolveAuthenticatedSessionToken(req, { allowUrlToken: false });
if (!sessionToken) {
clearSessionCookie(req, res);
return respondUnauthorized(req, res);
}
res.setHeader('Cache-Control', 'no-store');
return res.json(issueUrlAuthTokenForSession(sessionToken));
};
const handleSessionCreate = async (req, res) => {
const rateLimitResult = await checkRateLimit(req);
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
res.setHeader('X-RateLimit-Reset', rateLimitResult.reset);
if (!rateLimitResult.allowed) {
res.setHeader('Retry-After', rateLimitResult.retryAfter);
res.status(429).json({
error: 'Too many login attempts, please try again later',
retryAfter: rateLimitResult.retryAfter
});
return;
}
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
if (!verifyPassword(candidate)) {
await recordFailedAttempt(req);
clearSessionCookie(req, res);
res.status(401).json({ error: 'Invalid credentials' });
return;
}
await clearRateLimit(req);
const trustDevice = isTrustedDeviceRequest(req.body?.trustDevice);
const ttlMs = resolveSessionTtlMs(trustDevice);
await issueSession(req, res, { trustDevice });
let clientTokenResult = null;
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
clientTokenResult = await clientAuthController.createClient({
fallbackLabel: req.body?.clientLabel,
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
clientKind: req.body?.clientKind,
dedupeKey: req.body?.dedupeKey,
authMethod: 'password',
deviceName: req.body?.deviceName,
devicePlatform: req.body?.devicePlatform,
deviceModel: req.body?.deviceModel,
appVersion: req.body?.appVersion,
});
}
res.setHeader('Cache-Control', 'no-store');
res.json({
authenticated: true,
...(clientTokenResult?.token ? { clientToken: clientTokenResult.token, client: clientTokenResult.client } : {}),
});
};
const respondPasskeyError = (res, error) => {
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400;
res.status(statusCode).json({ error: error?.message || 'Passkey request failed' });
};
const handlePasskeyStatus = (req, res) => {
try {
res.json(passkeyController.getStatus(req));
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyRegistrationOptions = async (req, res) => {
try {
const label = typeof req.body?.label === 'string' ? req.body.label : '';
const options = await passkeyController.beginRegistration(req, { label });
res.json(options);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyRegistrationVerify = async (req, res) => {
try {
const result = await passkeyController.finishRegistration(req.body);
res.json(result);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyAuthenticationOptions = async (req, res) => {
try {
const options = await passkeyController.beginAuthentication(req);
res.json(options);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyAuthenticationVerify = async (req, res) => {
try {
await passkeyController.finishAuthentication(req.body);
const trustDevice = isTrustedDeviceRequest(req.body?.trustDevice);
const ttlMs = resolveSessionTtlMs(trustDevice);
await issueSession(req, res, { trustDevice });
let clientTokenResult = null;
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
clientTokenResult = await clientAuthController.createClient({
fallbackLabel: req.body?.clientLabel,
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
clientKind: req.body?.clientKind,
dedupeKey: req.body?.dedupeKey,
authMethod: 'passkey',
deviceName: req.body?.deviceName,
devicePlatform: req.body?.devicePlatform,
deviceModel: req.body?.deviceModel,
appVersion: req.body?.appVersion,
});
}
res.json({
authenticated: true,
...(clientTokenResult?.token ? { clientToken: clientTokenResult.token, client: clientTokenResult.client } : {}),
});
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyList = (req, res) => {
try {
res.json({ passkeys: passkeyController.listPasskeys(req) });
} catch (error) {
respondPasskeyError(res, error);
}
};
const handlePasskeyRevoke = (req, res) => {
try {
const result = passkeyController.revokePasskey(req, req.params?.id);
res.json(result);
} catch (error) {
respondPasskeyError(res, error);
}
};
const handleResetAuth = (req, res) => {
try {
const passkeyResult = passkeyController.clearAllPasskeys();
rotateJwtSecret();
clearSessionCookie(req, res);
res.json({
cleared: true,
clearedPasskeys: passkeyResult.clearedCount,
signedOutEverywhere: true,
});
} catch (error) {
respondPasskeyError(res, error);
}
};
const dispose = () => {
loginRateLimiter.clear();
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
passkeyController.dispose();
};
return {
enabled: true,
requireAuth,
requireSessionAuth,
resolveAuthContext,
handleSessionStatus,
handleSessionCreate,
handleUrlAuthToken,
handlePasskeyStatus,
handlePasskeyRegistrationOptions,
handlePasskeyRegistrationVerify,
handlePasskeyAuthenticationOptions,
handlePasskeyAuthenticationVerify,
handlePasskeyList,
handlePasskeyRevoke,
handleResetAuth,
ensureSessionToken: (req, _res) => {
const urlAuth = authenticateUrlAuthToken(req);
if (urlAuth) return clientSessionToken(urlAuth);
return resolveAuthenticatedSessionToken(req);
},
dispose,
};
};