kiro-mobile-bridge 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@ A lightweight mobile interface that lets you monitor and control Kiro IDE agent
8
8
  ## Features
9
9
 
10
10
  - 📱 Mobile-optimized web interface with tab navigation
11
+ - 🔑 **OTP Authentication** - 6-digit access code generated on server startup
11
12
  - 💬 **Chat** - View and send messages to Kiro's agent
12
13
  - 📝 **Code** - Browse file explorer and view files with syntax highlighting
13
14
  - 📋 **Tasks** - View and navigate Kiro spec task files
@@ -24,11 +25,13 @@ A lightweight mobile interface that lets you monitor and control Kiro IDE agent
24
25
 
25
26
  Start Kiro with the remote debugging port enabled:
26
27
 
27
- **Run Kiro with debugging port on terminal:**
28
+ **Run Kiro with debugging port on CMD/Terminal:**
28
29
  ```bash
29
30
  kiro --remote-debugging-port=9000
30
31
  ```
31
32
 
33
+ **Important:** Your project must be open in Kiro before you close it - the bridge needs an active session to detect and connect to. After that, start Kiro from the terminal with the remote debugging port enabled.
34
+
32
35
  ### 2. Run with npx (Recommended)
33
36
 
34
37
  Start Server
@@ -53,15 +56,29 @@ Kiro Mobile Bridge
53
56
  ─────────────────────
54
57
  Local: http://localhost:3000
55
58
  Network: http://192.168.16.106:3000
56
- Open the Network URL on your phone to monitor Kiro.
59
+
60
+ 🔑 Access Code: 847291
61
+
62
+ Enter this code on your device to connect.
57
63
  ```
58
64
 
59
65
  ### 3. Open on Your Phone
60
66
 
61
67
  1. Make sure your phone is on the **same WiFi network** as your computer
62
68
  2. Open the **Network URL** (e.g., `http://192.168.1.100:3000`) in your phone's browser
63
- 3. The interface will automatically connect and show your Kiro session
64
- 4. Use the tabs to switch between Chat, Code, and Tasks panels
69
+ 3. Enter the **6-digit access code** shown in the terminal
70
+ 4. The interface will connect and show your Kiro session
71
+ 5. Use the tabs to switch between Chat, Code, and Tasks panels
72
+
73
+ > **Note:** The access code is single-use — only one device can authenticate per server session. Restart the server to generate a new code.
74
+
75
+ #### Disable Authentication
76
+
77
+ For trusted environments where you want the original no-auth experience:
78
+
79
+ ```bash
80
+ npx kiro-mobile-bridge --no-auth
81
+ ```
65
82
 
66
83
 
67
84
  #### How It Works
@@ -99,6 +116,23 @@ Open the Network URL on your phone to monitor Kiro.
99
116
  - Check your firewall allows connections on port 3000
100
117
  - Try the IP address shown in the server output (not `localhost`)
101
118
 
119
+ #### Windows: Works on your computer but not on mobile, even on same WiFi.
120
+
121
+ **Root Cause:** Node.js firewall rule only allows **Public** networks by default. If your network is set to **Private**, mobile devices can't connect.
122
+
123
+ **Quick Fix - Option 1: Change Network to Public (Easiest)**
124
+ 1. Open **Settings** → **Network & Internet**
125
+ 2. Click your connection (WiFi or Ethernet)
126
+ 3. Under "Network profile type", select **Public network (Recommended)**
127
+ 4. Try accessing from mobile again
128
+
129
+ **Quick Fix - Option 2: Update Firewall Rule (Better for home networks)**
130
+
131
+ Run this command **as Administrator** (Win + X → Terminal Admin):
132
+ ```cmd
133
+ netsh advfirewall firewall set rule name="Node.js JavaScript Runtime" new profile=private,public
134
+ ```
135
+
102
136
  #### Linux: Firewall blocking connections
103
137
 
104
138
  If you're on Linux and can't connect from your phone, your firewall may be blocking port 3000. Allow it with:
@@ -113,13 +147,12 @@ sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT
113
147
 
114
148
  ## Security Notes
115
149
 
116
- #### Only run this on trusted networks.
117
- **This is designed for local network use only:**
118
-
119
- - No authentication
120
- - No HTTPS
121
- - Exposes Kiro's chat interface to anyone on your network
122
-
150
+ #### OTP Authentication
151
+ - A **6-digit access code** is generated on each server startup and displayed in the terminal
152
+ - The code is **single-use** — once a device authenticates, the code is consumed and all other devices are immediately locked out
153
+ - New devices opening the login page during lockout or after the code is consumed will see a locked UI immediately
154
+ - Sessions use **HttpOnly cookies** — tokens are not exposed to client-side JavaScript
155
+ - Use `--no-auth` to disable authentication for fully trusted environments
123
156
 
124
157
  ## License
