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 c45a093cb..c1958d408 100644 --- a/packages/server/src/middleware/user-auth.ts +++ b/packages/server/src/middleware/user-auth.ts @@ -43,6 +43,14 @@ 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 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 { return Buffer.from(JSON.stringify(value)).toString('base64url') } @@ -151,7 +159,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..d058bd6f6 100644 --- a/tests/server/user-auth.test.ts +++ b/tests/server/user-auth.test.ts @@ -266,6 +266,48 @@ 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) + + 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() + } + }) + + 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 + + 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 () => { const { auth } = await initUsers() const token = auth.signUserJwt({ id: 1, username: 'admin', role: 'super_admin' }, 'secret', 1000)