@yeaft/webchat-agent 1.0.219 → 1.0.220
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/local-runtime/server/api.js +18 -15
- package/local-runtime/server/auth/request-auth.js +82 -0
- package/local-runtime/server/index.js +1 -1
- package/local-runtime/server/routes/auth-routes.js +30 -16
- package/local-runtime/server/ws-client.js +12 -11
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +78 -77
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
|
@@ -2,7 +2,8 @@ import { readFileSync } from 'fs';
|
|
|
2
2
|
import { dirname, join } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { CONFIG } from './config.js';
|
|
5
|
-
import {
|
|
5
|
+
import { maybeRenewToken } from './auth.js';
|
|
6
|
+
import { authenticateRequest, setSessionCookie } from './auth/request-auth.js';
|
|
6
7
|
import { registerAuthRoutes } from './routes/auth-routes.js';
|
|
7
8
|
import { registerInvitationRoutes } from './routes/invitation-routes.js';
|
|
8
9
|
import { registerUserRoutes } from './routes/user-routes.js';
|
|
@@ -44,27 +45,29 @@ function requireAuth(req, res, next) {
|
|
|
44
45
|
return next();
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
48
|
+
const result = authenticateRequest({
|
|
49
|
+
authorizationHeader: req.headers.authorization,
|
|
50
|
+
cookieHeader: req.headers.cookie,
|
|
51
|
+
});
|
|
52
|
+
if (!result) {
|
|
53
|
+
const hasCredential = !!req.headers.authorization || !!req.headers.cookie;
|
|
54
|
+
return res.status(401).json({
|
|
55
|
+
error: hasCredential ? 'Invalid or expired token' : 'Authentication required',
|
|
56
|
+
});
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Sliding renewal: if the token is in the last `jwtRenewThresholdMs` of its
|
|
60
|
-
// life, mint a fresh one and
|
|
61
|
-
// browser fetch wrapper picks this up and swaps localStorage transparently.
|
|
60
|
+
// life, mint a fresh one and update both browser auth channels.
|
|
62
61
|
// Skip renewal for non-session tokens (temp/totp/totp-setup) — those have
|
|
63
62
|
// their own short-lived semantics and must not be promoted to full sessions.
|
|
64
63
|
if (!result.type) {
|
|
65
|
-
const fresh = maybeRenewToken(token, result.exp, result.username);
|
|
64
|
+
const fresh = maybeRenewToken(result.token, result.exp, result.username);
|
|
66
65
|
if (fresh) {
|
|
67
|
-
|
|
66
|
+
setSessionCookie(req, res, fresh);
|
|
67
|
+
if (result.source !== 'cookie') res.setHeader('X-New-Token', fresh);
|
|
68
|
+
} else if (result.source !== 'cookie') {
|
|
69
|
+
// Repair the cookie for existing bearer-only sessions after deployment.
|
|
70
|
+
setSessionCookie(req, res, result.token);
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import { verifyToken } from './token.js';
|
|
3
|
+
|
|
4
|
+
export const SESSION_COOKIE_NAME = 'yeaft_session';
|
|
5
|
+
|
|
6
|
+
function readBearerToken(authorizationHeader) {
|
|
7
|
+
if (typeof authorizationHeader !== 'string' || !authorizationHeader.startsWith('Bearer ')) return null;
|
|
8
|
+
const token = authorizationHeader.slice('Bearer '.length).trim();
|
|
9
|
+
return token || null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readCookieToken(cookieHeader) {
|
|
13
|
+
if (typeof cookieHeader !== 'string' || !cookieHeader) return null;
|
|
14
|
+
for (const part of cookieHeader.split(';')) {
|
|
15
|
+
const separator = part.indexOf('=');
|
|
16
|
+
if (separator < 0) continue;
|
|
17
|
+
const name = part.slice(0, separator).trim();
|
|
18
|
+
if (name !== SESSION_COOKIE_NAME) continue;
|
|
19
|
+
const value = part.slice(separator + 1).trim();
|
|
20
|
+
if (!value) return null;
|
|
21
|
+
try {
|
|
22
|
+
return decodeURIComponent(value);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve one authenticated browser session for HTTP and WebSocket requests.
|
|
32
|
+
* Explicit bearer/query tokens remain the compatibility path; a valid
|
|
33
|
+
* HttpOnly cookie is the browser fallback when that token is absent or stale.
|
|
34
|
+
*/
|
|
35
|
+
export function authenticateRequest({ authorizationHeader, cookieHeader, queryToken } = {}) {
|
|
36
|
+
const candidates = [
|
|
37
|
+
['bearer', readBearerToken(authorizationHeader)],
|
|
38
|
+
['query', typeof queryToken === 'string' && queryToken ? queryToken : null],
|
|
39
|
+
['cookie', readCookieToken(cookieHeader)],
|
|
40
|
+
];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
for (const [source, token] of candidates) {
|
|
43
|
+
if (!token || seen.has(token)) continue;
|
|
44
|
+
seen.add(token);
|
|
45
|
+
const result = verifyToken(token);
|
|
46
|
+
if (result.valid) return { ...result, token, source };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requestIsSecure(req) {
|
|
52
|
+
if (req?.secure) return true;
|
|
53
|
+
const forwardedProto = req?.headers?.['x-forwarded-proto'];
|
|
54
|
+
return typeof forwardedProto === 'string' && forwardedProto.split(',')[0].trim() === 'https';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sessionCookieOptions(req, token) {
|
|
58
|
+
const decoded = token ? jwt.decode(token) : null;
|
|
59
|
+
const remainingMs = decoded?.exp ? Math.max(0, decoded.exp * 1000 - Date.now()) : undefined;
|
|
60
|
+
return {
|
|
61
|
+
httpOnly: true,
|
|
62
|
+
sameSite: 'lax',
|
|
63
|
+
secure: requestIsSecure(req),
|
|
64
|
+
path: '/',
|
|
65
|
+
...(remainingMs !== undefined && { maxAge: remainingMs }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function setSessionCookie(req, res, token) {
|
|
70
|
+
if (!token || typeof res?.cookie !== 'function') return;
|
|
71
|
+
res.cookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(req, token));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function clearSessionCookie(req, res) {
|
|
75
|
+
if (typeof res?.clearCookie !== 'function') return;
|
|
76
|
+
res.clearCookie(SESSION_COOKIE_NAME, {
|
|
77
|
+
httpOnly: true,
|
|
78
|
+
sameSite: 'lax',
|
|
79
|
+
secure: requestIsSecure(req),
|
|
80
|
+
path: '/',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -172,7 +172,7 @@ wss.on('connection', (ws, req) => {
|
|
|
172
172
|
if (clientType === 'agent') {
|
|
173
173
|
handleAgentConnection(ws, url);
|
|
174
174
|
} else if (clientType === 'web') {
|
|
175
|
-
handleWebConnection(ws, url);
|
|
175
|
+
handleWebConnection(ws, url, req);
|
|
176
176
|
} else {
|
|
177
177
|
ws.close(1008, 'Invalid client type');
|
|
178
178
|
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { CONFIG, isEmailConfigured, isTotpEnabled, isAadEnabled, getEnabledSsoProviders, getUserByUsername } from '../config.js';
|
|
2
2
|
import { loginStep1, loginStep2, logout, verifyTotpStep, completeTotpSetup, register, loginWithAad } from '../auth.js';
|
|
3
3
|
import { buildAuthorizeUrl, handleCallback, peekStateMode, storePendingResult, consumePendingResult } from '../auth/oauth-flow.js';
|
|
4
|
-
import {
|
|
4
|
+
import { authenticateRequest, clearSessionCookie, setSessionCookie } from '../auth/request-auth.js';
|
|
5
5
|
import { requestPasswordReset, verifyPasswordReset } from '../auth/password-reset.js';
|
|
6
6
|
import { identityDb, userDb } from '../database.js';
|
|
7
7
|
|
|
8
|
+
function sendLoginResult(req, res, result) {
|
|
9
|
+
if (result?.success && result.token) setSessionCookie(req, res, result.token);
|
|
10
|
+
return res.json(result);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function authenticateBrowserRequest(req, queryToken = undefined) {
|
|
14
|
+
return authenticateRequest({
|
|
15
|
+
authorizationHeader: req.headers.authorization,
|
|
16
|
+
cookieHeader: req.headers.cookie,
|
|
17
|
+
queryToken,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
/**
|
|
9
22
|
* Register authentication-related API routes.
|
|
10
23
|
*/
|
|
@@ -39,7 +52,7 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
39
52
|
}
|
|
40
53
|
try {
|
|
41
54
|
const result = await loginStep1(username, password);
|
|
42
|
-
res
|
|
55
|
+
sendLoginResult(req, res, result);
|
|
43
56
|
} catch (err) {
|
|
44
57
|
console.error('Login error:', err);
|
|
45
58
|
res.status(500).json({ success: false, error: 'Internal server error' });
|
|
@@ -55,14 +68,15 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
55
68
|
return res.status(400).json({ success: false, error: 'Token and code are required' });
|
|
56
69
|
}
|
|
57
70
|
const result = loginStep2(tempToken, code);
|
|
58
|
-
res
|
|
71
|
+
sendLoginResult(req, res, result);
|
|
59
72
|
});
|
|
60
73
|
|
|
61
74
|
app.post('/api/auth/logout', (req, res) => {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
const bearerSession = authenticateRequest({ authorizationHeader: req.headers.authorization });
|
|
76
|
+
const cookieSession = authenticateRequest({ cookieHeader: req.headers.cookie });
|
|
77
|
+
const tokens = new Set([bearerSession?.token, cookieSession?.token].filter(Boolean));
|
|
78
|
+
for (const token of tokens) logout(token);
|
|
79
|
+
clearSessionCookie(req, res);
|
|
66
80
|
res.json({ success: true });
|
|
67
81
|
});
|
|
68
82
|
|
|
@@ -76,7 +90,7 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
76
90
|
}
|
|
77
91
|
try {
|
|
78
92
|
const result = await verifyTotpStep(tempToken, totpCode);
|
|
79
|
-
res
|
|
93
|
+
sendLoginResult(req, res, result);
|
|
80
94
|
} catch (err) {
|
|
81
95
|
console.error('TOTP verification error:', err);
|
|
82
96
|
res.status(500).json({ success: false, error: 'Internal server error' });
|
|
@@ -90,7 +104,7 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
90
104
|
}
|
|
91
105
|
try {
|
|
92
106
|
const result = await completeTotpSetup(setupToken, totpCode);
|
|
93
|
-
res
|
|
107
|
+
sendLoginResult(req, res, result);
|
|
94
108
|
} catch (err) {
|
|
95
109
|
console.error('TOTP setup error:', err);
|
|
96
110
|
res.status(500).json({ success: false, error: 'Internal server error' });
|
|
@@ -156,7 +170,7 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
156
170
|
}
|
|
157
171
|
try {
|
|
158
172
|
const result = await loginWithAad(idToken);
|
|
159
|
-
res
|
|
173
|
+
sendLoginResult(req, res, result);
|
|
160
174
|
} catch (err) {
|
|
161
175
|
console.error('AAD login error:', err);
|
|
162
176
|
res.status(500).json({ success: false, error: 'Internal server error' });
|
|
@@ -177,9 +191,8 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
177
191
|
// here directly so we read the token from the `Authorization` query
|
|
178
192
|
// parameter (frontend hands it over explicitly because top-level navigation
|
|
179
193
|
// can't set headers).
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
if (!ver.valid) {
|
|
194
|
+
const ver = authenticateBrowserRequest(req, String(req.query.token || ''));
|
|
195
|
+
if (!ver) {
|
|
183
196
|
return res.status(401).send('Authentication required to bind an identity');
|
|
184
197
|
}
|
|
185
198
|
const u = userDb.getByUsername(ver.username);
|
|
@@ -204,9 +217,8 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
204
217
|
const intent = req.query.intent === 'bind' ? 'bind' : 'login';
|
|
205
218
|
let userId = null;
|
|
206
219
|
if (intent === 'bind') {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
if (!ver.valid) return res.status(401).json({ success: false, error: 'auth required' });
|
|
220
|
+
const ver = authenticateBrowserRequest(req, String(req.query.token || ''));
|
|
221
|
+
if (!ver) return res.status(401).json({ success: false, error: 'auth required' });
|
|
210
222
|
const u = userDb.getByUsername(ver.username);
|
|
211
223
|
if (!u) return res.status(401).json({ success: false, error: 'user not found' });
|
|
212
224
|
userId = u.id;
|
|
@@ -226,6 +238,7 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
226
238
|
const r = consumePendingResult(req.params.state);
|
|
227
239
|
if (!r) return res.json({ status: 'pending' });
|
|
228
240
|
if (r.kind === 'login') {
|
|
241
|
+
setSessionCookie(req, res, r.token);
|
|
229
242
|
return res.json({ status: 'login', token: r.token, sessionKey: r.sessionKey, role: r.role });
|
|
230
243
|
}
|
|
231
244
|
if (r.kind === 'bind') {
|
|
@@ -261,6 +274,7 @@ export function registerAuthRoutes(app, { requireAuth, checkRateLimit }) {
|
|
|
261
274
|
}
|
|
262
275
|
|
|
263
276
|
if (result.kind === 'login') {
|
|
277
|
+
setSessionCookie(req, res, result.token);
|
|
264
278
|
const params = new URLSearchParams({
|
|
265
279
|
token: result.token,
|
|
266
280
|
sessionKey: result.sessionKey,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { WebSocket } from 'ws';
|
|
3
3
|
import { CONFIG } from './config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { generateSkipAuthSession } from './auth.js';
|
|
5
|
+
import { authenticateRequest } from './auth/request-auth.js';
|
|
5
6
|
import { encodeKey } from './encryption.js';
|
|
6
7
|
import { userDb } from './database.js';
|
|
7
8
|
import { agents, webClients, isHeartbeatMessageType, trackRequest } from './context.js';
|
|
@@ -15,9 +16,8 @@ import { handleClientMisc } from './handlers/client-misc.js';
|
|
|
15
16
|
import { clearWorkCenterRequestsForClient, handleClientWorkCenter } from './handlers/client-work-center.js';
|
|
16
17
|
import { recordPerfTraceEvent } from './perf-trace.js';
|
|
17
18
|
|
|
18
|
-
export function handleWebConnection(ws, url) {
|
|
19
|
+
export function handleWebConnection(ws, url, req = {}) {
|
|
19
20
|
const clientId = randomUUID();
|
|
20
|
-
const token = url.searchParams.get('token');
|
|
21
21
|
|
|
22
22
|
let authenticated = false;
|
|
23
23
|
let sessionKey = null;
|
|
@@ -32,14 +32,15 @@ export function handleWebConnection(ws, url) {
|
|
|
32
32
|
sessionKey = session.sessionKey;
|
|
33
33
|
username = 'dev-user';
|
|
34
34
|
role = 'admin';
|
|
35
|
-
} else
|
|
36
|
-
const result =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
} else {
|
|
36
|
+
const result = authenticateRequest({
|
|
37
|
+
cookieHeader: req.headers?.cookie,
|
|
38
|
+
queryToken: url.searchParams.get('token'),
|
|
39
|
+
});
|
|
40
|
+
authenticated = !!result;
|
|
41
|
+
sessionKey = result?.sessionKey || null;
|
|
42
|
+
username = result?.username || null;
|
|
43
|
+
role = result?.role === 'admin' ? 'admin' : (result ? 'pro' : null);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
// 获取或创建用户
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.220"}
|