auth: add context-aware control-plane target resolver · Entire
auth: add context-aware control-plane target resolver
a2ba226→main·
toothbrush·1mo ago·4 files·+207 added/-0 removed
ResolveControlPlaneTarget picks the core + bearer for control-plane commands from the active contexts.json login instead of always using the static AuthBaseURL default. Precedence: explicit ENTIRE_AUTH_BASE_URL override -> active context (per-context refreshing bearer, keyed on its own CoreURL so refresh/STS hit the right core) -> static default.
Adds the two readers it needs: api.AuthBaseURLOverridden() and auth.insecureHTTPEnabled(). Not yet wired into coreapi.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
915fbde090f0View transcript
Changes
4
cmd/entire/cli
api
Mbase_url.go+9
auth
Acontrol_plane.go+93
Acontrol_plane_test.go+97
Mexchange.go+8
63 unmodified lines
return NormalizeOriginURL(raw)
// AuthBaseURLOverridden reports whether ENTIRE_AUTH_BASE_URL is explicitly
// set (non-empty after trimming). Control-plane host resolution treats an
// explicit override as unconditional: it pins the core to that origin and
// skips the active-context lookup, so split-host / local-dev invocations that
// already set this var keep their exact behaviour.
func AuthBaseURLOverridden() bool {
return strings.TrimSpace(os.Getenv(AuthBaseURLEnvVar)) != ""
}
// IsSplitHost reports whether the CLI is configured for split-host —
// i.e. ENTIRE_AUTH_BASE_URL points at a different origin than the data
// API. Both sides are canonicalised via NormalizeOriginURL before
Mcmd/entire/cli/api/base_url.go+9
package auth
import (
"context"
"fmt"
"strings"
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/internal/entireclient/contexts"
)
// ControlPlaneTarget is the resolved login server a control-plane request
// (org/repo/project/grant) should dial, plus the bearer source for it.
//
// CoreURL is an origin (no /api/v1 suffix); the caller appends the API base
// path. TokenSource returns a bearer valid for CoreURL, re-minting silently
// from the stored refresh token when the active context drives resolution.
type ControlPlaneTarget struct {
CoreURL string
TokenSource func(context.Context) (string, error)
}
// ResolveControlPlaneTarget chooses which core the control-plane commands talk
// to and how their bearer is obtained. The control-plane host *is* a core, so
// there is no /.well-known discovery here — the active context already names
// the core. Precedence:
//
// 1. ENTIRE_AUTH_BASE_URL explicitly set -> that origin verbatim, bearer via
// the singleton manager (TokenForResource), exactly as before. The env
// var stays an unconditional override so split-host / local-dev
// invocations are untouched.
// 2. otherwise the active contexts.json login -> its CoreURL, with a
// per-context refreshing bearer (silent JWT re-mint). This is what makes
// `entire auth use <ctx>` retarget the control plane onto that core.
// 3. no active context -> the configured default origin + TokenForResource,
// the pre-contexts behaviour for users who never ran `entire auth use`.
func ResolveControlPlaneTarget() (ControlPlaneTarget, error) {
if api.AuthBaseURLOverridden() {
return staticControlPlaneTarget(), nil
}
c, ok, err := activeContext()
if err != nil {
return ControlPlaneTarget{}, err
}
if !ok {
return staticControlPlaneTarget(), nil
}
// The refreshing provider keys its own token manager on c.CoreURL as the
// issuer, so its store reads and STS/refresh both target the right core —
// the bug the singleton manager (pinned to AuthBaseURL) has when the
// active context lives on a different core.
src, err := NewRefreshingLoginProvider(c, nil, insecureHTTPEnabled() || isLoopbackHTTP(c.CoreURL))
if err != nil {
return ControlPlaneTarget{}, fmt.Errorf("build token source for context %q: %w", c.Name, err)
}
return ControlPlaneTarget{CoreURL: strings.TrimRight(c.CoreURL, "/"), TokenSource: src}, nil
}
// staticControlPlaneTarget is the pre-contexts path: dial the configured auth
// origin and resolve the bearer through the singleton manager, which performs
// an RFC 8693 exchange when the stored token's audience doesn't cover the
// core. Used for an explicit ENTIRE_AUTH_BASE_URL override and as the
// no-active-context fallback.
func staticControlPlaneTarget() ControlPlaneTarget {
base := strings.TrimRight(api.AuthBaseURL(), "/")
// The exchange's resource must be the bare origin; OriginOnly strips any
// path/query so the audience the manager keys on matches the server's.
resource := api.OriginOnly(base)
return ControlPlaneTarget{
CoreURL: base,
TokenSource: func(ctx context.Context) (string, error) {
return TokenForResource(ctx, resource)
},
}
}
// activeContext returns the active contexts.json login and ok=true, or
// ok=false when there is no current context or it carries no CoreURL (an
// unusable pointer we treat as "no active context" rather than dialing an
// empty host).
func activeContext() (c *contexts.Context, ok bool, err error) {
f, err := contexts.Load(contexts.DefaultConfigDir())
if err != nil {
return nil, false, fmt.Errorf("load contexts: %w", err)
}
c = f.Find(f.CurrentContext)
if c == nil || c.CoreURL == "" {
return nil, false, nil
}
return c, true, nil
}
Acmd/entire/cli/auth/control_plane.go+93
package auth
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/internal/entireclient/contexts"
"github.com/entireio/cli/internal/entireclient/tokenstore"
)
// These tests drive process-global state (ENTIRE_AUTH_BASE_URL,
// ENTIRE_CONFIG_DIR, the token-store backend) so they cannot run in parallel.
// writeActiveContext writes a single-context contexts.json under configDir and
// marks it current.
func writeActiveContext(t *testing.T, configDir, name, coreURL, handle, svc string) {
t.Helper()
c := &contexts.Context{Name: name, CoreURL: coreURL, Handle: handle, KeychainService: svc}
if err := contexts.Save(configDir, &contexts.File{CurrentContext: name, Contexts: []*contexts.Context{c}}); err != nil {
t.Fatalf("write contexts.json: %v", err)
}
}
// An explicit ENTIRE_AUTH_BASE_URL pins the core to that origin and skips the
// active-context lookup entirely — the override is unconditional.
func TestResolveControlPlaneTarget_EnvOverrideWins(t *testing.T) {
configDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", configDir)
t.Setenv(api.AuthBaseURLEnvVar, "https://override.example")
// An active context on a *different* core must be ignored under override.
writeActiveContext(t, configDir, "ctx", "https://other-core.example", "alice", "svc")
target, err := ResolveControlPlaneTarget()
if err != nil {
t.Fatalf("ResolveControlPlaneTarget: %v", err)
}
if want := api.AuthBaseURL(); target.CoreURL != want {
t.Fatalf("CoreURL = %q, want the override origin %q (not the context core)", target.CoreURL, want)
}
}
// With no override and an active context, the target is that context's core and
// the bearer comes from the context's keyring slot (the refreshing provider).
func TestResolveControlPlaneTarget_ActiveContextWins(t *testing.T) {
configDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", configDir)
t.Setenv(api.AuthBaseURLEnvVar, "") // ensure no override leaks in from the env
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
t.Cleanup(restore)
const coreURL = "https://ctx-core.example"
svc := tokenstore.CoreKeyringService(coreURL)
jwt := makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"alice","exp":%d}`, coreURL, time.Now().Add(2*time.Hour).Unix()))
if err := tokenstore.Set(svc, "alice", tokenstore.EncodeTokenWithExpiration(jwt, 7200)); err != nil {
t.Fatalf("seed token: %v", err)
}
writeActiveContext(t, configDir, "alice@core", coreURL, "alice", svc)
target, err := ResolveControlPlaneTarget()
if err != nil {
t.Fatalf("ResolveControlPlaneTarget: %v", err)
}
if target.CoreURL != coreURL {
t.Fatalf("CoreURL = %q, want the active context's core %q", target.CoreURL, coreURL)
}
// The fresh token is returned with no network call, proving the source is
// wired to the context's keyring slot.
got, err := target.TokenSource(context.Background())
if err != nil {
t.Fatalf("TokenSource: %v", err)
}
if got != jwt {
t.Fatalf("TokenSource returned %q, want the context's stored JWT", got)
}
}
// With no override and no active context, the target falls back to the
// configured default auth origin (pre-contexts behaviour).
func TestResolveControlPlaneTarget_NoContextFallsBackToDefault(t *testing.T) {
configDir := t.TempDir() // empty: no contexts.json
setenv("ENTIRE_CONFIG_DIR", configDir)
t.Setenv(api.AuthBaseURLEnvVar, "")
target, err := ResolveControlPlaneTarget()
if err != nil {
t.Fatalf("ResolveControlPlaneTarget: %v", err)
}
if want := api.AuthBaseURL(); target.CoreURL != want {
t.Fatalf("CoreURL = %q, want the default auth origin %q", target.CoreURL, want)
}
}
Acmd/entire/cli/auth/control_plane_test.go+97