tabminal 3.0.28 → 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 +1 -1
- package/public/app.js +42 -20
- package/public/modules/url-auth.js +84 -0
- package/public/styles.css +3 -3
- package/src/auth.mjs +229 -44
- package/src/server.mjs +27 -6
- package/src/terminal-session.mjs +3 -1
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -19,7 +19,7 @@ const {
|
|
|
19
19
|
buildAuthStateStorageKey,
|
|
20
20
|
makeSessionKey,
|
|
21
21
|
splitSessionKey,
|
|
22
|
-
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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(
|
|
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
|
-
|
|
10093
|
-
|
|
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:
|
|
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:
|
|
548
|
-
right:
|
|
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
|
|
261
|
+
function verifyAuthChallengeResponse(challenge, response) {
|
|
152
262
|
if (isLocked) {
|
|
153
263
|
return { success: false, locked: true };
|
|
154
264
|
}
|
|
155
|
-
const normalized = typeof
|
|
156
|
-
?
|
|
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,
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
190
|
-
? body.
|
|
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
|
|
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({
|
|
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) => {
|
package/src/terminal-session.mjs
CHANGED
|
@@ -1001,7 +1001,9 @@ export class TerminalSession {
|
|
|
1001
1001
|
});
|
|
1002
1002
|
|
|
1003
1003
|
// Auto-Fix: If command failed, ask AI for help
|
|
1004
|
-
|
|
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) {
|