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 };
});