fix(auth): derive jurisdiction mint environment from the active context · Entire
fix(auth): derive jurisdiction mint environment from the active context
bfdc8b2·
jagregory·5d ago·5 files·+293 added/-13 removed
entire auth token --jurisdiction <slug> (-j) mints a jurisdictional identity token, and the shared entire-api cell resolution templates the audience/exchange-core from an environment "family". Both derived that family from the data-API origin, which defaults to prod entire.io when ENTIRE_API_BASE_URL is unset — so a partial.to login minted a token audienced to https://us.entire.io (iss https://us.auth.entire.io), which a partial.to cell rejects. Plain entire auth token was already correct because it keys off the active login context's core_url.
Fix at the shared resolution point:
environmentFamilyno longer lets the default data origin mask the login environment. An explicitENTIRE_API_BASE_URLstill wins; the default falls through to the discovered login core (the core that issued/trusts the JWT being exchanged), so a partial.to login yields a partial.to family.- New
auth.MintJurisdictionIdentityTokenexchanges the caller's login credential (active-context JWT orENTIRE_TOKEN) for a jurisdictional identity token whose environment is taken from the context core, never a constantentire.iobase. - Wire it into
entire auth tokenbehind--jurisdiction/-j.
Before (active context us.auth.partial.to): aud=https://us.entire.io. After: aud=https://us.partial.to (exchange at https://us.auth.partial.to). The entire.io context is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
879dcfce8ae4View transcript
Changes
5
MCLAUDE.md+7/-1
cmd/entire/cli
Mauth.go+22
auth
Mcell_data_api.go+82/-9
Mcell_data_api_test.go+123/-3
Mauth_token_test.go+59
46 unmodified lines
47
48
49
50
50
51
52
53
54
55
56
57
58
59
46 unmodified lines
options, summary provider). Agent CRUD lives under `entire agent`.
- `auth`: `login`, `logout`, `status`, `contexts`, `use`, plus the hidden `token` (prints the active control-plane bearer to stdout for scripting/curl; honors `ENTIRE_TOKEN`, else the refreshed active-context login JWT). `logout` honors `ENTIRE_TOKEN`, else the refreshed active-context login JWT). With `--jurisdiction`/`-j <slug>` `token` instead exchanges that same credential for a jurisdictional identity token (scope=openid) audienced to the slug in the **active context's environment** — a partial.to login mints a partial.to jurisdiction token, not a hardwired entire.io one (see `auth.MintJurisdictionIdentityToken`; the environment is derived from the active context/env-token aud, never a constant `entire.io` base). `logout` takes `--everywhere` (revoke every session on the active core, not just the current one) and `--all-contexts` (log out of every saved login)
- `doctor`: bare runs the scan-and-fix flow, plus `trace`, `logs`, `bundle`
MCLAUDE.md+7/-1
137 unmodified lines
46 unmodified lines
// login JWT, refreshed if it's near expiry — and prints nothing but the token // (errors and the not-logged-in hint go to stderr) so command substitution // stays clean. // // With --jurisdiction/-j it instead exchanges that same login credential for a // jurisdictional identity token (scope=openid) audienced to the given // jurisdiction in the ACTIVE CONTEXT'S environment — so a partial.to login mints // a partial.to jurisdiction token, not a hardwired entire.io one. This is the // credential a repo-hosting entire-api cell accepts (the control-plane bearer // and the BFF api-access token are not accepted there). func newAuthTokenCmd() *cobra.Command { var insecureHTTPAuth bool var jurisdiction string cmd := &cobra.Command{ Use: "token", Short: "Print the active control-plane bearer token (for scripting)", ... } addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) cmd.Flags().StringVarP(&jurisdiction, "jurisdiction", "j", "", "Mint a jurisdictional identity token for this jurisdiction slug (e.g. "us"), audienced to the active context's environment, instead of the control-plane bearer") return cmd }
26 unmodified lines
// clustersAPIPath is entire-core's cluster catalog endpoint.
clustersAPIPath = "/api/v1/clusters"
// familyEntireIO / familyPartialTo are the registrable apexes the two Entire
// environments live under (prod / staging). Used to template jurisdiction
audience/core URLs from whichever environment the caller is logged into.
familyEntireIO = "entire.io"
familyPartialTo = "partial.to"
// jurisdictionLabelPattern bounds a home_jurisdiction claim to a single DNS
217 unmodified lines
}
func environmentFamily(dataOrigin, discoveredCore string) string { if fam := entireDomainFamily(dataOrigin); fam != "" { return fam } if fam := entireDomainFamily(discoveredCore); fam != "" { return fam } return entireDomainFamily(dataOrigin) }
// jurisdictionAudience returns the aud the entire-api cell for jurisdiction
151 unmodified lines
return strings.TrimRight(chosen.APIURL, "/"), nil }
// MintJurisdictionIdentityToken exchanges subjectToken (the caller's login JWT,
// or ENTIRE_TOKEN) for a jurisdictional identity token (scope=openid) audienced
// to jurisdiction in the environment named by contextCoreURL.
//
// contextCoreURL is the active login context's core (or, under ENTIRE_TOKEN,
// the token's aud) — the same prod-vs-staging signal entire auth token keys off —
// so the mint follows the environment the caller is logged into rather than the
// data-API default (prod entire.io). It drives both the exchange core
// (https://
// Pass contextCoreURL as both the data origin and the discovered core: the // mint's environment is the login context, never the data-API origin, so even // the ENTIRE_API_BASE_URL override branch inside environmentFamily resolves // from the context core (not a possibly-prod data URL a script may have set). audience = jurisdictionAudience(jurisdiction, contextCoreURL, contextCoreURL) exchangeCore = jurisdictionCoreURL(jurisdiction, contextCoreURL, contextCoreURL) if err := requireSafeExchangeURL("entire-core", exchangeCore); err != nil { return "", "", "", err }
httpClient := &http.Client{Timeout: cellDataAPITimeout, Transport: cellExchangeTransportForTest} token, err = exchangeJurisdictionToken(ctx, exchangeCore, subjectToken, audience, httpClient) if err != nil { return "", "", "", fmt.Errorf("exchange jurisdictional identity token: %w", err) } return token, audience, exchangeCore, nil }
func exchangeJurisdictionToken(ctx context.Context, coreURL, loginJWT, audience string, httpClient *http.Client) (string, error) { if coreURL == "" { return "", errors.New("no entire-core URL configured for jurisdiction token exchange") } return "", nil }
2 unmodified lines
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 30 unmodified lines 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
2 unmodified lines
import ( "bytes" "encoding/base64" "fmt" "net/http" "net/http/httptest" "net/url" "os" "testing"
"github.com/entireio/cli/cmd/entire/cli/auth" "github.com/stretchr/testify/require" )
// redirectTransport rewrites every request to point at target, so a mint that // dials a templated prod host (https://us.auth.partial.to) is intercepted by a // loopback httptest server. It records the last request it saw. type redirectTransport struct { target *url.URL }
func (rt redirectTransport) RoundTrip(r *http.Request) (*http.Response, error) { clone := r.Clone(r.Context()) clone.URL.Scheme = rt.target.Scheme clone.URL.Host = rt.target.Host clone.Host = rt.target.Host return http.DefaultTransport.RoundTrip(clone) }
// makeTestJWT builds an unsigned JWT with the given payload JSON. ParseClaims // (used by ENTIRE_TOKEN resolution) reads the payload without verifying the // signature, so an unsigned token is enough to exercise the resolution path. 30 unmodified lines
require.Empty(t, errOut.String()) })
t.Run("jurisdiction mints an identity token audienced to the context environment", func(t *testing.T) { // Neutralise any inherited overrides so the environment family is derived // purely from the context core (the env token's aud). t.Setenv("ENTIRE_API_BASE_URL", "") t.Setenv("ENTIRE_API_AUDIENCE_TEMPLATE", "") t.Setenv("ENTIRE_CORE_BASE_URL_TEMPLATE", "")
var gotAudience, gotSubject string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm() //nolint:errcheck // test handler
gotAudience = r.FormValue("audience")
gotSubject = r.FormValue("subject_token")
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, {"access_token":"partial-juris-token","token_type":"Bearer","expires_in":3600})
}))
defer srv.Close()
target, err := url.Parse(srv.URL) require.NoError(t, err) t.Cleanup(auth.SetCellExchangeTransportForTest(t, redirectTransport{target: target}))
// ENTIRE_TOKEN whose aud is a partial.to core: the mint's environment must
// follow that (partial.to), NOT a hardwired entire.io base.
envTok := makeTestJWT(t, {"sub":"ci","aud":"https://us.auth.partial.to"})
t.Setenv("ENTIRE_TOKEN", envTok)
cmd := newAuthTokenCmd() cmd.SetArgs([]string{"--jurisdiction", "us"}) var out, errOut bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&errOut) require.NoError(t, cmd.ExecuteContext(t.Context()))
require.Equal(t, "partial-juris-token\n", out.String()) require.Empty(t, errOut.String()) require.Equal(t, "https://us.partial.to", gotAudience, "aud must follow the partial.to context, not entire.io") require.Equal(t, envTok, gotSubject) })
t.Run("not logged in errors silently with a hint", func(t *testing.T) { // Isolated empty config so there's no active context to resolve. t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir())