Files
Commilitia-Drop/web/src/net/api.ts
T
admin 10cf36ecee 鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移
后端(委托 Auth Broker,路径 A):
- 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest()
- internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等

统一会话模型“委派设备会话”(Delegated Device Sessions):
- 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表
- 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备
- 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定
- 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行

Web 前端:
- 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh
- 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环
- 去 OIDC PKCE / step-up(删 oauth.callback / stepUp)

桌面客户端(Wails):
- loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置

iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥):
- 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库)
- 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId

实时 / 健壮性:
- presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线)
- Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热)

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
2026-06-26 22:10:19 +08:00

114 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useAppStore } from "../store";
import { refreshTokens, forceLogout } from "../features/auth/auth";
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
import { toAsciiDeviceName } from "../utils/format";
import { t } from "../i18n";
// /api 请求的来源前缀:桌面 Windows 注入 loopback 代理地址(绕开会缓冲 SSE 的
// custom scheme),macOS / 浏览器为空(同源相对 URL)。读一次注入值即定。
const API_BASE = readInjectedApiBase();
// 客户端类型(browser / macos / windows / linux),随每个请求发给后端登记设备。
const DEVICE_TYPE = readInjectedDeviceType();
function withBase(input: RequestInfo): RequestInfo
{
if (typeof input === "string" && API_BASE && input.startsWith("/"))
{
return API_BASE + input;
}
return input;
}
// Centralised fetch wrapper:
// - in dev, attaches Authorization: Bearer <VITE_CDROP_DEV_TOKEN> + X-Dev-User
// - in prod, attaches Authorization: Bearer <accessToken from store>
// - always sets X-Device-Name from the store
// - in prod, on a 401 response transparently refreshes the access token via
// /api/auth/refresh and retries once
// - in prod, ONLY if the server authoritatively confirms the session is gone
// (refresh → HTTP 401), forces a local logout so the app returns to login
//
// Returns the fetch Response untouched so callers can drive streaming bodies
// (SSE, relay GET stream) themselves.
//
// State is cleared ONLY on server-confirmed credential invalidation. Short-term
// failures never clear it: a transient refresh response (5xx / same-origin 403)
// yields "transient", and a network error during refresh THROWS out of here —
// both keep the session, leaving the original 401 for the caller as a retryable
// error. So a flaky network or a server blip can't log the user out; only a
// genuine revoke/expire (refresh → 401) does.
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
let r = await doFetch(input, init);
if (r.status === 401 && useAppStore.getState().authMode === "prod")
{
const outcome = await refreshTokens();
if (outcome === "refreshed")
{
r = await doFetch(input, init);
}
else if (outcome === "invalid")
{
// 服务端确证会话已失效(/auth/refresh 返回 401,已被吊销 / 过期)→
// 清空本地登录态,由 __root 守卫把界面送回登录页。返回这次 401 让调用
// 方照常收尾。"transient"5xx / 网络抖动)不进此分支:保留登录态。
forceLogout();
}
}
return r;
}
async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{
const state = useAppStore.getState();
const headers = new Headers(init?.headers ?? {});
if (state.authMode === "dev")
{
const devToken = import.meta.env.VITE_CDROP_DEV_TOKEN;
if (!devToken)
{
throw new Error(t("errors.devTokenMissing"));
}
headers.set("Authorization", `Bearer ${devToken}`);
if (state.user?.id)
{
headers.set("X-Dev-User", state.user.id);
}
}
else
{
// A QR-paired device sends its broker access token as Bearer. A global-SSO
// browser has none — the broker's domain cookie carries identity, so we send
// no Authorization header and let the edge inject X-Auth from that cookie.
if (state.accessToken)
{
headers.set("Authorization", `Bearer ${state.accessToken}`);
}
}
if (state.selfDeviceName)
{
// Device names are ASCII-only; coerce here so a legacy non-ASCII name
// can't make Headers.set throw (the server sanitises identically).
const safeName = toAsciiDeviceName(state.selfDeviceName);
if (safeName) { headers.set("X-Device-Name", safeName); }
}
headers.set("X-Device-Type", DEVICE_TYPE);
return fetch(withBase(input), { ...init, headers });
}
export async function apiJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T>
{
const r = await apiFetch(input, init);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
if (r.status === 204) { return undefined as T; }
return ( await r.json() ) as T;
}