Skip to content

Commit 97207ef

Browse files
toothbrushclaude
andcommitted
auth: make auth status context-aware; hit /me on the active core
`auth status` queried /me against the static api.AuthBaseURL(), so with an active context on a different core (e.g. `auth use eu.auth.entire.io` while AuthBaseURL defaults to us.*) it sent the context's token to the wrong core and got a 401 — surfaced as a raw ogen decode dump because the 401 body was text/plain. - Resolve the active contexts.json context first (resolveStatusTarget): use its CoreURL + session token, falling back to AuthBaseURL + the legacy keyring entry only when no context is active. `auth use` now retargets status. "Logged in to <core>" reflects the active context. - Add coreapi.NewWithBearer(coreURL, token) to hit a specific login server with a fixed bearer (no STS), used by status's /me. - Harden isKeychainTokenRejected: a non-JSON 401 (ogen "decode response: ... (code 401)") now maps to the friendly re-login hint, not a raw dump. - TLS-guard the resolved context core URL before sending the token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 1385b003c7a8
1 parent f8390f9 commit 97207ef

4 files changed

Lines changed: 197 additions & 142 deletions

File tree

cmd/entire/cli/auth.go

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ func isKeychainTokenRejected(err error) bool {
119119
if errors.Is(err, auth.ErrNotLoggedIn) {
120120
return true
121121
}
122+
// A 401 whose body isn't JSON (e.g. a gateway returning text/plain) fails
123+
// the ogen typed decode, so it never becomes an ErrorModelStatusCode — it
124+
// arrives as a decode error whose message carries "(code 401)". Match that
125+
// so the user still gets the re-login hint, not a raw decode dump.
126+
if strings.Contains(err.Error(), "code 401") {
127+
return true
128+
}
122129
return strings.Contains(err.Error(), "token exchange: status 4")
123130
}
124131

@@ -160,8 +167,15 @@ func newAuthStatusCmd() *cobra.Command {
160167
if err := requireSecureBaseURL(insecureHTTPAuth); err != nil {
161168
return err
162169
}
163-
return runAuthStatus(cmd.Context(), cmd.OutOrStdout(),
164-
auth.NewContextStore(), defaultFetchProfile, auth.Contexts, api.AuthBaseURL())
170+
target := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL())
171+
// We send the session token to target.coreURL; enforce TLS on it
172+
// too (it may differ from AuthBaseURL when a context is active).
173+
if !insecureHTTPAuth {
174+
if err := api.RequireSecureURL(target.coreURL); err != nil {
175+
return fmt.Errorf("context core URL check: %w", err)
176+
}
177+
}
178+
return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), defaultFetchProfile, target)
165179
},
166180
}
167181
addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
@@ -178,23 +192,57 @@ type authProfile struct {
178192
ProviderUserID string
179193
}
180194

181-
// profileFetcher fetches the logged-in user's profile via GET /me on the core
182-
// API. Injected so status stays unit-testable without a live core.
183-
type profileFetcher func(ctx context.Context) (*authProfile, error)
195+
// profileFetcher fetches a user's profile via GET /me on coreURL, authenticated
196+
// with token. Injected so status stays unit-testable without a live core.
197+
type profileFetcher func(ctx context.Context, coreURL, token string) (*authProfile, error)
184198

185199
// contextsProvider returns the stored login contexts and the active context
186-
// name, for the local-context lines in `entire auth status`. Injected for
187-
// testability; production wires auth.Contexts.
200+
// name. Injected for testability; production wires auth.Contexts.
188201
type contextsProvider func() ([]*contexts.Context, string, error)
189202

