Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 日志级别。 |
Expand Down
10 changes: 9 additions & 1 deletion packages/server/src/middleware/user-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down Expand Up @@ -151,7 +159,7 @@ export function verifyUserJwt(token: string, secret: string, now = Date.now()):

export async function issueUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>): Promise<string> {
const secret = await getJwtSecret()
return signUserJwt(user, secret)
return signUserJwt(user, secret, Date.now(), userJwtExpiresSeconds())
}

export async function issueModelRunJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>): Promise<string> {
Expand Down
42 changes: 42 additions & 0 deletions tests/server/user-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading