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。
275 lines
11 KiB
Go
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
|
|
}
|