tabminal 3.0.27 → 3.0.28
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/AGENTS.md +13 -7
- package/package.json +1 -1
- package/public/app.js +682 -123
- package/public/index.html +28 -0
- package/public/modules/url-auth.js +57 -35
- package/public/styles.css +113 -0
- package/src/auth.mjs +471 -37
- package/src/persistence.mjs +75 -0
- package/src/server.mjs +99 -1
package/src/auth.mjs
CHANGED
|
@@ -1,88 +1,522 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import { config } from './config.mjs';
|
|
4
|
+
import {
|
|
5
|
+
loadAuthSessions,
|
|
6
|
+
saveAuthSessions
|
|
7
|
+
} from './persistence.mjs';
|
|
8
|
+
|
|
9
|
+
const MAX_ATTEMPTS = 30;
|
|
10
|
+
const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
|
|
11
|
+
const REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const ACCESS_TOKEN_REPLAY_LEEWAY_MS = 30 * 1000;
|
|
2
13
|
|
|
3
14
|
let failedAttempts = 0;
|
|
4
15
|
let isLocked = false;
|
|
5
|
-
|
|
16
|
+
let authStoreInitialized = false;
|
|
17
|
+
let authStoreInitPromise = null;
|
|
18
|
+
const refreshSessions = new Map();
|
|
19
|
+
const accessTokens = new Map();
|
|
20
|
+
|
|
21
|
+
function nowTimestamp() {
|
|
22
|
+
return Date.now();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function nowIso() {
|
|
26
|
+
return new Date().toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sha256(input) {
|
|
30
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeEqualHex(left, right) {
|
|
34
|
+
if (left.length !== right.length) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return crypto.timingSafeEqual(
|
|
38
|
+
Buffer.from(left, 'hex'),
|
|
39
|
+
Buffer.from(right, 'hex')
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getPasswordFingerprint() {
|
|
44
|
+
return sha256(`tabminal-auth-password:${config.passwordHash}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function generateOpaqueToken(prefix) {
|
|
48
|
+
return `${prefix}_${crypto.randomBytes(32).toString('base64url')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeAuthHeader(value) {
|
|
52
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
53
|
+
if (!raw) {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
if (/^bearer\s+/i.test(raw)) {
|
|
57
|
+
return raw.replace(/^bearer\s+/i, '').trim();
|
|
58
|
+
}
|
|
59
|
+
return raw;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeUserAgent(value) {
|
|
63
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
64
|
+
return raw.length > 500 ? raw.slice(0, 500) : raw;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isIsoExpired(value, now = nowTimestamp()) {
|
|
68
|
+
if (!value) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
const timestamp = Date.parse(value);
|
|
72
|
+
if (!Number.isFinite(timestamp)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return timestamp <= now;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toIsoOffset(baseMs, deltaMs) {
|
|
79
|
+
return new Date(baseMs + deltaMs).toISOString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function serializeRefreshSessions() {
|
|
83
|
+
return Array.from(refreshSessions.values())
|
|
84
|
+
.sort((left, right) => {
|
|
85
|
+
return String(left.createdAt || '').localeCompare(
|
|
86
|
+
String(right.createdAt || '')
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function persistRefreshSessions() {
|
|
92
|
+
await saveAuthSessions(serializeRefreshSessions());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function removeAccessTokensForSession(sessionId) {
|
|
96
|
+
for (const [tokenHash, entry] of accessTokens.entries()) {
|
|
97
|
+
if (entry?.sessionId === sessionId) {
|
|
98
|
+
accessTokens.delete(tokenHash);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pruneExpiredAccessTokens(now = nowTimestamp()) {
|
|
104
|
+
for (const [tokenHash, entry] of accessTokens.entries()) {
|
|
105
|
+
if (!entry || isIsoExpired(entry.expiresAt, now)) {
|
|
106
|
+
accessTokens.delete(tokenHash);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pruneExpiredRefreshSessions(now = nowTimestamp()) {
|
|
112
|
+
let changed = false;
|
|
113
|
+
for (const [sessionId, session] of refreshSessions.entries()) {
|
|
114
|
+
if (
|
|
115
|
+
!session
|
|
116
|
+
|| session.passwordFingerprint !== getPasswordFingerprint()
|
|
117
|
+
|| session.revokedAt
|
|
118
|
+
|| isIsoExpired(session.refreshExpiresAt, now)
|
|
119
|
+
) {
|
|
120
|
+
refreshSessions.delete(sessionId);
|
|
121
|
+
removeAccessTokensForSession(sessionId);
|
|
122
|
+
changed = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return changed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function ensureAuthStoreInitialized() {
|
|
129
|
+
if (authStoreInitialized) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!authStoreInitPromise) {
|
|
133
|
+
authStoreInitPromise = (async () => {
|
|
134
|
+
const sessions = await loadAuthSessions();
|
|
135
|
+
refreshSessions.clear();
|
|
136
|
+
for (const session of sessions) {
|
|
137
|
+
refreshSessions.set(session.id, session);
|
|
138
|
+
}
|
|
139
|
+
const changed = pruneExpiredRefreshSessions();
|
|
140
|
+
if (changed) {
|
|
141
|
+
await persistRefreshSessions();
|
|
142
|
+
}
|
|
143
|
+
authStoreInitialized = true;
|
|
144
|
+
})().finally(() => {
|
|
145
|
+
authStoreInitPromise = null;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
await authStoreInitPromise;
|
|
149
|
+
}
|
|
6
150
|
|
|
7
|
-
|
|
151
|
+
function verifyPasswordHash(passwordHash) {
|
|
8
152
|
if (isLocked) {
|
|
9
153
|
return { success: false, locked: true };
|
|
10
154
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
155
|
+
const normalized = typeof passwordHash === 'string'
|
|
156
|
+
? passwordHash.trim().toLowerCase()
|
|
157
|
+
: '';
|
|
158
|
+
if (
|
|
159
|
+
!/^[0-9a-f]{64}$/.test(normalized)
|
|
160
|
+
|| !safeEqualHex(normalized, config.passwordHash)
|
|
161
|
+
) {
|
|
162
|
+
failedAttempts += 1;
|
|
14
163
|
if (failedAttempts >= MAX_ATTEMPTS) {
|
|
15
164
|
isLocked = true;
|
|
16
|
-
console.error(
|
|
165
|
+
console.error(
|
|
166
|
+
'[Auth] Maximum failed attempts reached. Service locked.'
|
|
167
|
+
);
|
|
17
168
|
}
|
|
18
169
|
return { success: false, locked: isLocked };
|
|
19
170
|
}
|
|
20
|
-
|
|
21
|
-
// Reset attempts on success?
|
|
22
|
-
// Requirement says "already wrong 30 times, service locked".
|
|
23
|
-
// Usually success resets counter, but strict interpretation might mean cumulative.
|
|
24
|
-
// Assuming standard behavior: success resets counter to avoid accidental lockout over long periods.
|
|
25
171
|
failedAttempts = 0;
|
|
26
172
|
return { success: true, locked: false };
|
|
27
173
|
}
|
|
28
174
|
|
|
175
|
+
function buildAuthPayload(rawAccessToken, rawRefreshToken, now = nowTimestamp()) {
|
|
176
|
+
return {
|
|
177
|
+
accessToken: rawAccessToken,
|
|
178
|
+
accessTokenExpiresAt: toIsoOffset(now, ACCESS_TOKEN_TTL_MS),
|
|
179
|
+
refreshToken: rawRefreshToken,
|
|
180
|
+
refreshTokenExpiresAt: toIsoOffset(now, REFRESH_TOKEN_TTL_MS)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function issueAccessToken(sessionId, now = nowTimestamp()) {
|
|
185
|
+
removeAccessTokensForSession(sessionId);
|
|
186
|
+
const rawAccessToken = generateOpaqueToken('ta');
|
|
187
|
+
const accessTokenHash = sha256(rawAccessToken);
|
|
188
|
+
accessTokens.set(accessTokenHash, {
|
|
189
|
+
sessionId,
|
|
190
|
+
expiresAt: toIsoOffset(now, ACCESS_TOKEN_TTL_MS)
|
|
191
|
+
});
|
|
192
|
+
return rawAccessToken;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function findRefreshSessionByToken(refreshToken) {
|
|
196
|
+
const tokenHash = sha256(refreshToken);
|
|
197
|
+
for (const session of refreshSessions.values()) {
|
|
198
|
+
if (session.refreshTokenHash === tokenHash) {
|
|
199
|
+
return session;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildSessionSummary(session, currentSessionId = '') {
|
|
206
|
+
return {
|
|
207
|
+
id: session.id,
|
|
208
|
+
createdAt: session.createdAt || '',
|
|
209
|
+
lastSeenAt: session.lastSeenAt || '',
|
|
210
|
+
refreshExpiresAt: session.refreshExpiresAt || '',
|
|
211
|
+
userAgent: session.userAgent || '',
|
|
212
|
+
current: session.id === currentSessionId
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function initAuthStore() {
|
|
217
|
+
await ensureAuthStoreInitialized();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function issueAuthTokensFromPasswordHash(
|
|
221
|
+
passwordHash,
|
|
222
|
+
{ userAgent = '' } = {}
|
|
223
|
+
) {
|
|
224
|
+
await ensureAuthStoreInitialized();
|
|
225
|
+
const { success, locked } = verifyPasswordHash(passwordHash);
|
|
226
|
+
if (locked) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
status: 403,
|
|
230
|
+
error: 'Service locked due to too many failed attempts. Please restart the service.'
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (!success) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
status: 401,
|
|
237
|
+
error: 'Unauthorized'
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
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
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function refreshAuthTokens(
|
|
269
|
+
refreshToken,
|
|
270
|
+
{ userAgent = '' } = {}
|
|
271
|
+
) {
|
|
272
|
+
await ensureAuthStoreInitialized();
|
|
273
|
+
const normalized = typeof refreshToken === 'string'
|
|
274
|
+
? refreshToken.trim()
|
|
275
|
+
: '';
|
|
276
|
+
if (!normalized) {
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
status: 401,
|
|
280
|
+
error: 'Unauthorized'
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const session = findRefreshSessionByToken(normalized);
|
|
285
|
+
if (!session) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
status: 401,
|
|
289
|
+
error: 'Unauthorized'
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const now = nowTimestamp();
|
|
294
|
+
if (
|
|
295
|
+
session.passwordFingerprint !== getPasswordFingerprint()
|
|
296
|
+
|| session.revokedAt
|
|
297
|
+
|| isIsoExpired(session.refreshExpiresAt, now)
|
|
298
|
+
) {
|
|
299
|
+
refreshSessions.delete(session.id);
|
|
300
|
+
removeAccessTokensForSession(session.id);
|
|
301
|
+
await persistRefreshSessions();
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
status: 401,
|
|
305
|
+
error: 'Unauthorized'
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const nextRefreshToken = generateOpaqueToken('tr');
|
|
310
|
+
session.refreshTokenHash = sha256(nextRefreshToken);
|
|
311
|
+
session.lastSeenAt = nowIso();
|
|
312
|
+
session.rotatedAt = session.lastSeenAt;
|
|
313
|
+
session.refreshExpiresAt = toIsoOffset(now, REFRESH_TOKEN_TTL_MS);
|
|
314
|
+
const normalizedUserAgent = normalizeUserAgent(userAgent);
|
|
315
|
+
if (normalizedUserAgent) {
|
|
316
|
+
session.userAgent = normalizedUserAgent;
|
|
317
|
+
}
|
|
318
|
+
refreshSessions.set(session.id, session);
|
|
319
|
+
await persistRefreshSessions();
|
|
320
|
+
|
|
321
|
+
const rawAccessToken = issueAccessToken(session.id, now);
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
status: 200,
|
|
325
|
+
sessionId: session.id,
|
|
326
|
+
...buildAuthPayload(rawAccessToken, nextRefreshToken, now)
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function listAuthSessions(currentSessionId = '') {
|
|
331
|
+
await ensureAuthStoreInitialized();
|
|
332
|
+
const changed = pruneExpiredRefreshSessions();
|
|
333
|
+
if (changed) {
|
|
334
|
+
await persistRefreshSessions();
|
|
335
|
+
}
|
|
336
|
+
return serializeRefreshSessions()
|
|
337
|
+
.map((session) => buildSessionSummary(session, currentSessionId))
|
|
338
|
+
.sort((left, right) => {
|
|
339
|
+
if (left.current !== right.current) {
|
|
340
|
+
return left.current ? -1 : 1;
|
|
341
|
+
}
|
|
342
|
+
return String(right.lastSeenAt || right.createdAt || '')
|
|
343
|
+
.localeCompare(String(left.lastSeenAt || left.createdAt || ''));
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function revokeAuthSessionById(sessionId) {
|
|
348
|
+
await ensureAuthStoreInitialized();
|
|
349
|
+
const normalizedSessionId = typeof sessionId === 'string'
|
|
350
|
+
? sessionId.trim()
|
|
351
|
+
: '';
|
|
352
|
+
if (!normalizedSessionId || !refreshSessions.has(normalizedSessionId)) {
|
|
353
|
+
return { ok: false, status: 404, error: 'Not found' };
|
|
354
|
+
}
|
|
355
|
+
refreshSessions.delete(normalizedSessionId);
|
|
356
|
+
removeAccessTokensForSession(normalizedSessionId);
|
|
357
|
+
await persistRefreshSessions();
|
|
358
|
+
return { ok: true, status: 204 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function revokeOtherAuthSessions(currentSessionId = '') {
|
|
362
|
+
await ensureAuthStoreInitialized();
|
|
363
|
+
const normalizedCurrentId = typeof currentSessionId === 'string'
|
|
364
|
+
? currentSessionId.trim()
|
|
365
|
+
: '';
|
|
366
|
+
let changed = false;
|
|
367
|
+
for (const sessionId of refreshSessions.keys()) {
|
|
368
|
+
if (sessionId === normalizedCurrentId) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
refreshSessions.delete(sessionId);
|
|
372
|
+
removeAccessTokensForSession(sessionId);
|
|
373
|
+
changed = true;
|
|
374
|
+
}
|
|
375
|
+
if (changed) {
|
|
376
|
+
await persistRefreshSessions();
|
|
377
|
+
}
|
|
378
|
+
return { ok: true, status: 204 };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function revokeAuthTokens({
|
|
382
|
+
refreshToken = '',
|
|
383
|
+
accessToken = ''
|
|
384
|
+
} = {}) {
|
|
385
|
+
await ensureAuthStoreInitialized();
|
|
386
|
+
const normalizedRefreshToken = typeof refreshToken === 'string'
|
|
387
|
+
? refreshToken.trim()
|
|
388
|
+
: '';
|
|
389
|
+
const normalizedAccessToken = normalizeAuthHeader(accessToken);
|
|
390
|
+
let sessionId = '';
|
|
391
|
+
|
|
392
|
+
if (normalizedRefreshToken) {
|
|
393
|
+
const session = findRefreshSessionByToken(normalizedRefreshToken);
|
|
394
|
+
sessionId = session?.id || '';
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!sessionId && normalizedAccessToken) {
|
|
398
|
+
pruneExpiredAccessTokens();
|
|
399
|
+
const accessEntry = accessTokens.get(sha256(normalizedAccessToken));
|
|
400
|
+
sessionId = accessEntry?.sessionId || '';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!sessionId) {
|
|
404
|
+
return { ok: true, status: 204 };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
refreshSessions.delete(sessionId);
|
|
408
|
+
removeAccessTokensForSession(sessionId);
|
|
409
|
+
await persistRefreshSessions();
|
|
410
|
+
return { ok: true, status: 204 };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function authenticateAccessToken(rawToken) {
|
|
414
|
+
await ensureAuthStoreInitialized();
|
|
415
|
+
const normalizedToken = normalizeAuthHeader(rawToken);
|
|
416
|
+
if (!normalizedToken) {
|
|
417
|
+
return { ok: false, status: 401, error: 'Unauthorized' };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
pruneExpiredAccessTokens();
|
|
421
|
+
|
|
422
|
+
const accessEntry = accessTokens.get(sha256(normalizedToken));
|
|
423
|
+
if (!accessEntry || isIsoExpired(accessEntry.expiresAt)) {
|
|
424
|
+
if (accessEntry) {
|
|
425
|
+
accessTokens.delete(sha256(normalizedToken));
|
|
426
|
+
}
|
|
427
|
+
return { ok: false, status: 401, error: 'Unauthorized' };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const session = refreshSessions.get(accessEntry.sessionId);
|
|
431
|
+
if (
|
|
432
|
+
!session
|
|
433
|
+
|| session.passwordFingerprint !== getPasswordFingerprint()
|
|
434
|
+
|| session.revokedAt
|
|
435
|
+
|| isIsoExpired(session.refreshExpiresAt)
|
|
436
|
+
) {
|
|
437
|
+
accessTokens.delete(sha256(normalizedToken));
|
|
438
|
+
if (session) {
|
|
439
|
+
refreshSessions.delete(session.id);
|
|
440
|
+
removeAccessTokensForSession(session.id);
|
|
441
|
+
await persistRefreshSessions();
|
|
442
|
+
}
|
|
443
|
+
return { ok: false, status: 401, error: 'Unauthorized' };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
session.lastSeenAt = nowIso();
|
|
447
|
+
refreshSessions.set(session.id, session);
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
status: 200,
|
|
451
|
+
auth: {
|
|
452
|
+
sessionId: session.id,
|
|
453
|
+
accessTokenExpiresAt: accessEntry.expiresAt,
|
|
454
|
+
refreshTokenExpiresAt: session.refreshExpiresAt
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
29
459
|
export async function authMiddleware(ctx, next) {
|
|
30
460
|
if (ctx.method === 'OPTIONS') {
|
|
31
461
|
ctx.status = 204;
|
|
32
462
|
return;
|
|
33
463
|
}
|
|
34
464
|
|
|
35
|
-
|
|
36
|
-
|
|
465
|
+
if (
|
|
466
|
+
ctx.path === '/healthz'
|
|
467
|
+
|| ctx.path === '/api/version'
|
|
468
|
+
|| ctx.path === '/api/auth/login'
|
|
469
|
+
|| ctx.path === '/api/auth/refresh'
|
|
470
|
+
|| ctx.path === '/api/auth/logout'
|
|
471
|
+
) {
|
|
37
472
|
return next();
|
|
38
473
|
}
|
|
39
474
|
|
|
40
|
-
// Check for Authorization header
|
|
41
475
|
const authHeader = ctx.get('Authorization') || ctx.query.token;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const { success, locked } = checkAuth(authHeader);
|
|
47
|
-
|
|
48
|
-
if (locked) {
|
|
49
|
-
ctx.status = 403;
|
|
50
|
-
ctx.body = { error: 'Service locked due to too many failed attempts. Please restart the service.' };
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (!success) {
|
|
55
|
-
ctx.status = 401;
|
|
56
|
-
ctx.body = { error: 'Unauthorized' };
|
|
476
|
+
const result = await authenticateAccessToken(authHeader);
|
|
477
|
+
if (!result.ok) {
|
|
478
|
+
ctx.status = result.status;
|
|
479
|
+
ctx.body = { error: result.error };
|
|
57
480
|
return;
|
|
58
481
|
}
|
|
59
482
|
|
|
483
|
+
ctx.state.auth = result.auth;
|
|
60
484
|
await next();
|
|
61
485
|
}
|
|
62
486
|
|
|
63
487
|
export function verifyClient(info, cb) {
|
|
64
488
|
const { req } = info;
|
|
65
|
-
// WebSocket headers are in req.headers
|
|
66
489
|
let authHeader = req.headers['authorization'];
|
|
67
490
|
|
|
68
|
-
// If no header, check query parameter
|
|
69
491
|
if (!authHeader && req.url) {
|
|
70
492
|
try {
|
|
71
493
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
72
494
|
authHeader = url.searchParams.get('token');
|
|
73
495
|
} catch {
|
|
74
|
-
//
|
|
496
|
+
// Ignore malformed URL.
|
|
75
497
|
}
|
|
76
498
|
}
|
|
77
|
-
|
|
78
|
-
const { success, locked } = checkAuth(authHeader);
|
|
79
499
|
|
|
80
|
-
|
|
81
|
-
|
|
500
|
+
const normalizedToken = normalizeAuthHeader(authHeader);
|
|
501
|
+
if (!normalizedToken) {
|
|
502
|
+
cb(false, 401, 'Unauthorized');
|
|
82
503
|
return;
|
|
83
504
|
}
|
|
84
505
|
|
|
85
|
-
|
|
506
|
+
pruneExpiredAccessTokens(nowTimestamp() + ACCESS_TOKEN_REPLAY_LEEWAY_MS);
|
|
507
|
+
const accessEntry = accessTokens.get(sha256(normalizedToken));
|
|
508
|
+
if (!accessEntry || isIsoExpired(accessEntry.expiresAt)) {
|
|
509
|
+
cb(false, 401, 'Unauthorized');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const session = refreshSessions.get(accessEntry.sessionId);
|
|
514
|
+
if (
|
|
515
|
+
!session
|
|
516
|
+
|| session.passwordFingerprint !== getPasswordFingerprint()
|
|
517
|
+
|| session.revokedAt
|
|
518
|
+
|| isIsoExpired(session.refreshExpiresAt)
|
|
519
|
+
) {
|
|
86
520
|
cb(false, 401, 'Unauthorized');
|
|
87
521
|
return;
|
|
88
522
|
}
|
package/src/persistence.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const MEMORY_FILE = path.join(BASE_DIR, 'memory.json');
|
|
|
9
9
|
const CLUSTER_FILE = path.join(BASE_DIR, 'cluster.json');
|
|
10
10
|
const AGENT_TABS_FILE = path.join(BASE_DIR, 'agent-tabs.json');
|
|
11
11
|
const AGENT_CONFIG_FILE = path.join(BASE_DIR, 'agent-config.json');
|
|
12
|
+
const AUTH_SESSIONS_FILE = path.join(BASE_DIR, 'auth-sessions.json');
|
|
12
13
|
const getSessionSnapshotPath = (id) => path.join(SESSIONS_DIR, `${id}.snapshot`);
|
|
13
14
|
|
|
14
15
|
// Ensure directories exist
|
|
@@ -196,6 +197,80 @@ export const saveCluster = async (servers) => {
|
|
|
196
197
|
}
|
|
197
198
|
};
|
|
198
199
|
|
|
200
|
+
// --- Auth Session Persistence ---
|
|
201
|
+
|
|
202
|
+
function normalizeAuthSessions(sessions) {
|
|
203
|
+
if (!Array.isArray(sessions)) return [];
|
|
204
|
+
const normalized = [];
|
|
205
|
+
for (const entry of sessions) {
|
|
206
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
207
|
+
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|
208
|
+
const passwordFingerprint = typeof entry.passwordFingerprint === 'string'
|
|
209
|
+
? entry.passwordFingerprint.trim()
|
|
210
|
+
: '';
|
|
211
|
+
const refreshTokenHash = typeof entry.refreshTokenHash === 'string'
|
|
212
|
+
? entry.refreshTokenHash.trim()
|
|
213
|
+
: '';
|
|
214
|
+
const createdAt = typeof entry.createdAt === 'string'
|
|
215
|
+
? entry.createdAt.trim()
|
|
216
|
+
: '';
|
|
217
|
+
const lastSeenAt = typeof entry.lastSeenAt === 'string'
|
|
218
|
+
? entry.lastSeenAt.trim()
|
|
219
|
+
: '';
|
|
220
|
+
const refreshExpiresAt = typeof entry.refreshExpiresAt === 'string'
|
|
221
|
+
? entry.refreshExpiresAt.trim()
|
|
222
|
+
: '';
|
|
223
|
+
const rotatedAt = typeof entry.rotatedAt === 'string'
|
|
224
|
+
? entry.rotatedAt.trim()
|
|
225
|
+
: '';
|
|
226
|
+
const revokedAt = typeof entry.revokedAt === 'string'
|
|
227
|
+
? entry.revokedAt.trim()
|
|
228
|
+
: '';
|
|
229
|
+
const userAgent = typeof entry.userAgent === 'string'
|
|
230
|
+
? entry.userAgent.trim()
|
|
231
|
+
: '';
|
|
232
|
+
if (!id || !passwordFingerprint || !refreshTokenHash) continue;
|
|
233
|
+
normalized.push({
|
|
234
|
+
id,
|
|
235
|
+
passwordFingerprint,
|
|
236
|
+
refreshTokenHash,
|
|
237
|
+
createdAt,
|
|
238
|
+
lastSeenAt,
|
|
239
|
+
refreshExpiresAt,
|
|
240
|
+
rotatedAt,
|
|
241
|
+
revokedAt,
|
|
242
|
+
userAgent
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return normalized;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const loadAuthSessions = async () => {
|
|
249
|
+
await init();
|
|
250
|
+
try {
|
|
251
|
+
const content = await fs.readFile(AUTH_SESSIONS_FILE, 'utf-8');
|
|
252
|
+
const parsed = JSON.parse(content);
|
|
253
|
+
if (Array.isArray(parsed)) {
|
|
254
|
+
return normalizeAuthSessions(parsed);
|
|
255
|
+
}
|
|
256
|
+
return normalizeAuthSessions(parsed?.sessions);
|
|
257
|
+
} catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const saveAuthSessions = async (sessions) => {
|
|
263
|
+
await init();
|
|
264
|
+
const normalized = normalizeAuthSessions(sessions);
|
|
265
|
+
const payload = { sessions: normalized };
|
|
266
|
+
try {
|
|
267
|
+
await fs.writeFile(AUTH_SESSIONS_FILE, JSON.stringify(payload, null, 2));
|
|
268
|
+
} catch (e) {
|
|
269
|
+
console.error('[Persistence] Failed to save auth sessions:', e);
|
|
270
|
+
throw e;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
199
274
|
// --- ACP Agent Tab Persistence ---
|
|
200
275
|
|
|
201
276
|
function normalizeAgentTabs(tabs) {
|