10cf36ecee
后端(委托 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 必填校验
244 lines
8.9 KiB
Go
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
|
|
}
|