// 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::); 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 }