Files
Commilitia-Drop/internal/brokerclient/client.go
T
admin a2cad11224 后台/会话三诉求:子会话(一台设备一会话)+ 离线消息队列 + 被登出推送 + 后台唤醒层
- Req 1 子会话(iOS 多进程在用户视角=一台设备一会话,内部多条独立刷新逻辑会话):brokerclient 加 MintParams.Sub + SessionInfo.Sub + RevokeDeviceSessions(user,meta) 级联;device-session sub!="" 挂在主 device_id 之下、走 tier=clipboard 且不建第二个 device 行;handleSessionsList 按 meta 归并、隐藏 sub!=""(但保留“仅控件会话存活”的设备,不简单丢弃);revokeDevice 改走 meta 级联(移除设备连带吊控件子会话)。iOS provisionWidgetSessionIfNeeded 改用主 device_id + sub="widget"(弃用独立 dev_widget),控件令牌仍隔离持有、独立刷新——不碰引擎主会话,#7 隔离不变。蓝本 auth/docs/子会话方案.md(cdrop 提案 + Broker 评审接受 + cdrop 确认 6 点:用 sub、R1 cdrop 归并、scope 复用 tier=clipboard、级联 DELETE …?meta=)。
- Req 2a 离线消息队列:新增 pending_messages 表(0001_init + sqlc 查询);message.go 收件设备离线即入队(无论是否配推送都入队,恒 202),修“离线消息只随推送横幅一闪、不入收件列表”;GET /api/messages/pending 取即删(DELETE..RETURNING,单次投递);web hub.ts onOpen 每次 SSE 连接 / 重连即拉取补收、逐条 addMessage(全端受益,iOS 经桥推原生 + 累积未读);复用 login-request reaper 清 TTL。
- Req 3 被登出推送:push 加 KindSessionRevoked + 本地化文案,apns/web 双通道;revokeDevice 加 notifyRevoked 参(跨端 revoke=true、自登出=false),跨端移除时推送告知被踢设备;iOS AppDelegate 收 session:revoked 即清 Keychain 主会话 + 控件会话、发 .cdropSessionRevoked,前台经 AppRoot 即时回登录页、关闭态下次启动即登出。
- #6 后台唤醒层:apns apsEnvelope 加 content-available:1(服务端有消息时既弹可点横幅又短暂后台唤醒);iOS DeviceItem 加 Codable、presence 快照持久化(冷启即时显示设备列表,缓解“长时间重连”观感,SSE 一连即整组替换自校正);控件会话回前台补铸(scenePhase active)+ content-available 唤醒时刷新隔离的控件会话(剪贴板保活,绝不碰引擎主会话以免 #7 回归)。
- 测试:qr_test mock broker 加 sub 幂等键 + 级联吊销端点;bootstrap_test 期望表集加 pending_messages。
2026-06-27 17:42:53 +08:00

275 lines
11 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
// Sub is the intra-device session discriminator under one Meta (device): "" = the main
// session, a non-empty value (e.g. "widget") a subordinate session that shares the device
// identity but holds its own independently-refreshed tokens. The broker keys R2 idempotency
// on (user, app, meta, sub), so a sub session coexists with the main instead of rotating it.
Sub 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"`
Sub string `json:"sub,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, Sub: p.Sub,
}
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
}
// RevokeDeviceSessions cascade-revokes every session under one device meta — the main session
// and any subordinate (e.g. clipboard widget) sessions sharing that device
// (DELETE /internal/sessions?user_id=&app=&meta=). cdrop calls this when removing a device or
// logging it out, so no orphan sub-session survives to keep reading the clipboard. Returns the
// count revoked; revoked:0 (nothing to revoke) is idempotent success, not an error.
func (c *Client) RevokeDeviceSessions(ctx context.Context, userID, meta string) (int, error) {
q := url.Values{"user_id": {userID}, "app": {c.app}, "meta": {meta}}
headers := map[string]string{
"X-Internal-Key": c.internalKey,
"X-Broker-App": c.app,
}
var out struct {
Revoked int `json:"revoked"`
}
// The cascade endpoint always returns 200 {revoked:N} (revoked:0 when nothing matched), never
// 404 — so a 404 here means the endpoint is absent (a broker predating sub support) and must
// surface as an error rather than masquerade as success and silently leak the session.
if err := c.do(ctx, http.MethodDelete, "/internal/sessions?"+q.Encode(), headers, nil, http.StatusOK, &out); err != nil {
return 0, err
}
return out.Revoked, nil
}
// 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
Sub string // intra-device discriminator: "" = main; non-empty = subordinate (cdrop hides it)
Scope string
Label string
Meta string
CreatedAt int64
LastUsedAt int64
ExpiresAt int64
}
type sessionInfoWire struct {
ID string `json:"id"`
Sub string `json:"sub"`
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, Sub: s.Sub, 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
}