tabminal 3.0.29 → 3.0.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabminal",
3
- "version": "3.0.29",
3
+ "version": "3.0.30",
4
4
  "description": "Tab(ter)minal, a Cloud-Native terminal and ACP agent workspace for desktop, tablet, and phone.",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -19,7 +19,7 @@ const {
19
19
  buildAuthStateStorageKey,
20
20
  makeSessionKey,
21
21
  splitSessionKey,
22
- hashPassword
22
+ buildLoginChallengeResponse
23
23
  } = await import(`./modules/url-auth.js${LOCAL_MODULE_VERSION}`);
24
24
  const {
25
25
  shortenPath,
@@ -33,6 +33,8 @@ const {
33
33
  } = await import(`./modules/notifications.js${LOCAL_MODULE_VERSION}`);
34
34
 
35
35
  const DEPRECATED_AUTH_TOKEN_STORAGE_PREFIX = 'tabminal_auth_token:';
36
+ const WEBSOCKET_PROTOCOL = 'tabminal.v1';
37
+ const WEBSOCKET_AUTH_PROTOCOL_PREFIX = 'tabminal.auth.';
36
38
 
37
39
  function clearDeprecatedPasswordHashAuthStorage() {
38
40
  try {
@@ -440,6 +442,7 @@ function buildTerminalBaseOptions(overrides = {}) {
440
442
  convertEol: true,
441
443
  fontFamily: TERMINAL_FONT_FAMILY,
442
444
  fontSize: getTerminalFontSize(),
445
+ lineHeight: 1.25,
443
446
  fontWeight: '450',
444
447
  fontWeightBold: '700',
445
448
  customGlyphs: true,
@@ -1023,7 +1026,7 @@ class ServerClient {
1023
1026
  return new URL(path, `${this.baseUrl}/`).toString();
1024
1027
  }
1025
1028
 
1026
- resolveWsUrl(sessionId, token = '') {
1029
+ resolveWsUrl(sessionId) {
1027
1030
  const base = new URL(this.baseUrl);
1028
1031
  const shouldUseSecureWs = (
1029
1032
  base.protocol === 'https:'
@@ -1031,13 +1034,10 @@ class ServerClient {
1031
1034
  );
1032
1035
  const wsProtocol = shouldUseSecureWs ? 'wss:' : 'ws:';
1033
1036
  const wsUrl = new URL(`/ws/${sessionId}`, `${wsProtocol}//${base.host}`);
1034
- if (token) {
1035
- wsUrl.searchParams.set('token', token);
1036
- }
1037
1037
  return wsUrl.toString();
1038
1038
  }
1039
1039
 
1040
- resolveAgentWsUrl(tabId, token = '') {
1040
+ resolveAgentWsUrl(tabId) {
1041
1041
  const base = new URL(this.baseUrl);
1042
1042
  const shouldUseSecureWs = (
1043
1043
  base.protocol === 'https:'
@@ -1048,18 +1048,40 @@ class ServerClient {
1048
1048
  `/ws/agents/${tabId}`,
1049
1049
  `${wsProtocol}//${base.host}`
1050
1050
  );
1051
- if (token) {
1052
- wsUrl.searchParams.set('token', token);
1053
- }
1054
1051
  return wsUrl.toString();
1055
1052
  }
1056
1053
 
1054
+ getWebSocketProtocols() {
1055
+ return this.token
1056
+ ? [
1057
+ WEBSOCKET_PROTOCOL,
1058
+ `${WEBSOCKET_AUTH_PROTOCOL_PREFIX}${this.token}`
1059
+ ]
1060
+ : [WEBSOCKET_PROTOCOL];
1061
+ }
1062
+
1057
1063
  async login(password) {
1058
- const passwordHash = await hashPassword(password);
1064
+ const challengeResponse = await this.fetchWithoutAuth(
1065
+ '/api/auth/challenge',
1066
+ {
1067
+ method: 'POST'
1068
+ }
1069
+ );
1070
+ if (!challengeResponse.ok) {
1071
+ throw new Error('Unable to start authentication.');
1072
+ }
1073
+ const challenge = await challengeResponse.json();
1074
+ const responseValue = await buildLoginChallengeResponse(
1075
+ password,
1076
+ challenge
1077
+ );
1059
1078
  const response = await this.fetchWithoutAuth('/api/auth/login', {
1060
1079
  method: 'POST',
1061
1080
  headers: { 'Content-Type': 'application/json' },
1062
- body: JSON.stringify({ passwordHash })
1081
+ body: JSON.stringify({
1082
+ challengeId: challenge.challengeId || '',
1083
+ response: responseValue
1084
+ })
1063
1085
  });
1064
1086
  if (!response.ok) {
1065
1087
  const data = await response.json().catch(() => ({}));
@@ -9512,12 +9534,12 @@ class Session {
9512
9534
  return;
9513
9535
  }
9514
9536
 
9515
- const endpoint = this.server.resolveWsUrl(
9516
- this.id,
9517
- this.server.token
9518
- );
9537
+ const endpoint = this.server.resolveWsUrl(this.id);
9519
9538
  try {
9520
- this.socket = new WebSocket(endpoint);
9539
+ this.socket = new WebSocket(
9540
+ endpoint,
9541
+ this.server.getWebSocketProtocols()
9542
+ );
9521
9543
  } catch (error) {
9522
9544
  const hostName = getDisplayHost(this.server);
9523
9545
  console.error(`[WS] Failed to connect ${hostName}:`, error);
@@ -10088,11 +10110,11 @@ class AgentTab {
10088
10110
  return;
10089
10111
  }
10090
10112
 
10091
- const endpoint = this.server.resolveAgentWsUrl(
10092
- this.id,
10093
- this.server.token
10113
+ const endpoint = this.server.resolveAgentWsUrl(this.id);
10114
+ this.socket = new WebSocket(
10115
+ endpoint,
10116
+ this.server.getWebSocketProtocols()
10094
10117
  );
10095
- this.socket = new WebSocket(endpoint);
10096
10118
  this.socket.addEventListener('message', (event) => {
10097
10119
  try {
10098
10120
  this.handleMessage(JSON.parse(event.data));
@@ -85,6 +85,90 @@ export async function hashPassword(password) {
85
85
  return sha256Fallback(normalized);
86
86
  }
87
87
 
88
+ export function buildLoginChallengeMessage(challenge) {
89
+ return [
90
+ 'tabminal-login-v1',
91
+ challenge?.challengeId || challenge?.id || '',
92
+ challenge?.salt || '',
93
+ challenge?.expiresAt || ''
94
+ ].join(':');
95
+ }
96
+
97
+ export async function buildLoginChallengeResponse(password, challenge) {
98
+ const passwordHash = await hashPassword(password);
99
+ const message = buildLoginChallengeMessage(challenge);
100
+ return hmacSha256Hex(passwordHash, message);
101
+ }
102
+
103
+ async function hmacSha256Hex(keyHex, message) {
104
+ if (window.crypto && window.crypto.subtle) {
105
+ try {
106
+ const key = await window.crypto.subtle.importKey(
107
+ 'raw',
108
+ hexToBytes(keyHex),
109
+ { name: 'HMAC', hash: 'SHA-256' },
110
+ false,
111
+ ['sign']
112
+ );
113
+ const signature = await window.crypto.subtle.sign(
114
+ 'HMAC',
115
+ key,
116
+ new TextEncoder().encode(message)
117
+ );
118
+ return bytesToHex(new Uint8Array(signature));
119
+ } catch (error) {
120
+ console.warn('Web Crypto HMAC failed, falling back to JS', error);
121
+ }
122
+ }
123
+ return hmacSha256Fallback(keyHex, message);
124
+ }
125
+
126
+ function hmacSha256Fallback(keyHex, message) {
127
+ const blockSize = 64;
128
+ let keyBytes = hexToBytes(keyHex);
129
+ if (keyBytes.length > blockSize) {
130
+ keyBytes = hexToBytes(sha256Fallback(bytesToBinary(keyBytes)));
131
+ }
132
+ const paddedKey = new Uint8Array(blockSize);
133
+ paddedKey.set(keyBytes);
134
+ const innerPad = new Uint8Array(blockSize);
135
+ const outerPad = new Uint8Array(blockSize);
136
+ for (let index = 0; index < blockSize; index += 1) {
137
+ innerPad[index] = paddedKey[index] ^ 0x36;
138
+ outerPad[index] = paddedKey[index] ^ 0x5c;
139
+ }
140
+ const innerHash = sha256Fallback(bytesToBinary(innerPad) + message);
141
+ return sha256Fallback(
142
+ bytesToBinary(outerPad) + bytesToBinary(hexToBytes(innerHash))
143
+ );
144
+ }
145
+
146
+ function hexToBytes(hex) {
147
+ const normalized = String(hex || '').trim().toLowerCase();
148
+ const bytes = new Uint8Array(normalized.length / 2);
149
+ for (let index = 0; index < bytes.length; index += 1) {
150
+ bytes[index] = Number.parseInt(
151
+ normalized.slice(index * 2, index * 2 + 2),
152
+ 16
153
+ );
154
+ }
155
+ return bytes;
156
+ }
157
+
158
+ function bytesToHex(bytes) {
159
+ return Array.from(bytes, (byte) => {
160
+ return byte.toString(16).padStart(2, '0');
161
+ }).join('');
162
+ }
163
+
164
+ function bytesToBinary(bytes) {
165
+ let result = '';
166
+ for (const byte of bytes) {
167
+ result += String.fromCharCode(byte);
168
+ }
169
+ return result;
170
+ }
171
+
88
172
  function sha256Fallback(ascii) {
89
173
  function rightRotate(value, amount) {
90
174
  return (value >>> amount) | (value << (32 - amount));
package/public/styles.css CHANGED
@@ -482,7 +482,7 @@ body {
482
482
  }
483
483
 
484
484
  .server-row.has-auth-sessions .server-main-button {
485
- padding-right: 42px;
485
+ padding-right: 48px;
486
486
  }
487
487
 
488
488
  .server-main-button:hover {
@@ -544,8 +544,8 @@ body {
544
544
 
545
545
  .server-auth-button {
546
546
  position: absolute;
547
- top: 4px;
548
- right: 4px;
547
+ top: 8px;
548
+ right: 8px;
549
549
  width: 28px;
550
550
  height: 28px;
551
551
  border-radius: 4px;
package/src/auth.mjs CHANGED
@@ -10,6 +10,13 @@ const MAX_ATTEMPTS = 30;
10
10
  const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
11
11
  const REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
12
12
  const ACCESS_TOKEN_REPLAY_LEEWAY_MS = 30 * 1000;
13
+ const AUTH_CHALLENGE_TTL_MS = 30 * 1000;
14
+ const AUTH_CHALLENGE_CLEANUP_MS = 15 * 1000;
15
+ const AUTH_CHALLENGE_MAX = 1000;
16
+ export const AUTH_CHALLENGE_ALGORITHM = 'tabminal-hmac-sha256-login-v1';
17
+ export const AUTH_CHALLENGE_MESSAGE_PREFIX = 'tabminal-login-v1';
18
+ export const WEBSOCKET_PROTOCOL = 'tabminal.v1';
19
+ export const WEBSOCKET_AUTH_PROTOCOL_PREFIX = 'tabminal.auth.';
13
20
 
14
21
  let failedAttempts = 0;
15
22
  let isLocked = false;
@@ -17,6 +24,8 @@ let authStoreInitialized = false;
17
24
  let authStoreInitPromise = null;
18
25
  const refreshSessions = new Map();
19
26
  const accessTokens = new Map();
27
+ const authChallenges = new Map();
28
+ let authChallengeCleanupTimer = null;
20
29
 
21
30
  function nowTimestamp() {
22
31
  return Date.now();
@@ -48,6 +57,10 @@ function generateOpaqueToken(prefix) {
48
57
  return `${prefix}_${crypto.randomBytes(32).toString('base64url')}`;
49
58
  }
50
59
 
60
+ function generateAuthChallengeSalt() {
61
+ return crypto.randomBytes(32).toString('base64url');
62
+ }
63
+
51
64
  function normalizeAuthHeader(value) {
52
65
  const raw = typeof value === 'string' ? value.trim() : '';
53
66
  if (!raw) {
@@ -59,6 +72,41 @@ function normalizeAuthHeader(value) {
59
72
  return raw;
60
73
  }
61
74
 
75
+ function parseWebSocketProtocols(value) {
76
+ return String(value || '')
77
+ .split(',')
78
+ .map((entry) => entry.trim())
79
+ .filter(Boolean);
80
+ }
81
+
82
+ export function extractWebSocketAuthToken(req) {
83
+ const authHeader = req?.headers?.authorization || '';
84
+ if (authHeader) {
85
+ return normalizeAuthHeader(authHeader);
86
+ }
87
+
88
+ const protocols = parseWebSocketProtocols(
89
+ req?.headers?.['sec-websocket-protocol']
90
+ );
91
+ const authProtocol = protocols.find((protocol) => (
92
+ protocol.startsWith(WEBSOCKET_AUTH_PROTOCOL_PREFIX)
93
+ ));
94
+ if (authProtocol) {
95
+ return authProtocol.slice(WEBSOCKET_AUTH_PROTOCOL_PREFIX.length);
96
+ }
97
+
98
+ if (req?.url) {
99
+ try {
100
+ const url = new URL(req.url, `http://${req.headers.host}`);
101
+ return normalizeAuthHeader(url.searchParams.get('token'));
102
+ } catch {
103
+ // Ignore malformed URL.
104
+ }
105
+ }
106
+
107
+ return '';
108
+ }
109
+
62
110
  function normalizeUserAgent(value) {
63
111
  const raw = typeof value === 'string' ? value.trim() : '';
64
112
  return raw.length > 500 ? raw.slice(0, 500) : raw;
@@ -79,6 +127,36 @@ function toIsoOffset(baseMs, deltaMs) {
79
127
  return new Date(baseMs + deltaMs).toISOString();
80
128
  }
81
129
 
130
+ function buildAuthChallengeMessage(challenge) {
131
+ return [
132
+ AUTH_CHALLENGE_MESSAGE_PREFIX,
133
+ challenge.id,
134
+ challenge.salt,
135
+ challenge.expiresAt
136
+ ].join(':');
137
+ }
138
+
139
+ function hmacSha256Hex(keyHex, message) {
140
+ return crypto
141
+ .createHmac('sha256', Buffer.from(keyHex, 'hex'))
142
+ .update(message)
143
+ .digest('hex');
144
+ }
145
+
146
+ export function buildAuthChallengeResponse(passwordHash, challenge) {
147
+ const normalizedPasswordHash = typeof passwordHash === 'string'
148
+ ? passwordHash.trim().toLowerCase()
149
+ : '';
150
+ return hmacSha256Hex(
151
+ normalizedPasswordHash,
152
+ buildAuthChallengeMessage({
153
+ id: challenge?.challengeId || challenge?.id || '',
154
+ salt: challenge?.salt || '',
155
+ expiresAt: challenge?.expiresAt || ''
156
+ })
157
+ );
158
+ }
159
+
82
160
  function serializeRefreshSessions() {
83
161
  return Array.from(refreshSessions.values())
84
162
  .sort((left, right) => {
@@ -108,6 +186,37 @@ function pruneExpiredAccessTokens(now = nowTimestamp()) {
108
186
  }
109
187
  }
110
188
 
189
+ export function pruneExpiredAuthChallenges(now = nowTimestamp()) {
190
+ let removed = 0;
191
+ for (const [challengeId, challenge] of authChallenges.entries()) {
192
+ if (!challenge || isIsoExpired(challenge.expiresAt, now)) {
193
+ authChallenges.delete(challengeId);
194
+ removed += 1;
195
+ }
196
+ }
197
+ while (authChallenges.size > AUTH_CHALLENGE_MAX) {
198
+ const oldestKey = authChallenges.keys().next().value;
199
+ if (!oldestKey) {
200
+ break;
201
+ }
202
+ authChallenges.delete(oldestKey);
203
+ removed += 1;
204
+ }
205
+ return removed;
206
+ }
207
+
208
+ function startAuthChallengeCleanup() {
209
+ if (authChallengeCleanupTimer) {
210
+ return;
211
+ }
212
+ authChallengeCleanupTimer = setInterval(() => {
213
+ pruneExpiredAuthChallenges();
214
+ }, AUTH_CHALLENGE_CLEANUP_MS);
215
+ if (typeof authChallengeCleanupTimer.unref === 'function') {
216
+ authChallengeCleanupTimer.unref();
217
+ }
218
+ }
219
+
111
220
  function pruneExpiredRefreshSessions(now = nowTimestamp()) {
112
221
  let changed = false;
113
222
  for (const [sessionId, session] of refreshSessions.entries()) {
@@ -140,6 +249,7 @@ async function ensureAuthStoreInitialized() {
140
249
  if (changed) {
141
250
  await persistRefreshSessions();
142
251
  }
252
+ startAuthChallengeCleanup();
143
253
  authStoreInitialized = true;
144
254
  })().finally(() => {
145
255
  authStoreInitPromise = null;
@@ -148,16 +258,20 @@ async function ensureAuthStoreInitialized() {
148
258
  await authStoreInitPromise;
149
259
  }
150
260
 
151
- function verifyPasswordHash(passwordHash) {
261
+ function verifyAuthChallengeResponse(challenge, response) {
152
262
  if (isLocked) {
153
263
  return { success: false, locked: true };
154
264
  }
155
- const normalized = typeof passwordHash === 'string'
156
- ? passwordHash.trim().toLowerCase()
265
+ const normalized = typeof response === 'string'
266
+ ? response.trim().toLowerCase()
157
267
  : '';
268
+ const expected = hmacSha256Hex(
269
+ config.passwordHash,
270
+ buildAuthChallengeMessage(challenge)
271
+ );
158
272
  if (
159
273
  !/^[0-9a-f]{64}$/.test(normalized)
160
- || !safeEqualHex(normalized, config.passwordHash)
274
+ || !safeEqualHex(normalized, expected)
161
275
  ) {
162
276
  failedAttempts += 1;
163
277
  if (failedAttempts >= MAX_ATTEMPTS) {
@@ -181,6 +295,37 @@ function buildAuthPayload(rawAccessToken, rawRefreshToken, now = nowTimestamp())
181
295
  };
182
296
  }
183
297
 
298
+ async function issueAuthTokensForVerifiedPassword({
299
+ userAgent = ''
300
+ } = {}) {
301
+ const now = nowTimestamp();
302
+ const sessionId = crypto.randomUUID();
303
+ const rawRefreshToken = generateOpaqueToken('tr');
304
+ const refreshTokenHash = sha256(rawRefreshToken);
305
+ const createdAt = nowIso();
306
+ const session = {
307
+ id: sessionId,
308
+ passwordFingerprint: getPasswordFingerprint(),
309
+ refreshTokenHash,
310
+ createdAt,
311
+ lastSeenAt: createdAt,
312
+ refreshExpiresAt: toIsoOffset(now, REFRESH_TOKEN_TTL_MS),
313
+ rotatedAt: createdAt,
314
+ revokedAt: '',
315
+ userAgent: normalizeUserAgent(userAgent)
316
+ };
317
+ refreshSessions.set(sessionId, session);
318
+ await persistRefreshSessions();
319
+
320
+ const rawAccessToken = issueAccessToken(sessionId, now);
321
+ return {
322
+ ok: true,
323
+ status: 200,
324
+ sessionId,
325
+ ...buildAuthPayload(rawAccessToken, rawRefreshToken, now)
326
+ };
327
+ }
328
+
184
329
  function issueAccessToken(sessionId, now = nowTimestamp()) {
185
330
  removeAccessTokensForSession(sessionId);
186
331
  const rawAccessToken = generateOpaqueToken('ta');
@@ -217,12 +362,86 @@ export async function initAuthStore() {
217
362
  await ensureAuthStoreInitialized();
218
363
  }
219
364
 
220
- export async function issueAuthTokensFromPasswordHash(
221
- passwordHash,
365
+ export async function createAuthChallenge() {
366
+ await ensureAuthStoreInitialized();
367
+ const now = nowTimestamp();
368
+ pruneExpiredAuthChallenges(now);
369
+ while (authChallenges.size >= AUTH_CHALLENGE_MAX) {
370
+ const oldestKey = authChallenges.keys().next().value;
371
+ if (!oldestKey) {
372
+ break;
373
+ }
374
+ authChallenges.delete(oldestKey);
375
+ }
376
+ const id = crypto.randomUUID();
377
+ const salt = generateAuthChallengeSalt();
378
+ const createdAt = new Date(now).toISOString();
379
+ const expiresAt = toIsoOffset(now, AUTH_CHALLENGE_TTL_MS);
380
+ authChallenges.set(id, {
381
+ id,
382
+ salt,
383
+ createdAt,
384
+ expiresAt
385
+ });
386
+ return {
387
+ challengeId: id,
388
+ salt,
389
+ expiresAt,
390
+ algorithm: AUTH_CHALLENGE_ALGORITHM
391
+ };
392
+ }
393
+
394
+ function consumeAuthChallenge(challengeId) {
395
+ const normalizedChallengeId = typeof challengeId === 'string'
396
+ ? challengeId.trim()
397
+ : '';
398
+ if (!normalizedChallengeId) {
399
+ return null;
400
+ }
401
+ const challenge = authChallenges.get(normalizedChallengeId) || null;
402
+ if (challenge) {
403
+ authChallenges.delete(normalizedChallengeId);
404
+ }
405
+ return challenge;
406
+ }
407
+
408
+ export async function issueAuthTokensFromChallenge(
409
+ { challengeId = '', response = '' } = {},
222
410
  { userAgent = '' } = {}
223
411
  ) {
224
412
  await ensureAuthStoreInitialized();
225
- const { success, locked } = verifyPasswordHash(passwordHash);
413
+ if (typeof challengeId !== 'string' || !challengeId.trim()) {
414
+ return {
415
+ ok: false,
416
+ status: 400,
417
+ error: 'Invalid login challenge.'
418
+ };
419
+ }
420
+
421
+ const challenge = consumeAuthChallenge(challengeId);
422
+ if (!challenge || isIsoExpired(challenge.expiresAt)) {
423
+ return {
424
+ ok: false,
425
+ status: 401,
426
+ error: 'Login challenge expired. Please try again.'
427
+ };
428
+ }
429
+
430
+ const normalizedResponse = typeof response === 'string'
431
+ ? response.trim().toLowerCase()
432
+ : '';
433
+ if (!/^[0-9a-f]{64}$/.test(normalizedResponse)) {
434
+ return {
435
+ ok: false,
436
+ status: 400,
437
+ error: 'Invalid login challenge.'
438
+ };
439
+ }
440
+
441
+ const { success, locked } = verifyAuthChallengeResponse(
442
+ challenge,
443
+ normalizedResponse
444
+ );
226
445
  if (locked) {
227
446
  return {
228
447
  ok: false,
@@ -238,31 +457,7 @@ export async function issueAuthTokensFromPasswordHash(
238
457
  };
239
458
  }
240
459
 
241
- const now = nowTimestamp();
242
- const sessionId = crypto.randomUUID();
243
- const rawRefreshToken = generateOpaqueToken('tr');
244
- const refreshTokenHash = sha256(rawRefreshToken);
245
- const session = {
246
- id: sessionId,
247
- passwordFingerprint: getPasswordFingerprint(),
248
- refreshTokenHash,
249
- createdAt: nowIso(),
250
- lastSeenAt: nowIso(),
251
- refreshExpiresAt: toIsoOffset(now, REFRESH_TOKEN_TTL_MS),
252
- rotatedAt: nowIso(),
253
- revokedAt: '',
254
- userAgent: normalizeUserAgent(userAgent)
255
- };
256
- refreshSessions.set(sessionId, session);
257
- await persistRefreshSessions();
258
-
259
- const rawAccessToken = issueAccessToken(sessionId, now);
260
- return {
261
- ok: true,
262
- status: 200,
263
- sessionId,
264
- ...buildAuthPayload(rawAccessToken, rawRefreshToken, now)
265
- };
460
+ return issueAuthTokensForVerifiedPassword({ userAgent });
266
461
  }
267
462
 
268
463
  export async function refreshAuthTokens(
@@ -465,6 +660,7 @@ export async function authMiddleware(ctx, next) {
465
660
  if (
466
661
  ctx.path === '/healthz'
467
662
  || ctx.path === '/api/version'
663
+ || ctx.path === '/api/auth/challenge'
468
664
  || ctx.path === '/api/auth/login'
469
665
  || ctx.path === '/api/auth/refresh'
470
666
  || ctx.path === '/api/auth/logout'
@@ -486,18 +682,7 @@ export async function authMiddleware(ctx, next) {
486
682
 
487
683
  export function verifyClient(info, cb) {
488
684
  const { req } = info;
489
- let authHeader = req.headers['authorization'];
490
-
491
- if (!authHeader && req.url) {
492
- try {
493
- const url = new URL(req.url, `http://${req.headers.host}`);
494
- authHeader = url.searchParams.get('token');
495
- } catch {
496
- // Ignore malformed URL.
497
- }
498
- }
499
-
500
- const normalizedToken = normalizeAuthHeader(authHeader);
685
+ const normalizedToken = extractWebSocketAuthToken(req);
501
686
  if (!normalizedToken) {
502
687
  cb(false, 401, 'Unauthorized');
503
688
  return;
package/src/server.mjs CHANGED
@@ -20,14 +20,16 @@ import { SystemMonitor } from './system-monitor.mjs';
20
20
  import { config } from './config.mjs';
21
21
  import {
22
22
  authMiddleware,
23
+ createAuthChallenge,
23
24
  initAuthStore,
24
- issueAuthTokensFromPasswordHash,
25
+ issueAuthTokensFromChallenge,
25
26
  listAuthSessions,
26
27
  refreshAuthTokens,
27
28
  revokeAuthSessionById,
28
29
  revokeOtherAuthSessions,
29
30
  revokeAuthTokens,
30
- verifyClient
31
+ verifyClient,
32
+ WEBSOCKET_PROTOCOL
31
33
  } from './auth.mjs';
32
34
  import {
33
35
  setupFsRoutes,
@@ -184,12 +186,22 @@ router.get('/healthz', (ctx) => {
184
186
  ctx.body = { status: 'ok' };
185
187
  });
186
188
 
189
+ router.post('/api/auth/challenge', async (ctx) => {
190
+ ctx.body = await createAuthChallenge();
191
+ });
192
+
187
193
  router.post('/api/auth/login', async (ctx) => {
188
194
  const body = ctx.request.body || {};
189
- const passwordHash = typeof body.passwordHash === 'string'
190
- ? body.passwordHash
195
+ const challengeId = typeof body.challengeId === 'string'
196
+ ? body.challengeId
197
+ : '';
198
+ const response = typeof body.response === 'string'
199
+ ? body.response
191
200
  : '';
192
- const result = await issueAuthTokensFromPasswordHash(passwordHash, {
201
+ const result = await issueAuthTokensFromChallenge({
202
+ challengeId,
203
+ response
204
+ }, {
193
205
  userAgent: ctx.get('user-agent')
194
206
  });
195
207
  ctx.status = result.status;
@@ -765,7 +777,16 @@ app.use(router.routes());
765
777
  app.use(router.allowedMethods());
766
778
 
767
779
  const httpServer = createServer(app.callback());
768
- const wss = new WebSocketServer({ noServer: true, verifyClient });
780
+ const wss = new WebSocketServer({
781
+ noServer: true,
782
+ verifyClient,
783
+ handleProtocols: (protocols) => {
784
+ if (protocols.has(WEBSOCKET_PROTOCOL)) {
785
+ return WEBSOCKET_PROTOCOL;
786
+ }
787
+ return false;
788
+ }
789
+ });
769
790
  const httpConnections = new Set();
770
791
 
771
792
  httpServer.on('connection', (socket) => {
@@ -1001,7 +1001,9 @@ export class TerminalSession {
1001
1001
  });
1002
1002
 
1003
1003
  // Auto-Fix: If command failed, ask AI for help
1004
- if (exitCode !== 0 && entry.command && this._isAiEnabled()) {
1004
+ const hasCapturedInput =
1005
+ typeof entry.input === 'string' && entry.input.trim().length > 0;
1006
+ if (exitCode !== 0 && entry.command && hasCapturedInput && this._isAiEnabled()) {
1005
1007
  // Don't trigger on simple interruptions (SIGINT=130) or common non-errors?
1006
1008
  // 130 = Ctrl+C. Usually user intention.
1007
1009
  if (exitCode !== 130) {