From 55a2294e0057896e751afb090f77e2cfde7f2367 Mon Sep 17 00:00:00 2001 From: qiont <1679242037@qq.com> Date: Thu, 18 Jun 2026 02:33:51 +0800 Subject: [PATCH 1/2] feat: configure user JWT expiry --- packages/server/src/middleware/user-auth.ts | 7 ++++++- tests/server/user-auth.test.ts | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/server/src/middleware/user-auth.ts b/packages/server/src/middleware/user-auth.ts index c45a093cb..ed5400a8a 100644 --- a/packages/server/src/middleware/user-auth.ts +++ b/packages/server/src/middleware/user-auth.ts @@ -43,6 +43,11 @@ const JWT_AUDIENCE = 'hermes-web-ui' const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30 export const MODEL_RUN_EXPIRES_SECONDS = 60 * 60 +function userJwtExpiresSeconds(env: NodeJS.ProcessEnv = process.env): number { + const configured = Number.parseInt(String(env.AUTH_JWT_EXPIRES_SECONDS || '').trim(), 10) + return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_EXPIRES_SECONDS +} + function base64UrlJson(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString('base64url') } @@ -151,7 +156,7 @@ export function verifyUserJwt(token: string, secret: string, now = Date.now()): export async function issueUserJwt(user: Pick): Promise { const secret = await getJwtSecret() - return signUserJwt(user, secret) + return signUserJwt(user, secret, Date.now(), userJwtExpiresSeconds()) } export async function issueModelRunJwt(user: Pick): Promise { diff --git a/tests/server/user-auth.test.ts b/tests/server/user-auth.test.ts index af293541c..151bb3d8c 100644 --- a/tests/server/user-auth.test.ts +++ b/tests/server/user-auth.test.ts @@ -266,6 +266,26 @@ describe('user auth tables and middleware', () => { expect(userCount.count).toBe(1) }) + it('uses AUTH_JWT_EXPIRES_SECONDS for issued login JWTs', async () => { + vi.stubEnv('AUTH_JWT_EXPIRES_SECONDS', '7200') + const { auth } = await initUsers() + const now = 1_000_000 + const spy = vi.spyOn(Date, 'now').mockReturnValue(now) + + const token = await auth.issueUserJwt({ id: 1, username: 'admin', role: 'super_admin' }) + const payload = auth.verifyUserJwt(token, 'test-secret', now) + + expect(payload).toMatchObject({ + sub: '1', + username: 'admin', + role: 'super_admin', + iat: 1000, + exp: 1000 + 7200, + }) + + spy.mockRestore() + }) + it('signs and verifies user JWTs', async () => { const { auth } = await initUsers() const token = auth.signUserJwt({ id: 1, username: 'admin', role: 'super_admin' }, 'secret', 1000) From 09fa1c0c599c615ed562028113b0a8b8e359b96f Mon Sep 17 00:00:00 2001 From: Qiang Han <70218387+qiontoo@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:14:16 +0800 Subject: [PATCH 2/2] fix: strictly parse configurable login jwt expiry --- README.md | 1 + README_zh.md | 1 + packages/server/src/middleware/user-auth.ts | 7 +++- tests/server/user-auth.test.ts | 42 ++++++++++++++++----- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 84d11fbd8..3ba720785 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ These variables configure Hermes Web UI, its local Hermes runtime integration, a | `CORS_ORIGINS` | same host only | Comma- or space-separated cross-origin allowlist for HTTP, Socket.IO, and WebSocket requests. Set `*` only when you intentionally need legacy wildcard CORS. | | `AUTH_TOKEN` | auto-generated | Explicit bearer token. If unset, Web UI creates one under `HERMES_WEB_UI_HOME`. | | `AUTH_JWT_SECRET` | `AUTH_TOKEN` | JWT signing secret override for username/password sessions. | +| `AUTH_JWT_EXPIRES_SECONDS` | `2592000` | Login JWT lifetime in seconds. Must be a positive integer; invalid values fall back to the 30-day default. | | `PROFILE` | `default` | Startup/default Hermes profile. Runtime requests use the profile selected by the frontend and authorized for the current account. | | `LOG_LEVEL` | `info` | Server log level. | | `BRIDGE_LOG_LEVEL` | `$LOG_LEVEL` or `info` | Bridge log level. | diff --git a/README_zh.md b/README_zh.md index a99e0215e..47fbe7b39 100644 --- a/README_zh.md +++ b/README_zh.md @@ -299,6 +299,7 @@ Web UI 启动后端聊天能力时,会优先使用包含 `run_agent.py` 的源 | `CORS_ORIGINS` | 仅同 host | HTTP、Socket.IO、WebSocket 跨源 allowlist,支持逗号或空格分隔。只有明确需要旧版 wildcard CORS 时才设置为 `*`。 | | `AUTH_TOKEN` | 自动生成 | 显式指定 bearer token。未设置时,Web UI 会在 `HERMES_WEB_UI_HOME` 下自动生成。 | | `AUTH_JWT_SECRET` | `AUTH_TOKEN` | 用户名/密码会话的 JWT 签名密钥覆盖。 | +| `AUTH_JWT_EXPIRES_SECONDS` | `2592000` | 登录 JWT 有效期,单位为秒。必须是正整数;非法值会回退到默认 30 天。 | | `PROFILE` | `default` | 启动/默认 Hermes profile。运行时请求使用前端当前选择且当前账号有权限访问的 Profile。 | | `LOG_LEVEL` | `info` | Server 日志级别。 | | `BRIDGE_LOG_LEVEL` | `$LOG_LEVEL` 或 `info` | Bridge 日志级别。 | diff --git a/packages/server/src/middleware/user-auth.ts b/packages/server/src/middleware/user-auth.ts index ed5400a8a..c1958d408 100644 --- a/packages/server/src/middleware/user-auth.ts +++ b/packages/server/src/middleware/user-auth.ts @@ -44,8 +44,11 @@ const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30 export const MODEL_RUN_EXPIRES_SECONDS = 60 * 60 function userJwtExpiresSeconds(env: NodeJS.ProcessEnv = process.env): number { - const configured = Number.parseInt(String(env.AUTH_JWT_EXPIRES_SECONDS || '').trim(), 10) - return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_EXPIRES_SECONDS + const rawValue = String(env.AUTH_JWT_EXPIRES_SECONDS || '').trim() + if (!/^\d+$/.test(rawValue)) return DEFAULT_EXPIRES_SECONDS + + const configured = Number(rawValue) + return Number.isSafeInteger(configured) && configured > 0 ? configured : DEFAULT_EXPIRES_SECONDS } function base64UrlJson(value: unknown): string { diff --git a/tests/server/user-auth.test.ts b/tests/server/user-auth.test.ts index 151bb3d8c..d058bd6f6 100644 --- a/tests/server/user-auth.test.ts +++ b/tests/server/user-auth.test.ts @@ -272,18 +272,40 @@ describe('user auth tables and middleware', () => { const now = 1_000_000 const spy = vi.spyOn(Date, 'now').mockReturnValue(now) - const token = await auth.issueUserJwt({ id: 1, username: 'admin', role: 'super_admin' }) - const payload = auth.verifyUserJwt(token, 'test-secret', now) + try { + const token = await auth.issueUserJwt({ id: 1, username: 'admin', role: 'super_admin' }) + const payload = auth.verifyUserJwt(token, 'test-secret', now) + + expect(payload).toMatchObject({ + sub: '1', + username: 'admin', + role: 'super_admin', + iat: 1000, + exp: 1000 + 7200, + }) + } finally { + spy.mockRestore() + } + }) - expect(payload).toMatchObject({ - sub: '1', - username: 'admin', - role: 'super_admin', - iat: 1000, - exp: 1000 + 7200, - }) + it('falls back to the default login JWT expiry for invalid AUTH_JWT_EXPIRES_SECONDS values', async () => { + const { auth } = await initUsers() + const now = 1_000_000 + const spy = vi.spyOn(Date, 'now').mockReturnValue(now) + const defaultExpiresSeconds = 60 * 60 * 24 * 30 - spy.mockRestore() + try { + for (const value of ['7200abc', '30 days', '0', '-1', '1.5', '', String(Number.MAX_SAFE_INTEGER + 1)]) { + vi.stubEnv('AUTH_JWT_EXPIRES_SECONDS', value) + + const token = await auth.issueUserJwt({ id: 1, username: 'admin', role: 'super_admin' }) + const payload = auth.verifyUserJwt(token, 'test-secret', now) + + expect(payload?.exp).toBe(1000 + defaultExpiresSeconds) + } + } finally { + spy.mockRestore() + } }) it('signs and verifies user JWTs', async () => {