cli/auth: add CellClientFactory — one subject, one token per jurisdiction · Entire

cli/auth: add CellClientFactory — one subject, one token per jurisdiction

64230af·

Soph·1w ago·2 files·+123 added/-10 removed

NewEntireAPICellClient resolved the stored login subject (discovery + login refresh) and ran the RFC 8693 exchange on every call. For a single-cell command that's fine, but a multi-cell fan-out (one request per cell hosting the caller's repos, the BFF's code-search pattern) would pay all of it once per cell — even though identity tokens are per-jurisdiction, not per-cell: every cell in a jurisdiction accepts the same token.

CellClientFactory resolves the subject once at construction and caches minted identity tokens by jurisdiction; ClientFor(target) reuses them across cells. NewEntireAPICellClient stays as the single-cell wrapper (factory of one), so existing callers are unchanged.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Sessions

935f91398e25View transcript

Changes

2

11 unmodified lines

12
13
14
15
16
17
18
79 unmodified lines

98
99
100
100
101
102
103
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
109
137
138
139
140
141
142
143
144
145
114
146
147
148
149
2 unmodified lines

152
153
154
123
155
156
157
158
1 unmodified line

160
161
162
131
132
163
164
134
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193

11 unmodified lines

"os"
    "regexp"
    "strings"
    "sync"
    "time"

"github.com/entireio/cli/cmd/entire/cli/api"
79 unmodified lines

//   - otherwise the data host is a BFF/apex: resolve the caller's home-cell
//     apiUrl from the cluster catalog (home-jurisdiction fallback).
func NewEntireAPICellClient(ctx context.Context, insecureHTTP bool, target *CellTarget) (*api.Client, error) {
    // NewEntireAPICellClient deliberately does NOT consult ENTIRE_TOKEN: it
    // resolves the active stored login context (like every other cell/data-API
    // command). Only `entire auth token --jurisdiction` (JurisdictionToken) adds
    // the env-token path.
    factory, err := NewEntireAPICellClientFactory(ctx, insecureHTTP)
    if err != nil {
        return nil, err
    }
    return factory.ClientFor(ctx, target)
}

// CellClientFactory builds entire-api cell clients from a single resolved
// exchange subject, minting at most one jurisdictional identity token per
// jurisdiction. Identity tokens are per-jurisdiction, not per-cell — every cell
// in a jurisdiction accepts the same token — so a caller dialing several cells
// in one operation (multi-cell fan-out over the caller's repos) should build
// one factory and reuse it for every cell, instead of paying discovery + login
// refresh + RFC 8693 exchange once per cell via NewEntireAPICellClient.
//
// A factory is safe for concurrent use, and holds credentials resolved at
// construction time — build it per operation, don't store it long-term. Like
// NewEntireAPICellClient it deliberately does NOT consult ENTIRE_TOKEN.
type CellClientFactory struct {
    subject cellSubject

mu     sync.Mutex
    tokens map[string]string // jurisdiction -> minted identity token
}

// NewEntireAPICellClientFactory resolves the exchange subject (active stored
// login context) once, for building clients aimed at several cells. See
// NewEntireAPICellClient for the single-cell convenience wrapper.
func NewEntireAPICellClientFactory(ctx context.Context, insecureHTTP bool) (*CellClientFactory, error) {
    subject, err := resolveStoredCellSubject(ctx, insecureHTTP)
    if err != nil {
        return nil, err
    }
    return &CellClientFactory{subject: subject, tokens: make(map[string]string)}, nil
}

jurisdiction, err := targetJurisdiction(target, subject.loginJWT)
// ClientFor returns an authenticated client for the given cell target (nil
// falls back to home-jurisdiction routing), reusing an already-minted identity
// token when the target's jurisdiction matches an earlier call.
func (f *CellClientFactory) ClientFor(ctx context.Context, target *CellTarget) (*api.Client, error) {
    jurisdiction, err := targetJurisdiction(target, f.subject.loginJWT)
    if err != nil {
        return nil, err
    }

coreURL := jurisdictionCoreURL(jurisdiction, subject.dataOrigin, subject.discoveredCore)
    coreURL := jurisdictionCoreURL(jurisdiction, f.subject.dataOrigin, f.subject.discoveredCore)
    if err := requireSafeExchangeURL("entire-core", coreURL); err != nil {
        return nil, err
    }
2 unmodified lines

// which is signed by the discovered login core — so list there, not at the
    // templated jurisdiction core (coreURL), which in a multi-core setup could
    // differ and reject the token. coreURL still governs the token exchange below.
    cellBaseURL, err := resolveTargetCellBaseURL(ctx, target, subject.dataOrigin, jurisdiction, subject.discoveredCore, subject.loginJWT, subject.httpClient)
    cellBaseURL, err := resolveTargetCellBaseURL(ctx, target, f.subject.dataOrigin, jurisdiction, f.subject.discoveredCore, f.subject.loginJWT, f.subject.httpClient)
    if err != nil {
        return nil, err
    }
1 unmodified line

audience := jurisdictionAudience(jurisdiction, subject.dataOrigin, subject.discoveredCore)
    token, err := exchangeJurisdictionToken(ctx, coreURL, subject.loginJWT, audience, subject.httpClient)
    token, err := f.tokenFor(ctx, jurisdiction, coreURL)
    if err != nil {
        return nil, fmt.Errorf("exchange jurisdictional identity token: %w", err)
        return nil, err
    }

return api.NewClientWithBaseURL(token, cellBaseURL), nil
}

