@yeaft/webchat-agent 1.0.218 → 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.
@@ -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 { verifyToken, maybeRenewToken } from './auth.js';
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 authHeader = req.headers.authorization;
48
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
49
- return res.status(401).json({ error: 'Authentication required' });
50
- }
51
-
52
- const token = authHeader.replace('Bearer ', '');
53
- const result = verifyToken(token);
54
-
55
- if (!result.valid) {
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 surface it to the client via X-New-Token. The
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
- res.setHeader('X-New-Token', fresh);
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 { verifyToken } from '../auth/token.js';
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.json(result);
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.json(result);
71
+ sendLoginResult(req, res, result);
59
72
  });
60
73
 
61
74
  app.post('/api/auth/logout', (req, res) => {
62
- const token = req.headers.authorization?.replace('Bearer ', '');
63
- if (token) {
64
- logout(token);
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.json(result);
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.json(result);
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.json(result);
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 token = String(req.query.token || '');
181
- const ver = verifyToken(token);
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 token = String(req.query.token || '');
208
- const ver = verifyToken(token);
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 { verifyToken, generateSkipAuthSession } from './auth.js';
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 if (token) {
36
- const result = verifyToken(token);
37
- if (result.valid) {
38
- authenticated = true;
39
- sessionKey = result.sessionKey;
40
- username = result.username;
41
- role = result.role === 'admin' ? 'admin' : 'pro';
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.218"}
1
+ {"version":"1.0.220"}