190-
// defaultFetchProfile fetches the current user's profile from the core API's
191-
// GET /me. It doubles as the liveness check for `entire auth status`: a 401
192-
// (or an expired login that can't be exchanged) means the stored token is no
193-
// longer usable, which isKeychainTokenRejected maps to a re-login hint.
194-
func defaultFetchProfile(ctx context.Context) (*authProfile, error) {
195-
client, err := coreapi.New()
203+
// statusTarget is the resolved core `entire auth status` should query: the
204+
// active context's CoreURL + its session token, or (no active context) the
205+
// configured AuthBaseURL + legacy keyring entry.
206+
type statusTarget struct {
207+
coreURL string
208+
token string
209+
activeContext string // "" when falling back to the legacy entry
210+
totalContexts int
211+
}
212+
213+
// resolveStatusTarget picks the core + token for `entire auth status`. The
214+
// active contexts.json context wins (so `auth use` retargets status onto that
215+
// login server); otherwise it falls back to the legacy keyring entry keyed by
216+
// the configured auth host.
217+
func resolveStatusTarget(store tokenStore, listContexts contextsProvider, fallbackBaseURL string) statusTarget {
218+
all, current, err := listContexts()
219+
total := 0
220+
if err == nil {
221+
total = len(all)
222+
for _, c := range all {
223+
if c.Name != current || c.CoreURL == "" {
224+
continue
225+
}
226+
if tok, terr := auth.LoginTokenForContext(c); terr == nil && tok != "" {
227+
return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total}
228+
}
229+
}
230+
}
231+
tok, gerr := store.GetToken(fallbackBaseURL)
232+
if gerr != nil {
233+
tok = "" // best-effort: a keyring read failure just reads as "no token"
234+
}
235+
return statusTarget{coreURL: fallbackBaseURL, token: tok, totalContexts: total}
236+
}
237+
238+
// defaultFetchProfile fetches a user's profile from coreURL's GET /me with the
239+
// given bearer. It doubles as the liveness check for `entire auth status`: a
240+
// 401 (or an expired login) means the token is no longer usable, which
241+
// isKeychainTokenRejected maps to a re-login hint.
242+
func defaultFetchProfile(ctx context.Context, coreURL, token string) (*authProfile, error) {
243+
client, err := coreapi.NewWithBearer(coreURL, token)
196244
if err != nil {
197-
return nil, fmt.Errorf("connect to Entire control plane: %w", err)
245+
return nil, fmt.Errorf("connect to %s: %w", coreURL, err)
198246
}
199247
me, err := client.GetMe(ctx)
200248
if err != nil {
@@ -213,44 +261,36 @@ func defaultFetchProfile(ctx context.Context) (*authProfile, error) {
213261
}
214262

215263
// runAuthStatus reports auth state without listing server-side sessions: GET
216-
// /me validates the token and supplies the profile header, and the active
217-
// login context is read locally. (Session listing/revocation lives on
218-
// entire-core and is reached only by logout — see newSessionsClient.)
219-
func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, fetchProfile profileFetcher, listContexts contextsProvider, baseURL string) error {
220-
token, err := store.GetToken(baseURL)
221-
if err != nil {
222-
return fmt.Errorf("read keychain: %w", err)
223-
}
224-
if token == "" {
225-
fmt.Fprintf(w, "Not logged in to %s\n", baseURL)
264+
// /me on the target core validates the token and supplies the profile header,
265+
// and the active login context is shown locally. (Session listing/revocation
266+
// lives on entire-core and is reached only by logout — see newSessionsClient.)
267+
func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher, t statusTarget) error {
268+
if t.token == "" {
269+
fmt.Fprintf(w, "Not logged in to %s\n", t.coreURL)
226270
fmt.Fprintln(w, "Run 'entire login' to authenticate.")
227271
return nil
228272
}
229273

230-
profile, err := fetchProfile(ctx)
274+
profile, err := fetchProfile(ctx, t.coreURL, t.token)
231275
if err != nil {
232276
if isKeychainTokenRejected(err) {
233-
fmt.Fprintf(w, "Token in keychain for %s is no longer valid.\n", baseURL)
277+
fmt.Fprintf(w, "Login for %s is no longer valid.\n", t.coreURL)
234278
fmt.Fprintln(w, "Run 'entire login' to re-authenticate.")
235279
return nil
236280
}
237281
return fmt.Errorf("validate token: %w", err)
238282
}
239283

240-
fmt.Fprintf(w, "Logged in to %s\n", baseURL)
284+
fmt.Fprintf(w, "Logged in to %s\n", t.coreURL)
241285
writeProfileLines(w, profile)
242-
243-
// Local context info is informational; a read failure shouldn't fail the
244-
// command, so on error we just skip the context lines.
245-
all, current, ctxErr := listContexts()
246-
if ctxErr == nil && current != "" {
247-
fmt.Fprintf(w, " %-9s %s\n", "Context:", current)
286+
if t.activeContext != "" {
287+
fmt.Fprintf(w, " %-9s %s\n", "Context:", t.activeContext)
248288
}
249289
fmt.Fprintf(w, " %-9s %s\n", "Token:", "stored in OS keychain")
250290

251-
if ctxErr == nil && len(all) > 1 {
291+
if t.totalContexts > 1 {
252292
fmt.Fprintln(w)
253-
fmt.Fprintf(w, "%d login contexts saved; run 'entire auth contexts' to list or 'entire auth use <name>' to switch.\n", len(all))
293+
fmt.Fprintf(w, "%d login contexts saved; run 'entire auth contexts' to list or 'entire auth use <name>' to switch.\n", t.totalContexts)
254294
}
255295
return nil
256296
}

cmd/entire/cli/auth_context_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,32 @@ import (
1313
"github.com/entireio/cli/internal/entireclient/tokenstore"
1414
)
1515

16+
// TestResolveStatusTarget_PrefersActiveContext pins the multi-core fix: status
17+
// targets the active context's CoreURL + its session token, recording a real
18+
// context and reading it back.
19+
func TestResolveStatusTarget_PrefersActiveContext(t *testing.T) {
20+
cfgDir := t.TempDir()
21+
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
22+
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
23+
t.Cleanup(restore)
24+
25+
exp := time.Now().Add(time.Hour).Unix()
26+
if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://eu.auth.entire.io","handle":"alice","exp":%d}`, exp)), "", true); err != nil {
27+
t.Fatalf("record context: %v", err)
28+
}
29+
30+
got := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, "https://fallback.example.com")
31+
if got.coreURL != "https://eu.auth.entire.io" {
32+
t.Errorf("coreURL = %q, want the active context's CoreURL", got.coreURL)
33+
}
34+
if got.token == "" {
35+
t.Error("token = empty, want the active context's session token")
36+
}
37+
if got.activeContext == "" {
38+
t.Error("activeContext = empty, want the active context name")
39+
}
40+
}
41+
1642
// makeContextJWT builds a JWT-shaped token (non-"none" alg) carrying the
1743
// given claims, which is all RecordLoginContext needs.
1844
func makeContextJWT(t *testing.T, payloadJSON string) string {

0 commit comments

Comments
 (0)