// tokenFor returns the cached identity token for jurisdiction, minting it on
// first use. The mutex is held across the mint: concurrent callers for the
// same jurisdiction wait for one exchange instead of duplicating it, at the
// cost of serializing cross-jurisdiction mints (fine for the handful of
// jurisdictions a fan-out touches).
func (f *CellClientFactory) tokenFor(ctx context.Context, jurisdiction, coreURL string) (string, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    if token, ok := f.tokens[jurisdiction]; ok {
        return token, nil
    }
    audience := jurisdictionAudience(jurisdiction, f.subject.dataOrigin, f.subject.discoveredCore)
    token, err := exchangeJurisdictionToken(ctx, coreURL, f.subject.loginJWT, audience, f.subject.httpClient)
    if err != nil {
        return "", fmt.Errorf("exchange jurisdictional identity token: %w", err)
    }
    f.tokens[jurisdiction] = token
    return token, nil
}

// JurisdictionToken mints and returns a jurisdictional identity token
// (scope=openid, aud=jurisdiction host) for `jurisdiction`, for authenticating
// against that jurisdiction's entire-api cells (e.g.

Mcmd/entire/cli/auth/cell_data_api.go+61/-10

541 unmodified lines

542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606

541 unmodified lines

t.Errorf("home-fallback audience = %q, want https://us.entire.io", got) }

// TestCellClientFactory_ReusesTokenPerJurisdiction pins the factory's core // contract: identity tokens are per-jurisdiction, not per-cell, so building // clients for several cells must mint one token per distinct jurisdiction and // reuse it across cells. Not parallel: manipulates env + token store. func TestCellClientFactory_ReusesTokenPerJurisdiction(t *testing.T) { t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir()) t.Setenv("ENTIRE_API_BASE_URL", "https://entire.io") t.Setenv("ENTIRE_API_AUDIENCE_TEMPLATE", "") t.Setenv("ENTIRE_CORE_BASE_URL_TEMPLATE", "")

restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")) t.Cleanup(restore)

var exchangeCount int var audiences []string coreSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != oauthTokenPath { http.NotFound(w, r) return } _ = r.ParseForm() //nolint:errcheck // test handler exchangeCount++ audiences = append(audiences, r.FormValue("audience")) tw.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintf(w, {"access_token":"identity-token-%d","token_type":"Bearer","expires_in":3600}, exchangeCount) })) defer coreSrv.Close()

svc := tokenstore.CoreKeyringService(coreSrv.URL) loginJWT := makeJWT(t, fmt.Sprintf({"iss":%q,"home_jurisdiction":"us","exp":%d}, coreSrv.URL, time.Now().Add(2*time.Hour).Unix())) if err := tokenstore.Set(svc, "me", tokenstore.EncodeTokenWithExpiration(loginJWT, 7200)); err != nil { t.Fatalf("seed token: %v", err) } ctxObj := &contexts.Context{Name: "me@core", CoreURL: coreSrv.URL, Handle: "me", KeychainService: svc} t.Cleanup(SetResolveContextForCellAPIForTest(t, func(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error) { return ctxObj, nil })) t.Cleanup(SetCellExchangeTransportForTest(t, coreSrv.Client().Transport))

factory, err := NewEntireAPICellClientFactory(context.Background(), false) if err != nil { t.Fatalf("NewEntireAPICellClientFactory: %v", err) }

// Two eu cells then one us cell: two exchanges total, the eu token reused // for the second eu cell. for _, target := range []*CellTarget{ {BaseURL: "https://cell-a.api.example", Jurisdiction: "eu"}, {BaseURL: "https://cell-b.api.example", Jurisdiction: "eu"}, {BaseURL: "https://cell-c.api.example", Jurisdiction: "us"}, } { if _, err := factory.ClientFor(context.Background(), target); err != nil { t.Fatalf("ClientFor(%s): %v", target.BaseURL, err) } } if exchangeCount != 2 { t.Fatalf("exchange count = %d, want 2 (one per distinct jurisdiction)", exchangeCount) } if got, want := strings.Join(audiences, ","), "https://eu.entire.io,"+usEntireAudience; got != want { t.Fatalf("exchange audiences = %q, want %q", got, want) } }