Add passkey login for protected UI (#845)

* feat(auth): add passkey login for protected UI

* fix: polish passkey setup and correct WebAuthn user IDs

* feat: improve passkey login management

* build: align passkey deps with upstream main

* refactor: move ui auth out of opencode module

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Dave Otero
2026-04-11 22:36:02 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 37cf7d9c79
commit 75a10ea66c
15 changed files with 1640 additions and 53 deletions
@@ -1,7 +1,7 @@
# OpenCode Module Documentation
## Purpose
This module provides OpenCode server integration utilities for the web server runtime, including configuration management, provider authentication, and UI authentication with rate limiting.
This module provides OpenCode server integration utilities for the web server runtime, including configuration management and provider authentication.
## Entrypoints and structure
- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
@@ -40,7 +40,8 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events.
- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout.
- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers.
- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting.
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI session authentication runtime (outside OpenCode module).
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: UI passkey storage and WebAuthn registration/authentication helpers (outside OpenCode module).
## Public exports (auth.js)
- `readAuthFile()`: Reads and parses `~/.local/share/opencode/auth.json`.
@@ -67,15 +68,6 @@ This module provides OpenCode server integration utilities for the web server ru
- `resolveSkillSearchDirectories(workingDirectory)`: Returns skill search path order (config, project, home, custom).
- `listSkillSupportingFiles(skillDir)`, `readSkillSupportingFile(skillDir, relativePath)`, `writeSkillSupportingFile(skillDir, relativePath, content)`, `deleteSkillSupportingFile(skillDir, relativePath)`: Skill supporting file management.
## Public exports (ui-auth.js)
- `createUiAuth({ password, cookieName, sessionTtlMs })`: Creates UI auth instance with methods:
- `enabled`: Boolean indicating if auth is configured.
- `requireAuth(req, res, next)`: Express middleware to enforce authentication.
- `handleSessionStatus(req, res)`: Returns authentication status.
- `handleSessionCreate(req, res)`: Handles login with rate limiting.
- `ensureSessionToken(req, res)`: Returns or creates session token.
- `dispose()`: Cleans up timers and state.
## Public exports (routes.js)
- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime:
- `GET /api/config/settings`
@@ -230,11 +222,19 @@ This module provides OpenCode server integration utilities for the web server ru
- `GET /health`
- `POST /api/system/shutdown`
- `GET /api/system/info`
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
- `GET /auth/session`
- `POST /auth/session`
- `GET /connect`
- `app.use('/api', ...)` auth/tunnel guard
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
- `GET /auth/session`
- `POST /auth/session`
- `GET /auth/passkey/status`
- `POST /auth/passkey/authenticate/options`
- `POST /auth/passkey/authenticate/verify`
- `POST /auth/passkey/register/options`
- `POST /auth/passkey/register/verify`
- `GET /api/passkeys`
- `DELETE /api/passkeys/:id`
- `POST /api/auth/reset`
- `GET /connect`
- `app.use('/api', ...)` auth/tunnel guard
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
- `GET /api/config/themes`
- `POST /api/config/reload`
+4 -1
View File
@@ -60,7 +60,10 @@ export const createBootstrapRuntime = (dependencies) => {
registerCommonRequestMiddleware(app, { express });
const uiAuthController = createUiAuth({ password: uiPassword });
const uiAuthController = createUiAuth({
password: uiPassword,
readSettingsFromDiskMigrated,
});
if (uiAuthController.enabled) {
console.log('UI password protection enabled for browser sessions');
}
@@ -67,6 +67,100 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return uiAuthController.handleSessionCreate(req, res);
});
app.get('/auth/passkey/status', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null, tunnelLocked: true });
}
return uiAuthController.handlePasskeyStatus(req, res);
});
app.post('/auth/passkey/authenticate/options', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
}
return uiAuthController.handlePasskeyAuthenticationOptions(req, res);
});
app.post('/auth/passkey/authenticate/verify', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey login is disabled for tunnel scope', tunnelLocked: true });
}
return uiAuthController.handlePasskeyAuthenticationVerify(req, res);
});
app.post('/auth/passkey/register/options', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyRegistrationOptions(req, res);
});
} catch (error) {
next(error);
}
});
app.post('/auth/passkey/register/verify', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey setup is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyRegistrationVerify(req, res);
});
} catch (error) {
next(error);
}
});
app.get('/api/passkeys', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyList(req, res);
});
} catch (error) {
next(error);
}
});
app.delete('/api/passkeys/:id', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Passkey management is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handlePasskeyRevoke(req, res);
});
} catch (error) {
next(error);
}
});
app.post('/api/auth/reset', async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Global sign-out is disabled for tunnel scope', tunnelLocked: true });
}
try {
await uiAuthController.requireAuth(req, res, async () => {
await uiAuthController.handleResetAuth(req, res);
});
} catch (error) {
next(error);
}
});
app.get('/connect', async (req, res) => {
try {
const token = typeof req.query?.t === 'string' ? req.query.t : '';
+1 -1
View File
@@ -55,7 +55,7 @@ export {
OPENCODE_DATA_DIR,
} from './auth.js';
export { createUiAuth } from './ui-auth.js';
export { createUiAuth } from '../ui-auth/ui-auth.js';
export {
listMcpConfigs,
-510
View File
@@ -1,510 +0,0 @@
import crypto from 'crypto';
import { SignJWT, jwtVerify } from 'jose';
import fs from 'fs';
import path from 'path';
import os from 'os';
const SESSION_COOKIE_NAME = 'oc_ui_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
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();
acc[key] = decodeURIComponent(value || '');
return acc;
}, {});
};
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 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);
}
export const createUiAuth = ({
password,
cookieName = SESSION_COOKIE_NAME,
sessionTtlMs = SESSION_TTL_MS,
} = {}) => {
const normalizedPassword = normalizePassword(password);
if (!normalizedPassword) {
const setSessionCookie = (req, res, token) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(sessionTtlMs / 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);
return token;
};
return {
enabled: false,
requireAuth: (_req, _res, next) => next(),
handleSessionStatus: (_req, res) => {
res.json({ authenticated: true, disabled: true });
},
handleSessionCreate: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
ensureSessionToken,
dispose: () => {
},
};
}
const salt = crypto.randomBytes(16);
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
const JWT_SECRET = getOrCreateJwtSecret();
const getTokenFromRequest = (req) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[cookieName]) {
return cookies[cookieName];
}
return null;
};
const setSessionCookie = (req, res, token) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(sessionTtlMs / 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, JWT_SECRET);
return true;
} catch {
return false;
}
};
const issueSession = async (req, res) => {
const token = await new SignJWT({ type: 'ui-session' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(sessionTtlMs / 1000 + 's')
.sign(JWT_SECRET);
setSessionCookie(req, res, token);
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();
}
clearSessionCookie(req, res);
return respondUnauthorized(req, res);
};
const handleSessionStatus = async (req, res) => {
const token = getTokenFromRequest(req);
if (await isSessionValid(token)) {
res.json({ authenticated: true });
return;
}
clearSessionCookie(req, res);
res.status(401).json({ authenticated: false, locked: true });
};
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);
await issueSession(req, res);
res.json({ authenticated: true });
};
const dispose = () => {
loginRateLimiter.clear();
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
};
return {
enabled: true,
requireAuth,
handleSessionStatus,
handleSessionCreate,
ensureSessionToken: async (req, _res) => {
const token = getTokenFromRequest(req);
return (await isSessionValid(token)) ? token : null;
},
dispose,
};
};