125
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiro-mobile-bridge",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "A simple mobile web interface for monitoring Kiro IDE agent sessions from your phone over LAN",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -0,0 +1,515 @@
1
+ /**
2
+ * OTP Authentication Middleware
3
+ * Generates a single-use 6-digit OTP on server startup.
4
+ * After successful verification, issues an HttpOnly session cookie.
5
+ * Single-device only — no regeneration until server restart.
6
+ *
7
+ * NOTE: crypto.randomInt is used for OTP generation (cryptographically secure).
8
+ * crypto.randomBytes is used for session tokens (cryptographically secure).
9
+ */
10
+ import crypto from 'crypto';
11
+ import { OTP_MAX_ATTEMPTS, OTP_LOCKOUT_MS } from '../utils/constants.js';
12
+
13
+ // =============================================================================
14
+ // State
15
+ // =============================================================================
16
+
17
+ /** @type {{ otp: string, consumed: boolean, sessionToken: string|null }} */
18
+ const authState = {
19
+ otp: '',
20
+ consumed: false,
21
+ sessionToken: null
22
+ };
23
+
24
+ /** @type {{ attempts: number, lockedUntil: number }} */
25
+ const rateLimitState = {
26
+ attempts: 0,
27
+ lockedUntil: 0
28
+ };
29
+
30
+ /** @type {boolean} */
31
+ let authEnabled = true;
32
+
33
+ // =============================================================================
34
+ // OTP Generation & Verification
35
+ // =============================================================================
36
+
37
+ /**
38
+ * Generate a new 6-digit OTP (cryptographically random)
39
+ * Called once on server startup
40
+ * @returns {string} 6-digit OTP
41
+ */
42
+ export function generateOTP() {
43
+ // crypto.randomInt generates a cryptographically secure random integer
44
+ const code = crypto.randomInt(100000, 999999 + 1);
45
+ authState.otp = String(code);
46
+ authState.consumed = false;
47
+ authState.sessionToken = null;
48
+ rateLimitState.attempts = 0;
49
+ rateLimitState.lockedUntil = 0;
50
+ return authState.otp;
51
+ }
52
+
53
+ /**
54
+ * Get the current OTP (for terminal display)
55
+ * @returns {string}
56
+ */
57
+ export function getOTP() {
58
+ return authState.otp;
59
+ }
60
+
61
+ /**
62
+ * Set whether auth is enabled
63
+ * @param {boolean} enabled
64
+ */
65
+ export function setAuthEnabled(enabled) {
66
+ authEnabled = enabled;
67
+ }
68
+
69
+ /**
70
+ * Check if auth is enabled
71
+ * @returns {boolean}
72
+ */
73
+ export function isAuthEnabled() {
74
+ return authEnabled;
75
+ }
76
+
77
+ /**
78
+ * Verify OTP code
79
+ * Returns session token on success, null on failure.
80
+ * OTP is single-use — once consumed, further attempts are rejected.
81
+ *
82
+ * @param {string} code - The 6-digit OTP to verify
83
+ * @returns {{ success: boolean, token?: string, error?: string, retryAfter?: number }}
84
+ */
85
+ export function verifyOTP(code) {
86
+ const now = Date.now();
87
+
88
+ // Check rate limit lockout
89
+ if (rateLimitState.lockedUntil > now) {
90
+ const retryAfter = Math.ceil((rateLimitState.lockedUntil - now) / 1000);
91
+ return {
92
+ success: false,
93
+ error: `Too many attempts. Try again in ${retryAfter}s.`,
94
+ retryAfter
95
+ };
96
+ }
97
+
98
+ // Reset attempt counter once lockout period has expired
99
+ if (rateLimitState.lockedUntil > 0 && rateLimitState.lockedUntil <= now) {
100
+ rateLimitState.attempts = 0;
101
+ rateLimitState.lockedUntil = 0;
102
+ }
103
+
104
+ // OTP already consumed — no new sessions allowed
105
+ // Still enforce rate limiting to prevent brute-force probing
106
+ if (authState.consumed) {
107
+ rateLimitState.attempts++;
108
+ if (rateLimitState.attempts >= OTP_MAX_ATTEMPTS) {
109
+ rateLimitState.lockedUntil = now + OTP_LOCKOUT_MS;
110
+ return {
111
+ success: false,
112
+ consumed: true,
113
+ error: `Too many attempts. Try again in ${OTP_LOCKOUT_MS / 1000}s.`,
114
+ retryAfter: OTP_LOCKOUT_MS / 1000
115
+ };
116
+ }
117
+ return {
118
+ success: false,
119
+ consumed: true,
120
+ error: 'Access code already used. Restart the server for a new code.'
121
+ };
122
+ }
123
+
124
+ // Validate input format
125
+ if (!code || typeof code !== 'string' || !/^\d{6}$/.test(code)) {
126
+ rateLimitState.attempts++;
127
+ if (rateLimitState.attempts >= OTP_MAX_ATTEMPTS) {
128
+ rateLimitState.lockedUntil = now + OTP_LOCKOUT_MS;
129
+ return {
130
+ success: false,
131
+ error: `Too many attempts. Try again in ${OTP_LOCKOUT_MS / 1000}s.`,
132
+ retryAfter: OTP_LOCKOUT_MS / 1000
133
+ };
134
+ }
135
+ return { success: false, error: 'Invalid code format.' };
136
+ }
137
+
138
+ // Timing-safe comparison to prevent timing attacks
139
+ const codeBuffer = Buffer.from(code);
140
+ const otpBuffer = Buffer.from(authState.otp);
141
+ if (codeBuffer.length !== otpBuffer.length || !crypto.timingSafeEqual(codeBuffer, otpBuffer)) {
142
+ rateLimitState.attempts++;
143
+ if (rateLimitState.attempts >= OTP_MAX_ATTEMPTS) {
144
+ rateLimitState.lockedUntil = now + OTP_LOCKOUT_MS;
145
+ return {
146
+ success: false,
147
+ error: `Too many attempts. Try again in ${OTP_LOCKOUT_MS / 1000}s.`,
148
+ retryAfter: OTP_LOCKOUT_MS / 1000
149
+ };
150
+ }
151
+ return {
152
+ success: false,
153
+ error: `Invalid code. ${OTP_MAX_ATTEMPTS - rateLimitState.attempts} attempts remaining.`
154
+ };
155
+ }
156
+
157
+ // Success — mark consumed, generate session token
158
+ authState.consumed = true;
159
+ authState.sessionToken = crypto.randomBytes(32).toString('hex');
160
+ rateLimitState.attempts = 0;
161
+
162
+ return { success: true, token: authState.sessionToken };
163
+ }
164
+
165
+ /**
166
+ * Get current rate limit status (for exposing via API)
167
+ * @returns {{ locked: boolean, retryAfter: number }}
168
+ */
169
+ export function getRateLimitStatus() {
170
+ const now = Date.now();
171
+ if (rateLimitState.lockedUntil > now) {
172
+ return { locked: true, consumed: authState.consumed, retryAfter: Math.ceil((rateLimitState.lockedUntil - now) / 1000) };
173
+ }
174
+ return { locked: false, consumed: authState.consumed, retryAfter: 0 };
175
+ }
176
+
177
+ /**
178
+ * Validate a session token
179
+ * @param {string} token - Session token to validate
180
+ * @returns {boolean}
181
+ */
182
+ export function validateSession(token) {
183
+ if (!token || typeof token !== 'string') return false;
184
+ if (!authState.sessionToken) return false;
185
+
186
+ // Timing-safe comparison
187
+ const tokenBuffer = Buffer.from(token);
188
+ const sessionBuffer = Buffer.from(authState.sessionToken);
189
+ if (tokenBuffer.length !== sessionBuffer.length) return false;
190
+ return crypto.timingSafeEqual(tokenBuffer, sessionBuffer);
191
+ }
192
+
193
+ // =============================================================================
194
+ // Cookie Parsing Helper
195
+ // =============================================================================
196
+
197
+ /**
198
+ * Parse session token from cookie header
199
+ * @param {string} cookieHeader - Raw Cookie header value
200
+ * @returns {string|null}
201
+ */
202
+ function parseSessionCookie(cookieHeader) {
203
+ if (!cookieHeader || typeof cookieHeader !== 'string') return null;
204
+ const match = cookieHeader.match(/(?:^|;\s*)kmb_session=([a-f0-9]{64})(?:;|$)/);
205
+ return match ? match[1] : null;
206
+ }
207
+
208
+ // =============================================================================
209
+ // Express Middleware
210
+ // =============================================================================
211
+
212
+ /** Routes that bypass authentication */
213
+ const PUBLIC_PATHS = new Set(['/auth/login', '/auth/verify', '/auth/status']);
214
+
215
+ /**
216
+ * Express authentication middleware
217
+ * Checks for valid session cookie on all requests except auth routes.
218
+ * Redirects page requests to login, returns 401 for API/fetch requests.
219
+ */
220
+ export function authMiddleware(req, res, next) {
221
+ // Skip auth entirely if disabled (--no-auth flag)
222
+ if (!authEnabled) return next();
223
+
224
+ // Allow public auth routes
225
+ if (PUBLIC_PATHS.has(req.path)) return next();
226
+
227
+ // Parse session token from cookie
228
+ const token = parseSessionCookie(req.headers.cookie);
229
+
230
+ if (token && validateSession(token)) {
231
+ return next();
232
+ }
233
+
234
+ // Not authenticated — determine response type
235
+ const wantsJSON = req.headers.accept?.includes('application/json') ||
236
+ req.headers['content-type']?.includes('application/json') ||
237
+ req.xhr;
238
+
239
+ if (wantsJSON) {
240
+ return res.status(401).json({ error: 'Authentication required' });
241
+ }
242
+
243
+ // Redirect browser requests to login page
244
+ return res.redirect('/auth/login');
245
+ }
246
+
247
+ // =============================================================================
248
+ // WebSocket Auth
249
+ // =============================================================================
250
+
251
+ /**
252
+ * Validate WebSocket connection authentication
253
+ * Checks session token from the cookie header on the upgrade request.
254
+ * Browsers automatically send cookies with WebSocket upgrade requests,
255
+ * so we don't need to pass the token via query string.
256
+ *
257
+ * @param {import('http').IncomingMessage} req - Upgrade request
258
+ * @returns {boolean}
259
+ */
260
+ export function validateWSAuth(req) {
261
+ if (!authEnabled) return true;
262
+
263
+ try {
264
+ const cookie = req.headers.cookie || '';
265
+ const match = cookie.match(/(?:^|;\s*)kmb_session=([a-f0-9]{64})(?:;|$)/);
266
+ const token = match ? match[1] : null;
267
+ return validateSession(token);
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ // =============================================================================
274
+ // Login Page HTML
275
+ // =============================================================================
276
+
277
+ /**
278
+ * Generate the self-contained login page HTML
279
+ * Matches the dark theme of the main interface
280
+ * @returns {string}
281
+ */
282
+ export function getLoginPageHTML() {
283
+ return `<!DOCTYPE html>
284
+ <html lang="en">
285
+ <head>
286
+ <meta charset="UTF-8">
287
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
288
+ <meta name="theme-color" content="#1e1e1e">
289
+ <meta name="apple-mobile-web-app-capable" content="yes">
290
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
291
+ <title>Kiro Mobile Bridge — Access Code</title>
292
+ <style>
293
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
294
+ html, body {
295
+ height: 100%; overflow: hidden; background: #1e1e1e;
296
+ font-family: "Segoe WPC", "Segoe UI", -apple-system, BlinkMacSystemFont, system-ui, Ubuntu, sans-serif;
297
+ color: #cccccc;
298
+ }
299
+ .login-container {
300
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
301
+ height: 100%; height: 100dvh; padding: 24px; text-align: center;
302
+ }
303
+ .logo { font-size: 28px; font-weight: 600; color: #ffffff; margin-bottom: 8px; }
304
+ .subtitle { font-size: 13px; color: #888; margin-bottom: 40px; }
305
+ .otp-label { font-size: 14px; color: #cccccc; margin-bottom: 16px; }
306
+ .otp-inputs {
307
+ display: flex; gap: 8px; margin-bottom: 24px; justify-content: center;
308
+ }
309
+ .otp-inputs input {
310
+ width: 48px; height: 56px; text-align: center; font-size: 24px; font-weight: 600;
311
+ background: #2d2d2d; border: 2px solid #3c3c3c; border-radius: 8px; color: #ffffff;
312
+ outline: none; caret-color: #0078d4; transition: border-color 0.15s;
313
+ -webkit-appearance: none; appearance: none;
314
+ }
315
+ .otp-inputs input:focus { border-color: #0078d4; }
316
+ .otp-inputs input.error { border-color: #f44336; animation: shake 0.4s; }
317
+ .otp-inputs input.success { border-color: #4caf50; }
318
+ .error-message {
319
+ color: #f44336; font-size: 13px; min-height: 20px; margin-bottom: 16px;
320
+ transition: opacity 0.2s;
321
+ }
322
+ .status-message {
323
+ color: #4caf50; font-size: 13px; min-height: 20px; margin-bottom: 16px;
324
+ }
325
+ .hint {
326
+ font-size: 12px; color: #666; max-width: 280px; line-height: 1.5;
327
+ }
328
+ @keyframes shake {
329
+ 0%, 100% { transform: translateX(0); }
330
+ 25% { transform: translateX(-6px); }
331
+ 50% { transform: translateX(6px); }
332
+ 75% { transform: translateX(-4px); }
333
+ }
334
+ @keyframes fadeIn {
335
+ from { opacity: 0; transform: translateY(10px); }
336
+ to { opacity: 1; transform: translateY(0); }
337
+ }
338
+ .login-container { animation: fadeIn 0.3s ease-out; }
339
+ </style>
340
+ </head>
341
+ <body>
342
+ <div class="login-container">
343
+ <div class="logo">Kiro Mobile Bridge</div>
344
+ <div class="subtitle">Remote IDE Monitor</div>
345
+ <div class="otp-label">Enter Access Code</div>
346
+ <div class="otp-inputs" id="otpInputs">
347
+ <input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Digit 1">
348
+ <input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 2">
349
+ <input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 3">
350
+ <input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 4">
351
+ <input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 5">
352
+ <input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 6">
353
+ </div>
354
+ <div id="errorMsg" class="error-message"></div>
355
+ <div class="hint">Check the terminal where you started the server for the 6-digit access code.</div>
356
+ </div>
357
+ <script>
358
+ const inputs = document.querySelectorAll('#otpInputs input');
359
+ const errorMsg = document.getElementById('errorMsg');
360
+ let submitting = false;
361
+
362
+ // Check lockout status on page load (covers new devices opening during lockout)
363
+ (async () => {
364
+ try {
365
+ const res = await fetch('/auth/status');
366
+ const data = await res.json();
367
+ if (data.consumed) {
368
+ // OTP already used by another device
369
+ showError('Access code already used. Restart the server for a new code.');
370
+ inputs.forEach(i => { i.disabled = true; });
371
+ } else if (data.locked && data.retryAfter > 0) {
372
+ startLockoutCountdown(data.retryAfter);
373
+ } else {
374
+ inputs[0].focus();
375
+ }
376
+ } catch {
377
+ inputs[0].focus();
378
+ }
379
+ })();
380
+
381
+ inputs.forEach((input, index) => {
382
+ input.addEventListener('input', (e) => {
383
+ const value = e.target.value.replace(/\\D/g, '');
384
+ e.target.value = value.slice(-1); // Keep only last digit
385
+
386
+ clearErrors();
387
+
388
+ if (value && index < inputs.length - 1) {
389
+ inputs[index + 1].focus();
390
+ }
391
+
392
+ // Auto-submit when all 6 digits entered
393
+ if (getCode().length === 6) {
394
+ submitOTP();
395
+ }
396
+ });
397
+
398
+ input.addEventListener('keydown', (e) => {
399
+ if (e.key === 'Backspace') {
400
+ if (!e.target.value && index > 0) {
401
+ inputs[index - 1].focus();
402
+ inputs[index - 1].value = '';
403
+ }
404
+ } else if (e.key === 'ArrowLeft' && index > 0) {
405
+ inputs[index - 1].focus();
406
+ } else if (e.key === 'ArrowRight' && index < inputs.length - 1) {
407
+ inputs[index + 1].focus();
408
+ }
409
+ });
410
+
411
+ // Handle paste (e.g., pasting full 6-digit code)
412
+ input.addEventListener('paste', (e) => {
413
+ e.preventDefault();
414
+ const pasted = (e.clipboardData.getData('text') || '').replace(/\\D/g, '').slice(0, 6);
415
+ if (pasted.length > 0) {
416
+ for (let i = 0; i < inputs.length; i++) {
417
+ inputs[i].value = pasted[i] || '';
418
+ }
419
+ const focusIndex = Math.min(pasted.length, inputs.length - 1);
420
+ inputs[focusIndex].focus();
421
+ if (pasted.length === 6) submitOTP();
422
+ }
423
+ });
424
+ });
425
+
426
+ function getCode() {
427
+ return Array.from(inputs).map(i => i.value).join('');
428
+ }
429
+
430
+ function clearErrors() {
431
+ errorMsg.textContent = '';
432
+ errorMsg.className = 'error-message';
433
+ inputs.forEach(i => i.classList.remove('error', 'success'));
434
+ }
435
+
436
+ function showError(msg) {
437
+ errorMsg.textContent = msg;
438
+ errorMsg.className = 'error-message';
439
+ inputs.forEach(i => i.classList.add('error'));
440
+ }
441
+
442
+ function showSuccess() {
443
+ errorMsg.textContent = 'Access granted!';
444
+ errorMsg.className = 'status-message';
445
+ inputs.forEach(i => {
446
+ i.classList.remove('error');
447
+ i.classList.add('success');
448
+ i.disabled = true;
449
+ });
450
+ }
451
+
452
+ let lockoutTimer = null;
453
+
454
+ function startLockoutCountdown(seconds) {
455
+ // Disable all inputs during lockout
456
+ inputs.forEach(i => { i.disabled = true; i.value = ''; });
457
+ let remaining = seconds;
458
+ showError('Too many attempts. Try again in ' + remaining + 's.');
459
+
460
+ if (lockoutTimer) clearInterval(lockoutTimer);
461
+ lockoutTimer = setInterval(() => {
462
+ remaining--;
463
+ if (remaining <= 0) {
464
+ clearInterval(lockoutTimer);
465
+ lockoutTimer = null;
466
+ clearErrors();
467
+ inputs.forEach(i => { i.disabled = false; });
468
+ inputs[0].focus();
469
+ } else {
470
+ showError('Too many attempts. Try again in ' + remaining + 's.');
471
+ }
472
+ }, 1000);
473
+ }
474
+
475
+ async function submitOTP() {
476
+ if (submitting || lockoutTimer) return;
477
+ submitting = true;
478
+
479
+ const code = getCode();
480
+ try {
481
+ const res = await fetch('/auth/verify', {
482
+ method: 'POST',
483
+ headers: { 'Content-Type': 'application/json' },
484
+ body: JSON.stringify({ otp: code })
485
+ });
486
+ const data = await res.json();
487
+
488
+ if (data.success) {
489
+ showSuccess();
490
+ // Redirect to main app after brief success indication
491
+ setTimeout(() => { window.location.href = '/'; }, 600);
492
+ } else if (data.consumed) {
493
+ // OTP already used by another device — permanently lock
494
+ showError(data.error || 'Access code already used. Restart the server for a new code.');
495
+ inputs.forEach(i => { i.disabled = true; i.value = ''; });
496
+ if (data.retryAfter) startLockoutCountdown(data.retryAfter);
497
+ } else if (data.retryAfter) {
498
+ // Rate limited — start countdown
499
+ startLockoutCountdown(data.retryAfter);
500
+ } else {
501
+ showError(data.error || 'Invalid code.');
502
+ // Clear inputs on failure for retry
503
+ inputs.forEach(i => i.value = '');
504
+ inputs[0].focus();
505
+ }
506
+ } catch (err) {
507
+ showError('Connection error. Please try again.');
508
+ } finally {
509
+ submitting = false;
510
+ }
511
+ }
512
+ </script>
513
+ </body>
514
+ </html>`;
515
+ }
@@ -1000,6 +1000,25 @@
1000
1000
  // State
1001
1001
  // =============================================================================
1002
1002
  let ws = null, reconnectAttempts = 0, reconnectTimeout = null;
1003
+
1004
+ // Handle 401 responses — redirect to login page
1005
+ function handleAuthError(response) {
1006
+ if (response && response.status === 401) {
1007
+ window.location.href = '/auth/login';
1008
+ return true;
1009
+ }
1010
+ return false;
1011
+ }
1012
+
1013
+ // Wrap native fetch to auto-handle 401 auth errors
1014
+ const _originalFetch = window.fetch;
1015
+ window.fetch = async function (...args) {
1016
+ const response = await _originalFetch.apply(this, args);
1017
+ if (response.status === 401) {
1018
+ window.location.href = '/auth/login';
1019
+ }
1020
+ return response;
1021
+ };
1003
1022
  let cascades = [], selectedCascadeId = null, currentStyles = null;
1004
1023
  let isTypingInKiroInput = false, activePanel = 'chat';
1005
1024
  let lastSuccessfulSnapshot = null, navigationPending = false;
@@ -1085,6 +1104,7 @@
1085
1104
  // =============================================================================
1086
1105
  function connect() {
1087
1106
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1107
+ // Session cookie is automatically sent with the WebSocket upgrade request
1088
1108
  ws = new WebSocket(`${protocol}//${window.location.host}`);
1089
1109
 
1090
1110
  ws.onopen = () => { setStatus('connected'); reconnectAttempts = 0; };
package/src/server.js CHANGED
@@ -27,6 +27,20 @@ import {
27
27
  SNAPSHOT_IDLE_THRESHOLD
28
28
  } from './utils/constants.js';
29
29
 
30
+ // Auth
31
+ import {
32
+ generateOTP,
33
+ getOTP,
34
+ setAuthEnabled,
35
+ isAuthEnabled,
36
+ authMiddleware,
37
+ validateWSAuth,
38
+ validateSession,
39
+ getLoginPageHTML,
40
+ verifyOTP,
41
+ getRateLimitStatus
42
+ } from './middleware/auth.js';
43
+
30
44
  // Routes
31
45
  import { createApiRouter } from './routes/api.js';
32
46
 
@@ -38,6 +52,13 @@ const __dirname = dirname(__filename);
38
52
  // =============================================================================
39
53
 
40
54
  const PORT = process.env.PORT || 3000;
55
+ const NO_AUTH = process.argv.includes('--no-auth');
56
+
57
+ // Configure authentication
58
+ setAuthEnabled(!NO_AUTH);
59
+ if (!NO_AUTH) {
60
+ generateOTP();
61
+ }
41
62
 
42
63
  // =============================================================================
43
64
  // State Management
@@ -66,24 +87,24 @@ async function discoverTargets() {
66
87
  const foundCascadeIds = new Set();
67
88
  let foundMainWindow = false;
68
89
  let stateChanged = false;
69
-
90
+
70
91
  const portResults = await Promise.allSettled(
71
92
  CDP_PORTS.map(port => fetchCDPTargets(port).then(targets => ({ port, targets })))
72
93
  );
73
-
94
+
74
95
  for (const result of portResults) {
75
96
  if (result.status !== 'fulfilled') continue;
76
97
  const { port, targets } = result.value;
77
-
98
+
78
99
  try {
79
100
  // Find main VS Code window
80
101
  const mainWindowTarget = targets.find(target => {
81
102
  const url = (target.url || '').toLowerCase();
82
- return target.type === 'page' &&
83
- (url.startsWith('vscode-file://') || url.includes('workbench')) &&
84
- target.webSocketDebuggerUrl;
103
+ return target.type === 'page' &&
104
+ (url.startsWith('vscode-file://') || url.includes('workbench')) &&
105
+ target.webSocketDebuggerUrl;
85
106
  });
86
-
107
+
87
108
  if (mainWindowTarget && !mainWindowCDP.connection) {
88
109
  console.log(`[Discovery] Found main VS Code window: ${mainWindowTarget.title}`);
89
110
  try {
@@ -92,7 +113,7 @@ async function discoverTargets() {
92
113
  mainWindowCDP.id = generateId(mainWindowTarget.webSocketDebuggerUrl);
93
114
  foundMainWindow = true;
94
115
  stateChanged = true;
95
-
116
+
96
117
  cdp.ws.on('close', () => {
97
118
  console.log(`[Discovery] Main window disconnected`);
98
119
  mainWindowCDP.connection = null;
@@ -105,25 +126,25 @@ async function discoverTargets() {
105
126
  } else if (mainWindowTarget) {
106
127
  foundMainWindow = true;
107
128
  }
108
-
129
+
109
130
  // Find Kiro Agent webviews
110
131
  const kiroAgentTargets = targets.filter(target => {
111
132
  const url = (target.url || '').toLowerCase();
112
- return (url.includes('kiroagent') || url.includes('vscode-webview')) &&
113
- target.webSocketDebuggerUrl && target.type !== 'page';
133
+ return (url.includes('kiroagent') || url.includes('vscode-webview')) &&
134
+ target.webSocketDebuggerUrl && target.type !== 'page';
114
135
  });
115
-
136
+
116
137
  for (const target of kiroAgentTargets) {
117
138
  const wsUrl = target.webSocketDebuggerUrl;
118
139
  const cascadeId = generateId(wsUrl);
119
140
  foundCascadeIds.add(cascadeId);
120
-
141
+
121
142
  if (!cascades.has(cascadeId)) {
122
143
  stateChanged = true;
123
-
144
+
124
145
  try {
125
146
  const cdp = await connectToCDP(wsUrl);
126
-
147
+
127
148
  cascades.set(cascadeId, {
128
149
  id: cascadeId,
129
150
  cdp,
@@ -134,14 +155,14 @@ async function discoverTargets() {
134
155
  editor: null,
135
156
  editorHash: null
136
157
  });
137
-
158
+
138
159
  cdp.ws.on('close', () => {
139
160
  console.log(`[Discovery] Cascade disconnected: ${cascadeId}`);
140
161
  cascades.delete(cascadeId);
141
162
  broadcastCascadeList();
142
163
  adjustDiscoveryInterval(true);
143
164
  });
144
-
165
+
145
166
  broadcastCascadeList();
146
167
  } catch (err) {
147
168
  console.error(`[Discovery] Failed to connect to ${cascadeId}: ${err.message}`);
@@ -155,14 +176,14 @@ async function discoverTargets() {
155
176
  console.debug(`[Discovery] Error scanning port ${port}: ${err.message}`);
156
177
  }
157
178
  }
158
-
179
+
159
180
  // Clean up disconnected targets
160
181
  for (const [cascadeId, cascade] of cascades) {
161
182
  if (!foundCascadeIds.has(cascadeId)) {
162
183
  console.log(`[Discovery] Target no longer available: ${cascadeId}`);
163
184
  stateChanged = true;
164
- try {
165
- cascade.cdp.close();
185
+ try {
186
+ cascade.cdp.close();
166
187
  } catch (e) {
167
188
  console.debug(`[Discovery] Error closing cascade ${cascadeId}: ${e.message}`);
168
189
  }
@@ -170,10 +191,10 @@ async function discoverTargets() {
170
191
  broadcastCascadeList();
171
192
  }
172
193
  }
173
-
194
+
174
195
  const mainWindowChanged = foundMainWindow !== pollingState.lastMainWindowConnected;
175
196
  const cascadeCountChanged = cascades.size !== pollingState.lastCascadeCount;
176
-
197
+
177
198
  if (stateChanged || mainWindowChanged || cascadeCountChanged) {
178
199
  console.log(`[Discovery] Active cascades: ${cascades.size}${foundMainWindow ? ' (main window connected)' : ''}`);
179
200
  pollingState.lastCascadeCount = cascades.size;
@@ -190,21 +211,21 @@ async function discoverTargets() {
190
211
 
191
212
  async function pollSnapshots() {
192
213
  let anyChanges = false;
193
-
214
+
194
215
  for (const [cascadeId, cascade] of cascades) {
195
216
  try {
196
217
  const cdp = cascade.cdp;
197
-
218
+
198
219
  // Capture CSS once
199
220
  if (cascade.css === null) {
200
221
  cascade.css = await captureCSS(cdp);
201
222
  }
202
-
223
+
203
224
  // Capture metadata
204
225
  const metadata = await captureMetadata(cdp);
205
226
  cascade.metadata.chatTitle = metadata.chatTitle || cascade.metadata.chatTitle;
206
227
  cascade.metadata.isActive = metadata.isActive;
207
-
228
+
208
229
  // Capture chat snapshot
209
230
  const snapshot = await captureSnapshot(cdp);
210
231
  if (snapshot) {
@@ -216,7 +237,7 @@ async function pollSnapshots() {
216
237
  anyChanges = true;
217
238
  }
218
239
  }
219
-
240
+
220
241
  // Capture editor from main window
221
242
  // Store rootContextId locally to avoid race conditions during async operations
222
243
  const mainCDP = mainWindowCDP.connection;
@@ -242,7 +263,7 @@ async function pollSnapshots() {
242
263
  console.error(`[Snapshot] Error polling cascade ${cascadeId}:`, err.message);
243
264
  }
244
265
  }
245
-
266
+
246
267
  adjustSnapshotInterval(anyChanges);
247
268
  }
248
269
 
@@ -316,7 +337,7 @@ function broadcastCascadeList() {
316
337
  window: c.metadata?.windowTitle || 'Unknown',
317
338
  active: c.metadata?.isActive || false
318
339
  }));
319
-
340
+
320
341
  const message = JSON.stringify({ type: 'cascade_list', cascades: cascadeList });
321
342
  for (const client of wss.clients) {
322
343
  if (client.readyState === WebSocket.OPEN) client.send(message);
@@ -338,6 +359,51 @@ app.use((req, res, next) => {
338
359
  next();
339
360
  });
340
361
 
362
+ // Auth routes — must be before authMiddleware
363
+ app.get('/auth/login', (req, res) => {
364
+ res.type('html').send(getLoginPageHTML());
365
+ });
366
+
367
+ app.post('/auth/verify', (req, res) => {
368
+ const { otp } = req.body;
369
+ const result = verifyOTP(otp);
370
+
371
+ if (result.success) {
372
+ // Set HttpOnly session cookie (no Secure flag — this is HTTP-only LAN tool)
373
+ res.cookie('kmb_session', result.token, {
374
+ httpOnly: true,
375
+ sameSite: 'strict',
376
+ path: '/'
377
+ });
378
+ console.log(`[Auth] Device authenticated successfully`);
379
+ res.json({ success: true });
380
+ } else {
381
+ console.log(`[Auth] Failed attempt: ${result.error}`);
382
+ res.status(401).json({
383
+ success: false,
384
+ error: result.error,
385
+ retryAfter: result.retryAfter || null
386
+ });
387
+ }
388
+ });
389
+
390
+ app.get('/auth/status', (req, res) => {
391
+ const cookie = req.headers.cookie || '';
392
+ const match = cookie.match(/(?:^|;\s*)kmb_session=([a-f0-9]{64})(?:;|$)/);
393
+ const token = match ? match[1] : null;
394
+ const rateLimit = getRateLimitStatus();
395
+ res.json({
396
+ authenticated: token ? validateSession(token) : false,
397
+ authEnabled: isAuthEnabled(),
398
+ locked: rateLimit.locked,
399
+ consumed: rateLimit.consumed,
400
+ retryAfter: rateLimit.retryAfter
401
+ });
402
+ });
403
+
404
+ // Authentication gate — all routes below require valid session
405
+ app.use(authMiddleware);
406
+
341
407
  app.use(express.static(join(__dirname, 'public')));
342
408
 
343
409
  // Mount API routes
@@ -353,8 +419,16 @@ wss = new WebSocketServer({ server: httpServer });
353
419
 
354
420
  wss.on('connection', (ws, req) => {
355
421
  const clientIP = req.socket.remoteAddress || 'unknown';
422
+
423
+ // Validate WebSocket authentication
424
+ if (!validateWSAuth(req)) {
425
+ console.log(`[WebSocket] Unauthorized connection from ${clientIP}`);
426
+ ws.close(4401, 'Unauthorized');
427
+ return;
428
+ }
429
+
356
430
  console.log(`[WebSocket] Client connected from ${clientIP}`);
357
-
431
+
358
432
  // Send cascade list on connect
359
433
  const cascadeList = Array.from(cascades.values()).map(c => ({
360
434
  id: c.id,
@@ -362,9 +436,9 @@ wss.on('connection', (ws, req) => {
362
436
  window: c.metadata?.windowTitle || 'Unknown',
363
437
  active: c.metadata?.isActive || false
364
438
  }));
365
-
439
+
366
440
  ws.send(JSON.stringify({ type: 'cascade_list', cascades: cascadeList }));
367
-
441
+
368
442
  ws.on('close', () => console.log(`[WebSocket] Client disconnected from ${clientIP}`));
369
443
  ws.on('error', (err) => console.error(`[WebSocket] Error from ${clientIP}:`, err.message));
370
444
  });
@@ -377,9 +451,15 @@ httpServer.listen(PORT, '0.0.0.0', () => {
377
451
  console.log(`Local: http://localhost:${PORT}`);
378
452
  console.log(`Network: http://${localIP}:${PORT}`);
379
453
  console.log('');
380
- console.log('Open the Network URL on your phone to monitor Kiro.');
454
+ if (isAuthEnabled()) {
455
+ console.log(`\x1b[33m\x1b[1m🔑 Access Code: ${getOTP()}\x1b[0m`);
456
+ console.log('');
457
+ console.log('Enter this code on your device to connect.');
458
+ } else {
459
+ console.log('Auth disabled (--no-auth). Open the Network URL on your phone.');
460
+ }
381
461
  console.log('');
382
-
462
+
383
463
  // Start discovery and polling
384
464
  discoverTargets();
385
465
  pollingState.discoveryInterval = setInterval(discoverTargets, pollingState.discoveryIntervalMs);
@@ -103,6 +103,12 @@ export const SNAPSHOT_INTERVAL_ACTIVE = 200; // 200ms when active (very fast
103
103
  export const SNAPSHOT_INTERVAL_IDLE = 800; // 800ms when idle
104
104
  export const SNAPSHOT_IDLE_THRESHOLD = 3000; // 3 seconds before considered idle
105
105
 
106
+ /**
107
+ * OTP authentication settings
108
+ */
109
+ export const OTP_MAX_ATTEMPTS = 5; // Max failed attempts before lockout
110
+ export const OTP_LOCKOUT_MS = 60000; // 60 second lockout after max attempts
111
+
106
112
  /**
107
113
  * Maximum depth for recursive file search
108
114
  * @type {number}