Why: the auth middleware prefers Bearer keys over the session cookie, so a stale key left in localStorage shadows valid cookie sessions and every request after password login returns 401 (observed at http://192.168.3.2:8788 on 2026-10-11). Evidence: modules/frontend vitest 19 files / 88 tests green; auth.test.ts covers beginSession clearing the stored Bearer before any fetch. Impact: password logins switch to the HttpOnly cookie session cleanly; API-key logins unchanged. Refs: task card feature-login-clear-stale-key in Agent-System-Dev.
48 lines
2.0 KiB
TypeScript
48 lines
2.0 KiB
TypeScript
// API key 凭证模块:登录门存取(localStorage 优先,无 DOM 环境回退内存)。
|
||
// 服务端开启认证时(PG 档默认),所有请求带 Bearer 头;SSE/静态资源链接走 ?key=。
|
||
const KEY = 'agent-system.apiKey'
|
||
|
||
interface KV { getItem(k: string): string | null; setItem(k: string, v: string): void; removeItem(k: string): void }
|
||
|
||
function pickStorage(): KV {
|
||
try {
|
||
if (typeof localStorage !== 'undefined') return localStorage
|
||
} catch { /* 隐私模式等 */ }
|
||
const mem = new Map<string, string>()
|
||
return {
|
||
getItem: k => mem.get(k) ?? null,
|
||
setItem: (k, v) => void mem.set(k, v),
|
||
removeItem: k => void mem.delete(k),
|
||
}
|
||
}
|
||
|
||
const storage = pickStorage()
|
||
|
||
export const auth = {
|
||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||
clear() { storage.removeItem(KEY) },
|
||
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||
beginSession() { this.clear() },
|
||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||
const key = this.apiKey()
|
||
if (!key) return fetch(input, init)
|
||
const headers = new Headers(init?.headers ?? {})
|
||
headers.set('Authorization', `Bearer ${key}`)
|
||
return fetch(input, { ...init, headers })
|
||
},
|
||
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
|
||
logout() {
|
||
this.clear()
|
||
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
||
},
|
||
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
|
||
withKey(url: string): string {
|
||
const key = this.apiKey()
|
||
if (!key) return url
|
||
return url + (url.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(key)
|
||
},
|
||
}
|