mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -161,10 +161,10 @@ journalctl --user -u codeman-web -f
|
|||||||
| `src/prompts/index.ts` | Barrel export for all agent prompts |
|
| `src/prompts/index.ts` | Barrel export for all agent prompts |
|
||||||
| `src/prompts/*.ts` | Agent prompts (research-agent, planner) |
|
| `src/prompts/*.ts` | Agent prompts (research-agent, planner) |
|
||||||
| `src/templates/claude-md.ts` | CLAUDE.md generation for new cases |
|
| `src/templates/claude-md.ts` | CLAUDE.md generation for new cases |
|
||||||
| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel remote access |
|
| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel + QR auth token rotation |
|
||||||
| `src/cli.ts` | Command-line interface handlers |
|
| `src/cli.ts` | Command-line interface handlers |
|
||||||
| `src/web/server.ts` | Fastify server setup, SSE at `/api/events`, delegates to route modules |
|
| `src/web/server.ts` | Fastify server setup, SSE at `/api/events`, delegates to route modules |
|
||||||
| `src/web/routes/*.ts` | 12 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` |
|
| `src/web/routes/*.ts` | 13 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` |
|
||||||
| `src/web/ports/*.ts` | Port interfaces (SessionPort, EventPort, etc.) — route modules declare dependencies via intersection types |
|
| `src/web/ports/*.ts` | Port interfaces (SessionPort, EventPort, etc.) — route modules declare dependencies via intersection types |
|
||||||
| `src/web/middleware/auth.ts` | Auth middleware: Basic Auth, session cookies, rate limiting, security headers, CORS |
|
| `src/web/middleware/auth.ts` | Auth middleware: Basic Auth, session cookies, rate limiting, security headers, CORS |
|
||||||
| `src/web/route-helpers.ts` | Shared helper utilities for route modules |
|
| `src/web/route-helpers.ts` | Shared helper utilities for route modules |
|
||||||
@@ -244,7 +244,7 @@ Re-exported via `src/utils/index.ts`. Key exports:
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading |
|
| `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading |
|
||||||
| `src/web/public/constants.js` | Shared constants, timing values, Z-index layers, Web Push utilities |
|
| `src/web/public/constants.js` | Shared constants, timing values, Z-index layers, Web Push utilities |
|
||||||
| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`) |
|
| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`, `_apiPut`) |
|
||||||
| `src/web/public/mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` objects |
|
| `src/web/public/mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` objects |
|
||||||
| `src/web/public/voice-input.js` | `DeepgramProvider`, `VoiceInput` objects for speech-to-text |
|
| `src/web/public/voice-input.js` | `DeepgramProvider`, `VoiceInput` objects for speech-to-text |
|
||||||
| `src/web/public/notification-manager.js` | `NotificationManager` class (5-layer notification system) |
|
| `src/web/public/notification-manager.js` | `NotificationManager` class (5-layer notification system) |
|
||||||
@@ -286,8 +286,10 @@ The frontend is split across multiple vanilla JS modules (extracted from the ori
|
|||||||
### Security
|
### Security
|
||||||
|
|
||||||
- **HTTP Basic Auth**: Optional via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars
|
- **HTTP Basic Auth**: Optional via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars
|
||||||
- **Session cookies**: After Basic Auth, a 24h session cookie (`codeman_session`) is issued so credentials aren't re-sent on every request. Active sessions auto-extend. SSE works via same-origin cookie (`EventSource` can't send custom headers).
|
- **QR Auth**: Single-use ephemeral 6-char tokens (60s TTL, 90s grace) for tunnel login without typing passwords. `TunnelManager` rotates tokens, serves cached SVG at `GET /api/tunnel/qr`, validates at `GET /q/:code`. Separate per-IP rate limit (10/15min) + global path limit (30/min). Desktop notification on consumption (QRLjacking detection). Audit logged as `qr_auth` in `session-lifecycle.jsonl`. See `docs/qr-auth-plan.md`.
|
||||||
- **Rate limiting**: 10 failed auth attempts per IP triggers 429 rejection (15-minute decay window). Manual `StaleExpirationMap` counter — no `@fastify/rate-limit` needed.
|
- **Session cookies**: After Basic Auth or QR Auth, a 24h session cookie (`codeman_session`) is issued so credentials aren't re-sent on every request. Active sessions auto-extend. SSE works via same-origin cookie (`EventSource` can't send custom headers). Sessions store device context (IP + User-Agent) for audit via `AuthSessionRecord`.
|
||||||
|
- **Session revocation**: `POST /api/auth/revoke` revokes individual sessions or all sessions.
|
||||||
|
- **Rate limiting**: 10 failed auth attempts per IP triggers 429 rejection (15-minute decay window). Manual `StaleExpirationMap` counter — no `@fastify/rate-limit` needed. QR auth has its own separate rate limiter.
|
||||||
- **Hook bypass**: `/api/hook-event` POST is exempt from auth — Claude Code hooks curl this from localhost and can't present credentials. Safe: validated by `HookEventSchema`, only triggers broadcasts.
|
- **Hook bypass**: `/api/hook-event` POST is exempt from auth — Claude Code hooks curl this from localhost and can't present credentials. Safe: validated by `HookEventSchema`, only triggers broadcasts.
|
||||||
- **CORS**: Restricted to localhost only
|
- **CORS**: Restricted to localhost only
|
||||||
- **Security headers**: X-Content-Type-Options, X-Frame-Options, CSP; HSTS if HTTPS
|
- **Security headers**: X-Content-Type-Options, X-Frame-Options, CSP; HSTS if HTTPS
|
||||||
@@ -309,6 +311,7 @@ The frontend is split across multiple vanilla JS modules (extracted from the ori
|
|||||||
| Hooks | `hook:{eventName}` (dynamic) | Claude Code hook events |
|
| Hooks | `hook:{eventName}` (dynamic) | Claude Code hook events |
|
||||||
| Plan | `plan:started/progress/completed/cancelled/subagent` | Plan orchestration |
|
| Plan | `plan:started/progress/completed/cancelled/subagent` | Plan orchestration |
|
||||||
| Mux | `mux:created/killed/died/statsUpdated` | tmux process monitor |
|
| Mux | `mux:created/killed/died/statsUpdated` | tmux process monitor |
|
||||||
|
| Tunnel | `tunnel:qrRotated/qrRegenerated/qrAuthUsed` | QR token lifecycle |
|
||||||
| Image | `image:detected` | Screenshot detection |
|
| Image | `image:detected` | Screenshot detection |
|
||||||
|
|
||||||
### API Route Categories
|
### API Route Categories
|
||||||
@@ -328,6 +331,7 @@ The frontend is split across multiple vanilla JS modules (extracted from the ori
|
|||||||
| Mux | `/api/mux-sessions` | 5 | tmux management, stats |
|
| Mux | `/api/mux-sessions` | 5 | tmux management, stats |
|
||||||
| Scheduled | `/api/scheduled` | 4 | CRUD for scheduled runs |
|
| Scheduled | `/api/scheduled` | 4 | CRUD for scheduled runs |
|
||||||
| Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe |
|
| Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe |
|
||||||
|
| Auth | `/api/auth`, `/q/:code` | 3 | QR validation, session revocation |
|
||||||
| Teams | `/api/teams` | 2 | list teams, get team tasks |
|
| Teams | `/api/teams` | 2 | list teams, get team tasks |
|
||||||
|
|
||||||
## Adding Features
|
## Adding Features
|
||||||
@@ -490,6 +494,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|
|||||||
| **Voice input** | `docs/voice-input-plan.md` |
|
| **Voice input** | `docs/voice-input-plan.md` |
|
||||||
| **Improvement roadmaps** | `docs/respawn-improvement-plan.md`, `docs/ralph-improvement-plan.md`, `docs/plan-improvement-roadmap.md` |
|
| **Improvement roadmaps** | `docs/respawn-improvement-plan.md`, `docs/ralph-improvement-plan.md`, `docs/plan-improvement-roadmap.md` |
|
||||||
| **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` |
|
| **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` |
|
||||||
|
| **QR auth design** | `docs/qr-auth-plan.md` |
|
||||||
| **Run summary** | `docs/run-summary-plan.md` |
|
| **Run summary** | `docs/run-summary-plan.md` |
|
||||||
|
|
||||||
Additional design docs and investigation reports are in the `docs/` directory.
|
Additional design docs and investigation reports are in the `docs/` directory.
|
||||||
@@ -572,7 +577,7 @@ journalctl --user -u codeman-tunnel -f
|
|||||||
|
|
||||||
### Auth Flow
|
### Auth Flow
|
||||||
|
|
||||||
1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`)
|
1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`), or scan QR code from tunnel settings panel
|
||||||
2. On success → server issues `codeman_session` HttpOnly cookie (24h TTL, auto-extends on activity)
|
2. On success → server issues `codeman_session` HttpOnly cookie (24h TTL, auto-extends on activity)
|
||||||
3. Subsequent requests → cookie authenticates silently (no more prompts)
|
3. Subsequent requests → cookie authenticates silently (no more prompts)
|
||||||
4. SSE works automatically — `EventSource` sends same-origin cookies
|
4. SSE works automatically — `EventSource` sends same-origin cookies
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
# QR Code Authentication Plan
|
||||||
|
|
||||||
|
> Ephemeral, single-use auth tokens embedded in the tunnel QR code — scan to auto-authenticate, while the bare tunnel URL stays password-protected.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When the Cloudflare tunnel is active, anyone who discovers the `*.trycloudflare.com` URL can access Codeman (they just need the Basic Auth password, or if no password is set, full open access). The QR code currently encodes the raw tunnel URL — it provides no additional security. We want:
|
||||||
|
|
||||||
|
1. **Scanning the QR code** → seamless, instant access (no password prompt)
|
||||||
|
2. **Having only the URL** → blocked by Basic Auth (no access without credentials)
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Core Concept: Ephemeral Single-Use QR Tokens
|
||||||
|
|
||||||
|
The server maintains a rotating pool of short-lived, single-use tokens. The QR code encodes a short URL containing a lookup code that maps to the real token server-side. When scanned, the server validates the token, atomically consumes it, issues a session cookie, and redirects to `/`. The token is **not** the password — it's a separate, independent, ephemeral authentication pathway.
|
||||||
|
|
||||||
|
```
|
||||||
|
Desktop → displays QR (auto-refreshes every 60s via SSE)
|
||||||
|
QR Code → https://abc-xyz.trycloudflare.com/q/Xk9mQ3
|
||||||
|
Phone → scans, GET /q/Xk9mQ3
|
||||||
|
Server → looks up short code via Map (hash-based, timing-safe)
|
||||||
|
→ finds token record → validates TTL
|
||||||
|
→ atomically consumes token (single-use)
|
||||||
|
→ issues codeman_session cookie
|
||||||
|
→ 302 redirect to /
|
||||||
|
→ SSE push: new QR with embedded SVG for desktop display
|
||||||
|
→ desktop toast: "Device [IP] authenticated via QR"
|
||||||
|
→ audit log entry to session-lifecycle.jsonl
|
||||||
|
User → lands on app, fully authenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
Someone who only has `https://abc-xyz.trycloudflare.com/` gets the standard Basic Auth prompt.
|
||||||
|
|
||||||
|
### Token Properties
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Length | 32 bytes (256 bits entropy) |
|
||||||
|
| Generation | `crypto.randomBytes(32).toString('hex')` |
|
||||||
|
| Short code | 6 chars base62, rejection-sampled (no modulo bias) |
|
||||||
|
| Short code derivation | Independent random generation (not derived from token) |
|
||||||
|
| Storage | In-memory `Map<shortCode, QrTokenRecord>` (no disk persistence) |
|
||||||
|
| TTL | 60 seconds (auto-rotation via timer), 90s grace for previous token |
|
||||||
|
| Effective window | Up to 90 seconds for the previous token (documented, not hidden) |
|
||||||
|
| Usage | **Single-use** — atomically consumed on first valid scan |
|
||||||
|
| URL format | Short code in path (`/q/Xk9mQ3`), not query params |
|
||||||
|
| URL length | ~53-56 chars total — targets QR Version 4 (33x33) for fast scanning |
|
||||||
|
| Scope | Only valid when `CODEMAN_PASSWORD` is set (no point without auth) |
|
||||||
|
| Lookup | `Map.get()` — hash-based O(1), no timing side-channel |
|
||||||
|
|
||||||
|
### Why This Design?
|
||||||
|
|
||||||
|
**Why not embed the password directly?**
|
||||||
|
- Password would appear in browser history, Cloudflare edge logs, and URL bars
|
||||||
|
- Password can't be rotated independently from QR access
|
||||||
|
|
||||||
|
**Why not a long-lived multi-use token? (original design)**
|
||||||
|
- A static token is functionally a second password — if the QR image leaks (screenshot shared, shoulder surfing, Cloudflare logs), the attacker has permanent access
|
||||||
|
- The USENIX Security 2025 paper ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) found 47 of the top-100 websites vulnerable due to exactly this pattern — missing single-use enforcement and long-lived tokens were 2 of the 6 critical design flaws identified
|
||||||
|
|
||||||
|
**Why short codes in the URL path instead of query params?**
|
||||||
|
- Query params (`?t=TOKEN`) leak into browser history, address bar, `Referer` headers, and Cloudflare edge logs
|
||||||
|
- Path-based short codes (`/q/Xk9mQ3`) are opaque references — the real token never appears in URLs
|
||||||
|
- Short codes are 6-char base62 (62^6 = 56.8 billion combinations), sufficient for lookup since they're backed by the full 256-bit token for validation and rate-limited to 10 attempts/IP
|
||||||
|
- The short `/q/` path (vs `/qr-auth/`) saves 7 bytes, helping keep the QR at Version 4 (33x33 modules) instead of Version 5 (37x37) — faster scanning on budget phones
|
||||||
|
|
||||||
|
## Auth Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ scan QR ┌──────────────────────────────────────┐
|
||||||
|
│ Mobile │ ────────────→ │ GET /q/Xk9mQ3 │
|
||||||
|
│ Device │ │ │
|
||||||
|
└─────────────┘ │ 1. Auth middleware sees /q/ │
|
||||||
|
│ → skips Basic Auth check │
|
||||||
|
│ 2. Route handler: Map.get(shortCode) │
|
||||||
|
│ → hash-based lookup (timing-safe) │
|
||||||
|
│ 3. Checks TTL (90s grace for prev) │
|
||||||
|
│ → token not expired? │
|
||||||
|
│ 4. Checks consumed flag │
|
||||||
|
│ → not already used? │
|
||||||
|
│ 5. Atomically marks token consumed │
|
||||||
|
│ 6. Issues codeman_session cookie │
|
||||||
|
│ 7. 302 redirect to / │
|
||||||
|
│ 8. Audit log → session-lifecycle.jsonl│
|
||||||
|
│ 9. SSE push: tunnel:qrRegenerated │
|
||||||
|
│ → desktop refreshes QR (SVG inline)│
|
||||||
|
│ 10. Desktop toast: "Device auth'd" │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────┐ replay URL ┌──────────────────────────────────────┐
|
||||||
|
│ Attacker │ ────────────→ │ GET /q/Xk9mQ3 │
|
||||||
|
│ (stale code) │ │ │
|
||||||
|
└─────────────┘ │ 1. Map.get(shortCode) → not found │
|
||||||
|
│ OR token consumed OR expired │
|
||||||
|
│ 2. Increment QR rate limit counter │
|
||||||
|
│ (separate from Basic Auth counter) │
|
||||||
|
│ 3. 401 Unauthorized │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────┐ URL only ┌──────────────────────────────────────┐
|
||||||
|
│ Attacker │ ────────────→ │ GET / │
|
||||||
|
│ (no token) │ │ │
|
||||||
|
└─────────────┘ │ 1. Auth middleware checks cookie │
|
||||||
|
│ → no cookie │
|
||||||
|
│ 2. Checks Basic Auth header │
|
||||||
|
│ → no header │
|
||||||
|
│ 3. Returns 401 + WWW-Authenticate │
|
||||||
|
│ → Browser shows password popup │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### 1. Token Manager — `src/tunnel-manager.ts`
|
||||||
|
|
||||||
|
Add a `QrTokenRecord` type and token rotation logic to `TunnelManager`. The token rotates every 60 seconds. A consumed token is immediately replaced. Up to 2 tokens can be valid simultaneously (current + previous, to handle the race where someone scans right as rotation happens). The previous token has a 90s grace period (not a full extra 60s — only enough to cover the scan-during-rotation race).
|
||||||
|
|
||||||
|
**Design decisions from security review:**
|
||||||
|
- **Map-based lookup** (not array scan) — `Map.get()` uses hash-based O(1) lookup, eliminating timing side-channels from string comparison
|
||||||
|
- **Rejection sampling** for short codes — avoids modulo bias (`256 % 62 != 0` gives 25% overrepresentation for first 6 charset chars)
|
||||||
|
- **SVG cache** — stores generated QR SVG per rotation cycle to avoid regenerating on every `/api/tunnel/qr` poll
|
||||||
|
- **Separate rate limit counter** — QR auth failures tracked independently from Basic Auth failures
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
interface QrTokenRecord {
|
||||||
|
token: string; // 64 hex chars (256 bits)
|
||||||
|
shortCode: string; // 6 chars base62 (for URL path)
|
||||||
|
createdAt: number; // Date.now()
|
||||||
|
consumed: boolean; // single-use flag
|
||||||
|
}
|
||||||
|
|
||||||
|
const QR_TOKEN_TTL_MS = 60_000; // 60 seconds
|
||||||
|
const QR_TOKEN_GRACE_MS = 90_000; // 90s grace for previous token (scan-during-rotation)
|
||||||
|
const SHORT_CODE_LENGTH = 6;
|
||||||
|
const QR_RATE_LIMIT_MAX = 30; // global rate limit across all IPs
|
||||||
|
const QR_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute window
|
||||||
|
|
||||||
|
/** Rejection-sampled short code generation — no modulo bias */
|
||||||
|
function generateShortCode(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const maxUnbiased = 248; // largest multiple of 62 that fits in a byte (248 = 62 * 4)
|
||||||
|
const result: string[] = [];
|
||||||
|
while (result.length < SHORT_CODE_LENGTH) {
|
||||||
|
const [byte] = randomBytes(1);
|
||||||
|
if (byte < maxUnbiased) result.push(chars[byte % 62]);
|
||||||
|
// else: discard and re-draw (rejection sampling)
|
||||||
|
}
|
||||||
|
return result.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TunnelManager extends EventEmitter {
|
||||||
|
// Map-based lookup: shortCode → QrTokenRecord (timing-safe, no string comparison)
|
||||||
|
private qrTokensByCode = new Map<string, QrTokenRecord>();
|
||||||
|
private currentShortCode: string | null = null;
|
||||||
|
private rotationTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// SVG cache — regenerated only on token rotation, not per request
|
||||||
|
private cachedQrSvg: { shortCode: string; svg: string } | null = null;
|
||||||
|
|
||||||
|
// Global rate limit counter (separate from Basic Auth rate limiting)
|
||||||
|
private qrAttemptCount = 0;
|
||||||
|
private qrRateLimitResetTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.rotateToken();
|
||||||
|
this.rotationTimer = setInterval(() => this.rotateToken(), QR_TOKEN_TTL_MS);
|
||||||
|
this.qrRateLimitResetTimer = setInterval(() => { this.qrAttemptCount = 0; }, QR_RATE_LIMIT_WINDOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotateToken(): void {
|
||||||
|
const record: QrTokenRecord = {
|
||||||
|
token: randomBytes(32).toString('hex'),
|
||||||
|
shortCode: generateShortCode(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
consumed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evict expired tokens from the Map
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [code, rec] of this.qrTokensByCode) {
|
||||||
|
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) {
|
||||||
|
this.qrTokensByCode.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.qrTokensByCode.set(record.shortCode, record);
|
||||||
|
this.currentShortCode = record.shortCode;
|
||||||
|
this.cachedQrSvg = null; // invalidate SVG cache
|
||||||
|
this.emit('qrTokenRotated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current (newest) token's short code for QR URL */
|
||||||
|
getCurrentShortCode(): string | undefined {
|
||||||
|
return this.currentShortCode ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get cached QR SVG, regenerating only if the short code changed */
|
||||||
|
async getQrSvg(tunnelUrl: string): Promise<string> {
|
||||||
|
const code = this.currentShortCode;
|
||||||
|
if (!code) throw new Error('No QR token available');
|
||||||
|
if (this.cachedQrSvg?.shortCode === code) return this.cachedQrSvg.svg;
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const svg = await QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 });
|
||||||
|
this.cachedQrSvg = { shortCode: code, svg };
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and atomically consume a token by short code.
|
||||||
|
* Returns { success, ip?, ua? } for audit logging on success.
|
||||||
|
* Map.get() is hash-based — no timing side-channel from string comparison.
|
||||||
|
*/
|
||||||
|
consumeToken(shortCode: string): boolean {
|
||||||
|
// Global rate limit (across all IPs)
|
||||||
|
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false;
|
||||||
|
this.qrAttemptCount++;
|
||||||
|
|
||||||
|
const record = this.qrTokensByCode.get(shortCode);
|
||||||
|
if (!record) return false;
|
||||||
|
if (record.consumed) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false;
|
||||||
|
|
||||||
|
// Atomic consume (single-threaded JS = no race)
|
||||||
|
record.consumed = true;
|
||||||
|
// Immediately rotate so desktop gets a fresh QR
|
||||||
|
this.rotateToken();
|
||||||
|
this.emit('qrTokenRegenerated');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force-regenerate (manual revocation via API) */
|
||||||
|
regenerateQrToken(): void {
|
||||||
|
// Invalidate all existing tokens
|
||||||
|
this.qrTokensByCode.clear();
|
||||||
|
this.currentShortCode = null;
|
||||||
|
this.rotateToken();
|
||||||
|
this.emit('qrTokenRegenerated');
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRotation(): void {
|
||||||
|
if (this.rotationTimer) {
|
||||||
|
clearInterval(this.rotationTimer);
|
||||||
|
this.rotationTimer = null;
|
||||||
|
}
|
||||||
|
if (this.qrRateLimitResetTimer) {
|
||||||
|
clearInterval(this.qrRateLimitResetTimer);
|
||||||
|
this.qrRateLimitResetTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Auth Middleware Bypass — `src/web/middleware/auth.ts`
|
||||||
|
|
||||||
|
Add `/q/` to the bypass list (same pattern as `/api/hook-event`). The route handler itself handles token validation and rate limiting.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In the onRequest hook, add before Basic Auth check:
|
||||||
|
if (req.url.startsWith('/q/')) {
|
||||||
|
done(); // Let the route handler deal with token validation
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Unlike `/api/hook-event` (localhost-only), `/q/` must be reachable from any IP (remote devices scan the QR). Rate limiting is handled by two independent mechanisms:
|
||||||
|
1. **Per-IP rate limit** — reuses the `authFailures` StaleExpirationMap (10 attempts/IP/15min), but tracked via a **separate counter** from Basic Auth failures (so a user who fat-fingers their password doesn't burn their QR attempts)
|
||||||
|
2. **Global path rate limit** — `TunnelManager.qrAttemptCount` caps total QR attempts to 30/minute across all IPs, defending against distributed brute force
|
||||||
|
|
||||||
|
### 3. Auto-Auth Route — `src/web/routes/system-routes.ts`
|
||||||
|
|
||||||
|
Add `GET /q/:code` as a top-level route (not under `/api/`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
if (!authPassword) {
|
||||||
|
return reply.redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-IP rate limit (separate counter from Basic Auth failures)
|
||||||
|
const clientIp = req.ip;
|
||||||
|
const qrFailures = ctx.authState.qrAuthFailures?.get(clientIp) ?? 0;
|
||||||
|
if (qrFailures >= 10) {
|
||||||
|
return reply.code(429).send('Too Many Requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and atomically consume the token
|
||||||
|
// consumeToken() also checks the global rate limit (30/min across all IPs)
|
||||||
|
if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) {
|
||||||
|
ctx.authState.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||||
|
return reply.code(401).send('Invalid or expired QR code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue session cookie (same as Basic Auth success path)
|
||||||
|
const sessionToken = randomBytes(32).toString('hex');
|
||||||
|
const clientUA = req.headers['user-agent'] ?? '';
|
||||||
|
ctx.authState.authSessions?.set(sessionToken, {
|
||||||
|
ip: clientIp,
|
||||||
|
ua: clientUA,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
ctx.authState.qrAuthFailures?.delete(clientIp);
|
||||||
|
|
||||||
|
// Audit log — write to session-lifecycle.jsonl for forensic analysis
|
||||||
|
ctx.lifecycleLog?.append({
|
||||||
|
event: 'qr_auth',
|
||||||
|
ip: clientIp,
|
||||||
|
ua: clientUA,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
shortCodePrefix: shortCode.slice(0, 3) + '***', // partial for privacy
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: ctx.https,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 86400, // 24h
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast auth notification — desktop sees who authenticated (QRLjacking detection)
|
||||||
|
broadcast('tunnel:qrAuthUsed', {
|
||||||
|
ip: clientIp,
|
||||||
|
ua: clientUA,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.redirect('/');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update QR Code URL — `src/web/routes/system-routes.ts`
|
||||||
|
|
||||||
|
Modify `/api/tunnel/qr` to encode the short-code URL. Uses the `TunnelManager.getQrSvg()` cache — SVG is regenerated only when the token rotates, not on every request.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||||
|
const url = ctx.tunnelManager.getUrl();
|
||||||
|
if (!url) {
|
||||||
|
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||||
|
|
||||||
|
// If auth is enabled, use the cached SVG with embedded short code
|
||||||
|
if (authPassword) {
|
||||||
|
const svg = await ctx.tunnelManager.getQrSvg(url);
|
||||||
|
return { svg, authEnabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth — just encode the raw tunnel URL
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const svg = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
|
||||||
|
return { svg, authEnabled: false };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Token Regeneration Endpoint — `src/web/routes/system-routes.ts`
|
||||||
|
|
||||||
|
Manual revocation — invalidates ALL existing tokens and creates a fresh one:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.post('/api/tunnel/qr/regenerate', async () => {
|
||||||
|
ctx.tunnelManager.regenerateQrToken();
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Frontend Updates — `src/web/public/app.js`
|
||||||
|
|
||||||
|
#### QR Overlay Changes
|
||||||
|
|
||||||
|
- **Auto-refresh via inline SVG**: Listen for `tunnel:qrRotated` SSE events which now include the SVG directly in the payload — no extra HTTP fetch needed, sub-50ms refresh on desktop.
|
||||||
|
- **Countdown indicator**: Small "expires in Xs" text under the QR that counts down from 60. Reassures the user the QR is live and not stale.
|
||||||
|
- **Regenerate button**: "Regenerate QR" button. Calls `POST /api/tunnel/qr/regenerate` — SSE event delivers the new SVG.
|
||||||
|
- **Auth badge**: Lock icon or "Single-use auth" label when auth is active.
|
||||||
|
- **URL display**: Show the raw tunnel URL (not the auth URL) for manual copy — users who copy the URL authenticate via Basic Auth. The QR is the fast path.
|
||||||
|
- **Auth notification toast**: When `tunnel:qrAuthUsed` fires, show a 10-second toast: "Device [IP] authenticated via QR (Safari). Not you? [Revoke]". This is the primary QRLjacking detection mechanism (USENIX Flaw-5).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Auto-refresh QR on rotation — SVG is inline in the event payload
|
||||||
|
addListener('tunnel:qrRotated', (data) => {
|
||||||
|
if (data.svg) {
|
||||||
|
updateQrDisplay(data.svg); // direct DOM update, no fetch
|
||||||
|
} else {
|
||||||
|
refreshTunnelQR(); // fallback: fetch from API
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also refresh on manual regeneration
|
||||||
|
addListener('tunnel:qrRegenerated', (data) => {
|
||||||
|
if (data.svg) {
|
||||||
|
updateQrDisplay(data.svg);
|
||||||
|
} else {
|
||||||
|
refreshTunnelQR();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// QRLjacking detection — notify desktop user when QR is consumed
|
||||||
|
addListener('tunnel:qrAuthUsed', (data) => {
|
||||||
|
showNotificationToast(
|
||||||
|
`Device authenticated via QR (${parseUAFamily(data.ua)}, ${data.ip}). Not you?`,
|
||||||
|
{
|
||||||
|
duration: 10000,
|
||||||
|
action: { label: 'Revoke', onClick: () => revokeAllSessions() },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// In showTunnelQR(), after fetching /api/tunnel/qr:
|
||||||
|
if (data.authEnabled) {
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.textContent = 'Single-use auth \u00b7 refreshes every 60s';
|
||||||
|
badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-secondary)';
|
||||||
|
container.parentElement.appendChild(badge);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Welcome Screen QR
|
||||||
|
|
||||||
|
Same auto-refresh behavior applies to `_updateWelcomeTunnelBtn()` — the QR is fetched from `/api/tunnel/qr` so token embedding happens automatically.
|
||||||
|
|
||||||
|
### 7. SSE Events
|
||||||
|
|
||||||
|
Three events for the frontend. QR rotation events embed the SVG directly in the payload to eliminate an extra HTTP fetch — the desktop gets the new QR in a single SSE push (~2-5KB SVG, well within SSE limits).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In server.ts, listen for tunnelManager events:
|
||||||
|
|
||||||
|
// Auto-rotation every 60s — desktop refreshes QR silently (SVG inline)
|
||||||
|
tunnelManager.on('qrTokenRotated', async () => {
|
||||||
|
const url = tunnelManager.getUrl();
|
||||||
|
if (url && process.env.CODEMAN_PASSWORD) {
|
||||||
|
const svg = await tunnelManager.getQrSvg(url);
|
||||||
|
broadcast('tunnel:qrRotated', { svg });
|
||||||
|
} else {
|
||||||
|
broadcast('tunnel:qrRotated', {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual regeneration or post-consumption — desktop refreshes QR (SVG inline)
|
||||||
|
tunnelManager.on('qrTokenRegenerated', async () => {
|
||||||
|
const url = tunnelManager.getUrl();
|
||||||
|
if (url && process.env.CODEMAN_PASSWORD) {
|
||||||
|
const svg = await tunnelManager.getQrSvg(url);
|
||||||
|
broadcast('tunnel:qrRegenerated', { svg });
|
||||||
|
} else {
|
||||||
|
broadcast('tunnel:qrRegenerated', {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// QR auth consumed — desktop shows notification toast (QRLjacking detection)
|
||||||
|
// Note: this is broadcast from the route handler, not tunnelManager
|
||||||
|
// Event: tunnel:qrAuthUsed { ip, ua, timestamp }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Session Cookie Binding & Revocation
|
||||||
|
|
||||||
|
Enhance session records to include device context for audit purposes. The UA is stored for **logging only** — not for blocking.
|
||||||
|
|
||||||
|
**Why no UA-family blocking (`majorUAChanged`)?** Security review found this is security theater:
|
||||||
|
- UA strings are trivially spoofable by any attacker who can steal a cookie
|
||||||
|
- Chrome UA reduction (2022+) makes family detection unreliable
|
||||||
|
- Mobile WebView → browser switches trigger false positives on the same device
|
||||||
|
- HttpOnly + Secure + SameSite=lax + 24h TTL already protect against cookie theft
|
||||||
|
- The attacker who can exfiltrate a cookie can also replay the exact UA
|
||||||
|
|
||||||
|
Instead, provide **manual session revocation** as the active defense:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Session record stores device context for audit logging (not blocking):
|
||||||
|
ctx.authState.authSessions?.set(sessionToken, {
|
||||||
|
ip: clientIp,
|
||||||
|
ua: req.headers['user-agent'] ?? '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
method: 'qr', // 'qr' | 'basic' — tracks how session was created
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual revocation endpoint — kill specific session or all sessions
|
||||||
|
app.post('/api/auth/revoke', async (req, reply) => {
|
||||||
|
const { sessionToken: target } = req.body as { sessionToken?: string };
|
||||||
|
if (target) {
|
||||||
|
ctx.authState.authSessions?.delete(target);
|
||||||
|
} else {
|
||||||
|
// Revoke all sessions (nuclear option)
|
||||||
|
ctx.authState.authSessions?.clear();
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This is a breaking type change. The `AuthState` interface must be updated from `StaleExpirationMap<string, string>` (token → clientIp) to `StaleExpirationMap<string, { ip, ua, createdAt, method }>`. All session validation code in `auth.ts` must be updated simultaneously.
|
||||||
|
|
||||||
|
### 9. Cleanup — `src/tunnel-manager.ts`
|
||||||
|
|
||||||
|
Stop the rotation timer in the `stop()` method:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
stop(): void {
|
||||||
|
this.stopRotation();
|
||||||
|
// ... existing cleanup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Analysis
|
||||||
|
|
||||||
|
### Threat Model
|
||||||
|
|
||||||
|
| Threat | Attack Vector | Mitigation | Residual Risk |
|
||||||
|
|--------|--------------|------------|---------------|
|
||||||
|
| **QR screenshot shared** | Attacker gets image of QR code | Single-use: token consumed on first scan. 60s TTL: expired by the time attacker tries. Desktop toast notification alerts user if someone else scans. | If attacker scans faster than legitimate user (~seconds), they win the race. Low risk: requires physical proximity + speed. User sees notification and can revoke. |
|
||||||
|
| **Cloudflare edge logs** | Cloudflare logs the full URL path | Short code is opaque (6-char lookup key), not the real token. Single-use: replaying from logs always fails. 60s TTL (90s grace): expired before log review. `trycloudflare.com` quick tunnels have no customer-accessible logging controls — the privacy implications are inherent to using free quick tunnels. | Cloudflare has TLS termination access regardless. Ephemeral short codes are far less valuable than a permanent token. |
|
||||||
|
| **Brute force short code** | Attacker guesses `/q/XXXXXX` | Per-IP rate limiting (10/IP/15min) + global path rate limit (30/min across all IPs). 62^6 = 56.8 billion combinations. Only ~2 valid codes at any time. | Infeasible: expected guesses to hit = ~2.8×10^10, rate limits block well before. |
|
||||||
|
| **Replay attack** | Reuse a previously valid URL | Single-use consumption + 60s TTL (90s grace). Old codes always 401. | None — replay is impossible by design. |
|
||||||
|
| **QRLjacking** | Attacker displays your QR on phishing site | No companion app = limited mitigation. However: 60s rotation means attacker must relay in real-time. Desktop toast notification ("Device [IP] authenticated via QR. Not you? [Revoke]") provides real-time detection. Self-hosted single-user context makes phishing implausible. | Theoretical risk for multi-user deployments. Mitigated by notification toast for single-user. Note: Signal's linked-device QR flow was exploited by Russian state actors (UNC5792/Sandworm) via quishing in 2025 — but that targeted a multi-user messaging platform, not a self-hosted dev tool. |
|
||||||
|
| **Session cookie theft** | XSS or network sniffing steals cookie | HttpOnly + Secure flags. SameSite=lax prevents CSRF. 24h TTL limits exposure window. Manual revocation via `/api/auth/revoke`. | Standard web cookie risks apply. Mitigated by security headers (CSP, etc.). |
|
||||||
|
| **Token in server logs** | Access log captures URL path | Log `/q/*` with short code masked or omitted. Configure Fastify logger to redact `/q/` paths. | Path still appears in server access logs (mitigated by masking). |
|
||||||
|
| **Timing attack** | Measure response time to leak short code | Map-based lookup (`Map.get()`) — hash-based O(1), no character-by-character timing leak. No string comparison in the hot path. | None — timing side channel eliminated by design. |
|
||||||
|
| **Token not in query params** | N/A (this is a mitigation) | Short code in URL path avoids browser history, Referer headers, and address bar exposure. | Path still appears in server access logs (mitigated by masking). |
|
||||||
|
| **Distributed brute force** | Multiple IPs guess codes simultaneously | Global rate limit (30/min total across all IPs) in addition to per-IP limit. | Infeasible given keyspace. Global limit prevents botnet-scale attempts. |
|
||||||
|
| **CSRF on regenerate** | Cross-origin POST to `/api/tunnel/qr/regenerate` | SameSite=lax cookies are NOT sent with cross-origin POST requests, providing CSRF protection. Endpoint requires authenticated session. | Verify SameSite=lax behavior through cloudflared tunnel. |
|
||||||
|
|
||||||
|
### USENIX Security 2025 Flaw Coverage
|
||||||
|
|
||||||
|
The [Zhang et al. paper](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025, 47 of top-100 websites vulnerable, 42 CVEs) identified 6 critical design flaws. Coverage:
|
||||||
|
|
||||||
|
| USENIX Flaw | Status | Implementation |
|
||||||
|
|-------------|--------|----------------|
|
||||||
|
| Flaw-1: Missing single-use enforcement | **Fixed** | Atomic `consumed` flag, Map-based lookup |
|
||||||
|
| Flaw-2: Long-lived tokens | **Fixed** | 60s TTL, 90s grace, auto-rotation |
|
||||||
|
| Flaw-3: Predictable QrId generation | **Fixed** | `crypto.randomBytes(32)` — 256-bit entropy, rejection-sampled short codes |
|
||||||
|
| Flaw-4: Client-side QrId generation | **Fixed** | Server-side generation only |
|
||||||
|
| Flaw-5: Missing status notification | **Fixed** | Desktop toast notification via `tunnel:qrAuthUsed` SSE event. Shows device IP/UA with [Revoke] button. |
|
||||||
|
| Flaw-6: Inadequate session binding | **Partial** | IP + UA stored for audit. No cryptographic channel binding (requires companion app / FIDO2 — overkill for single-user). Manual revocation as active defense. |
|
||||||
|
|
||||||
|
### Industry Comparison
|
||||||
|
|
||||||
|
| Platform | Model | How This Plan Compares |
|
||||||
|
|----------|-------|----------------------|
|
||||||
|
| **Discord** | Long-lived session token, no confirmation, repeatedly exploited via QRLjacking | **Better** — single-use + TTL + notification toast |
|
||||||
|
| **WhatsApp Web** | Pre-authenticated phone confirms "Link device?", ~60s rotation | **Comparable** rotation model; missing WhatsApp's explicit confirmation prompt (acceptable: single-user, no account selection) |
|
||||||
|
| **Signal** | Ephemeral public key in QR, E2E encrypted channel via Signal protocol | **Below** — no cryptographic channel binding. Note: Signal's QR flow was exploited by state actors in 2025 despite stronger crypto, showing that protocol strength alone doesn't prevent social engineering. |
|
||||||
|
| **1Password** | Noise framework E2E channel, post-quantum pre-shared keys, confirmation codes | **Below** — but 1Password is a credential manager with different threat model. Overkill for a dev tool. |
|
||||||
|
| **FIDO2 CTAP 2.2** | BLE proximity + cryptographic binding + biometric verification | **Below** — but requires BLE stack, FIDO server, and companion authenticator. Completely inappropriate here. |
|
||||||
|
|
||||||
|
### Comparison to Prior Design
|
||||||
|
|
||||||
|
| Property | Original Plan | Current Plan |
|
||||||
|
|----------|--------------|--------------|
|
||||||
|
| Token TTL | Infinite (until restart) | 60 seconds (90s grace for previous token) |
|
||||||
|
| Reuse | Multi-use (same QR works forever) | Single-use (consumed atomically on first scan) |
|
||||||
|
| Secret in URL | Query param (`?t=64-char-hex`) | Opaque short code in path (`/q/Xk9mQ3`) |
|
||||||
|
| Leak impact | Permanent access until manual revoke | Worthless after first use or 90s, whichever comes first |
|
||||||
|
| Desktop QR refresh | Manual only | Auto-refresh every 60s via SSE with inline SVG |
|
||||||
|
| Session binding | IP only | IP + UA stored for audit (not blocking). Manual revocation endpoint. |
|
||||||
|
| Auth notification | None | Desktop toast: "Device [IP] authenticated via QR. Not you? [Revoke]" |
|
||||||
|
| Audit logging | None | `session-lifecycle.jsonl` entry on every QR auth event |
|
||||||
|
| Rate limiting | Per-IP only, shared with Basic Auth | Per-IP (separate counter) + global path limit (30/min) |
|
||||||
|
| Short code generation | Modulo-biased | Rejection-sampled (no bias) |
|
||||||
|
| Short code lookup | Array scan (timing leak) | Map-based O(1) (timing-safe) |
|
||||||
|
| Connect latency | ~50ms (localhost only) | ~150-300ms through Cloudflare tunnel (honest estimate) |
|
||||||
|
|
||||||
|
### What This Does NOT Protect Against
|
||||||
|
|
||||||
|
- **FIDO2/passkey-level phishing resistance**: Would require BLE proximity verification and cryptographic channel binding. Overkill for a self-hosted single-user dev tool. The FIDO2 CTAP 2.2 hybrid transport is the gold standard but requires BLE hardware and a companion authenticator.
|
||||||
|
- **Compromised phone**: If the attacker has physical access to the phone that scans, no QR scheme helps.
|
||||||
|
- **Compromised Cloudflare tunnel**: Cloudflare terminates TLS and can inspect all traffic. This is inherent to using `trycloudflare.com` quick tunnels — use `--https` for end-to-end encryption if this matters.
|
||||||
|
- **State-sponsored quishing**: Sophisticated attackers could create convincing phishing pages that relay the QR in real-time. The 60s rotation and desktop notification toast mitigate this for the single-user case, but a dedicated attacker with social engineering could theoretically succeed within the TTL window.
|
||||||
|
|
||||||
|
### Standards Compliance Note
|
||||||
|
|
||||||
|
This design is **inspired by but does not conform to** [OASIS SQRAP v1.0](https://docs.oasis-open.org/esat/sqrap/v1.0/cs01/sqrap-v1.0-cs01.html). SQRAP's architecture requires a companion mobile app with stored identity keys, public key channel binding, back-channel authentication, and user presence verification (biometric/PIN). These are fundamentally incompatible with a browser-scan-to-authenticate flow. SQRAP is referenced for awareness of formal QR auth standards, not as a compliance target.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
The design prioritizes speed on connect. Latency depends on whether the request goes through a Cloudflare tunnel or is localhost:
|
||||||
|
|
||||||
|
### Localhost (no tunnel)
|
||||||
|
|
||||||
|
| Step | Latency |
|
||||||
|
|------|---------|
|
||||||
|
| QR scan (physical) | ~1-2s (user action) |
|
||||||
|
| `GET /q/:code` → Map.get() lookup + consume | <1ms |
|
||||||
|
| Cookie set + 302 redirect | <1ms |
|
||||||
|
| Browser follows redirect to `/` | <5ms |
|
||||||
|
| **Total (after scan)** | **<10ms** |
|
||||||
|
|
||||||
|
### Through Cloudflare Tunnel (typical mobile use case)
|
||||||
|
|
||||||
|
Each request traverses: phone → Cloudflare edge (TLS termination) → cloudflared → localhost. The 302 redirect means **two full round trips** through the tunnel.
|
||||||
|
|
||||||
|
| Step | Latency |
|
||||||
|
|------|---------|
|
||||||
|
| QR scan (physical) | ~1-2s (user action) |
|
||||||
|
| DNS resolution for `*.trycloudflare.com` | 20-80ms (first request, cached after) |
|
||||||
|
| TLS handshake to Cloudflare edge | 50-100ms (first request, 0 with TLS resumption) |
|
||||||
|
| `GET /q/:code` through tunnel (request + response) | 30-90ms |
|
||||||
|
| Browser follows 302 redirect: `GET /` through tunnel | 30-90ms |
|
||||||
|
| **Total first connection (cold)** | **~200-400ms** |
|
||||||
|
| **Total subsequent (TLS/DNS cached)** | **~100-200ms** |
|
||||||
|
|
||||||
|
This is still fast — **imperceptible after the 1-2s physical QR scan action**. For comparison, VS Code Remote Tunnels (through Azure) adds 20-100ms per hop.
|
||||||
|
|
||||||
|
### Why Not Eliminate the Redirect?
|
||||||
|
|
||||||
|
The 302 means two round trips. Alternatives considered:
|
||||||
|
- **200 + serve `index.html` directly**: URL bar shows `/q/Xk9mQ3`, relative paths break, couples auth to static serving. Not worth the complexity.
|
||||||
|
- **200 + `<meta http-equiv="refresh">`**: Still two requests, plus HTML parse delay. Actually slower.
|
||||||
|
- **200 + JavaScript redirect**: Same problem, plus fails if JS disabled.
|
||||||
|
|
||||||
|
The 302 is clean, universally supported, and the extra 30-90ms is invisible to users.
|
||||||
|
|
||||||
|
### QR Code Size Optimization
|
||||||
|
|
||||||
|
The URL `https://xxx-yyy.trycloudflare.com/q/Xk9mQ3` is ~53-56 characters. At QR Error Correction Level M:
|
||||||
|
|
||||||
|
| QR Version | Grid Size | Byte Capacity | Fits? |
|
||||||
|
|------------|-----------|---------------|-------|
|
||||||
|
| Version 3 | 29x29 | 42 bytes | No |
|
||||||
|
| Version 4 | 33x33 | 62 bytes | Yes (comfortably) |
|
||||||
|
| Version 5 | 37x37 | 84 bytes | Yes |
|
||||||
|
|
||||||
|
The shortened `/q/` path (vs `/qr-auth/`) and 6-char code (vs 8-char) save 9 bytes, targeting Version 4 (33x33) for faster scanning on budget Android phones. Modern phones scan Version 4 QR codes in 100-300ms — the user action of pointing the camera dominates.
|
||||||
|
|
||||||
|
### Desktop QR Refresh
|
||||||
|
|
||||||
|
Token rotation SSE events now embed the SVG directly in the payload (~2-5KB). The desktop gets the new QR in a single SSE push — no extra HTTP fetch needed. Refresh latency: **sub-50ms** (SSE adaptive batching at 16-50ms).
|
||||||
|
|
||||||
|
### SVG Caching
|
||||||
|
|
||||||
|
QR SVG is cached per rotation cycle on `TunnelManager.cachedQrSvg`. The SVG is regenerated only when the token rotates (every 60s), not on every `/api/tunnel/qr` request. SVG format is optimal: resolution-independent (retina-safe), inline-able (no extra HTTP request), ~2-5KB, renders in <1ms.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **Scan during rotation**: The server keeps 2 tokens (current + previous). If the user scans right as rotation happens, the previous token is still valid for up to 60s more. Seamless.
|
||||||
|
|
||||||
|
2. **Server restart**: All tokens cleared (in-memory). New token generated immediately. Tunnel URL also changes (trycloudflare gives a new subdomain), so old QR codes are doubly dead.
|
||||||
|
|
||||||
|
3. **Multiple devices**: Each scan consumes the token and triggers a fresh one. To auth a second device, wait for the QR to refresh (≤60s) or hit "Regenerate QR" on the desktop, then scan the new code.
|
||||||
|
|
||||||
|
4. **Token without tunnel**: `/qr-auth/:code` works even on localhost. If you have the code and it's valid, you get authenticated regardless of access method.
|
||||||
|
|
||||||
|
5. **Tunnel restart (same server)**: Tokens survive tunnel restarts (stored on `TunnelManager` instance). But new tunnel URL = new QR code generated. Short code stays valid until consumed or expired.
|
||||||
|
|
||||||
|
6. **Desktop browser closed during scan**: Token is consumed server-side. The scanning phone gets authenticated. When the desktop reopens, SSE reconnects and shows a fresh QR. No state corruption.
|
||||||
|
|
||||||
|
7. **Race condition: two phones scan same QR**: First scanner wins (atomic `consumed = true`). Second scanner gets 401. This is correct behavior — single-use by design.
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `src/tunnel-manager.ts` | `QrTokenRecord` type, `Map<shortCode, record>` token pool, rejection-sampled `generateShortCode()`, rotation timer, `consumeToken()`, `getCurrentShortCode()`, `getQrSvg()` (cached), `regenerateQrToken()`, global rate limit counter, cleanup in `stop()` |
|
||||||
|
| `src/web/middleware/auth.ts` | Add `/q/` bypass in `onRequest` hook. Enhance session record type from `string` to `{ ip, ua, createdAt, method }` (**breaking type change** — all consumers must update). Add `qrAuthFailures` StaleExpirationMap (separate from Basic Auth `authFailures`). |
|
||||||
|
| `src/web/routes/system-routes.ts` | Modify `/api/tunnel/qr` to use `getQrSvg()` cache. Add `GET /q/:code` with atomic consume, audit log, and `tunnel:qrAuthUsed` broadcast. Add `POST /api/tunnel/qr/regenerate`. Add `POST /api/auth/revoke`. |
|
||||||
|
| `src/web/server.ts` | Pass `authState` + `lifecycleLog` to route context. Listen for `qrTokenRotated` and `qrTokenRegenerated` events → broadcast SSE with inline SVG. |
|
||||||
|
| `src/web/public/app.js` | Auto-refresh QR from inline SSE SVG payload (no extra fetch). Countdown timer. Regenerate button. Auth badge. Auth notification toast on `tunnel:qrAuthUsed` with [Revoke] action. |
|
||||||
|
| `src/session-lifecycle-log.ts` | Add `qr_auth` event type to lifecycle log schema |
|
||||||
|
| `src/types/api.ts` | Update `AuthState` interface: `authSessions` value type, add `qrAuthFailures` map |
|
||||||
|
|
||||||
|
## Complexity Estimate
|
||||||
|
|
||||||
|
Medium change. Core logic (Map-based token pool, rejection-sampled short codes, SVG cache, atomic consumption, cookie issuance, audit logging) is ~120 lines. Rate limiting (separate QR counter + global path limit) adds ~20 lines. SSE plumbing with inline SVG adds ~30 lines. Frontend (inline SVG refresh, auth notification toast with revoke, countdown) is ~40 lines. Auth type migration (session record type change) touches ~10 lines across middleware. No new dependencies — `crypto` and `qrcode` are already available.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit test for token manager
|
||||||
|
npx vitest run test/qr-auth.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Token rotation generates unique short codes (6-char, base62)
|
||||||
|
- Short codes have uniform character distribution (no modulo bias — verify with chi-squared test over 10K samples)
|
||||||
|
- `consumeToken()` returns true on first use, false on second
|
||||||
|
- Expired tokens (>90s old) return false
|
||||||
|
- Previous token still works during 90s grace period
|
||||||
|
- Token at exactly 60s still valid (within grace), token at 91s rejected
|
||||||
|
- `regenerateQrToken()` invalidates all existing tokens (Map cleared)
|
||||||
|
- Short code lookup is case-sensitive
|
||||||
|
- Per-IP rate limiting increments on invalid codes (separate from Basic Auth counter)
|
||||||
|
- Global rate limit (30/min) blocks attempts across all IPs
|
||||||
|
- SVG cache returns same string for same short code, regenerates on rotation
|
||||||
|
- Audit log entry written on successful QR auth
|
||||||
|
- `tunnel:qrAuthUsed` SSE event broadcast on successful QR auth
|
||||||
|
- `tunnel:qrRotated` SSE event includes inline SVG payload
|
||||||
|
- Map-based lookup does not leak timing information (no string comparison in hot path)
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
1. Start server with `CODEMAN_PASSWORD=test`
|
||||||
|
2. Enable tunnel
|
||||||
|
3. Verify `/api/tunnel/qr` returns QR encoding `https://...trycloudflare.com/q/Xk9mQ3`
|
||||||
|
4. Open the QR URL in incognito → should auto-redirect to `/` with session cookie
|
||||||
|
5. Verify desktop shows notification toast: "Device [IP] authenticated via QR"
|
||||||
|
6. Open the **same** URL again → should get 401 (single-use consumed)
|
||||||
|
7. Wait 60s → verify QR display auto-updated (new short code, inline SVG via SSE)
|
||||||
|
8. Open just the tunnel URL → should get Basic Auth prompt
|
||||||
|
9. Call `POST /api/tunnel/qr/regenerate` → old QR URL returns 401, new QR appears
|
||||||
|
10. Verify per-IP rate limiting: 10+ failed `/q/badcode` → 429
|
||||||
|
11. Verify Basic Auth failures don't consume QR rate limit budget (and vice versa)
|
||||||
|
12. Check `~/.codeman/session-lifecycle.jsonl` for `qr_auth` entries after successful scan
|
||||||
|
13. Click [Revoke] on the notification toast → verify session is invalidated
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [USENIX Security 2025: "Demystifying the (In)Security of QR Code-based Login in Real-world Deployments"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) — 6 design flaws, 5 attack types, 42 CVEs across 47 of top-100 websites. Primary design reference for this plan.
|
||||||
|
- [OWASP QRLJacking](https://owasp.org/www-community/attacks/Qrljacking) — canonical QR session hijacking reference
|
||||||
|
- [OASIS SQRAP v1.0 Standard](https://docs.oasis-open.org/esat/sqrap/v1.0/cs01/sqrap-v1.0-cs01.html) — formal standard for secure QR authentication. **Not a compliance target** for this plan (requires companion app + PKI). Referenced for awareness only.
|
||||||
|
- [FIDO2 CTAP 2.2 Hybrid Transport](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html) — gold standard for cross-device auth (overkill for this use case)
|
||||||
|
- [Google GTIG: Signal QR quishing by Russian state actors (2025)](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) — UNC5792/Sandworm exploited Signal's linked-device QR flow via phishing. Demonstrates that even cryptographically strong QR auth can be defeated by social engineering.
|
||||||
|
- [CVE-2026-2144: Magic Login QR Code Plugin race condition](https://www.cvedetails.com/cve/CVE-2026-2144/) — QR token stored as predictable static file, race window between creation and deletion. Validates this plan's in-memory-only approach.
|
||||||
@@ -18,6 +18,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -26,8 +27,41 @@ export interface TunnelStatus {
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QrTokenRecord {
|
||||||
|
token: string; // 64 hex chars (256 bits)
|
||||||
|
shortCode: string; // 6 chars base62 (for URL path)
|
||||||
|
createdAt: number; // Date.now()
|
||||||
|
consumed: boolean; // single-use flag
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
|
/** QR token auto-rotation interval */
|
||||||
|
const QR_TOKEN_TTL_MS = 60_000;
|
||||||
|
/** Grace period for previous token (scan-during-rotation race) */
|
||||||
|
const QR_TOKEN_GRACE_MS = 90_000;
|
||||||
|
/** Length of short code in QR URL path */
|
||||||
|
const SHORT_CODE_LENGTH = 6;
|
||||||
|
/** Global rate limit for QR attempts across all IPs */
|
||||||
|
const QR_RATE_LIMIT_MAX = 30;
|
||||||
|
/** Global rate limit reset window */
|
||||||
|
const QR_RATE_LIMIT_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
/** Rejection-sampled base62 short code — no modulo bias */
|
||||||
|
function generateShortCode(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const maxUnbiased = 248; // largest multiple of 62 that fits in a byte (248 = 62 * 4)
|
||||||
|
const result: string[] = [];
|
||||||
|
while (result.length < SHORT_CODE_LENGTH) {
|
||||||
|
const [byte] = randomBytes(1);
|
||||||
|
if (byte < maxUnbiased) result.push(chars[byte % 62]);
|
||||||
|
// else: discard and re-draw (rejection sampling)
|
||||||
|
}
|
||||||
|
return result.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Constants (Tunnel) ==========
|
||||||
|
|
||||||
/** Regex to extract the trycloudflare.com URL from cloudflared output */
|
/** Regex to extract the trycloudflare.com URL from cloudflared output */
|
||||||
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
||||||
|
|
||||||
@@ -54,6 +88,17 @@ export class TunnelManager extends EventEmitter {
|
|||||||
private localPort = 3000;
|
private localPort = 3000;
|
||||||
private useHttps = false;
|
private useHttps = false;
|
||||||
|
|
||||||
|
// ========== QR Token State ==========
|
||||||
|
/** Map-based lookup: shortCode → QrTokenRecord (hash-based, timing-safe) */
|
||||||
|
private qrTokensByCode = new Map<string, QrTokenRecord>();
|
||||||
|
private currentShortCode: string | null = null;
|
||||||
|
private rotationTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
/** SVG cache — regenerated only on token rotation, not per request */
|
||||||
|
private cachedQrSvg: { shortCode: string; svg: string } | null = null;
|
||||||
|
/** Global rate limit counter (separate from Basic Auth rate limiting) */
|
||||||
|
private qrAttemptCount = 0;
|
||||||
|
private qrRateLimitResetTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve cloudflared binary path.
|
* Resolve cloudflared binary path.
|
||||||
* Checks ~/.local/bin first, then falls back to PATH.
|
* Checks ~/.local/bin first, then falls back to PATH.
|
||||||
@@ -170,6 +215,10 @@ export class TunnelManager extends EventEmitter {
|
|||||||
// Detach listeners — no need to parse further output
|
// Detach listeners — no need to parse further output
|
||||||
this.process?.stdout?.off('data', handleOutput);
|
this.process?.stdout?.off('data', handleOutput);
|
||||||
this.process?.stderr?.off('data', handleOutput);
|
this.process?.stderr?.off('data', handleOutput);
|
||||||
|
// Start QR token rotation when tunnel URL is acquired (only if auth enabled)
|
||||||
|
if (process.env.CODEMAN_PASSWORD) {
|
||||||
|
this.startTokenRotation();
|
||||||
|
}
|
||||||
this.emit('started', { url: this.url });
|
this.emit('started', { url: this.url });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -243,6 +292,7 @@ export class TunnelManager extends EventEmitter {
|
|||||||
stop(): void {
|
stop(): void {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
|
this.stopTokenRotation();
|
||||||
|
|
||||||
if (this.process) {
|
if (this.process) {
|
||||||
const pid = this.process.pid;
|
const pid = this.process.pid;
|
||||||
@@ -264,6 +314,111 @@ export class TunnelManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== QR Token Management ==========
|
||||||
|
|
||||||
|
/** Start token rotation — called after tunnel URL is acquired */
|
||||||
|
startTokenRotation(): void {
|
||||||
|
this.stopTokenRotation();
|
||||||
|
this.rotateToken();
|
||||||
|
this.rotationTimer = setInterval(() => this.rotateToken(), QR_TOKEN_TTL_MS);
|
||||||
|
this.qrRateLimitResetTimer = setInterval(() => {
|
||||||
|
this.qrAttemptCount = 0;
|
||||||
|
}, QR_RATE_LIMIT_WINDOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop token rotation and clear all tokens */
|
||||||
|
stopTokenRotation(): void {
|
||||||
|
if (this.rotationTimer) {
|
||||||
|
clearInterval(this.rotationTimer);
|
||||||
|
this.rotationTimer = null;
|
||||||
|
}
|
||||||
|
if (this.qrRateLimitResetTimer) {
|
||||||
|
clearInterval(this.qrRateLimitResetTimer);
|
||||||
|
this.qrRateLimitResetTimer = null;
|
||||||
|
}
|
||||||
|
this.qrTokensByCode.clear();
|
||||||
|
this.currentShortCode = null;
|
||||||
|
this.cachedQrSvg = null;
|
||||||
|
this.qrAttemptCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new token, evict expired/consumed ones, emit rotation event */
|
||||||
|
private rotateToken(): void {
|
||||||
|
const record: QrTokenRecord = {
|
||||||
|
token: randomBytes(32).toString('hex'),
|
||||||
|
shortCode: generateShortCode(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
consumed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evict expired or consumed tokens
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [code, rec] of this.qrTokensByCode) {
|
||||||
|
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) {
|
||||||
|
this.qrTokensByCode.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.qrTokensByCode.set(record.shortCode, record);
|
||||||
|
this.currentShortCode = record.shortCode;
|
||||||
|
this.cachedQrSvg = null; // invalidate SVG cache
|
||||||
|
this.emit('qrTokenRotated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current (newest) token's short code for QR URL */
|
||||||
|
getCurrentShortCode(): string | undefined {
|
||||||
|
return this.currentShortCode ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get cached QR SVG, regenerating only if the short code changed */
|
||||||
|
async getQrSvg(tunnelUrl: string): Promise<string> {
|
||||||
|
const code = this.currentShortCode;
|
||||||
|
if (!code) throw new Error('No QR token available');
|
||||||
|
if (this.cachedQrSvg?.shortCode === code) return this.cachedQrSvg.svg;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const svg: string = await QRCode.toString(`${tunnelUrl}/q/${code}`, {
|
||||||
|
type: 'svg',
|
||||||
|
margin: 2,
|
||||||
|
width: 256,
|
||||||
|
});
|
||||||
|
this.cachedQrSvg = { shortCode: code, svg };
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and atomically consume a token by short code.
|
||||||
|
* Map.get() is hash-based — no timing side-channel from string comparison.
|
||||||
|
*/
|
||||||
|
consumeToken(shortCode: string): boolean {
|
||||||
|
// Global rate limit (across all IPs)
|
||||||
|
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false;
|
||||||
|
this.qrAttemptCount++;
|
||||||
|
|
||||||
|
const record = this.qrTokensByCode.get(shortCode);
|
||||||
|
if (!record) return false;
|
||||||
|
if (record.consumed) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false;
|
||||||
|
|
||||||
|
// Atomic consume (single-threaded JS = no race)
|
||||||
|
record.consumed = true;
|
||||||
|
// Immediately rotate so desktop gets a fresh QR
|
||||||
|
this.rotateToken();
|
||||||
|
this.emit('qrTokenRegenerated');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force-regenerate (manual revocation via API) */
|
||||||
|
regenerateQrToken(): void {
|
||||||
|
this.qrTokensByCode.clear();
|
||||||
|
this.currentShortCode = null;
|
||||||
|
this.rotateToken();
|
||||||
|
this.emit('qrTokenRegenerated');
|
||||||
|
}
|
||||||
|
|
||||||
isRunning(): boolean {
|
isRunning(): boolean {
|
||||||
return this.process !== null || this.restartTimer !== null;
|
return this.process !== null || this.restartTimer !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export type LifecycleEventType =
|
|||||||
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
||||||
| 'mux_died' // tmux session died (detected by reconciliation)
|
| 'mux_died' // tmux session died (detected by reconciliation)
|
||||||
| 'server_started' // Server started (marker for restart detection)
|
| 'server_started' // Server started (marker for restart detection)
|
||||||
| 'server_stopped'; // Server shutting down
|
| 'server_stopped' // Server shutting down
|
||||||
|
| 'qr_auth'; // Device authenticated via QR code scan
|
||||||
|
|
||||||
/** A single entry in the session lifecycle audit log */
|
/** A single entry in the session lifecycle audit log */
|
||||||
export interface LifecycleEntry {
|
export interface LifecycleEntry {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { StaleExpirationMap } from '../../utils/index.js';
|
import { StaleExpirationMap } from '../../utils/index.js';
|
||||||
|
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||||
|
|
||||||
// Auth session cookie TTL (24h — matches autonomous run length)
|
// Auth session cookie TTL (24h — matches autonomous run length)
|
||||||
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -25,8 +26,9 @@ const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
|||||||
|
|
||||||
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
authSessions: StaleExpirationMap<string, string> | null;
|
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||||
authFailures: StaleExpirationMap<string, number> | null;
|
authFailures: StaleExpirationMap<string, number> | null;
|
||||||
|
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +41,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
const state: AuthState = {
|
const state: AuthState = {
|
||||||
authSessions: null,
|
authSessions: null,
|
||||||
authFailures: null,
|
authFailures: null,
|
||||||
|
qrAuthFailures: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||||
@@ -48,7 +51,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||||
|
|
||||||
// Session token store — active sessions extend TTL on access
|
// Session token store — active sessions extend TTL on access
|
||||||
state.authSessions = new StaleExpirationMap<string, string>({
|
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
|
||||||
ttlMs: AUTH_SESSION_TTL_MS,
|
ttlMs: AUTH_SESSION_TTL_MS,
|
||||||
refreshOnGet: true,
|
refreshOnGet: true,
|
||||||
});
|
});
|
||||||
@@ -59,6 +62,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
refreshOnGet: false,
|
refreshOnGet: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Separate QR auth failure counter — independent from Basic Auth failures
|
||||||
|
state.qrAuthFailures = new StaleExpirationMap<string, number>({
|
||||||
|
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||||
|
refreshOnGet: false,
|
||||||
|
});
|
||||||
|
|
||||||
const authSessions = state.authSessions;
|
const authSessions = state.authSessions;
|
||||||
const authFailures = state.authFailures;
|
const authFailures = state.authFailures;
|
||||||
|
|
||||||
@@ -75,6 +84,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
// Non-localhost hook requests fall through to normal auth
|
// Non-localhost hook requests fall through to normal auth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QR auth path — handled by the route itself (token validation + rate limiting)
|
||||||
|
if (req.url?.startsWith('/q/')) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const clientIp = req.ip;
|
const clientIp = req.ip;
|
||||||
|
|
||||||
// Rate limit: reject if too many failed attempts from this IP
|
// Rate limit: reject if too many failed attempts from this IP
|
||||||
@@ -106,7 +121,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
if (oldestKey !== undefined) authSessions.delete(oldestKey);
|
if (oldestKey !== undefined) authSessions.delete(oldestKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
authSessions.set(token, clientIp);
|
authSessions.set(token, {
|
||||||
|
ip: clientIp,
|
||||||
|
ua: req.headers['user-agent'] ?? '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
method: 'basic',
|
||||||
|
});
|
||||||
|
|
||||||
// Reset failure count on successful auth
|
// Reset failure count on successful auth
|
||||||
authFailures.delete(clientIp);
|
authFailures.delete(clientIp);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Auth port — capabilities for authentication state.
|
||||||
|
* Route modules that need access to auth sessions or QR rate limiting depend on this port.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StaleExpirationMap } from '../../utils/index.js';
|
||||||
|
|
||||||
|
/** Enhanced session record with device context for audit logging */
|
||||||
|
export interface AuthSessionRecord {
|
||||||
|
ip: string;
|
||||||
|
ua: string;
|
||||||
|
createdAt: number;
|
||||||
|
method: 'qr' | 'basic';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPort {
|
||||||
|
readonly authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||||
|
readonly qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||||
|
readonly https: boolean;
|
||||||
|
}
|
||||||
@@ -11,3 +11,4 @@ export type { EventPort } from './event-port.js';
|
|||||||
export type { RespawnPort } from './respawn-port.js';
|
export type { RespawnPort } from './respawn-port.js';
|
||||||
export type { ConfigPort } from './config-port.js';
|
export type { ConfigPort } from './config-port.js';
|
||||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||||
|
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return this._api(path, { method: 'POST', body });
|
return this._api(path, { method: 'POST', body });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT JSON to an API endpoint.
|
||||||
|
* @param {string} path - API path
|
||||||
|
* @param {object} body - JSON body
|
||||||
|
* @returns {Promise<Response|null>}
|
||||||
|
*/
|
||||||
|
async _apiPut(path, body) {
|
||||||
|
return this._api(path, { method: 'PUT', body });
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE an API resource.
|
* DELETE an API resource.
|
||||||
* @param {string} path - API path
|
* @param {string} path - API path
|
||||||
|
|||||||
+153
-172
@@ -2326,6 +2326,48 @@ class CodemanApp {
|
|||||||
if (btn) { btn.disabled = false; btn.classList.remove('connecting'); }
|
if (btn) { btn.disabled = false; btn.classList.remove('connecting'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// QR auto-refresh — inline SVG from SSE (no extra fetch)
|
||||||
|
addListener('tunnel:qrRotated', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.svg) {
|
||||||
|
const container = document.getElementById('tunnelQrContainer');
|
||||||
|
if (container) container.innerHTML = data.svg;
|
||||||
|
const welcomeInner = document.getElementById('welcomeQrInner');
|
||||||
|
if (welcomeInner) welcomeInner.innerHTML = data.svg;
|
||||||
|
} else {
|
||||||
|
this._refreshTunnelQrFromApi();
|
||||||
|
}
|
||||||
|
this._resetQrCountdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
addListener('tunnel:qrRegenerated', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.svg) {
|
||||||
|
const container = document.getElementById('tunnelQrContainer');
|
||||||
|
if (container) container.innerHTML = data.svg;
|
||||||
|
const welcomeInner = document.getElementById('welcomeQrInner');
|
||||||
|
if (welcomeInner) welcomeInner.innerHTML = data.svg;
|
||||||
|
} else {
|
||||||
|
this._refreshTunnelQrFromApi();
|
||||||
|
}
|
||||||
|
this._resetQrCountdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// QR auth consumed — notify desktop user (QRLjacking detection)
|
||||||
|
addListener('tunnel:qrAuthUsed', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const ua = data.ua || 'Unknown device';
|
||||||
|
const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser';
|
||||||
|
this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', {
|
||||||
|
duration: 10000,
|
||||||
|
action: { label: 'Revoke All', onClick: () => {
|
||||||
|
fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||||
|
.then(() => this.showToast('All sessions revoked', 'success'))
|
||||||
|
.catch(() => this.showToast('Failed to revoke sessions', 'error'));
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Plan subagent visibility events (show Opus agents during plan generation)
|
// Plan subagent visibility events (show Opus agents during plan generation)
|
||||||
addListener('plan:subagent', (e) => {
|
addListener('plan:subagent', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
@@ -2459,16 +2501,8 @@ class CodemanApp {
|
|||||||
this._updateConnectionIndicator();
|
this._updateConnectionIndicator();
|
||||||
|
|
||||||
for (const [sessionId, input] of queued) {
|
for (const [sessionId, input] of queued) {
|
||||||
try {
|
const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input });
|
||||||
const resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
if (!resp?.ok) {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ input })
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
this._enqueueInput(sessionId, input);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this._enqueueInput(sessionId, input);
|
this._enqueueInput(sessionId, input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3107,94 +3141,6 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Show subagent dropdown on hover
|
|
||||||
showSubagentDropdown(badgeEl) {
|
|
||||||
this.cancelHideSubagentDropdown();
|
|
||||||
const dropdown = badgeEl.querySelector('.subagent-dropdown');
|
|
||||||
if (!dropdown || dropdown.classList.contains('open')) return;
|
|
||||||
|
|
||||||
// Close other dropdowns first
|
|
||||||
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
|
|
||||||
d.classList.remove('open', 'pinned');
|
|
||||||
if (d.parentElement === document.body && d._originalParent) {
|
|
||||||
d._originalParent.appendChild(d);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move to body to escape clipping
|
|
||||||
dropdown._originalParent = badgeEl;
|
|
||||||
document.body.appendChild(dropdown);
|
|
||||||
|
|
||||||
// Position below badge
|
|
||||||
const rect = badgeEl.getBoundingClientRect();
|
|
||||||
dropdown.style.top = `${rect.bottom + 2}px`;
|
|
||||||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
||||||
dropdown.style.transform = 'translateX(-50%)';
|
|
||||||
dropdown.classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule hide after delay (allows moving mouse to dropdown)
|
|
||||||
scheduleHideSubagentDropdown(badgeEl) {
|
|
||||||
this._subagentHideTimeout = setTimeout(() => {
|
|
||||||
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
|
|
||||||
document.querySelector('.subagent-dropdown.open');
|
|
||||||
if (dropdown && !dropdown.classList.contains('pinned')) {
|
|
||||||
dropdown.classList.remove('open');
|
|
||||||
if (dropdown._originalParent) {
|
|
||||||
dropdown._originalParent.appendChild(dropdown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel scheduled hide
|
|
||||||
cancelHideSubagentDropdown() {
|
|
||||||
if (this._subagentHideTimeout) {
|
|
||||||
clearTimeout(this._subagentHideTimeout);
|
|
||||||
this._subagentHideTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin dropdown open on click (stays until clicking outside)
|
|
||||||
pinSubagentDropdown(badgeEl) {
|
|
||||||
const dropdown = document.querySelector('.subagent-dropdown.open');
|
|
||||||
if (!dropdown) {
|
|
||||||
this.showSubagentDropdown(badgeEl);
|
|
||||||
// On mobile/touch, pin immediately so onmouseleave doesn't close it
|
|
||||||
const openedDropdown = document.querySelector('.subagent-dropdown.open');
|
|
||||||
if (openedDropdown) {
|
|
||||||
openedDropdown.classList.add('pinned');
|
|
||||||
const closeHandler = (e) => {
|
|
||||||
if (!badgeEl.contains(e.target) && !openedDropdown.contains(e.target)) {
|
|
||||||
openedDropdown.classList.remove('open', 'pinned');
|
|
||||||
if (openedDropdown._originalParent) {
|
|
||||||
openedDropdown._originalParent.appendChild(openedDropdown);
|
|
||||||
}
|
|
||||||
document.removeEventListener('click', closeHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dropdown.classList.toggle('pinned');
|
|
||||||
|
|
||||||
if (dropdown.classList.contains('pinned')) {
|
|
||||||
// Close on outside click
|
|
||||||
const closeHandler = (e) => {
|
|
||||||
if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) {
|
|
||||||
dropdown.classList.remove('open', 'pinned');
|
|
||||||
if (dropdown._originalParent) {
|
|
||||||
dropdown._originalParent.appendChild(dropdown);
|
|
||||||
}
|
|
||||||
document.removeEventListener('click', closeHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
getSessionName(session) {
|
getSessionName(session) {
|
||||||
// Use custom name if set
|
// Use custom name if set
|
||||||
if (session.name) {
|
if (session.name) {
|
||||||
@@ -3514,7 +3460,7 @@ class CodemanApp {
|
|||||||
|
|
||||||
async closeSession(sessionId, killMux = true) {
|
async closeSession(sessionId, killMux = true) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${sessionId}?killMux=${killMux}`, { method: 'DELETE' });
|
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
||||||
this._cleanupSessionData(sessionId);
|
this._cleanupSessionData(sessionId);
|
||||||
|
|
||||||
if (this.activeSessionId === sessionId) {
|
if (this.activeSessionId === sessionId) {
|
||||||
@@ -4543,7 +4489,7 @@ class CodemanApp {
|
|||||||
async stopRespawn() {
|
async stopRespawn() {
|
||||||
if (!this.activeSessionId) return;
|
if (!this.activeSessionId) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' });
|
await this._apiPost(`/api/sessions/${this.activeSessionId}/respawn/stop`, {});
|
||||||
delete this.respawnTimers[this.activeSessionId];
|
delete this.respawnTimers[this.activeSessionId];
|
||||||
this.clearCountdownTimers(this.activeSessionId);
|
this.clearCountdownTimers(this.activeSessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -4567,7 +4513,7 @@ class CodemanApp {
|
|||||||
if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return;
|
if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/sessions', { method: 'DELETE' });
|
await this._apiDelete('/api/sessions');
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBufferCache.clear();
|
||||||
@@ -4949,11 +4895,7 @@ class CodemanApp {
|
|||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
const name = document.getElementById('modalSessionName').value.trim();
|
const name = document.getElementById('modalSessionName').value.trim();
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.editingSessionId}/name`, {
|
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name })
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showToast('Failed to save session name: ' + err.message, 'error');
|
this.showToast('Failed to save session name: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -4962,14 +4904,10 @@ class CodemanApp {
|
|||||||
async autoSaveAutoCompact() {
|
async autoSaveAutoCompact() {
|
||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, {
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, {
|
||||||
method: 'POST',
|
enabled: document.getElementById('modalAutoCompactEnabled').checked,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
|
||||||
body: JSON.stringify({
|
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
|
||||||
enabled: document.getElementById('modalAutoCompactEnabled').checked,
|
|
||||||
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
|
|
||||||
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -4977,13 +4915,9 @@ class CodemanApp {
|
|||||||
async autoSaveAutoClear() {
|
async autoSaveAutoClear() {
|
||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
||||||
method: 'POST',
|
enabled: document.getElementById('modalAutoClearEnabled').checked,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: document.getElementById('modalAutoClearEnabled').checked,
|
|
||||||
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -4992,11 +4926,7 @@ class CodemanApp {
|
|||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.editingSessionId}/image-watcher`, {
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled })
|
|
||||||
});
|
|
||||||
// Update local session state
|
// Update local session state
|
||||||
const session = this.sessions.get(this.editingSessionId);
|
const session = this.sessions.get(this.editingSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -5012,11 +4942,7 @@ class CodemanApp {
|
|||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
const enabled = document.getElementById('modalFlickerFilterEnabled').checked;
|
const enabled = document.getElementById('modalFlickerFilterEnabled').checked;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.editingSessionId}/flicker-filter`, {
|
await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled });
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled })
|
|
||||||
});
|
|
||||||
// Update local session state
|
// Update local session state
|
||||||
const session = this.sessions.get(this.editingSessionId);
|
const session = this.sessions.get(this.editingSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -5038,11 +4964,7 @@ class CodemanApp {
|
|||||||
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
|
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.editingSessionId}/respawn/config`, {
|
await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config);
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(config)
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Silent save - don't interrupt user
|
// Silent save - don't interrupt user
|
||||||
}
|
}
|
||||||
@@ -5841,9 +5763,8 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Get VAPID public key from server
|
// Get VAPID public key from server
|
||||||
const keyRes = await fetch('/api/push/vapid-key');
|
const keyData = await this._apiJson('/api/push/vapid-key');
|
||||||
const keyData = await keyRes.json();
|
if (!keyData?.success) throw new Error('Failed to get VAPID key');
|
||||||
if (!keyData.success) throw new Error('Failed to get VAPID key');
|
|
||||||
|
|
||||||
const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey);
|
const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey);
|
||||||
const subscription = await this._swRegistration.pushManager.subscribe({
|
const subscription = await this._swRegistration.pushManager.subscribe({
|
||||||
@@ -5853,18 +5774,16 @@ class CodemanApp {
|
|||||||
|
|
||||||
// Send subscription to server
|
// Send subscription to server
|
||||||
const subJson = subscription.toJSON();
|
const subJson = subscription.toJSON();
|
||||||
const res = await fetch('/api/push/subscribe', {
|
const data = await this._apiJson('/api/push/subscribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: {
|
||||||
body: JSON.stringify({
|
|
||||||
endpoint: subJson.endpoint,
|
endpoint: subJson.endpoint,
|
||||||
keys: subJson.keys,
|
keys: subJson.keys,
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
pushPreferences: this._buildPushPreferences(),
|
pushPreferences: this._buildPushPreferences(),
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
if (!data?.success) throw new Error('Failed to register subscription');
|
||||||
if (!data.success) throw new Error('Failed to register subscription');
|
|
||||||
|
|
||||||
this._pushSubscription = subscription;
|
this._pushSubscription = subscription;
|
||||||
this._pushSubscriptionId = data.data.id;
|
this._pushSubscriptionId = data.data.id;
|
||||||
@@ -6175,6 +6094,27 @@ class CodemanApp {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
const container = document.getElementById('tunnelQrContainer');
|
const container = document.getElementById('tunnelQrContainer');
|
||||||
if (container && data.svg) container.innerHTML = data.svg;
|
if (container && data.svg) container.innerHTML = data.svg;
|
||||||
|
// Show auth badge, countdown, and regenerate button when auth is enabled
|
||||||
|
if (data.authEnabled) {
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.id = 'tunnelQrBadge';
|
||||||
|
badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)';
|
||||||
|
badge.textContent = 'Single-use auth \u00b7 expires in 60s';
|
||||||
|
const regenBtn = document.createElement('button');
|
||||||
|
regenBtn.textContent = 'Regenerate QR';
|
||||||
|
regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px';
|
||||||
|
regenBtn.onclick = () => {
|
||||||
|
fetch('/api/tunnel/qr/regenerate', { method: 'POST' })
|
||||||
|
.then(() => this.showToast('QR code regenerated', 'success'))
|
||||||
|
.catch(() => this.showToast('Failed to regenerate QR', 'error'));
|
||||||
|
};
|
||||||
|
const card = container.parentElement;
|
||||||
|
if (card) {
|
||||||
|
card.appendChild(badge);
|
||||||
|
card.appendChild(regenBtn);
|
||||||
|
}
|
||||||
|
this._resetQrCountdown();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
const container = document.getElementById('tunnelQrContainer');
|
const container = document.getElementById('tunnelQrContainer');
|
||||||
@@ -6209,6 +6149,50 @@ class CodemanApp {
|
|||||||
document.removeEventListener('keydown', this._tunnelQrEscHandler);
|
document.removeEventListener('keydown', this._tunnelQrEscHandler);
|
||||||
this._tunnelQrEscHandler = null;
|
this._tunnelQrEscHandler = null;
|
||||||
}
|
}
|
||||||
|
this._clearQrCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback: fetch QR SVG from API when SSE payload lacks it */
|
||||||
|
_refreshTunnelQrFromApi() {
|
||||||
|
fetch('/api/tunnel/qr')
|
||||||
|
.then(res => res.ok ? res.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (!data?.svg) return;
|
||||||
|
const container = document.getElementById('tunnelQrContainer');
|
||||||
|
if (container) container.innerHTML = data.svg;
|
||||||
|
const welcomeInner = document.getElementById('welcomeQrInner');
|
||||||
|
if (welcomeInner) welcomeInner.innerHTML = data.svg;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start or reset the 60s countdown on the QR badge */
|
||||||
|
_resetQrCountdown() {
|
||||||
|
this._clearQrCountdown();
|
||||||
|
this._qrCountdownSec = 60;
|
||||||
|
this._updateQrCountdownText();
|
||||||
|
this._qrCountdownTimer = setInterval(() => {
|
||||||
|
this._qrCountdownSec--;
|
||||||
|
if (this._qrCountdownSec <= 0) {
|
||||||
|
this._clearQrCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._updateQrCountdownText();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateQrCountdownText() {
|
||||||
|
const badge = document.getElementById('tunnelQrBadge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = `Single-use auth \u00b7 expires in ${this._qrCountdownSec}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearQrCountdown() {
|
||||||
|
if (this._qrCountdownTimer) {
|
||||||
|
clearInterval(this._qrCountdownTimer);
|
||||||
|
this._qrCountdownTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleTunnelFromWelcome() {
|
async toggleTunnelFromWelcome() {
|
||||||
@@ -6613,11 +6597,7 @@ class CodemanApp {
|
|||||||
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
|
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
|
||||||
const { localEchoEnabled: _leo, ...serverSettings } = settings;
|
const { localEchoEnabled: _leo, ...serverSettings } = settings;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/settings', {
|
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings })
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save model configuration separately
|
// Save model configuration separately
|
||||||
await this.saveModelConfigFromSettings();
|
await this.saveModelConfigFromSettings();
|
||||||
@@ -7666,11 +7646,7 @@ class CodemanApp {
|
|||||||
this.ralphClosedSessions.add(this.activeSessionId);
|
this.ralphClosedSessions.add(this.activeSessionId);
|
||||||
|
|
||||||
// Disable tracker via API
|
// Disable tracker via API
|
||||||
await fetch(`/api/sessions/${this.activeSessionId}/ralph-config`, {
|
await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-config`, { enabled: false });
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled: false })
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear local state and hide panel
|
// Clear local state and hide panel
|
||||||
this.ralphStates.delete(this.activeSessionId);
|
this.ralphStates.delete(this.activeSessionId);
|
||||||
@@ -7697,12 +7673,10 @@ class CodemanApp {
|
|||||||
if (!this.activeSessionId) return;
|
if (!this.activeSessionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {
|
const response = await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {});
|
||||||
method: 'POST',
|
const data = await response?.json();
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data?.success) {
|
||||||
this.notificationManager?.notify({
|
this.notificationManager?.notify({
|
||||||
urgency: 'info',
|
urgency: 'info',
|
||||||
category: 'circuit-breaker',
|
category: 'circuit-breaker',
|
||||||
@@ -8120,12 +8094,7 @@ class CodemanApp {
|
|||||||
async resetCircuitBreaker() {
|
async resetCircuitBreaker() {
|
||||||
if (!this.activeSessionId) return;
|
if (!this.activeSessionId) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {
|
await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {});
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('Circuit breaker reset');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to reset circuit breaker:', err);
|
console.error('Failed to reset circuit breaker:', err);
|
||||||
}
|
}
|
||||||
@@ -8882,9 +8851,9 @@ class CodemanApp {
|
|||||||
|
|
||||||
async killSubagent(agentId) {
|
async killSubagent(agentId) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/subagents/${agentId}`, { method: 'DELETE' });
|
const res = await this._apiDelete(`/api/subagents/${agentId}`);
|
||||||
const data = await res.json();
|
const data = await res?.json();
|
||||||
if (data.success) {
|
if (data?.success) {
|
||||||
// Update local state
|
// Update local state
|
||||||
const agent = this.subagents.get(agentId);
|
const agent = this.subagents.get(agentId);
|
||||||
if (agent) {
|
if (agent) {
|
||||||
@@ -11384,10 +11353,22 @@ class CodemanApp {
|
|||||||
return this.showToast(message, type);
|
return this.showToast(message, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(message, type = 'info') {
|
showToast(message, type = 'info', opts = {}) {
|
||||||
|
const { duration = 3000, action } = opts;
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
toast.textContent = message;
|
|
||||||
|
const msgSpan = document.createElement('span');
|
||||||
|
msgSpan.textContent = message;
|
||||||
|
toast.appendChild(msgSpan);
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = action.label;
|
||||||
|
btn.style.cssText = 'margin-left:12px;padding:2px 10px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);border-radius:3px;color:inherit;cursor:pointer;font-size:12px';
|
||||||
|
btn.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); };
|
||||||
|
toast.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache toast container reference
|
// Cache toast container reference
|
||||||
if (!this._toastContainer) {
|
if (!this._toastContainer) {
|
||||||
@@ -11405,7 +11386,7 @@ class CodemanApp {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.remove('show');
|
toast.classList.remove('show');
|
||||||
setTimeout(() => toast.remove(), 200);
|
setTimeout(() => toast.remove(), 200);
|
||||||
}, 3000);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== System Stats ==========
|
// ========== System Stats ==========
|
||||||
|
|||||||
@@ -1029,4 +1029,91 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Return listener references for cleanup
|
// Return listener references for cleanup
|
||||||
return { move: moveListener, up: upListener, touchMove: touchMoveListener };
|
return { move: moveListener, up: upListener, touchMove: touchMoveListener };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Show subagent dropdown on hover
|
||||||
|
showSubagentDropdown(badgeEl) {
|
||||||
|
this.cancelHideSubagentDropdown();
|
||||||
|
const dropdown = badgeEl.querySelector('.subagent-dropdown');
|
||||||
|
if (!dropdown || dropdown.classList.contains('open')) return;
|
||||||
|
|
||||||
|
// Close other dropdowns first
|
||||||
|
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
|
||||||
|
d.classList.remove('open', 'pinned');
|
||||||
|
if (d.parentElement === document.body && d._originalParent) {
|
||||||
|
d._originalParent.appendChild(d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move to body to escape clipping
|
||||||
|
dropdown._originalParent = badgeEl;
|
||||||
|
document.body.appendChild(dropdown);
|
||||||
|
|
||||||
|
// Position below badge
|
||||||
|
const rect = badgeEl.getBoundingClientRect();
|
||||||
|
dropdown.style.top = `${rect.bottom + 2}px`;
|
||||||
|
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||||||
|
dropdown.style.transform = 'translateX(-50%)';
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Schedule hide after delay (allows moving mouse to dropdown)
|
||||||
|
scheduleHideSubagentDropdown(badgeEl) {
|
||||||
|
this._subagentHideTimeout = setTimeout(() => {
|
||||||
|
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
|
||||||
|
document.querySelector('.subagent-dropdown.open');
|
||||||
|
if (dropdown && !dropdown.classList.contains('pinned')) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
if (dropdown._originalParent) {
|
||||||
|
dropdown._originalParent.appendChild(dropdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel scheduled hide
|
||||||
|
cancelHideSubagentDropdown() {
|
||||||
|
if (this._subagentHideTimeout) {
|
||||||
|
clearTimeout(this._subagentHideTimeout);
|
||||||
|
this._subagentHideTimeout = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pin dropdown open on click (stays until clicking outside)
|
||||||
|
pinSubagentDropdown(badgeEl) {
|
||||||
|
const dropdown = document.querySelector('.subagent-dropdown.open');
|
||||||
|
if (!dropdown) {
|
||||||
|
this.showSubagentDropdown(badgeEl);
|
||||||
|
// On mobile/touch, pin immediately so onmouseleave doesn't close it
|
||||||
|
const openedDropdown = document.querySelector('.subagent-dropdown.open');
|
||||||
|
if (openedDropdown) {
|
||||||
|
openedDropdown.classList.add('pinned');
|
||||||
|
const closeHandler = (e) => {
|
||||||
|
if (!badgeEl.contains(e.target) && !openedDropdown.contains(e.target)) {
|
||||||
|
openedDropdown.classList.remove('open', 'pinned');
|
||||||
|
if (openedDropdown._originalParent) {
|
||||||
|
openedDropdown._originalParent.appendChild(openedDropdown);
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', closeHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dropdown.classList.toggle('pinned');
|
||||||
|
|
||||||
|
if (dropdown.classList.contains('pinned')) {
|
||||||
|
// Close on outside click
|
||||||
|
const closeHandler = (e) => {
|
||||||
|
if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) {
|
||||||
|
dropdown.classList.remove('open', 'pinned');
|
||||||
|
if (dropdown._originalParent) {
|
||||||
|
dropdown._originalParent.appendChild(dropdown);
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', closeHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
|||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.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 { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||||
import { RunSummaryTracker } from '../../run-summary.js';
|
import { RunSummaryTracker } from '../../run-summary.js';
|
||||||
|
|
||||||
@@ -56,11 +56,16 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
|||||||
|
|
||||||
export function registerSessionRoutes(
|
export function registerSessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort
|
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||||
): void {
|
): void {
|
||||||
// ========== Logout ==========
|
// ========== 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: '/' });
|
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||||
import {
|
import {
|
||||||
ConfigUpdateSchema,
|
ConfigUpdateSchema,
|
||||||
@@ -23,7 +24,8 @@ import { subagentWatcher } from '../../subagent-watcher.js';
|
|||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.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)
|
// Maximum screenshot upload size (10MB)
|
||||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||||
@@ -81,7 +83,7 @@ function getSystemStats(): {
|
|||||||
|
|
||||||
export function registerSystemRoutes(
|
export function registerSystemRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort
|
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||||
): void {
|
): void {
|
||||||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
||||||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.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'));
|
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
||||||
}
|
}
|
||||||
try {
|
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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency
|
||||||
const QRCode = require('qrcode');
|
const QRCode = require('qrcode');
|
||||||
const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
|
const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
|
||||||
return { svg };
|
return { svg, authEnabled: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(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 ==========
|
// ========== OpenCode ==========
|
||||||
|
|
||||||
app.get('/api/opencode/status', async () => {
|
app.get('/api/opencode/status', async () => {
|
||||||
|
|||||||
+35
-1
@@ -255,8 +255,9 @@ export class WebServer extends EventEmitter {
|
|||||||
error: (error: Error, sessionId?: string) => void;
|
error: (error: Error, sessionId?: string) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private tunnelManager: TunnelManager = new TunnelManager();
|
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 authFailures: StaleExpirationMap<string, number> | null = null;
|
||||||
|
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||||
private teamWatcherHandlers: {
|
private teamWatcherHandlers: {
|
||||||
@@ -321,6 +322,31 @@ export class WebServer extends EventEmitter {
|
|||||||
this.tunnelManager.on('progress', (data: { message: string }) => {
|
this.tunnelManager.on('progress', (data: { message: string }) => {
|
||||||
this.broadcast('tunnel:progress', data);
|
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,
|
pushStore: this.pushStore,
|
||||||
startScheduledRun: this.startScheduledRun.bind(this),
|
startScheduledRun: this.startScheduledRun.bind(this),
|
||||||
stopScheduledRun: this.stopScheduledRun.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) {
|
if (authState) {
|
||||||
this.authSessions = authState.authSessions;
|
this.authSessions = authState.authSessions;
|
||||||
this.authFailures = authState.authFailures;
|
this.authFailures = authState.authFailures;
|
||||||
|
this.qrAuthFailures = authState.qrAuthFailures;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security headers + CORS
|
// Security headers + CORS
|
||||||
@@ -2652,6 +2682,10 @@ export class WebServer extends EventEmitter {
|
|||||||
this.authFailures.dispose();
|
this.authFailures.dispose();
|
||||||
this.authFailures = null;
|
this.authFailures = null;
|
||||||
}
|
}
|
||||||
|
if (this.qrAuthFailures) {
|
||||||
|
this.qrAuthFailures.dispose();
|
||||||
|
this.qrAuthFailures = null;
|
||||||
|
}
|
||||||
this.activePlanOrchestrators.clear();
|
this.activePlanOrchestrators.clear();
|
||||||
this.cleaningUp.clear();
|
this.cleaningUp.clear();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* QR Authentication tests — verifies:
|
||||||
|
* 1. Token rotation generates unique 6-char base62 short codes
|
||||||
|
* 2. Short code generation has no modulo bias (rejection sampling)
|
||||||
|
* 3. consumeToken() is single-use (true first, false after)
|
||||||
|
* 4. Expired tokens (>90s grace) are rejected
|
||||||
|
* 5. Previous token works within 90s grace period
|
||||||
|
* 6. regenerateQrToken() clears all tokens
|
||||||
|
* 7. Per-IP QR rate limiting (separate from Basic Auth)
|
||||||
|
* 8. Global rate limiting (30/min across all IPs)
|
||||||
|
* 9. SVG caching (same SVG for same short code)
|
||||||
|
* 10. Full server integration: GET /q/:code issues cookie + redirects
|
||||||
|
* 11. Session revocation via POST /api/auth/revoke
|
||||||
|
* 12. QR auth bypass in auth middleware
|
||||||
|
*
|
||||||
|
* Port: 3162 (qr-auth tests)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { TunnelManager } from '../src/tunnel-manager.js';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
const QR_AUTH_PORT = 3162;
|
||||||
|
const TEST_PASS = 'qr-test-pass-xyz';
|
||||||
|
const TEST_USER = 'admin';
|
||||||
|
|
||||||
|
function basicAuthHeader(user: string, pass: string): string {
|
||||||
|
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Unit Tests: TunnelManager Token Logic ==========
|
||||||
|
|
||||||
|
describe('QR Token Manager (unit)', () => {
|
||||||
|
let tm: TunnelManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tm = new TunnelManager();
|
||||||
|
// Start token rotation manually (normally triggered on tunnel URL acquisition)
|
||||||
|
tm.startTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Clean up any lingering timers
|
||||||
|
tm?.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a 6-char base62 short code', () => {
|
||||||
|
const code = tm.getCurrentShortCode();
|
||||||
|
expect(code).toBeDefined();
|
||||||
|
expect(code!.length).toBe(6);
|
||||||
|
expect(code).toMatch(/^[A-Za-z0-9]{6}$/);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique short codes on rotation', () => {
|
||||||
|
const codes = new Set<string>();
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
tm.regenerateQrToken();
|
||||||
|
const code = tm.getCurrentShortCode();
|
||||||
|
expect(code).toBeDefined();
|
||||||
|
codes.add(code!);
|
||||||
|
}
|
||||||
|
// All 20 codes should be unique (collision on 62^6 space is vanishingly unlikely)
|
||||||
|
expect(codes.size).toBe(20);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumeToken should return true on first use, false on second', () => {
|
||||||
|
const code = tm.getCurrentShortCode()!;
|
||||||
|
expect(tm.consumeToken(code)).toBe(true);
|
||||||
|
// After consumption, a new code is generated — old code should be consumed
|
||||||
|
expect(tm.consumeToken(code)).toBe(false);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject unknown short codes', () => {
|
||||||
|
expect(tm.consumeToken('ZZZZZZ')).toBe(false);
|
||||||
|
expect(tm.consumeToken('')).toBe(false);
|
||||||
|
expect(tm.consumeToken('short')).toBe(false);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject expired tokens beyond grace period', () => {
|
||||||
|
const code = tm.getCurrentShortCode()!;
|
||||||
|
|
||||||
|
// Manually expire the token by manipulating its createdAt
|
||||||
|
// Access the private map — this is a unit test, we need to verify the TTL logic
|
||||||
|
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> })
|
||||||
|
.qrTokensByCode;
|
||||||
|
const record = tokenMap.get(code)!;
|
||||||
|
record.createdAt = Date.now() - 91_000; // 91 seconds ago (beyond 90s grace)
|
||||||
|
|
||||||
|
expect(tm.consumeToken(code)).toBe(false);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept tokens within grace period', () => {
|
||||||
|
const code = tm.getCurrentShortCode()!;
|
||||||
|
|
||||||
|
// Set createdAt to 80 seconds ago (within 90s grace)
|
||||||
|
const tokenMap = (tm as unknown as { qrTokensByCode: Map<string, { createdAt: number }> })
|
||||||
|
.qrTokensByCode;
|
||||||
|
const record = tokenMap.get(code)!;
|
||||||
|
record.createdAt = Date.now() - 80_000;
|
||||||
|
|
||||||
|
expect(tm.consumeToken(code)).toBe(true);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerateQrToken should invalidate all existing tokens', () => {
|
||||||
|
const oldCode = tm.getCurrentShortCode()!;
|
||||||
|
tm.regenerateQrToken();
|
||||||
|
const newCode = tm.getCurrentShortCode()!;
|
||||||
|
|
||||||
|
expect(newCode).not.toBe(oldCode);
|
||||||
|
expect(tm.consumeToken(oldCode)).toBe(false);
|
||||||
|
expect(tm.consumeToken(newCode)).toBe(true);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce global rate limit', () => {
|
||||||
|
// Exhaust global rate limit (30 attempts)
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
tm.consumeToken('BADCODE');
|
||||||
|
}
|
||||||
|
// Now even valid codes should be rejected
|
||||||
|
const validCode = tm.getCurrentShortCode()!;
|
||||||
|
expect(tm.consumeToken(validCode)).toBe(false);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit qrTokenRotated on rotation', () => {
|
||||||
|
let rotateCount = 0;
|
||||||
|
tm.on('qrTokenRotated', () => rotateCount++);
|
||||||
|
tm.regenerateQrToken(); // calls rotateToken() internally
|
||||||
|
// regenerateQrToken calls rotateToken which emits qrTokenRotated
|
||||||
|
expect(rotateCount).toBeGreaterThanOrEqual(1);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit qrTokenRegenerated on consume and regenerate', () => {
|
||||||
|
let regenCount = 0;
|
||||||
|
tm.on('qrTokenRegenerated', () => regenCount++);
|
||||||
|
|
||||||
|
const code = tm.getCurrentShortCode()!;
|
||||||
|
tm.consumeToken(code); // should emit qrTokenRegenerated
|
||||||
|
expect(regenCount).toBe(1);
|
||||||
|
|
||||||
|
tm.regenerateQrToken(); // should also emit
|
||||||
|
expect(regenCount).toBe(2);
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SVG cache should return same string for same short code', async () => {
|
||||||
|
const fakeUrl = 'https://test.trycloudflare.com';
|
||||||
|
const svg1 = await tm.getQrSvg(fakeUrl);
|
||||||
|
const svg2 = await tm.getQrSvg(fakeUrl);
|
||||||
|
expect(svg1).toBe(svg2); // Same reference (cached)
|
||||||
|
expect(svg1).toContain('<svg');
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SVG cache should regenerate after rotation', async () => {
|
||||||
|
const fakeUrl = 'https://test.trycloudflare.com';
|
||||||
|
const svg1 = await tm.getQrSvg(fakeUrl);
|
||||||
|
tm.regenerateQrToken();
|
||||||
|
const svg2 = await tm.getQrSvg(fakeUrl);
|
||||||
|
expect(svg1).not.toBe(svg2); // Different content (new short code)
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Short code distribution (bias check)', () => {
|
||||||
|
it('should produce roughly uniform character distribution', () => {
|
||||||
|
// Generate 6000 codes (36000 chars) and check distribution
|
||||||
|
const tm = new TunnelManager();
|
||||||
|
tm.startTokenRotation();
|
||||||
|
|
||||||
|
const charCounts = new Map<string, number>();
|
||||||
|
for (let i = 0; i < 6000; i++) {
|
||||||
|
tm.regenerateQrToken();
|
||||||
|
const code = tm.getCurrentShortCode()!;
|
||||||
|
for (const ch of code) {
|
||||||
|
charCounts.set(ch, (charCounts.get(ch) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected count per char: 36000 / 62 ≈ 580.6
|
||||||
|
const expected = 36000 / 62;
|
||||||
|
let maxDeviation = 0;
|
||||||
|
for (const [, count] of charCounts) {
|
||||||
|
const deviation = Math.abs(count - expected) / expected;
|
||||||
|
maxDeviation = Math.max(maxDeviation, deviation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With rejection sampling, deviation should be < 15% (generous)
|
||||||
|
// Without rejection sampling (modulo bias), first 6 chars would be ~25% overrepresented
|
||||||
|
expect(maxDeviation).toBeLessThan(0.15);
|
||||||
|
|
||||||
|
tm.stopTokenRotation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Integration Tests: Full Server ==========
|
||||||
|
|
||||||
|
describe('QR Auth Integration', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||||
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
|
server = new WebServer(QR_AUTH_PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
baseUrl = `http://localhost:${QR_AUTH_PORT}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.stop();
|
||||||
|
delete process.env.CODEMAN_PASSWORD;
|
||||||
|
delete process.env.CODEMAN_USERNAME;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /q/:code should bypass auth middleware (not 401)', async () => {
|
||||||
|
// Even with a bad code, we should get 401 from the route handler,
|
||||||
|
// NOT from the auth middleware (which would show WWW-Authenticate)
|
||||||
|
const res = await fetch(`${baseUrl}/q/BADCODE`, { redirect: 'manual' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
// The auth middleware's 401 sends WWW-Authenticate header; the route handler doesn't
|
||||||
|
expect(res.headers.get('www-authenticate')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /q/:code should redirect to / when no auth configured', async () => {
|
||||||
|
// Temporarily remove password
|
||||||
|
const savedPass = process.env.CODEMAN_PASSWORD;
|
||||||
|
delete process.env.CODEMAN_PASSWORD;
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/q/ANYCODE`, { redirect: 'manual' });
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.get('location')).toBe('/');
|
||||||
|
|
||||||
|
process.env.CODEMAN_PASSWORD = savedPass;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/tunnel/qr should return authEnabled flag', async () => {
|
||||||
|
// Tunnel is not running, so this should 404
|
||||||
|
const res = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||||
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||||
|
});
|
||||||
|
// Tunnel not running = 404 (expected)
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/tunnel/qr/regenerate should succeed', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/tunnel/qr/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/revoke should revoke all sessions', async () => {
|
||||||
|
// First authenticate to create a session
|
||||||
|
const authRes = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||||
|
});
|
||||||
|
expect(authRes.status).toBe(200);
|
||||||
|
const setCookie = authRes.headers.get('set-cookie')!;
|
||||||
|
const cookieMatch = setCookie.match(/codeman_session=([^;]+)/);
|
||||||
|
expect(cookieMatch).toBeTruthy();
|
||||||
|
const cookie = `codeman_session=${cookieMatch![1]}`;
|
||||||
|
|
||||||
|
// Verify cookie works
|
||||||
|
const beforeRes = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
expect(beforeRes.status).toBe(200);
|
||||||
|
|
||||||
|
// Revoke all sessions
|
||||||
|
const revokeRes = await fetch(`${baseUrl}/api/auth/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Cookie: cookie,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(revokeRes.status).toBe(200);
|
||||||
|
|
||||||
|
// Cookie should no longer work
|
||||||
|
const afterRes = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
expect(afterRes.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/auth/revoke should revoke a specific session', async () => {
|
||||||
|
// Create two sessions
|
||||||
|
const auth1 = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||||
|
});
|
||||||
|
const cookie1 = auth1.headers.get('set-cookie')!.match(/codeman_session=([^;]+)/)![1];
|
||||||
|
|
||||||
|
const auth2 = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||||
|
});
|
||||||
|
const cookie2 = auth2.headers.get('set-cookie')!.match(/codeman_session=([^;]+)/)![1];
|
||||||
|
|
||||||
|
// Revoke only cookie1
|
||||||
|
await fetch(`${baseUrl}/api/auth/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Cookie: `codeman_session=${cookie2}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ sessionToken: cookie1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// cookie1 should fail
|
||||||
|
const res1 = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Cookie: `codeman_session=${cookie1}` },
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(401);
|
||||||
|
|
||||||
|
// cookie2 should still work
|
||||||
|
const res2 = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Cookie: `codeman_session=${cookie2}` },
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('QR auth failures should not affect Basic Auth rate limit', async () => {
|
||||||
|
// Send QR auth failures
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await fetch(`${baseUrl}/q/BAD${i}xx`, { redirect: 'manual' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic Auth should still work (not rate-limited by QR failures)
|
||||||
|
const res = await fetch(`${baseUrl}/api/status`, {
|
||||||
|
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user