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
+35 -1
View File
@@ -255,8 +255,9 @@ export class WebServer extends EventEmitter {
error: (error: Error, sessionId?: string) => void;
} | null = null;
private tunnelManager: TunnelManager = new TunnelManager();
private authSessions: StaleExpirationMap<string, string> | null = null;
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
private authFailures: StaleExpirationMap<string, number> | null = null;
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private teamWatcherHandlers: {
@@ -321,6 +322,31 @@ export class WebServer extends EventEmitter {
this.tunnelManager.on('progress', (data: { message: string }) => {
this.broadcast('tunnel:progress', data);
});
// QR token rotation — broadcast inline SVG for instant desktop refresh
this.tunnelManager.on('qrTokenRotated', async () => {
const url = this.tunnelManager.getUrl();
if (url && process.env.CODEMAN_PASSWORD) {
try {
const svg = await this.tunnelManager.getQrSvg(url);
this.broadcast('tunnel:qrRotated', { svg });
} catch {
// QR generation failed — skip this rotation
}
}
});
this.tunnelManager.on('qrTokenRegenerated', async () => {
const url = this.tunnelManager.getUrl();
if (url && process.env.CODEMAN_PASSWORD) {
try {
const svg = await this.tunnelManager.getQrSvg(url);
this.broadcast('tunnel:qrRegenerated', { svg });
} catch {
// QR generation failed — skip
}
}
});
}
/**
@@ -485,6 +511,9 @@ export class WebServer extends EventEmitter {
pushStore: this.pushStore,
startScheduledRun: this.startScheduledRun.bind(this),
stopScheduledRun: this.stopScheduledRun.bind(this),
// AuthPort
authSessions: this.authSessions,
qrAuthFailures: this.qrAuthFailures,
};
}
@@ -510,6 +539,7 @@ export class WebServer extends EventEmitter {
if (authState) {
this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures;
this.qrAuthFailures = authState.qrAuthFailures;
}
// Security headers + CORS
@@ -2652,6 +2682,10 @@ export class WebServer extends EventEmitter {
this.authFailures.dispose();
this.authFailures = null;
}
if (this.qrAuthFailures) {
this.qrAuthFailures.dispose();
this.qrAuthFailures = null;
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();