Files
Commilitia-Drop/internal/brokerclient/client.go
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

244 lines
8.9 KiB
Go

// Package brokerclient is cdrop's thin client for the Auth Broker's HTTP API
// (path A). cdrop delegates session minting, revocation, and refresh to the broker
// rather than self-signing; this package wraps the three calls cdrop makes.
//
// Every call goes DIRECTLY to the broker's internal network address (e.g.
// http://broker-internal-ip:8080), never through the public reverse proxy — the broker's
// /internal/* endpoints 404 any request carrying X-Forwarded-For, which a request
// relayed through the edge always has. The Go HTTP client never sets that header, so
// a direct dial passes the guard; the shared X-Internal-Key is the second factor.
package brokerclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// ErrNotFound is returned for a broker 404. Revocation maps it to success
// (idempotent: an unknown / already-gone / not-ours sid needs no action).
var ErrNotFound = errors.New("brokerclient: not found")
// Client talks to one Auth Broker instance on behalf of one app.
type Client struct {
baseURL string
internalKey string
app string
httpClient *http.Client
}
// New builds a Client. baseURL is the broker's internal origin; internalKey is the
// shared secret for /internal/*; app is this app's key in the broker apps registry.
func New(baseURL, internalKey, app string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
internalKey: internalKey,
app: app,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// MintParams is one delegated-session request. The app vouches for UserID (the
// subject the broker just verified for the approver). Tier is appended to the app
// scope (app:<app>:<tier>); Meta is opaque correlation data (cdrop's device_id) the
// broker echoes back on /verify as X-Auth-Meta.
type MintParams struct {
UserID string
Tier string
AccessTTL int
RefreshTTL int
Sliding bool
Label string
Meta string
}
// Session is a freshly minted delegated session. SID is the broker's session id —
// cdrop stores it privately to revoke later; it never travels in a /verify header.
type Session struct {
SID string
Access string
Refresh string
AccessExpires int64
RefreshExpires int64
}
type mintReqWire struct {
UserID string `json:"user_id"`
App string `json:"app"`
Tier string `json:"tier,omitempty"`
AccessTTL int `json:"access_ttl,omitempty"`
RefreshTTL int `json:"refresh_ttl,omitempty"`
Sliding bool `json:"sliding,omitempty"`
Label string `json:"label,omitempty"`
Meta string `json:"meta,omitempty"`
}
type sessionWire struct {
ID string `json:"id"`
Access string `json:"access"`
Refresh string `json:"refresh"`
AccessExpires int64 `json:"access_expires"`
RefreshExpires int64 `json:"refresh_expires"`
}
// MintSession asks the broker to mint a session for the vouched user (POST
// /internal/sessions). The plaintext access + refresh come back exactly once.
func (c *Client) MintSession(ctx context.Context, p MintParams) (Session, error) {
body := mintReqWire{
UserID: p.UserID, App: c.app, Tier: p.Tier,
AccessTTL: p.AccessTTL, RefreshTTL: p.RefreshTTL, Sliding: p.Sliding,
Label: p.Label, Meta: p.Meta,
}
headers := map[string]string{"X-Internal-Key": c.internalKey}
var out sessionWire
if err := c.do(ctx, http.MethodPost, "/internal/sessions", headers, body, http.StatusOK, &out); err != nil {
return Session{}, err
}
return Session{
SID: out.ID, Access: out.Access, Refresh: out.Refresh,
AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires,
}, nil
}
// RevokeSession revokes a delegated session by sid (DELETE /internal/sessions/{sid}).
// X-Broker-App scopes the revoke to this app: the broker confirms the session belongs
// to cdrop before tearing it down (a mismatch 404s). A 404 is treated as success —
// revocation is idempotent.
func (c *Client) RevokeSession(ctx context.Context, sid string) error {
headers := map[string]string{
"X-Internal-Key": c.internalKey,
"X-Broker-App": c.app,
}
err := c.do(ctx, http.MethodDelete, "/internal/sessions/"+url.PathEscape(sid), headers, nil, http.StatusNoContent, nil)
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}
// Refreshed is the result of rolling a session's access token. The broker rotates
// the refresh credential, so the old one is now dead and the new one must be stored.
type Refreshed struct {
Access string
Refresh string
AccessExpires int64
RefreshExpires int64
}
// RefreshSession exchanges a refresh credential for a fresh access token and a
// rotated refresh credential (POST /refresh — a public broker endpoint, no internal
// key). ErrNotFound is not used here; an invalid/expired/rotated credential is a 401.
func (c *Client) RefreshSession(ctx context.Context, refresh string) (Refreshed, error) {
body := map[string]string{"refresh": refresh}
var out sessionWire
if err := c.do(ctx, http.MethodPost, "/refresh", nil, body, http.StatusOK, &out); err != nil {
return Refreshed{}, err
}
return Refreshed{
Access: out.Access, Refresh: out.Refresh,
AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires,
}, nil
}
// SessionInfo is one of the user's delegated device sessions, as returned by the broker's
// R1 listing (GET /internal/sessions?user_id=&app=). After the unified-session-model
// rework this IS the authoritative device list: cdrop overlays type (local cache), online
// (hub presence), and current (meta == this request's X-Auth-Meta) on top of it, and never
// keeps a parallel authoritative session table. SID is the broker session id — the revoke
// handle cdrop stores privately; it is never exposed to clients. Meta is cdrop's device_id
// (the session<->device join key). The broker returns only kind==machine sessions for this
// app and never includes credentials.
type SessionInfo struct {
SID string
Scope string
Label string
Meta string
CreatedAt int64
LastUsedAt int64
ExpiresAt int64
}
type sessionInfoWire struct {
ID string `json:"id"`
Scope string `json:"scope"`
Label string `json:"label"`
Meta string `json:"meta"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
}
// ListSessions lists the user's delegated device sessions for this app (R1). It hits the
// same internal trust boundary as MintSession — X-Internal-Key over a direct internal dial,
// so the request carries no X-Forwarded-For and passes the broker's guard. cdrop vouches
// for userID (the subject the edge already verified). The broker returns only kind==machine
// sessions for app==c.app; cdrop treats the result as the authoritative device list.
func (c *Client) ListSessions(ctx context.Context, userID string) ([]SessionInfo, error) {
q := url.Values{"user_id": {userID}, "app": {c.app}}
headers := map[string]string{"X-Internal-Key": c.internalKey}
var out struct {
Sessions []sessionInfoWire `json:"sessions"`
}
if err := c.do(ctx, http.MethodGet, "/internal/sessions?"+q.Encode(), headers, nil, http.StatusOK, &out); err != nil {
return nil, err
}
sessions := make([]SessionInfo, 0, len(out.Sessions))
for _, s := range out.Sessions {
sessions = append(sessions, SessionInfo{
SID: s.ID, Scope: s.Scope, Label: s.Label, Meta: s.Meta,
CreatedAt: s.CreatedAt, LastUsedAt: s.LastUsedAt, ExpiresAt: s.ExpiresAt,
})
}
return sessions, nil
}
// do issues one request and decodes a successful response into out (when non-nil).
// A response whose status differs from wantStatus is an error; 404 maps to
// ErrNotFound so callers can special-case it.
func (c *Client) do(ctx context.Context, method, path string, headers map[string]string, body any, wantStatus int, out any) error {
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("brokerclient: marshal body: %w", err)
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("brokerclient: build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("brokerclient: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode != wantStatus {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("brokerclient: %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("brokerclient: decode response: %w", err)
}
}
return nil
}