feat: implement QR code authentication for tunnel access

Adds ephemeral single-use QR tokens for passwordless tunnel login.
Scanning the QR auto-authenticates; bare tunnel URL requires Basic Auth.

Backend:
- TunnelManager: 60s token rotation, 90s grace, rejection-sampled 6-char
  base62 short codes, Map-based O(1) lookup, SVG caching, global rate limit
- Auth middleware: /q/ bypass, separate qrAuthFailures counter, enhanced
  AuthSessionRecord with device context (ip, ua, createdAt, method)
- Routes: GET /q/:code (consume + cookie + redirect), POST /api/tunnel/qr/
  regenerate, POST /api/auth/revoke, updated GET /api/tunnel/qr with cache
- SSE: tunnel:qrRotated, tunnel:qrRegenerated, tunnel:qrAuthUsed events
- Audit: qr_auth lifecycle log entries

Frontend:
- Auto-refresh QR via inline SVG in SSE (fallback fetch if absent)
- 60s countdown indicator on QR badge
- Regenerate QR button
- QRLjacking detection toast with [Revoke All] action button (10s duration)
- showToast enhanced with optional duration and action button support

Fixes:
- /api/logout now invalidates server-side session token (was only clearing
  browser cookie, leaving token valid for replay)

Tests: 20 new tests in test/qr-auth.test.ts covering token lifecycle,
bias check, rate limiting, SVG caching, and full server integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 06:05:26 +01:00
co-authored by Claude Opus 4.6
parent 295190cc72
commit 746004c461
14 changed files with 1671 additions and 189 deletions
+8 -3
View File
@@ -37,7 +37,7 @@ import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
import { RunSummaryTracker } from '../../run-summary.js';
@@ -56,11 +56,16 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
): void {
// ========== Logout ==========
app.post('/api/logout', async (_req, reply) => {
app.post('/api/logout', async (req, reply) => {
// Invalidate server-side session token (not just the browser cookie)
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
if (sessionToken) {
ctx.authSessions?.delete(sessionToken);
}
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
return { success: true };
});
+98 -3
View File
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import {
ConfigUpdateSchema,
@@ -23,7 +24,8 @@ import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
@@ -81,7 +83,7 @@ function getSystemStats(): {
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
): void {
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
@@ -100,15 +102,108 @@ export function registerSystemRoutes(
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
}
try {
const authPassword = process.env.CODEMAN_PASSWORD;
if (authPassword) {
// Auth enabled — use cached SVG with embedded short code
const svg = await ctx.tunnelManager.getQrSvg(url);
return { svg, authEnabled: true };
}
// No auth — just encode the raw tunnel URL
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency
const QRCode = require('qrcode');
const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
return { svg };
return { svg, authEnabled: false };
} catch (err) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)));
}
});
// ========== QR Auth Route ==========
app.get('/q/:code', async (req, reply) => {
const shortCode = (req.params as { code: string }).code;
const authPassword = process.env.CODEMAN_PASSWORD;
// No point if auth isn't enabled — just redirect
if (!authPassword) {
return reply.redirect('/');
}
const clientIp = req.ip;
// Per-IP rate limit (separate counter from Basic Auth failures)
const qrFailures = ctx.qrAuthFailures?.get(clientIp) ?? 0;
if (qrFailures >= 10) {
return reply.code(429).send('Too Many Requests');
}
// Validate and atomically consume the token
if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) {
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
return reply.code(401).send('Invalid or expired QR code');
}
// Issue session cookie (same pattern as Basic Auth success path)
const sessionToken = randomBytes(32).toString('hex');
const clientUA = req.headers['user-agent'] ?? '';
ctx.authSessions?.set(sessionToken, {
ip: clientIp,
ua: clientUA,
createdAt: Date.now(),
method: 'qr',
});
ctx.qrAuthFailures?.delete(clientIp);
// Audit log
const lifecycleLog = getLifecycleLog();
lifecycleLog.log({
event: 'qr_auth',
sessionId: 'system',
extra: {
ip: clientIp,
ua: clientUA,
shortCodePrefix: shortCode.slice(0, 3) + '***',
},
});
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
httpOnly: true,
secure: ctx.https,
sameSite: 'lax',
maxAge: 86400, // 24h
path: '/',
});
// Broadcast auth notification — desktop sees who authenticated
ctx.broadcast('tunnel:qrAuthUsed', {
ip: clientIp,
ua: clientUA,
timestamp: Date.now(),
});
return reply.redirect('/');
});
// ========== QR Regeneration ==========
app.post('/api/tunnel/qr/regenerate', async () => {
ctx.tunnelManager.regenerateQrToken();
return { success: true };
});
// ========== Auth Session Revocation ==========
app.post('/api/auth/revoke', async (req) => {
const body = req.body as { sessionToken?: string } | undefined;
if (body?.sessionToken) {
ctx.authSessions?.delete(body.sessionToken);
} else {
// Revoke all sessions (nuclear option)
ctx.authSessions?.clear();
}
return { success: true };
});
// ========== OpenCode ==========
app.get('/api/opencode/status', async () => {