From b97efaf912edb24e34987c330b71b8c19e82899d Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:55:23 +0930 Subject: [PATCH 01/21] auth: drop `auth list`, show active sessions in `auth status` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `entire auth list` command. The rows it listed are server-side login sessions (OAuth refresh-token families), not personal access tokens — nothing functional depends on listing them (see COR-389 notes). Fold that view into `entire auth status` as a clearly-labelled "Active sessions" table, reusing the table renderer. `auth revoke ` still works; the session IDs now come from `auth status`. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 1774a9e75c37 --- cmd/entire/cli/auth.go | 132 +++++++++++++----------------------- cmd/entire/cli/auth_test.go | 84 +++++++---------------- 2 files changed, 74 insertions(+), 142 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 9c39002b97..2010a8b48f 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -2,7 +2,6 @@ package cli import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -17,11 +16,15 @@ import ( "github.com/spf13/cobra" ) -// authTokenLister lists API tokens for the authenticated user. The -// implementation resolves its own data-API bearer via -// auth.TokenForResource (RFC 8693 exchange in split-host setups, same- -// host shortcut otherwise); callers don't pass a bearer through, which -// removes the temptation to forward the wrong-audience keyring token. +// authTokenLister lists the authenticated user's active login sessions — +// the server-side refresh-token families (one per `entire login`, across +// all devices), surfaced by `entire auth status` and used by revoke as a +// liveness probe. Despite the api.Token name, these are sessions, not +// personal access tokens; the CLI never mints them. The implementation +// resolves its own data-API bearer via auth.TokenForResource (RFC 8693 +// exchange in split-host setups, same-host shortcut otherwise); callers +// don't pass a bearer through, which removes the temptation to forward the +// wrong-audience keyring token. type authTokenLister func(ctx context.Context) ([]api.Token, error) // authTokenRevoker revokes a single API token by id. Same bearer- @@ -150,7 +153,6 @@ func newAuthCmd() *cobra.Command { cmd.AddCommand(newLoginCmd()) cmd.AddCommand(newLogoutCmd()) cmd.AddCommand(newAuthStatusCmd()) - cmd.AddCommand(newAuthListCmd()) cmd.AddCommand(newAuthRevokeCmd()) cmd.AddCommand(newAuthContextsCmd()) cmd.AddCommand(newAuthUseCmd()) @@ -176,6 +178,8 @@ func newAuthStatusCmd() *cobra.Command { return cmd } +// defaultListTokens fetches the authenticated user's active login sessions +// from the server. See authTokenLister for what these rows actually are. func defaultListTokens(ctx context.Context) ([]api.Token, error) { token, err := resolveDataAPIToken(ctx) if err != nil { @@ -195,7 +199,7 @@ func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list auth return nil } - tokens, err := list(ctx) + sessions, err := list(ctx) if err != nil { if isKeychainTokenRejected(err) { fmt.Fprintf(w, "Token in keychain for %s is no longer valid.\n", baseURL) @@ -207,83 +211,43 @@ func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list auth fmt.Fprintf(w, "Logged in to %s\n", baseURL) fmt.Fprintln(w, " Token: stored in OS keychain") - fmt.Fprintf(w, " Active tokens on this account: %d\n", len(tokens)) - return nil -} - -// --- list ------------------------------------------------------------------- - -func newAuthListCmd() *cobra.Command { - var jsonOut bool - var insecureHTTPAuth bool - cmd := &cobra.Command{ - Use: "list", - Short: "List active API tokens for the authenticated user", - RunE: func(cmd *cobra.Command, _ []string) error { - if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { - return err - } - return runAuthList(cmd.Context(), cmd.OutOrStdout(), - auth.NewContextStore(), defaultListTokens, api.AuthBaseURL(), jsonOut) - }, - } - cmd.Flags().BoolVar(&jsonOut, "json", false, "Print tokens as JSON") - addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) - return cmd -} - -func runAuthList(ctx context.Context, w io.Writer, store tokenStore, list authTokenLister, baseURL string, jsonOut bool) error { - token, err := store.GetToken(baseURL) - if err != nil { - return fmt.Errorf("read keychain: %w", err) - } - if token == "" { - return fmt.Errorf("not logged in to %s; run 'entire login' first", baseURL) - } - - tokens, err := list(ctx) - if err != nil { - return err - } + fmt.Fprintln(w) - if jsonOut { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - if err := enc.Encode(tokens); err != nil { - return fmt.Errorf("encode JSON: %w", err) - } + if len(sessions) == 0 { + fmt.Fprintln(w, "No active sessions.") return nil } - if len(tokens) == 0 { - fmt.Fprintln(w, "No active tokens.") - return nil - } + fmt.Fprintln(w, "Active sessions:") + sortSessionsByRecency(sessions) + renderSessionsTable(w, newSessionsTableStyles(w), sessions, time.Now()) + return nil +} - // Deterministic order: most recently used first, then most recently - // created, then by id as a final tie-breaker so the output is fully - // specified regardless of the server's response order. - sort.Slice(tokens, func(i, j int) bool { - li := lastUsedSortKey(tokens[i]) - lj := lastUsedSortKey(tokens[j]) +// sortSessionsByRecency orders sessions most-recently-used first, then most +// recently created, then by id — a fully specified order independent of the +// server's response ordering. +func sortSessionsByRecency(sessions []api.Token) { + sort.Slice(sessions, func(i, j int) bool { + li := lastUsedSortKey(sessions[i]) + lj := lastUsedSortKey(sessions[j]) if li != lj { return li > lj } - if tokens[i].CreatedAt != tokens[j].CreatedAt { - return tokens[i].CreatedAt > tokens[j].CreatedAt + if sessions[i].CreatedAt != sessions[j].CreatedAt { + return sessions[i].CreatedAt > sessions[j].CreatedAt } - return tokens[i].ID < tokens[j].ID + return sessions[i].ID < sessions[j].ID }) - - sty := newAuthListStyles(w) - renderAuthListTable(w, sty, tokens, time.Now()) - return nil } -// authListStyles holds the lipgloss styles for `entire auth list`. Mirrors the -// approach in activity_render.go: keep style construction tied to color -// detection, and render plain text when color is disabled. -type authListStyles struct { +// --- active-sessions table --------------------------------------------------- + +// sessionsTableStyles holds the lipgloss styles for the `entire auth status` +// active-sessions table. Mirrors the approach in activity_render.go: keep +// style construction tied to color detection, and render plain text when +// color is disabled. +type sessionsTableStyles struct { colorEnabled bool header lipgloss.Style // bold + dim, used for column headers @@ -295,9 +259,9 @@ type authListStyles struct { expired lipgloss.Style // already expired } -func newAuthListStyles(w io.Writer) authListStyles { +func newSessionsTableStyles(w io.Writer) sessionsTableStyles { useColor := shouldUseColor(w) - s := authListStyles{colorEnabled: useColor} + s := sessionsTableStyles{colorEnabled: useColor} if !useColor { return s } @@ -311,18 +275,18 @@ func newAuthListStyles(w io.Writer) authListStyles { return s } -func (s authListStyles) render(style lipgloss.Style, text string) string { +func (s sessionsTableStyles) render(style lipgloss.Style, text string) string { if !s.colorEnabled { return text } return style.Render(text) } -// renderAuthListTable prints a styled, column-aligned table of tokens. Column -// padding is computed via lipgloss.Width — it strips ANSI escapes, so a styled -// cell's visible width matches its plain text. tabwriter can't be used here -// once cells contain ANSI codes. -func renderAuthListTable(w io.Writer, sty authListStyles, tokens []api.Token, now time.Time) { +// renderSessionsTable prints a styled, column-aligned table of login sessions. +// Column padding is computed via lipgloss.Width — it strips ANSI escapes, so a +// styled cell's visible width matches its plain text. tabwriter can't be used +// here once cells contain ANSI codes. +func renderSessionsTable(w io.Writer, sty sessionsTableStyles, tokens []api.Token, now time.Time) { headerCells := []string{"ID", "NAME", "SCOPE", "CREATED", "LAST USED", "EXPIRES"} header := make([]string, len(headerCells)) for i, h := range headerCells { @@ -369,21 +333,21 @@ func writeRow(w io.Writer, cells []string, widths []int) { fmt.Fprintln(w) } -func styleName(sty authListStyles, name string) string { +func styleName(sty sessionsTableStyles, name string) string { if name == "" { return sty.render(sty.dim, placeholderDash) } return sty.render(sty.name, name) } -func styleLastUsed(sty authListStyles, lastUsed *string, now time.Time) string { +func styleLastUsed(sty sessionsTableStyles, lastUsed *string, now time.Time) string { if lastUsed == nil { return sty.render(sty.dim, lastUsedNever) } return sty.render(sty.value, formatAuthLastUsed(lastUsed, now)) } -func styleExpires(sty authListStyles, expiresAt string, now time.Time) string { +func styleExpires(sty sessionsTableStyles, expiresAt string, now time.Time) string { formatted := formatAuthDate(expiresAt) switch classifyExpiresAt(expiresAt, now) { case expiresExpired: diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index 2945ef2a9a..6cb405bb5b 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -70,8 +70,11 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { if !strings.Contains(out.String(), "Logged in to "+testBaseURL) { t.Fatalf("output = %q, want 'Logged in' message", out.String()) } - if !strings.Contains(out.String(), "Active tokens on this account: 2") { - t.Fatalf("output = %q, want token count", out.String()) + if !strings.Contains(out.String(), "Active sessions:") { + t.Fatalf("output = %q, want active-sessions heading", out.String()) + } + if !strings.Contains(out.String(), "laptop") || !strings.Contains(out.String(), "ci") { + t.Fatalf("output = %q, want session rows", out.String()) } } @@ -199,26 +202,9 @@ func TestRunAuthStatus_ServerError(t *testing.T) { } } -// --- list ------------------------------------------------------------------- +// --- active-sessions table --------------------------------------------------- -func TestRunAuthList_NotLoggedInErrors(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - - var out bytes.Buffer - err := runAuthList(context.Background(), &out, store, - func(context.Context) ([]api.Token, error) { return nil, nil }, - testBaseURL, false) - if err == nil { - t.Fatal("expected error when not logged in") - } - if !strings.Contains(err.Error(), "not logged in") { - t.Fatalf("error = %v, want 'not logged in' message", err) - } -} - -func TestRunAuthList_TablePrintsRows(t *testing.T) { +func TestRunAuthStatus_SessionsTablePrintsRows(t *testing.T) { t.Parallel() store := newMockTokenStore() @@ -227,11 +213,11 @@ func TestRunAuthList_TablePrintsRows(t *testing.T) { lastUsed := "2026-04-01T12:00:00Z" list := func(context.Context) ([]api.Token, error) { return []api.Token{ - {ID: "tok-1", Name: "laptop", Scope: "cli", + {ID: "fam-1", Name: "laptop", Scope: "cli", CreatedAt: "2026-01-01T00:00:00Z", ExpiresAt: "2027-01-01T00:00:00Z", LastUsedAt: &lastUsed}, - {ID: "tok-2", Name: "ci", Scope: "cli", + {ID: "fam-2", Name: "ci", Scope: "cli", CreatedAt: "2026-02-01T00:00:00Z", ExpiresAt: "2027-01-01T00:00:00Z", LastUsedAt: nil}, @@ -239,51 +225,30 @@ func TestRunAuthList_TablePrintsRows(t *testing.T) { } var out bytes.Buffer - if err := runAuthList(context.Background(), &out, store, list, testBaseURL, false); err != nil { + if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } output := out.String() + if !strings.Contains(output, "Active sessions:") { + t.Fatalf("output = %q, want active-sessions heading", output) + } if !strings.Contains(output, "ID") || !strings.Contains(output, "NAME") { t.Fatalf("output = %q, want table headers", output) } - if !strings.Contains(output, "tok-1") || !strings.Contains(output, "laptop") { + if !strings.Contains(output, "fam-1") || !strings.Contains(output, "laptop") { t.Fatalf("output = %q, want first row", output) } - if !strings.Contains(output, "tok-2") || !strings.Contains(output, "never") { + if !strings.Contains(output, "fam-2") || !strings.Contains(output, "never") { t.Fatalf("output = %q, want second row with 'never' last-used", output) } - // tok-1 last-used recently so should sort before tok-2 in the table. - if strings.Index(output, "tok-1") > strings.Index(output, "tok-2") { - t.Fatalf("output = %q, want tok-1 before tok-2 (recent-first)", output) - } -} - -func TestRunAuthList_JSONOutput(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - list := func(context.Context) ([]api.Token, error) { - return []api.Token{{ID: "tok-1", Name: "laptop"}}, nil - } - - var out bytes.Buffer - if err := runAuthList(context.Background(), &out, store, list, testBaseURL, true); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - output := out.String() - if !strings.HasPrefix(strings.TrimSpace(output), "[") { - t.Fatalf("output = %q, want JSON array", output) - } - if !strings.Contains(output, `"id": "tok-1"`) { - t.Fatalf("output = %q, want decoded id", output) + // fam-1 used recently, so it should sort before fam-2 in the table. + if strings.Index(output, "fam-1") > strings.Index(output, "fam-2") { + t.Fatalf("output = %q, want fam-1 before fam-2 (recent-first)", output) } } -func TestRunAuthList_EmptyPrintsMessage(t *testing.T) { +func TestRunAuthStatus_NoSessionsPrintsMessage(t *testing.T) { t.Parallel() store := newMockTokenStore() @@ -292,11 +257,14 @@ func TestRunAuthList_EmptyPrintsMessage(t *testing.T) { list := func(context.Context) ([]api.Token, error) { return nil, nil } var out bytes.Buffer - if err := runAuthList(context.Background(), &out, store, list, testBaseURL, false); err != nil { + if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out.String(), "No active tokens") { - t.Fatalf("output = %q, want 'No active tokens' message", out.String()) + if !strings.Contains(out.String(), "Logged in to "+testBaseURL) { + t.Fatalf("output = %q, want 'Logged in' message", out.String()) + } + if !strings.Contains(out.String(), "No active sessions") { + t.Fatalf("output = %q, want 'No active sessions' message", out.String()) } } @@ -527,7 +495,7 @@ func TestAuthCmd_RegistersExpectedSubcommands(t *testing.T) { name := strings.Fields(sub.Use)[0] subcommands[name] = true } - for _, want := range []string{"login", "logout", "status", "list", "revoke"} { + for _, want := range []string{"login", "logout", "status", "revoke"} { if !subcommands[want] { t.Errorf("auth missing subcommand %q (got: %v)", want, subcommands) } From 9ca47056c1702d79122d7bac02f97ccae7a8fb79 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:12:33 +0930 Subject: [PATCH 02/21] auth: remove `auth revoke`; redefine `logout --all` to revoke sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the `entire auth revoke` command. Session management collapses to two verbs: `auth status` shows active sessions, `logout` ends them. Redefine the `logout --all` flag — it no longer removes all *local* contexts. Instead: - `logout` revokes the active session server-side (DELETE .../tokens/current) and removes the active context locally. (Unchanged default behaviour.) - `logout --all` additionally asks the server to revoke *every* session on the active core (list families -> delete each by id). The local side is identical to the default. After a logout clears the active context, the next saved context is promoted to active, so running `entire logout` repeatedly drains every saved login in turn. Cross-core revoke is out of scope: these endpoints target AuthBaseURL's core only, pending the COR-389 control-plane retargeting. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 9dc3b1c4312c --- cmd/entire/cli/auth.go | 99 +------------------ cmd/entire/cli/auth_context_test.go | 35 +++++-- cmd/entire/cli/auth_test.go | 142 +--------------------------- cmd/entire/cli/logout.go | 87 +++++++++++------ cmd/entire/cli/logout_test.go | 45 +++++++-- 5 files changed, 126 insertions(+), 282 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 2010a8b48f..ba2bae8b7c 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -27,10 +27,6 @@ import ( // wrong-audience keyring token. type authTokenLister func(ctx context.Context) ([]api.Token, error) -// authTokenRevoker revokes a single API token by id. Same bearer- -// resolution contract as authTokenLister. -type authTokenRevoker func(ctx context.Context, id string) error - // User-visible placeholder strings. Promoted to constants so tests and // production share a single source of truth. const ( @@ -143,8 +139,8 @@ func addInsecureHTTPAuthFlag(cmd *cobra.Command, target *bool) { func newAuthCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", - Short: "Manage authentication and API tokens", - Long: "Authentication subcommands. Includes login, logout, status, listing tokens, and revoking tokens.", + Short: "Manage authentication", + Long: "Authentication subcommands. Includes login, logout, status, and login-context management (contexts, use).", RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, @@ -153,7 +149,6 @@ func newAuthCmd() *cobra.Command { cmd.AddCommand(newLoginCmd()) cmd.AddCommand(newLogoutCmd()) cmd.AddCommand(newAuthStatusCmd()) - cmd.AddCommand(newAuthRevokeCmd()) cmd.AddCommand(newAuthContextsCmd()) cmd.AddCommand(newAuthUseCmd()) return cmd @@ -435,93 +430,3 @@ func fallback(s, alt string) string { } return s } - -// --- revoke ----------------------------------------------------------------- - -func newAuthRevokeCmd() *cobra.Command { - var revokeCurrent bool - var insecureHTTPAuth bool - cmd := &cobra.Command{ - Use: "revoke [id]", - Short: "Revoke an API token by id", - Long: "Revoke a specific API token. Use --current to revoke the token used by this CLI (equivalent to 'entire logout').", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id := "" - if len(args) == 1 { - id = args[0] - } - if id == "" && !revokeCurrent { - return cmd.Help() - } - if id != "" && revokeCurrent { - return errors.New("cannot use both and --current") - } - if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { - return err - } - return runAuthRevoke(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), - auth.NewContextStore(), defaultListTokens, defaultRevokeTokenByID, defaultRevokeCurrentToken, - auth.RemoveCurrentContext, api.AuthBaseURL(), id, revokeCurrent) - }, - } - cmd.Flags().BoolVar(&revokeCurrent, "current", false, "Revoke the token used by this CLI and remove the local copy") - addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) - return cmd -} - -func defaultRevokeTokenByID(ctx context.Context, id string) error { - token, err := resolveDataAPIToken(ctx) - if err != nil { - return err - } - return newAPITokensClient(token).RevokeToken(ctx, id) //nolint:wrapcheck // RevokeToken already wraps with action context -} - -func runAuthRevoke( - ctx context.Context, - outW, errW io.Writer, - store tokenStore, - list authTokenLister, - revokeByID authTokenRevoker, - revokeCurrent revokeCurrentFunc, - clearContext clearContextFunc, - baseURL, id string, - current bool, -) error { - token, err := store.GetToken(baseURL) - if err != nil { - return fmt.Errorf("read keychain: %w", err) - } - if token == "" { - return fmt.Errorf("not logged in to %s; run 'entire login' first", baseURL) - } - - if current { - // Revoking our own token is just logout — reuse that path so behavior - // stays identical (best-effort revoke + local delete + context clear). - return runLogout(ctx, outW, errW, store, revokeCurrent, clearContext, baseURL) - } - - if err := revokeByID(ctx, id); err != nil { - return err - } - - // The list endpoint requires bearer auth, so a 401 here means the id we - // just revoked was the same one this CLI is using — the local copy is now - // stale and would otherwise produce confusing 401s on every command, so - // remove both the legacy keyring entry and the active context. - if _, listErr := list(ctx); listErr != nil && api.IsHTTPErrorStatus(listErr, http.StatusUnauthorized) { - if delErr := store.DeleteToken(baseURL); delErr != nil { - return fmt.Errorf("revoked token %s but failed to remove local copy: %w", id, delErr) - } - if ctxErr := clearContext(); ctxErr != nil { - fmt.Fprintf(errW, "Warning: revoked token %s but failed to clear current context: %v\n", id, ctxErr) - } - fmt.Fprintf(outW, "Revoked token %s (this was your local token; removed from keychain).\n", id) - return nil - } - - fmt.Fprintf(outW, "Revoked token %s.\n", id) - return nil -} diff --git a/cmd/entire/cli/auth_context_test.go b/cmd/entire/cli/auth_context_test.go index 7f0df65413..8ee0c926e9 100644 --- a/cmd/entire/cli/auth_context_test.go +++ b/cmd/entire/cli/auth_context_test.go @@ -87,7 +87,7 @@ func TestWarnIfCrossCoreContext(t *testing.T) { } } -func TestNoteRemainingLogins(t *testing.T) { +func TestPromoteNextLogin(t *testing.T) { cfgDir := t.TempDir() t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")) @@ -95,19 +95,36 @@ func TestNoteRemainingLogins(t *testing.T) { // No contexts: silent. var empty bytes.Buffer - noteRemainingLogins(&empty) + promoteNextLogin(&empty, &empty) if empty.Len() != 0 { - t.Fatalf("no remaining contexts should be silent, got %q", empty.String()) + t.Fatalf("no contexts should be silent, got %q", empty.String()) } - // A surviving context: names it and points at --all. exp := time.Now().Add(time.Hour).Unix() - if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://core.example.com","handle":"alice","exp":%d}`, exp)), "", true); err != nil { - t.Fatalf("record: %v", err) + if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://a.example.com","handle":"alice","exp":%d}`, exp)), "", true); err != nil { + t.Fatalf("record a: %v", err) + } + if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://b.example.com","handle":"bob","exp":%d}`, exp)), "", true); err != nil { + t.Fatalf("record b: %v", err) + } + + // A current context is set: promotion is a no-op (nothing to promote into). + var noop bytes.Buffer + promoteNextLogin(&noop, &noop) + if noop.Len() != 0 { + t.Fatalf("with a current context set, promote should be silent, got %q", noop.String()) + } + + // Clear the active context (as logout does): the remaining login is promoted. + if err := auth.RemoveCurrentContext(); err != nil { + t.Fatalf("remove current: %v", err) } var buf bytes.Buffer - noteRemainingLogins(&buf) - if !strings.Contains(buf.String(), "core.example.com") || !strings.Contains(buf.String(), "--all") { - t.Fatalf("expected note naming the context and --all, got %q", buf.String()) + promoteNextLogin(&buf, &buf) + if !strings.Contains(buf.String(), "Now using") { + t.Fatalf("expected promotion message, got %q", buf.String()) + } + if _, current, err := auth.Contexts(); err != nil || current == "" { + t.Fatalf("expected a context to be promoted to current (current=%q, err=%v)", current, err) } } diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index 6cb405bb5b..886a383f68 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -20,7 +20,6 @@ import ( const ( testBaseURL = "https://entire.io" testAuthTok = "tok" - testTokenID = "target-id" ) // --- status ----------------------------------------------------------------- @@ -341,145 +340,6 @@ func TestClassifyExpiresAt_Buckets(t *testing.T) { func ptr(s string) *string { return &s } -// --- revoke ----------------------------------------------------------------- - -func TestRunAuthRevoke_ByIDCallsRevoker(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - var gotID string - revokeByID := func(_ context.Context, id string) error { - gotID = id - return nil - } - - revokeCurrentCalled := false - revokeCurrent := func(context.Context) error { - revokeCurrentCalled = true - return nil - } - - // list returns 200 → token id was someone else's, no local cleanup expected. - list := func(context.Context) ([]api.Token, error) { - return []api.Token{{ID: "other"}}, nil - } - - var out, errOut bytes.Buffer - err := runAuthRevoke(context.Background(), &out, &errOut, store, - list, revokeByID, revokeCurrent, func() error { return nil }, testBaseURL, testTokenID, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if revokeCurrentCalled { - t.Fatal("revokeCurrent should not be called when revoking by id") - } - if gotID != testTokenID { - t.Errorf("revokeByID called with id=%q, want %q", gotID, testTokenID) - } - if store.deleted[testBaseURL] { - t.Fatal("local token should NOT be deleted when revoking another token") - } - if !strings.Contains(out.String(), "Revoked token "+testTokenID) { - t.Fatalf("output = %q, want confirmation", out.String()) - } - if strings.Contains(out.String(), "removed from keychain") { - t.Fatalf("output = %q, should not mention keychain cleanup for non-self revoke", out.String()) - } -} - -func TestRunAuthRevoke_ByIDSelfRevokeCleansLocal(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - revokeByID := func(context.Context, string) error { return nil } - revokeCurrent := func(context.Context) error { return nil } - - // list returns 401 → the id we just revoked was our own bearer. - list := func(context.Context) ([]api.Token, error) { - return nil, &api.HTTPError{StatusCode: http.StatusUnauthorized, Message: "Not authenticated"} - } - - var out, errOut bytes.Buffer - err := runAuthRevoke(context.Background(), &out, &errOut, store, - list, revokeByID, revokeCurrent, func() error { return nil }, testBaseURL, testTokenID, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !store.deleted[testBaseURL] { - t.Fatal("local token should be deleted after self-revoke") - } - if !strings.Contains(out.String(), "removed from keychain") { - t.Fatalf("output = %q, want self-revoke confirmation message", out.String()) - } -} - -func TestRunAuthRevoke_CurrentDelegatesToLogout(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - revokeByIDCalled := false - revokeByID := func(context.Context, string) error { - revokeByIDCalled = true - return nil - } - - revokeCurrentCalled := false - revokeCurrent := func(context.Context) error { - revokeCurrentCalled = true - return nil - } - - list := func(context.Context) ([]api.Token, error) { return nil, nil } - - var out, errOut bytes.Buffer - err := runAuthRevoke(context.Background(), &out, &errOut, store, - list, revokeByID, revokeCurrent, func() error { return nil }, testBaseURL, "", true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if revokeByIDCalled { - t.Fatal("revokeByID should not be called when --current is set") - } - if !revokeCurrentCalled { - t.Fatal("revokeCurrent should be called when --current is set") - } - if !store.deleted[testBaseURL] { - t.Fatal("local token should be deleted via logout path") - } - if !strings.Contains(out.String(), "Logged out.") { - t.Fatalf("output = %q, want 'Logged out.' message from logout path", out.String()) - } -} - -func TestRunAuthRevoke_NotLoggedInErrors(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - - var out, errOut bytes.Buffer - err := runAuthRevoke(context.Background(), &out, &errOut, store, - func(context.Context) ([]api.Token, error) { return nil, nil }, - func(context.Context, string) error { return nil }, - func(context.Context) error { return nil }, - func() error { return nil }, - testBaseURL, "some-id", false) - if err == nil { - t.Fatal("expected error when not logged in") - } - if !strings.Contains(err.Error(), "not logged in") { - t.Fatalf("error = %v, want 'not logged in' message", err) - } -} - // --- registration ----------------------------------------------------------- func TestAuthCmd_RegistersExpectedSubcommands(t *testing.T) { @@ -495,7 +355,7 @@ func TestAuthCmd_RegistersExpectedSubcommands(t *testing.T) { name := strings.Fields(sub.Use)[0] subcommands[name] = true } - for _, want := range []string{"login", "logout", "status", "revoke"} { + for _, want := range []string{"login", "logout", "status", "contexts", "use"} { if !subcommands[want] { t.Errorf("auth missing subcommand %q (got: %v)", want, subcommands) } diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index 98cebea457..ed3fd59040 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "strings" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" @@ -39,49 +38,48 @@ func newLogoutCmd() *cobra.Command { Use: "logout", Short: "Log out of Entire", Long: "Log out of Entire.\n\n" + - "By default this removes the active login only. Other saved logins (contexts)\n" + - "remain and can still authenticate `git clone entire://…` against any cluster\n" + - "fronted by their login server. Use --all to remove every saved login.", + "By default this ends the active session only (server-side) and removes the\n" + + "active login from this machine. Other saved logins (contexts) remain and can\n" + + "still authenticate `git clone entire://…` against clusters fronted by their\n" + + "login server. Pass --all to additionally revoke every session on the active\n" + + "core server-side.\n\n" + + "After logging out, the next saved login (if any) becomes active, so running\n" + + "`entire logout` repeatedly drains every saved login in turn.", RunE: func(cmd *cobra.Command, _ []string) error { if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { return err } outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr() - clearFn := auth.RemoveCurrentContext - if all { - clearFn = func() error { _, err := auth.RemoveAllContexts(); return err } //nolint:wrapcheck // RemoveAllContexts already returns a contextual error - } if err := runLogout(cmd.Context(), outW, errW, - auth.NewContextStore(), defaultRevokeCurrentToken, clearFn, api.AuthBaseURL()); err != nil { + auth.NewContextStore(), defaultRevokeCurrentToken, defaultRevokeAllSessions, + auth.RemoveCurrentContext, api.AuthBaseURL(), all); err != nil { return err } - if !all { - noteRemainingLogins(errW) - } + promoteNextLogin(outW, errW) return nil }, } - cmd.Flags().BoolVar(&all, "all", false, "Remove all saved logins (contexts), not just the active one") + cmd.Flags().BoolVar(&all, "all", false, "Also revoke every session on the active core server-side, not just the active one") addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) return cmd } -// noteRemainingLogins warns when other saved contexts survive a default -// logout — they can still authenticate clone/push against clusters bound to -// them, so "Logged out." alone would overstate the result. -func noteRemainingLogins(errW io.Writer) { - all, _, err := auth.Contexts() - if err != nil || len(all) == 0 { +// promoteNextLogin makes the first remaining saved context active after a +// logout cleared the previous one. This is what lets `entire logout` drain +// every login when run repeatedly: each call ends the active login and +// promotes the next, until none remain. Best-effort and informational — +// logout already succeeded by the time we get here. +func promoteNextLogin(outW, errW io.Writer) { + all, current, err := auth.Contexts() + if err != nil || current != "" || len(all) == 0 { return } - names := make([]string, 0, len(all)) - for _, c := range all { - names = append(names, c.Name) + next := all[0].Name + if err := auth.SetCurrentContext(next); err != nil { + fmt.Fprintf(errW, "Note: %d saved login(s) remain; run `entire auth use ` to switch.\n", len(all)) + return } - fmt.Fprintf(errW, - "Note: %d other saved login(s) remain and can still authenticate clones: %s\n"+ - "Run `entire logout --all` to remove them, or `entire auth use ` to switch.\n", - len(all), strings.Join(names, ", ")) + fmt.Fprintf(outW, "Now using %q (%d saved login(s) remain; run `entire logout` again to remove each).\n", next, len(all)) } func defaultRevokeCurrentToken(ctx context.Context) error { @@ -92,7 +90,36 @@ func defaultRevokeCurrentToken(ctx context.Context) error { return newAPITokensClient(token).RevokeCurrentToken(ctx) //nolint:wrapcheck // RevokeCurrentToken already wraps with action context } -func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revoke revokeCurrentFunc, clearContext clearContextFunc, baseURL string) error { +// defaultRevokeAllSessions revokes every active login session on the active +// core (the `entire logout --all` path). It resolves a data-API bearer once, +// lists the user's sessions, and deletes each by id. Best-effort across +// sessions: it attempts them all and returns the first failure, so one stuck +// session doesn't strand the rest. Cross-core revoke is out of scope — these +// endpoints target api.AuthBaseURL()'s core only. +func defaultRevokeAllSessions(ctx context.Context) error { + token, err := resolveDataAPIToken(ctx) + if err != nil { + return err + } + client := newAPITokensClient(token) + sessions, err := client.ListTokens(ctx) + if err != nil { + return fmt.Errorf("list sessions: %w", err) + } + var firstErr error + for _, s := range sessions { + if err := client.RevokeToken(ctx, s.ID); err != nil && firstErr == nil { + firstErr = fmt.Errorf("revoke session %s: %w", s.ID, err) + } + } + return firstErr +} + +// runLogout ends the user's login. revokeCurrent revokes just the active +// session; revokeAll (used when all is set) revokes every session on the +// active core. Either way the local keyring entry and active context are +// removed, so the CLI reports logged-out even if the server call fails. +func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revokeCurrent, revokeAll revokeCurrentFunc, clearContext clearContextFunc, baseURL string, all bool) error { token, err := store.GetToken(baseURL) if err != nil { // Fall through to the local delete: we still want the keyring entry @@ -100,11 +127,15 @@ func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revo fmt.Fprintf(errW, "Warning: failed to read token before revocation: %v\n", err) } if token != "" { + revoke := revokeCurrent + if all { + revoke = revokeAll + } if err := revoke(ctx); err != nil && !api.IsHTTPErrorStatus(err, http.StatusUnauthorized) { // Best-effort: a transient network error shouldn't block local // logout. A 401 means the token is already invalid server-side, // so the desired state is achieved — no warning needed. - fmt.Fprintf(errW, "Warning: server-side token revocation failed: %v\n", err) + fmt.Fprintf(errW, "Warning: server-side session revocation failed: %v\n", err) } } diff --git a/cmd/entire/cli/logout_test.go b/cmd/entire/cli/logout_test.go index a04ceb4c83..3fafca5f08 100644 --- a/cmd/entire/cli/logout_test.go +++ b/cmd/entire/cli/logout_test.go @@ -59,7 +59,7 @@ func TestRunLogout_RevokesServerSideThenDeletesLocally(t *testing.T) { } var out, errOut bytes.Buffer - err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io") + err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -90,7 +90,7 @@ func TestRunLogout_NoTokenSkipsRevoke(t *testing.T) { } var out, errOut bytes.Buffer - err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io") + err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -117,7 +117,7 @@ func TestRunLogout_RevokeFailureWarnsButSucceeds(t *testing.T) { } var out, errOut bytes.Buffer - err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io") + err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -125,7 +125,7 @@ func TestRunLogout_RevokeFailureWarnsButSucceeds(t *testing.T) { if !store.deleted["https://entire.io"] { t.Fatal("local token should still be deleted when server revoke fails") } - if !strings.Contains(errOut.String(), "server-side token revocation failed") { + if !strings.Contains(errOut.String(), "server-side session revocation failed") { t.Fatalf("stderr = %q, want warning about revoke failure", errOut.String()) } if !strings.Contains(errOut.String(), "connection refused") { @@ -147,7 +147,7 @@ func TestRunLogout_RevokeUnauthorizedIsSilent(t *testing.T) { } var out, errOut bytes.Buffer - err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io") + err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -176,7 +176,7 @@ func TestRunLogout_GetTokenErrorWarnsAndFallsThrough(t *testing.T) { } var out, errOut bytes.Buffer - err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io") + err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -202,7 +202,7 @@ func TestRunLogout_ReturnsErrorOnDeleteFailure(t *testing.T) { revoke := func(context.Context) error { return nil } var out, errOut bytes.Buffer - err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io") + err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false) if err == nil { t.Fatal("expected error, got nil") } @@ -214,6 +214,37 @@ func TestRunLogout_ReturnsErrorOnDeleteFailure(t *testing.T) { } } +func TestRunLogout_AllRevokesAllSessions(t *testing.T) { + t.Parallel() + + store := newMockTokenStore() + store.tokens["https://entire.io"] = testLogoutToken + + currentCalled, allCalled := false, false + revokeCurrent := func(context.Context) error { currentCalled = true; return nil } + revokeAll := func(context.Context) error { allCalled = true; return nil } + + var out, errOut bytes.Buffer + err := runLogout(context.Background(), &out, &errOut, store, + revokeCurrent, revokeAll, func() error { return nil }, "https://entire.io", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if currentCalled { + t.Error("--all should not call the current-session revoke") + } + if !allCalled { + t.Error("--all should call the revoke-all path") + } + if !store.deleted["https://entire.io"] { + t.Fatal("local token should still be deleted under --all") + } + if !strings.Contains(out.String(), "Logged out.") { + t.Fatalf("stdout = %q, want to contain %q", out.String(), "Logged out.") + } +} + func TestLogoutCmd_IsRegistered(t *testing.T) { t.Parallel() From f716ebfafb3261ddd5d8235274701881ee71d181 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:15:49 +0930 Subject: [PATCH 03/21] auth: styled table with headers for `entire auth contexts` Replace the tab-separated, headerless context listing with an aligned, styled table (CONTEXT / HANDLE / CORE URL columns, "*" marks the active context), matching the `auth status` active-sessions table. Extract the column-sizing/writing loop into a shared renderAlignedTable helper and rename the shared style set authTableStyles, since both auth tables now use it. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 52bdb2ae5d4e --- cmd/entire/cli/auth.go | 38 +++++++++++++++++------------ cmd/entire/cli/auth_context.go | 34 +++++++++++++++++++++++--- cmd/entire/cli/auth_context_test.go | 12 +++++++-- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index ba2bae8b7c..7c5b3c7822 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -215,7 +215,7 @@ func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list auth fmt.Fprintln(w, "Active sessions:") sortSessionsByRecency(sessions) - renderSessionsTable(w, newSessionsTableStyles(w), sessions, time.Now()) + renderSessionsTable(w, newAuthTableStyles(w), sessions, time.Now()) return nil } @@ -236,13 +236,13 @@ func sortSessionsByRecency(sessions []api.Token) { }) } -// --- active-sessions table --------------------------------------------------- +// --- auth tables ------------------------------------------------------------- -// sessionsTableStyles holds the lipgloss styles for the `entire auth status` -// active-sessions table. Mirrors the approach in activity_render.go: keep -// style construction tied to color detection, and render plain text when -// color is disabled. -type sessionsTableStyles struct { +// authTableStyles holds the lipgloss styles shared by the `entire auth status` +// active-sessions table and the `entire auth contexts` table. Mirrors the +// approach in activity_render.go: keep style construction tied to color +// detection, and render plain text when color is disabled. +type authTableStyles struct { colorEnabled bool header lipgloss.Style // bold + dim, used for column headers @@ -254,9 +254,9 @@ type sessionsTableStyles struct { expired lipgloss.Style // already expired } -func newSessionsTableStyles(w io.Writer) sessionsTableStyles { +func newAuthTableStyles(w io.Writer) authTableStyles { useColor := shouldUseColor(w) - s := sessionsTableStyles{colorEnabled: useColor} + s := authTableStyles{colorEnabled: useColor} if !useColor { return s } @@ -270,7 +270,7 @@ func newSessionsTableStyles(w io.Writer) sessionsTableStyles { return s } -func (s sessionsTableStyles) render(style lipgloss.Style, text string) string { +func (s authTableStyles) render(style lipgloss.Style, text string) string { if !s.colorEnabled { return text } @@ -281,7 +281,7 @@ func (s sessionsTableStyles) render(style lipgloss.Style, text string) string { // Column padding is computed via lipgloss.Width — it strips ANSI escapes, so a // styled cell's visible width matches its plain text. tabwriter can't be used // here once cells contain ANSI codes. -func renderSessionsTable(w io.Writer, sty sessionsTableStyles, tokens []api.Token, now time.Time) { +func renderSessionsTable(w io.Writer, sty authTableStyles, tokens []api.Token, now time.Time) { headerCells := []string{"ID", "NAME", "SCOPE", "CREATED", "LAST USED", "EXPIRES"} header := make([]string, len(headerCells)) for i, h := range headerCells { @@ -300,7 +300,15 @@ func renderSessionsTable(w io.Writer, sty sessionsTableStyles, tokens []api.Toke }) } - widths := make([]int, len(headerCells)) + renderAlignedTable(w, header, rows) +} + +// renderAlignedTable writes header followed by rows in left-aligned columns, +// sizing each column to its widest (possibly pre-styled) cell. Column widths +// use lipgloss.Width so ANSI escapes don't inflate the padding. Shared by the +// auth-status and auth-contexts tables. +func renderAlignedTable(w io.Writer, header []string, rows [][]string) { + widths := make([]int, len(header)) for i, h := range header { widths[i] = lipgloss.Width(h) } @@ -328,21 +336,21 @@ func writeRow(w io.Writer, cells []string, widths []int) { fmt.Fprintln(w) } -func styleName(sty sessionsTableStyles, name string) string { +func styleName(sty authTableStyles, name string) string { if name == "" { return sty.render(sty.dim, placeholderDash) } return sty.render(sty.name, name) } -func styleLastUsed(sty sessionsTableStyles, lastUsed *string, now time.Time) string { +func styleLastUsed(sty authTableStyles, lastUsed *string, now time.Time) string { if lastUsed == nil { return sty.render(sty.dim, lastUsedNever) } return sty.render(sty.value, formatAuthLastUsed(lastUsed, now)) } -func styleExpires(sty sessionsTableStyles, expiresAt string, now time.Time) string { +func styleExpires(sty authTableStyles, expiresAt string, now time.Time) string { formatted := formatAuthDate(expiresAt) switch classifyExpiresAt(expiresAt, now) { case expiresExpired: diff --git a/cmd/entire/cli/auth_context.go b/cmd/entire/cli/auth_context.go index a416f24e59..7c6eb31b13 100644 --- a/cmd/entire/cli/auth_context.go +++ b/cmd/entire/cli/auth_context.go @@ -6,6 +6,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/internal/entireclient/contexts" "github.com/spf13/cobra" ) @@ -91,12 +92,39 @@ func runAuthContexts(w io.Writer) error { fmt.Fprintln(w, "No login contexts. Run 'entire login' to authenticate.") return nil } + renderContextsTable(w, all, current) + return nil +} + +// renderContextsTable prints the saved login contexts as a styled, aligned +// table with column headers. The active context is flagged with "*" in the +// leading column. Purely local data — no network, no timestamps — so it +// reuses the auth-table styles but only the header/name/value/accent slots. +func renderContextsTable(w io.Writer, all []*contexts.Context, current string) { + sty := newAuthTableStyles(w) + + header := []string{ + "", // active marker + sty.render(sty.header, "CONTEXT"), + sty.render(sty.header, "HANDLE"), + sty.render(sty.header, "CORE URL"), + } + + rows := make([][]string, 0, len(all)) for _, c := range all { marker := " " + name := sty.render(sty.value, c.Name) if c.Name == current { - marker = "*" + marker = sty.render(sty.id, "*") + name = sty.render(sty.name, c.Name) } - fmt.Fprintf(w, "%s %s\t%s\t%s\n", marker, c.Name, c.Handle, c.CoreURL) + rows = append(rows, []string{ + marker, + name, + sty.render(sty.value, fallback(c.Handle, placeholderDash)), + sty.render(sty.value, fallback(c.CoreURL, placeholderDash)), + }) } - return nil + + renderAlignedTable(w, header, rows) } diff --git a/cmd/entire/cli/auth_context_test.go b/cmd/entire/cli/auth_context_test.go index 8ee0c926e9..cbd466d7a7 100644 --- a/cmd/entire/cli/auth_context_test.go +++ b/cmd/entire/cli/auth_context_test.go @@ -47,8 +47,16 @@ func TestRunAuthContexts(t *testing.T) { t.Fatalf("runAuthContexts: %v", err) } got := out.String() - if !strings.Contains(got, "* core.example.com") { - t.Fatalf("listing = %q, want current-marked core.example.com", got) + for _, hdr := range []string{"CONTEXT", "HANDLE", "CORE URL"} { + if !strings.Contains(got, hdr) { + t.Fatalf("listing = %q, want column header %q", got, hdr) + } + } + if !strings.Contains(got, "*") { + t.Fatalf("listing = %q, want an active-context marker", got) + } + if !strings.Contains(got, "core.example.com") { + t.Fatalf("listing = %q, want context core.example.com", got) } if !strings.Contains(got, "alice") { t.Fatalf("listing = %q, want handle alice", got) From ed57e73ea92a5cd5068f04343eafebd26cd1baa7 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:20:22 +0930 Subject: [PATCH 04/21] auth: rename api.Token -> api.Session (and methods) to reflect reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These rows are OAuth refresh-token families (login sessions), not personal access tokens — the CLI never mints them. Rename so the types are self-documenting and drop the "Despite the api.Token name…" caveat: api.Token -> api.Session api.TokensResponse -> api.SessionsResponse (wire key stays "tokens") (*Client).ListTokens -> ListSessions (*Client).RevokeToken -> RevokeSession (*Client).RevokeCurrentToken -> RevokeCurrentSession cli: authTokenLister -> sessionLister defaultListTokens -> defaultListSessions defaultRevokeCurrentToken -> defaultRevokeCurrentSession newAPITokensClient -> newSessionsClient Pure rename: wire paths and JSON field names are unchanged, so the server contract is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 0e17fccbbe86 --- cmd/entire/cli/api/auth_tokens.go | 57 ++++++++++++++------------ cmd/entire/cli/api/auth_tokens_test.go | 30 +++++++------- cmd/entire/cli/api/client.go | 4 +- cmd/entire/cli/auth.go | 54 ++++++++++++------------ cmd/entire/cli/auth_test.go | 26 ++++++------ cmd/entire/cli/logout.go | 14 +++---- 6 files changed, 94 insertions(+), 91 deletions(-) diff --git a/cmd/entire/cli/api/auth_tokens.go b/cmd/entire/cli/api/auth_tokens.go index 557c9e6ab3..0552d42884 100644 --- a/cmd/entire/cli/api/auth_tokens.go +++ b/cmd/entire/cli/api/auth_tokens.go @@ -7,9 +7,12 @@ import ( "net/url" ) -// Token is a single API token row returned by the auth-tokens endpoint. -// Plaintext token values are never returned by the server — only metadata. -type Token struct { +// Session is a single active login session — an OAuth refresh-token family — +// returned by the auth-tokens endpoint. One is created per `entire login`, +// across all of a user's devices. Plaintext token values are never returned by +// the server, only metadata. (The wire endpoint is historically named +// "tokens"; these rows are sessions, not personal access tokens.) +type Session struct { ID string `json:"id"` UserID string `json:"user_id"` Name string `json:"name"` @@ -19,13 +22,14 @@ type Token struct { CreatedAt string `json:"created_at"` } -// TokensResponse is the envelope returned by the list endpoint. -type TokensResponse struct { - Tokens []Token `json:"tokens"` +// SessionsResponse is the envelope returned by the list endpoint. The wire key +// stays "tokens" — that is the server's contract. +type SessionsResponse struct { + Sessions []Session `json:"tokens"` } -// errAuthTokensPathUnset surfaces when an auth-tokens method is called -// on a Client that wasn't given a base path. Construct via +// errAuthTokensPathUnset surfaces when a session method is called on a +// Client that wasn't given a base path. Construct via // NewClientWithBaseURL(...).WithAuthTokensPath(...) — the active path // lives in cmd/entire/cli/auth.CurrentProvider().AuthTokensPath, the // single source of truth for provider-version routing. @@ -38,61 +42,62 @@ func (c *Client) authTokensBasePath() (string, error) { return c.authTokensPath, nil } -// ListTokens returns the authenticated user's non-expired API tokens. -func (c *Client) ListTokens(ctx context.Context) ([]Token, error) { +// ListSessions returns the authenticated user's active login sessions. +func (c *Client) ListSessions(ctx context.Context) ([]Session, error) { base, err := c.authTokensBasePath() if err != nil { - return nil, fmt.Errorf("list tokens: %w", err) + return nil, fmt.Errorf("list sessions: %w", err) } resp, err := c.Get(ctx, base) if err != nil { - return nil, fmt.Errorf("list tokens: %w", err) + return nil, fmt.Errorf("list sessions: %w", err) } defer resp.Body.Close() if err := CheckResponse(resp); err != nil { - return nil, fmt.Errorf("list tokens: %w", err) + return nil, fmt.Errorf("list sessions: %w", err) } - var out TokensResponse + var out SessionsResponse if err := DecodeJSON(resp, &out); err != nil { - return nil, fmt.Errorf("list tokens: %w", err) + return nil, fmt.Errorf("list sessions: %w", err) } - return out.Tokens, nil + return out.Sessions, nil } -// RevokeCurrentToken revokes the bearer token used to authenticate this client. -func (c *Client) RevokeCurrentToken(ctx context.Context) error { +// RevokeCurrentSession revokes the login session this client is authenticating +// with (the family the current bearer belongs to). +func (c *Client) RevokeCurrentSession(ctx context.Context) error { base, err := c.authTokensBasePath() if err != nil { - return fmt.Errorf("revoke current token: %w", err) + return fmt.Errorf("revoke current session: %w", err) } resp, err := c.Delete(ctx, base+"/current") if err != nil { - return fmt.Errorf("revoke current token: %w", err) + return fmt.Errorf("revoke current session: %w", err) } defer resp.Body.Close() if err := CheckResponse(resp); err != nil { - return fmt.Errorf("revoke current token: %w", err) + return fmt.Errorf("revoke current session: %w", err) } return nil } -// RevokeToken revokes the API token with the given id. -func (c *Client) RevokeToken(ctx context.Context, id string) error { +// RevokeSession revokes the login session with the given id. +func (c *Client) RevokeSession(ctx context.Context, id string) error { base, err := c.authTokensBasePath() if err != nil { - return fmt.Errorf("revoke token %s: %w", id, err) + return fmt.Errorf("revoke session %s: %w", id, err) } resp, err := c.Delete(ctx, base+"/"+url.PathEscape(id)) if err != nil { - return fmt.Errorf("revoke token %s: %w", id, err) + return fmt.Errorf("revoke session %s: %w", id, err) } defer resp.Body.Close() if err := CheckResponse(resp); err != nil { - return fmt.Errorf("revoke token %s: %w", id, err) + return fmt.Errorf("revoke session %s: %w", id, err) } return nil } diff --git a/cmd/entire/cli/api/auth_tokens_test.go b/cmd/entire/cli/api/auth_tokens_test.go index a4a52f5e73..dc76bc8530 100644 --- a/cmd/entire/cli/api/auth_tokens_test.go +++ b/cmd/entire/cli/api/auth_tokens_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func TestClient_RevokeCurrentToken_SendsDeleteWithBearer(t *testing.T) { +func TestClient_RevokeCurrentSession_SendsDeleteWithBearer(t *testing.T) { t.Parallel() var gotMethod, gotPath, gotAuth string @@ -26,8 +26,8 @@ func TestClient_RevokeCurrentToken_SendsDeleteWithBearer(t *testing.T) { c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") c.baseURL = server.URL - if err := c.RevokeCurrentToken(context.Background()); err != nil { - t.Fatalf("RevokeCurrentToken() error = %v", err) + if err := c.RevokeCurrentSession(context.Background()); err != nil { + t.Fatalf("RevokeCurrentSession() error = %v", err) } if gotMethod != http.MethodDelete { @@ -41,7 +41,7 @@ func TestClient_RevokeCurrentToken_SendsDeleteWithBearer(t *testing.T) { } } -func TestClient_RevokeCurrentToken_ReturnsHTTPErrorOn401(t *testing.T) { +func TestClient_RevokeCurrentSession_ReturnsHTTPErrorOn401(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -54,7 +54,7 @@ func TestClient_RevokeCurrentToken_ReturnsHTTPErrorOn401(t *testing.T) { c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") c.baseURL = server.URL - err := c.RevokeCurrentToken(context.Background()) + err := c.RevokeCurrentSession(context.Background()) if err == nil { t.Fatal("expected error for 401 response") } @@ -70,7 +70,7 @@ func TestClient_RevokeCurrentToken_ReturnsHTTPErrorOn401(t *testing.T) { } } -func TestClient_ListTokens_DecodesResponse(t *testing.T) { +func TestClient_ListSessions_DecodesResponse(t *testing.T) { t.Parallel() var gotMethod, gotPath, gotAuth string @@ -90,9 +90,9 @@ func TestClient_ListTokens_DecodesResponse(t *testing.T) { c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") c.baseURL = server.URL - tokens, err := c.ListTokens(context.Background()) + tokens, err := c.ListSessions(context.Background()) if err != nil { - t.Fatalf("ListTokens() error = %v", err) + t.Fatalf("ListSessions() error = %v", err) } if gotMethod != http.MethodGet { @@ -119,7 +119,7 @@ func TestClient_ListTokens_DecodesResponse(t *testing.T) { } } -func TestClient_ListTokens_ReturnsHTTPErrorOn401(t *testing.T) { +func TestClient_ListSessions_ReturnsHTTPErrorOn401(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -132,7 +132,7 @@ func TestClient_ListTokens_ReturnsHTTPErrorOn401(t *testing.T) { c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") c.baseURL = server.URL - _, err := c.ListTokens(context.Background()) + _, err := c.ListSessions(context.Background()) if err == nil { t.Fatal("expected error for 401") } @@ -141,7 +141,7 @@ func TestClient_ListTokens_ReturnsHTTPErrorOn401(t *testing.T) { } } -func TestClient_RevokeToken_SendsDeleteWithEscapedID(t *testing.T) { +func TestClient_RevokeSession_SendsDeleteWithEscapedID(t *testing.T) { t.Parallel() var gotMethod, gotEscapedPath, gotDecodedPath string @@ -159,8 +159,8 @@ func TestClient_RevokeToken_SendsDeleteWithEscapedID(t *testing.T) { c.baseURL = server.URL // Use an id that needs URL escaping to verify we don't blindly concat. - if err := c.RevokeToken(context.Background(), "abc/def 1"); err != nil { - t.Fatalf("RevokeToken() error = %v", err) + if err := c.RevokeSession(context.Background(), "abc/def 1"); err != nil { + t.Fatalf("RevokeSession() error = %v", err) } if gotMethod != http.MethodDelete { @@ -174,7 +174,7 @@ func TestClient_RevokeToken_SendsDeleteWithEscapedID(t *testing.T) { } } -func TestClient_RevokeToken_ReturnsErrorBody(t *testing.T) { +func TestClient_RevokeSession_ReturnsErrorBody(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -187,7 +187,7 @@ func TestClient_RevokeToken_ReturnsErrorBody(t *testing.T) { c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") c.baseURL = server.URL - err := c.RevokeToken(context.Background(), "missing") + err := c.RevokeSession(context.Background(), "missing") if err == nil { t.Fatal("expected error for 404") } diff --git a/cmd/entire/cli/api/client.go b/cmd/entire/cli/api/client.go index 453207b361..a2df83f40f 100644 --- a/cmd/entire/cli/api/client.go +++ b/cmd/entire/cli/api/client.go @@ -29,8 +29,8 @@ type Client struct { authTokensPath string } -// WithAuthTokensPath sets the base path used by ListTokens, -// RevokeCurrentToken, and RevokeToken. The path is supplied by the +// WithAuthTokensPath sets the base path used by ListSessions, +// RevokeCurrentSession, and RevokeSession. The path is supplied by the // auth shim from auth.CurrentProvider().AuthTokensPath, which is the // single source of truth for provider-version routing — the api // package no longer reads ENTIRE_AUTH_PROVIDER_VERSION itself. diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 7c5b3c7822..ea3def953b 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -16,16 +16,14 @@ import ( "github.com/spf13/cobra" ) -// authTokenLister lists the authenticated user's active login sessions — -// the server-side refresh-token families (one per `entire login`, across -// all devices), surfaced by `entire auth status` and used by revoke as a -// liveness probe. Despite the api.Token name, these are sessions, not -// personal access tokens; the CLI never mints them. The implementation -// resolves its own data-API bearer via auth.TokenForResource (RFC 8693 -// exchange in split-host setups, same-host shortcut otherwise); callers -// don't pass a bearer through, which removes the temptation to forward the -// wrong-audience keyring token. -type authTokenLister func(ctx context.Context) ([]api.Token, error) +// sessionLister lists the authenticated user's active login sessions — the +// server-side refresh-token families, one per `entire login` across all +// devices — surfaced by `entire auth status`. The implementation resolves its +// own data-API bearer via auth.TokenForResource (RFC 8693 exchange in +// split-host setups, same-host shortcut otherwise); callers don't pass a +// bearer through, which removes the temptation to forward the wrong-audience +// keyring token. +type sessionLister func(ctx context.Context) ([]api.Session, error) // User-visible placeholder strings. Promoted to constants so tests and // production share a single source of truth. @@ -37,11 +35,11 @@ const ( // requireSecureBaseURL enforces TLS unless insecureHTTPAuth is set. Every // command that sends a bearer token over the network (login, logout, -// auth status/list/revoke) must call this so credentials don't leak over -// plaintext HTTP without explicit opt-in. +// auth status) must call this so credentials don't leak over plaintext HTTP +// without explicit opt-in. // // Both the auth and data API origins are checked: the bearer travels to the -// auth host for login + auth-token management, and to the data host for +// auth host for login + session management, and to the data host for // search/activity/dispatch/etc. When both origins resolve to the same host // (e.g. an explicitly collapsed single-host deployment) the redundant second // parse is skipped. @@ -68,18 +66,18 @@ func requireSecureBaseURL(insecureHTTPAuth bool) error { return nil } -// newAPITokensClient builds an api.Client for the auth-token management -// endpoints (list / revoke / current). API tokens live on the data API -// regardless of split-host config — the auth host (entire-core in v2) -// mints OAuth tokens but doesn't host application API token management -// endpoints — so this targets api.BaseURL(). +// newSessionsClient builds an api.Client for the session management endpoints +// (list / revoke / current). These live on the data API regardless of +// split-host config — the auth host (entire-core in v2) mints OAuth tokens but +// doesn't host the session management endpoints — so this targets +// api.BaseURL(). // // The supplied token must already be scoped for api.BaseURL(). Callers // must obtain it via resolveDataAPIToken (or auth.TokenForResource // directly) rather than handing through the raw keyring entry — the // keyring stores the auth-host-issued core token, which the data API // rejects in split-host setups. -func newAPITokensClient(token string) *api.Client { +func newSessionsClient(token string) *api.Client { return api.NewClientWithBaseURL(token, api.BaseURL()). WithAuthTokensPath(auth.CurrentProvider().AuthTokensPath) } @@ -166,24 +164,24 @@ func newAuthStatusCmd() *cobra.Command { return err } return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), - auth.NewContextStore(), defaultListTokens, api.AuthBaseURL()) + auth.NewContextStore(), defaultListSessions, api.AuthBaseURL()) }, } addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) return cmd } -// defaultListTokens fetches the authenticated user's active login sessions -// from the server. See authTokenLister for what these rows actually are. -func defaultListTokens(ctx context.Context) ([]api.Token, error) { +// defaultListSessions fetches the authenticated user's active login sessions +// from the server. See sessionLister for what these rows actually are. +func defaultListSessions(ctx context.Context) ([]api.Session, error) { token, err := resolveDataAPIToken(ctx) if err != nil { return nil, err } - return newAPITokensClient(token).ListTokens(ctx) //nolint:wrapcheck // ListTokens already wraps with action context + return newSessionsClient(token).ListSessions(ctx) //nolint:wrapcheck // ListSessions already wraps with action context } -func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list authTokenLister, baseURL string) error { +func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list sessionLister, baseURL string) error { token, err := store.GetToken(baseURL) if err != nil { return fmt.Errorf("read keychain: %w", err) @@ -222,7 +220,7 @@ func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list auth // sortSessionsByRecency orders sessions most-recently-used first, then most // recently created, then by id — a fully specified order independent of the // server's response ordering. -func sortSessionsByRecency(sessions []api.Token) { +func sortSessionsByRecency(sessions []api.Session) { sort.Slice(sessions, func(i, j int) bool { li := lastUsedSortKey(sessions[i]) lj := lastUsedSortKey(sessions[j]) @@ -281,7 +279,7 @@ func (s authTableStyles) render(style lipgloss.Style, text string) string { // Column padding is computed via lipgloss.Width — it strips ANSI escapes, so a // styled cell's visible width matches its plain text. tabwriter can't be used // here once cells contain ANSI codes. -func renderSessionsTable(w io.Writer, sty authTableStyles, tokens []api.Token, now time.Time) { +func renderSessionsTable(w io.Writer, sty authTableStyles, tokens []api.Session, now time.Time) { headerCells := []string{"ID", "NAME", "SCOPE", "CREATED", "LAST USED", "EXPIRES"} header := make([]string, len(headerCells)) for i, h := range headerCells { @@ -363,7 +361,7 @@ func styleExpires(sty authTableStyles, expiresAt string, now time.Time) string { return sty.render(sty.value, formatted) } -func lastUsedSortKey(t api.Token) string { +func lastUsedSortKey(t api.Session) string { if t.LastUsedAt == nil { return "" } diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index 886a383f68..c740343458 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -30,7 +30,7 @@ func TestRunAuthStatus_NotLoggedIn(t *testing.T) { store := newMockTokenStore() listCalled := false - list := func(context.Context) ([]api.Token, error) { + list := func(context.Context) ([]api.Session, error) { listCalled = true return nil, nil } @@ -41,7 +41,7 @@ func TestRunAuthStatus_NotLoggedIn(t *testing.T) { } if listCalled { - t.Fatal("ListTokens should not be called when no token is stored") + t.Fatal("ListSessions should not be called when no token is stored") } if !strings.Contains(out.String(), "Not logged in to "+testBaseURL) { t.Fatalf("output = %q, want 'Not logged in' message", out.String()) @@ -54,8 +54,8 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Token, error) { - return []api.Token{ + list := func(context.Context) ([]api.Session, error) { + return []api.Session{ {ID: "a", Name: "laptop"}, {ID: "b", Name: "ci"}, }, nil @@ -83,7 +83,7 @@ func TestRunAuthStatus_TokenInvalid(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Token, error) { + list := func(context.Context) ([]api.Session, error) { return nil, &api.HTTPError{StatusCode: http.StatusUnauthorized, Message: "Not authenticated"} } @@ -111,7 +111,7 @@ func TestRunAuthStatus_STSRejectionRendersInvalidMessage(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Token, error) { + list := func(context.Context) ([]api.Session, error) { // Exact format auth-go's sts package emits for an invalid_grant // 4xx (see internal/oauthhttp's readAPIError). Without the // detection in isKeychainTokenRejected this would fall through @@ -145,13 +145,13 @@ func TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Token, error) { + list := func(context.Context) ([]api.Session, error) { return nil, errors.New("resolve API token: " + auth.ErrNotLoggedIn.Error()) } // errors.New above is intentionally string-only to defeat the // detection — confirm the substring fallback alone isn't what's // catching this case. The real production path wraps with %w. - listWithChain := func(context.Context) ([]api.Token, error) { + listWithChain := func(context.Context) ([]api.Session, error) { return nil, &wrappedTestError{msg: "resolve API token", inner: auth.ErrNotLoggedIn} } @@ -187,7 +187,7 @@ func TestRunAuthStatus_ServerError(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Token, error) { + list := func(context.Context) ([]api.Session, error) { return nil, errors.New("connection refused") } @@ -210,8 +210,8 @@ func TestRunAuthStatus_SessionsTablePrintsRows(t *testing.T) { store.tokens[testBaseURL] = testAuthTok lastUsed := "2026-04-01T12:00:00Z" - list := func(context.Context) ([]api.Token, error) { - return []api.Token{ + list := func(context.Context) ([]api.Session, error) { + return []api.Session{ {ID: "fam-1", Name: "laptop", Scope: "cli", CreatedAt: "2026-01-01T00:00:00Z", ExpiresAt: "2027-01-01T00:00:00Z", @@ -253,7 +253,7 @@ func TestRunAuthStatus_NoSessionsPrintsMessage(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Token, error) { return nil, nil } + list := func(context.Context) ([]api.Session, error) { return nil, nil } var out bytes.Buffer if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { @@ -373,7 +373,7 @@ func TestAuthCmd_RegistersExpectedSubcommands(t *testing.T) { // tokenmanager.Manager via auth.SetManagerForTest and stub only the // STS wire call via SetExchangeForTest. That covers the audience- // matching logic the function-injection tests above can't reach -// (defaultListTokens / defaultRevokeTokenByID call resolveDataAPIToken +// (defaultListSessions / defaultRevokeAllSessions call resolveDataAPIToken // directly, but unit tests for the surrounding flows inject fakes // that bypass it). diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index ed3fd59040..54a64b8cfd 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -21,7 +21,7 @@ type tokenStore interface { // revokeCurrentFunc revokes the CLI's current token server-side. The // implementation resolves its own data-API bearer (same audience- -// matching rule as authTokenLister); callers don't pass the keyring +// matching rule as sessionLister); callers don't pass the keyring // entry through. type revokeCurrentFunc func(ctx context.Context) error @@ -51,7 +51,7 @@ func newLogoutCmd() *cobra.Command { } outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr() if err := runLogout(cmd.Context(), outW, errW, - auth.NewContextStore(), defaultRevokeCurrentToken, defaultRevokeAllSessions, + auth.NewContextStore(), defaultRevokeCurrentSession, defaultRevokeAllSessions, auth.RemoveCurrentContext, api.AuthBaseURL(), all); err != nil { return err } @@ -82,12 +82,12 @@ func promoteNextLogin(outW, errW io.Writer) { fmt.Fprintf(outW, "Now using %q (%d saved login(s) remain; run `entire logout` again to remove each).\n", next, len(all)) } -func defaultRevokeCurrentToken(ctx context.Context) error { +func defaultRevokeCurrentSession(ctx context.Context) error { token, err := resolveDataAPIToken(ctx) if err != nil { return err } - return newAPITokensClient(token).RevokeCurrentToken(ctx) //nolint:wrapcheck // RevokeCurrentToken already wraps with action context + return newSessionsClient(token).RevokeCurrentSession(ctx) //nolint:wrapcheck // RevokeCurrentSession already wraps with action context } // defaultRevokeAllSessions revokes every active login session on the active @@ -101,14 +101,14 @@ func defaultRevokeAllSessions(ctx context.Context) error { if err != nil { return err } - client := newAPITokensClient(token) - sessions, err := client.ListTokens(ctx) + client := newSessionsClient(token) + sessions, err := client.ListSessions(ctx) if err != nil { return fmt.Errorf("list sessions: %w", err) } var firstErr error for _, s := range sessions { - if err := client.RevokeToken(ctx, s.ID); err != nil && firstErr == nil { + if err := client.RevokeSession(ctx, s.ID); err != nil && firstErr == nil { firstErr = fmt.Errorf("revoke session %s: %w", s.ID, err) } } From d3427dda09066995d7e143df14d565cb4005ecad Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:03:14 +0930 Subject: [PATCH 05/21] auth: remove unused RemoveAllContexts Dead since `logout --all` was redefined to revoke server-side sessions rather than nuke all local contexts. Nothing else references it. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 7dc263eaaac8 --- cmd/entire/cli/auth/context_store.go | 29 ---------------- cmd/entire/cli/auth/contexts_test.go | 51 ---------------------------- 2 files changed, 80 deletions(-) diff --git a/cmd/entire/cli/auth/context_store.go b/cmd/entire/cli/auth/context_store.go index 135653a283..5ff2a782cf 100644 --- a/cmd/entire/cli/auth/context_store.go +++ b/cmd/entire/cli/auth/context_store.go @@ -62,35 +62,6 @@ func RemoveCurrentContext() error { return nil } -// RemoveAllContexts deletes every stored context and its keyring token — a -// full local logout. Returns the number of contexts removed. Best-effort on -// the keyring deletes; the contexts.json clear is what makes the CLI fully -// logged out. -func RemoveAllContexts() (int, error) { - var removed int - if err := contexts.Modify(contexts.DefaultConfigDir(), func(f *contexts.File) (bool, error) { - if len(f.Contexts) == 0 && f.CurrentContext == "" { - return false, nil - } - for _, c := range f.Contexts { - if c.KeychainService != "" && c.Handle != "" { - // Delete both slots: the access token and its paired refresh - // token. Dropping the refresh slot is what actually scrubs the - // machine — otherwise a leftover refresh token outlives logout. - _ = tokenstore.Delete(c.KeychainService, c.Handle) //nolint:errcheck // best-effort; the contexts.json clear below is authoritative - _ = tokenstore.Delete(tokenstore.RefreshService(c.KeychainService), c.Handle) //nolint:errcheck // best-effort; absent refresh slot is fine - } - removed++ - } - f.Contexts = nil - f.CurrentContext = "" - return true, nil - }); err != nil { - return 0, fmt.Errorf("remove all contexts: %w", err) - } - return removed, nil -} - // SetCurrentContext makes name the active context. Returns an error when // no context with that name exists (a stale current pointer is a foot-gun). func SetCurrentContext(name string) error { diff --git a/cmd/entire/cli/auth/contexts_test.go b/cmd/entire/cli/auth/contexts_test.go index 347a7ef28e..f2bba8a196 100644 --- a/cmd/entire/cli/auth/contexts_test.go +++ b/cmd/entire/cli/auth/contexts_test.go @@ -269,57 +269,6 @@ func TestRemoveCurrentContext(t *testing.T) { } } -func TestRemoveAllContexts(t *testing.T) { - cfgDir := t.TempDir() - t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) - restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")) - t.Cleanup(restore) - - exp := time.Now().Add(time.Hour).Unix() - if _, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":"https://a.example.com","handle":"alice","exp":%d}`, exp)), "entr_a", true); err != nil { - t.Fatalf("record a: %v", err) - } - if _, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":"https://b.example.com","handle":"bob","exp":%d}`, exp)), "entr_b", true); err != nil { - t.Fatalf("record b: %v", err) - } - n, err := RemoveAllContexts() - if err != nil { - t.Fatalf("RemoveAllContexts: %v", err) - } - if n != 2 { - t.Fatalf("removed %d, want 2", n) - } - f, err := contexts.Load(cfgDir) - if err != nil { - t.Fatalf("load: %v", err) - } - if len(f.Contexts) != 0 || f.CurrentContext != "" { - t.Fatalf("expected fully cleared, got contexts=%d current=%q", len(f.Contexts), f.CurrentContext) - } - // Every refresh slot must be gone too, for both removed contexts. - for _, tc := range []struct{ iss, handle string }{ - {"https://a.example.com", "alice"}, - {"https://b.example.com", "bob"}, - } { - svc := tokenstore.CoreKeyringService(tc.iss) - if v, err := tokenstore.Get(svc, tc.handle); !errors.Is(err, tokenstore.ErrNotFound) { - t.Fatalf("access slot for %s survived: value=%q err=%v", tc.handle, v, err) - } - if v, err := tokenstore.Get(tokenstore.RefreshService(svc), tc.handle); !errors.Is(err, tokenstore.ErrNotFound) { - t.Fatalf("refresh slot for %s survived: value=%q err=%v", tc.handle, v, err) - } - } - - // Idempotent. - n2, err := RemoveAllContexts() - if err != nil { - t.Fatalf("second RemoveAllContexts: %v", err) - } - if n2 != 0 { - t.Fatalf("second call removed %d, want 0", n2) - } -} - func TestRemoveCurrentContext_DoesNotSwitchToAnother(t *testing.T) { cfgDir := t.TempDir() t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) From 3b286260df140b422b6b6689cca67e52306d95be Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:17:29 +0930 Subject: [PATCH 06/21] auth: show user profile in `auth status` via core GET /me `entire auth status` now calls the core API's GET /me, which both validates the stored token (liveness) and supplies a profile header: Logged in to https://us.auth.entire.io User: Alice Smith (@alice) Identity: github/alice Token: stored in OS keychain Active sessions: ... /me is the primary liveness gate (a 401 surfaces as *coreapi.ErrorModelStatusCode, now recognised by isKeychainTokenRejected). The active-sessions list runs after, on the data API; since the token is already known good, a list failure degrades to a stderr warning instead of failing the command. Empty profile fields are omitted. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 5ca10643dc53 --- cmd/entire/cli/auth.go | 110 +++++++++++++++++++++++++---- cmd/entire/cli/auth_test.go | 134 ++++++++++++++++++++++++++---------- 2 files changed, 195 insertions(+), 49 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index ea3def953b..c4084e8203 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -13,6 +13,7 @@ import ( "charm.land/lipgloss/v2" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/internal/coreapi" "github.com/spf13/cobra" ) @@ -119,6 +120,12 @@ func isKeychainTokenRejected(err error) bool { if api.IsHTTPErrorStatus(err, http.StatusUnauthorized) { return true } + // The /me liveness probe goes through the core API client, whose 401 + // surfaces as *coreapi.ErrorModelStatusCode rather than api.HTTPError. + var coreErr *coreapi.ErrorModelStatusCode + if errors.As(err, &coreErr) && coreErr.StatusCode == http.StatusUnauthorized { + return true + } if errors.Is(err, auth.ErrNotLoggedIn) { return true } @@ -163,14 +170,53 @@ func newAuthStatusCmd() *cobra.Command { if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { return err } - return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), - auth.NewContextStore(), defaultListSessions, api.AuthBaseURL()) + return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), + auth.NewContextStore(), defaultFetchProfile, defaultListSessions, api.AuthBaseURL()) }, } addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) return cmd } +// authProfile is the subset of the core API's GET /me that `entire auth +// status` renders. +type authProfile struct { + Handle string + DisplayName string + Email string + Provider string + ProviderUserID string +} + +// profileFetcher fetches the logged-in user's profile via GET /me on the core +// API. Injected so status stays unit-testable without a live core. +type profileFetcher func(ctx context.Context) (*authProfile, error) + +// defaultFetchProfile fetches the current user's profile from the core API's +// GET /me. It doubles as the liveness check for `entire auth status`: a 401 +// (or an expired login that can't be exchanged) means the stored token is no +// longer usable, which isKeychainTokenRejected maps to a re-login hint. +func defaultFetchProfile(ctx context.Context) (*authProfile, error) { + client, err := coreapi.New() + if err != nil { + return nil, fmt.Errorf("connect to Entire control plane: %w", err) + } + me, err := client.GetMe(ctx) + if err != nil { + return nil, fmt.Errorf("fetch profile: %w", err) + } + p := &authProfile{ + Provider: me.Auth.Provider, + ProviderUserID: me.Auth.ProviderUserId, + } + p.Handle, _ = me.Global.Handle.Get() + if reg, ok := me.Regional.Get(); ok { + p.DisplayName, _ = reg.DisplayName.Get() + p.Email, _ = reg.Email.Get() + } + return p, nil +} + // defaultListSessions fetches the authenticated user's active login sessions // from the server. See sessionLister for what these rows actually are. func defaultListSessions(ctx context.Context) ([]api.Session, error) { @@ -181,42 +227,78 @@ func defaultListSessions(ctx context.Context) ([]api.Session, error) { return newSessionsClient(token).ListSessions(ctx) //nolint:wrapcheck // ListSessions already wraps with action context } -func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list sessionLister, baseURL string) error { +func runAuthStatus(ctx context.Context, outW, errW io.Writer, store tokenStore, fetchProfile profileFetcher, list sessionLister, baseURL string) error { token, err := store.GetToken(baseURL) if err != nil { return fmt.Errorf("read keychain: %w", err) } if token == "" { - fmt.Fprintf(w, "Not logged in to %s\n", baseURL) - fmt.Fprintln(w, "Run 'entire login' to authenticate.") + fmt.Fprintf(outW, "Not logged in to %s\n", baseURL) + fmt.Fprintln(outW, "Run 'entire login' to authenticate.") return nil } - sessions, err := list(ctx) + // GET /me both validates the stored token (liveness) and supplies the + // profile header below. + profile, err := fetchProfile(ctx) if err != nil { if isKeychainTokenRejected(err) { - fmt.Fprintf(w, "Token in keychain for %s is no longer valid.\n", baseURL) - fmt.Fprintln(w, "Run 'entire login' to re-authenticate.") + fmt.Fprintf(outW, "Token in keychain for %s is no longer valid.\n", baseURL) + fmt.Fprintln(outW, "Run 'entire login' to re-authenticate.") return nil } return fmt.Errorf("validate token: %w", err) } - fmt.Fprintf(w, "Logged in to %s\n", baseURL) - fmt.Fprintln(w, " Token: stored in OS keychain") - fmt.Fprintln(w) + fmt.Fprintf(outW, "Logged in to %s\n", baseURL) + writeProfileLines(outW, profile) + fmt.Fprintf(outW, " %-9s %s\n", "Token:", "stored in OS keychain") + fmt.Fprintln(outW) + + // The token is already known good; a sessions-list failure is non-fatal, + // so warn and still report logged-in rather than erroring the command. + sessions, err := list(ctx) + if err != nil { + fmt.Fprintf(errW, "Warning: could not list active sessions: %v\n", err) + return nil + } if len(sessions) == 0 { - fmt.Fprintln(w, "No active sessions.") + fmt.Fprintln(outW, "No active sessions.") return nil } - fmt.Fprintln(w, "Active sessions:") + fmt.Fprintln(outW, "Active sessions:") sortSessionsByRecency(sessions) - renderSessionsTable(w, newAuthTableStyles(w), sessions, time.Now()) + renderSessionsTable(outW, newAuthTableStyles(outW), sessions, time.Now()) return nil } +// writeProfileLines renders the user identity from GET /me as aligned +// label/value lines, omitting any field the server didn't populate. +func writeProfileLines(w io.Writer, p *authProfile) { + var parts []string + if p.DisplayName != "" { + parts = append(parts, p.DisplayName) + } + if p.Handle != "" { + parts = append(parts, "@"+p.Handle) + } + if p.Email != "" { + parts = append(parts, "<"+p.Email+">") + } + if len(parts) > 0 { + fmt.Fprintf(w, " %-9s %s\n", "User:", strings.Join(parts, " ")) + } + if p.Provider != "" { + identity := p.Provider + if p.ProviderUserID != "" { + identity += "/" + p.ProviderUserID + } + fmt.Fprintf(w, " %-9s %s\n", "Identity:", identity) + } +} + // sortSessionsByRecency orders sessions most-recently-used first, then most // recently created, then by id — a fully specified order independent of the // server's response ordering. diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index c740343458..7622354c45 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -15,6 +15,7 @@ import ( "github.com/entireio/auth-go/tokenstore" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/internal/coreapi" ) const ( @@ -24,24 +25,50 @@ const ( // --- status ----------------------------------------------------------------- +// okProfile is a profileFetcher returning a fully-populated profile, for the +// happy-path status tests. +func okProfile(context.Context) (*authProfile, error) { + return &authProfile{ + Handle: "alice", + DisplayName: "Alice Smith", + Email: "alice@example.com", + Provider: "github", + ProviderUserID: "alice", + }, nil +} + +// unusedList is a sessionLister that fails the test if called — used by the +// liveness tests where /me rejects the token before the list is reached. +func unusedList(t *testing.T) sessionLister { + return func(context.Context) ([]api.Session, error) { + t.Helper() + t.Fatal("session list should not be called on the rejected-token path") + return nil, nil + } +} + func TestRunAuthStatus_NotLoggedIn(t *testing.T) { t.Parallel() store := newMockTokenStore() - listCalled := false + profileCalled, listCalled := false, false + fetchProfile := func(context.Context) (*authProfile, error) { + profileCalled = true + return &authProfile{}, nil + } list := func(context.Context) ([]api.Session, error) { listCalled = true return nil, nil } - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { + var out, errOut bytes.Buffer + if err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, list, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } - if listCalled { - t.Fatal("ListSessions should not be called when no token is stored") + if profileCalled || listCalled { + t.Fatal("no network calls expected when no token is stored") } if !strings.Contains(out.String(), "Not logged in to "+testBaseURL) { t.Fatalf("output = %q, want 'Not logged in' message", out.String()) @@ -61,19 +88,26 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { }, nil } - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { + var out, errOut bytes.Buffer + if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out.String(), "Logged in to "+testBaseURL) { - t.Fatalf("output = %q, want 'Logged in' message", out.String()) + got := out.String() + if !strings.Contains(got, "Logged in to "+testBaseURL) { + t.Fatalf("output = %q, want 'Logged in' message", got) + } + if !strings.Contains(got, "Alice Smith") || !strings.Contains(got, "@alice") || !strings.Contains(got, "") { + t.Fatalf("output = %q, want profile header (name/@handle/)", got) } - if !strings.Contains(out.String(), "Active sessions:") { - t.Fatalf("output = %q, want active-sessions heading", out.String()) + if !strings.Contains(got, "github/alice") { + t.Fatalf("output = %q, want provider identity", got) } - if !strings.Contains(out.String(), "laptop") || !strings.Contains(out.String(), "ci") { - t.Fatalf("output = %q, want session rows", out.String()) + if !strings.Contains(got, "Active sessions:") { + t.Fatalf("output = %q, want active-sessions heading", got) + } + if !strings.Contains(got, "laptop") || !strings.Contains(got, "ci") { + t.Fatalf("output = %q, want session rows", got) } } @@ -83,12 +117,14 @@ func TestRunAuthStatus_TokenInvalid(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Session, error) { - return nil, &api.HTTPError{StatusCode: http.StatusUnauthorized, Message: "Not authenticated"} + // A 401 from GET /me arrives as *coreapi.ErrorModelStatusCode, not + // api.HTTPError — exercise isKeychainTokenRejected's core-API branch. + fetchProfile := func(context.Context) (*authProfile, error) { + return nil, &coreapi.ErrorModelStatusCode{StatusCode: http.StatusUnauthorized} } - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { + var out, errOut bytes.Buffer + if err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, unusedList(t), testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -111,7 +147,7 @@ func TestRunAuthStatus_STSRejectionRendersInvalidMessage(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Session, error) { + fetchProfile := func(context.Context) (*authProfile, error) { // Exact format auth-go's sts package emits for an invalid_grant // 4xx (see internal/oauthhttp's readAPIError). Without the // detection in isKeychainTokenRejected this would fall through @@ -120,8 +156,8 @@ func TestRunAuthStatus_STSRejectionRendersInvalidMessage(t *testing.T) { return nil, errors.New("token exchange: status 400: invalid_grant: subject_token expired") } - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { + var out, errOut bytes.Buffer + if err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, unusedList(t), testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -145,25 +181,25 @@ func TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Session, error) { - return nil, errors.New("resolve API token: " + auth.ErrNotLoggedIn.Error()) + fetchProfile := func(context.Context) (*authProfile, error) { + return nil, errors.New("fetch profile: " + auth.ErrNotLoggedIn.Error()) } // errors.New above is intentionally string-only to defeat the // detection — confirm the substring fallback alone isn't what's // catching this case. The real production path wraps with %w. - listWithChain := func(context.Context) ([]api.Session, error) { - return nil, &wrappedTestError{msg: "resolve API token", inner: auth.ErrNotLoggedIn} + fetchWithChain := func(context.Context) (*authProfile, error) { + return nil, &wrappedTestError{msg: "fetch profile", inner: auth.ErrNotLoggedIn} } // Sanity: string-only does NOT match (no sentinel chain). - var out1 bytes.Buffer - if err := runAuthStatus(context.Background(), &out1, store, list, testBaseURL); err == nil { + var out1, errOut1 bytes.Buffer + if err := runAuthStatus(context.Background(), &out1, &errOut1, store, fetchProfile, unusedList(t), testBaseURL); err == nil { t.Fatal("string-only ErrNotLoggedIn should not match — keep the test honest") } // Real path: errors.Is sees the sentinel through the %w chain. - var out2 bytes.Buffer - if err := runAuthStatus(context.Background(), &out2, store, listWithChain, testBaseURL); err != nil { + var out2, errOut2 bytes.Buffer + if err := runAuthStatus(context.Background(), &out2, &errOut2, store, fetchWithChain, unusedList(t), testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(out2.String(), "no longer valid") { @@ -187,12 +223,12 @@ func TestRunAuthStatus_ServerError(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Session, error) { + fetchProfile := func(context.Context) (*authProfile, error) { return nil, errors.New("connection refused") } - var out bytes.Buffer - err := runAuthStatus(context.Background(), &out, store, list, testBaseURL) + var out, errOut bytes.Buffer + err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, unusedList(t), testBaseURL) if err == nil { t.Fatal("expected error for non-401 failure") } @@ -223,8 +259,8 @@ func TestRunAuthStatus_SessionsTablePrintsRows(t *testing.T) { }, nil } - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { + var out, errOut bytes.Buffer + if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -255,8 +291,8 @@ func TestRunAuthStatus_NoSessionsPrintsMessage(t *testing.T) { list := func(context.Context) ([]api.Session, error) { return nil, nil } - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, list, testBaseURL); err != nil { + var out, errOut bytes.Buffer + if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(out.String(), "Logged in to "+testBaseURL) { @@ -267,6 +303,34 @@ func TestRunAuthStatus_NoSessionsPrintsMessage(t *testing.T) { } } +// TestRunAuthStatus_SessionsListFailureIsSoftWarning pins that once /me has +// confirmed the token, a sessions-list failure degrades to a stderr warning +// rather than failing the command — the user is still logged in. +func TestRunAuthStatus_SessionsListFailureIsSoftWarning(t *testing.T) { + t.Parallel() + + store := newMockTokenStore() + store.tokens[testBaseURL] = testAuthTok + + list := func(context.Context) ([]api.Session, error) { + return nil, errors.New("data API unreachable") + } + + var out, errOut bytes.Buffer + if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "Logged in to "+testBaseURL) { + t.Fatalf("stdout = %q, want 'Logged in' message", out.String()) + } + if !strings.Contains(errOut.String(), "could not list active sessions") { + t.Fatalf("stderr = %q, want sessions-list warning", errOut.String()) + } + if !strings.Contains(errOut.String(), "data API unreachable") { + t.Fatalf("stderr = %q, want underlying error", errOut.String()) + } +} + func TestFormatAuthLastUsed_RelativeBuckets(t *testing.T) { t.Parallel() From 04e54e606a9e878dcd61155a236afe7453e61cc6 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:56:47 +0930 Subject: [PATCH 07/21] auth: stop hitting entire.io PAT endpoint; sessions live on entire-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auth status` and `logout` were pointing session list/revoke at entire.io's /api/v1/auth/tokens — which is the legacy `ent_` personal-access-token surface, not login sessions. For a JWT login that endpoint lists nothing (no ent_ PATs) and rejects DELETE /current with 400 ("revoke entire-core JWTs via entire-core"). The CLI never mints or sends ent_ PATs, so it has no business there. Repoint session management at entire-core (the auth host) /api/auth/tokens, authenticated with the session-scoped login JWT (resolveAuthHostToken — a same-host resolution that preserves the entire:session scope core's session routes require): - auth status: drop the server-side session table entirely. Status is now local: GET /me (profile + liveness) + the active login context. No PAT endpoint, no empty "active sessions". - logout: revoke the current session (and --all: every session on the core) via entire-core, not entire.io. Removes the now-dead session-table rendering + date-formatting helpers. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 99a4950a334d --- cmd/entire/cli/auth.go | 297 +++++++------------------------ cmd/entire/cli/auth_test.go | 346 +++++++++--------------------------- cmd/entire/cli/logout.go | 4 +- 3 files changed, 151 insertions(+), 496 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index c4084e8203..f324ba1604 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -6,31 +6,28 @@ import ( "fmt" "io" "net/http" - "sort" "strings" - "time" "charm.land/lipgloss/v2" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/internal/coreapi" + "github.com/entireio/cli/internal/entireclient/contexts" "github.com/spf13/cobra" ) -// sessionLister lists the authenticated user's active login sessions — the -// server-side refresh-token families, one per `entire login` across all -// devices — surfaced by `entire auth status`. The implementation resolves its -// own data-API bearer via auth.TokenForResource (RFC 8693 exchange in -// split-host setups, same-host shortcut otherwise); callers don't pass a -// bearer through, which removes the temptation to forward the wrong-audience -// keyring token. -type sessionLister func(ctx context.Context) ([]api.Session, error) - -// User-visible placeholder strings. Promoted to constants so tests and -// production share a single source of truth. +// coreSessionsPath is entire-core's login-session endpoint family +// (list / revoke / current). These are OAuth refresh-token families, served +// by the auth host (entire-core), NOT entire.io's `/api/v1/auth/tokens` — +// that path is entire.io's legacy `ent_` personal-access-token surface, which +// rejects entire-core JWTs. The CLI authenticates with a core JWT, so all +// session management goes to core. +const coreSessionsPath = "/api/auth/tokens" + +// User-visible placeholder strings. lastUsedJustNow is consumed by +// formatRelativeDuration in status.go. const ( placeholderDash = "-" - lastUsedNever = "never" lastUsedJustNow = "just now" ) @@ -67,42 +64,36 @@ func requireSecureBaseURL(insecureHTTPAuth bool) error { return nil } -// newSessionsClient builds an api.Client for the session management endpoints -// (list / revoke / current). These live on the data API regardless of -// split-host config — the auth host (entire-core in v2) mints OAuth tokens but -// doesn't host the session management endpoints — so this targets -// api.BaseURL(). -// -// The supplied token must already be scoped for api.BaseURL(). Callers -// must obtain it via resolveDataAPIToken (or auth.TokenForResource -// directly) rather than handing through the raw keyring entry — the -// keyring stores the auth-host-issued core token, which the data API -// rejects in split-host setups. +// newSessionsClient builds an api.Client for entire-core's login-session +// endpoints (coreSessionsPath). It targets the auth host (api.AuthBaseURL()), +// since that's where the session/refresh-token families live; the supplied +// bearer must be the session-scoped login JWT, obtained via +// resolveAuthHostToken (a same-host resolution that returns the login token +// unchanged, preserving its entire:session scope — entire-core's session +// routes require it). func newSessionsClient(token string) *api.Client { - return api.NewClientWithBaseURL(token, api.BaseURL()). - WithAuthTokensPath(auth.CurrentProvider().AuthTokensPath) + return api.NewClientWithBaseURL(token, api.AuthBaseURL()). + WithAuthTokensPath(coreSessionsPath) } -// resolveDataAPIToken returns a bearer scoped for the data API. In -// split-host setups this triggers an RFC 8693 exchange against the -// auth host's STS endpoint; in single-host setups the tokenmanager -// hits the same-host shortcut and returns the core token unchanged. -// Centralised so the audience-mismatch bug that motivated this fix -// can't be reintroduced piecemeal at individual call sites. -func resolveDataAPIToken(ctx context.Context) (string, error) { - token, err := auth.TokenForResource(ctx, api.OriginOnly(api.BaseURL())) +// resolveAuthHostToken returns a bearer scoped for the auth host (entire-core). +// For the auth host's own origin the tokenmanager hits the same-host shortcut +// and returns the stored login JWT unchanged — keeping the entire:session +// scope that core's session endpoints (and /me) require, with no STS exchange. +func resolveAuthHostToken(ctx context.Context) (string, error) { + token, err := auth.TokenForResource(ctx, api.OriginOnly(api.AuthBaseURL())) if err != nil { - return "", fmt.Errorf("resolve API token: %w", err) + return "", fmt.Errorf("resolve auth-host token: %w", err) } return token, nil } // isKeychainTokenRejected reports whether err indicates the stored -// keyring token can't authenticate against the data API. Three failure -// modes collapse into this single "the user must re-login" branch: +// keyring token can't authenticate against entire-core. Failure modes that +// collapse into the single "the user must re-login" branch: // -// - data API returned 401 (single-host, or after a successful STS -// exchange whose result the data API then rejected), +// - core API returned 401 (surfaces as *coreapi.ErrorModelStatusCode), +// or a data API 401 (api.HTTPError), // - tokenmanager's preflight rejected an expired core token JWT // (surfacing as auth.ErrNotLoggedIn even though the keyring entry // is still present), @@ -170,8 +161,8 @@ func newAuthStatusCmd() *cobra.Command { if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { return err } - return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), - auth.NewContextStore(), defaultFetchProfile, defaultListSessions, api.AuthBaseURL()) + return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), + auth.NewContextStore(), defaultFetchProfile, auth.Contexts, api.AuthBaseURL()) }, } addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) @@ -192,6 +183,11 @@ type authProfile struct { // API. Injected so status stays unit-testable without a live core. type profileFetcher func(ctx context.Context) (*authProfile, error) +// contextsProvider returns the stored login contexts and the active context +// name, for the local-context lines in `entire auth status`. Injected for +// testability; production wires auth.Contexts. +type contextsProvider func() ([]*contexts.Context, string, error) + // defaultFetchProfile fetches the current user's profile from the core API's // GET /me. It doubles as the liveness check for `entire auth status`: a 401 // (or an expired login that can't be exchanged) means the stored token is no @@ -217,60 +213,46 @@ func defaultFetchProfile(ctx context.Context) (*authProfile, error) { return p, nil } -// defaultListSessions fetches the authenticated user's active login sessions -// from the server. See sessionLister for what these rows actually are. -func defaultListSessions(ctx context.Context) ([]api.Session, error) { - token, err := resolveDataAPIToken(ctx) - if err != nil { - return nil, err - } - return newSessionsClient(token).ListSessions(ctx) //nolint:wrapcheck // ListSessions already wraps with action context -} - -func runAuthStatus(ctx context.Context, outW, errW io.Writer, store tokenStore, fetchProfile profileFetcher, list sessionLister, baseURL string) error { +// runAuthStatus reports auth state without listing server-side sessions: GET +// /me validates the token and supplies the profile header, and the active +// login context is read locally. (Session listing/revocation lives on +// entire-core and is reached only by logout — see newSessionsClient.) +func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, fetchProfile profileFetcher, listContexts contextsProvider, baseURL string) error { token, err := store.GetToken(baseURL) if err != nil { return fmt.Errorf("read keychain: %w", err) } if token == "" { - fmt.Fprintf(outW, "Not logged in to %s\n", baseURL) - fmt.Fprintln(outW, "Run 'entire login' to authenticate.") + fmt.Fprintf(w, "Not logged in to %s\n", baseURL) + fmt.Fprintln(w, "Run 'entire login' to authenticate.") return nil } - // GET /me both validates the stored token (liveness) and supplies the - // profile header below. profile, err := fetchProfile(ctx) if err != nil { if isKeychainTokenRejected(err) { - fmt.Fprintf(outW, "Token in keychain for %s is no longer valid.\n", baseURL) - fmt.Fprintln(outW, "Run 'entire login' to re-authenticate.") + fmt.Fprintf(w, "Token in keychain for %s is no longer valid.\n", baseURL) + fmt.Fprintln(w, "Run 'entire login' to re-authenticate.") return nil } return fmt.Errorf("validate token: %w", err) } - fmt.Fprintf(outW, "Logged in to %s\n", baseURL) - writeProfileLines(outW, profile) - fmt.Fprintf(outW, " %-9s %s\n", "Token:", "stored in OS keychain") - fmt.Fprintln(outW) + fmt.Fprintf(w, "Logged in to %s\n", baseURL) + writeProfileLines(w, profile) - // The token is already known good; a sessions-list failure is non-fatal, - // so warn and still report logged-in rather than erroring the command. - sessions, err := list(ctx) - if err != nil { - fmt.Fprintf(errW, "Warning: could not list active sessions: %v\n", err) - return nil + // Local context info is informational; a read failure shouldn't fail the + // command, so on error we just skip the context lines. + all, current, ctxErr := listContexts() + if ctxErr == nil && current != "" { + fmt.Fprintf(w, " %-9s %s\n", "Context:", current) } + fmt.Fprintf(w, " %-9s %s\n", "Token:", "stored in OS keychain") - if len(sessions) == 0 { - fmt.Fprintln(outW, "No active sessions.") - return nil + if ctxErr == nil && len(all) > 1 { + fmt.Fprintln(w) + fmt.Fprintf(w, "%d login contexts saved; run 'entire auth contexts' to list or 'entire auth use ' to switch.\n", len(all)) } - - fmt.Fprintln(outW, "Active sessions:") - sortSessionsByRecency(sessions) - renderSessionsTable(outW, newAuthTableStyles(outW), sessions, time.Now()) return nil } @@ -299,39 +281,18 @@ func writeProfileLines(w io.Writer, p *authProfile) { } } -// sortSessionsByRecency orders sessions most-recently-used first, then most -// recently created, then by id — a fully specified order independent of the -// server's response ordering. -func sortSessionsByRecency(sessions []api.Session) { - sort.Slice(sessions, func(i, j int) bool { - li := lastUsedSortKey(sessions[i]) - lj := lastUsedSortKey(sessions[j]) - if li != lj { - return li > lj - } - if sessions[i].CreatedAt != sessions[j].CreatedAt { - return sessions[i].CreatedAt > sessions[j].CreatedAt - } - return sessions[i].ID < sessions[j].ID - }) -} - // --- auth tables ------------------------------------------------------------- -// authTableStyles holds the lipgloss styles shared by the `entire auth status` -// active-sessions table and the `entire auth contexts` table. Mirrors the -// approach in activity_render.go: keep style construction tied to color -// detection, and render plain text when color is disabled. +// authTableStyles holds the lipgloss styles for the `entire auth contexts` +// table. Mirrors the approach in activity_render.go: keep style construction +// tied to color detection, and render plain text when color is disabled. type authTableStyles struct { colorEnabled bool - header lipgloss.Style // bold + dim, used for column headers - id lipgloss.Style // yellow accent - name lipgloss.Style // bold - value lipgloss.Style // default fg for scope/dates (no color) - dim lipgloss.Style // "never", "-" - warning lipgloss.Style // expires-soon - expired lipgloss.Style // already expired + header lipgloss.Style // bold + dim, used for column headers + id lipgloss.Style // yellow accent (active-context marker) + name lipgloss.Style // bold (active context name) + value lipgloss.Style // default fg } func newAuthTableStyles(w io.Writer) authTableStyles { @@ -344,9 +305,6 @@ func newAuthTableStyles(w io.Writer) authTableStyles { s.id = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow s.name = lipgloss.NewStyle().Bold(true) s.value = lipgloss.NewStyle() // default fg - s.dim = lipgloss.NewStyle().Faint(true) - s.warning = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - s.expired = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red return s } @@ -357,36 +315,9 @@ func (s authTableStyles) render(style lipgloss.Style, text string) string { return style.Render(text) } -// renderSessionsTable prints a styled, column-aligned table of login sessions. -// Column padding is computed via lipgloss.Width — it strips ANSI escapes, so a -// styled cell's visible width matches its plain text. tabwriter can't be used -// here once cells contain ANSI codes. -func renderSessionsTable(w io.Writer, sty authTableStyles, tokens []api.Session, now time.Time) { - headerCells := []string{"ID", "NAME", "SCOPE", "CREATED", "LAST USED", "EXPIRES"} - header := make([]string, len(headerCells)) - for i, h := range headerCells { - header[i] = sty.render(sty.header, h) - } - - rows := make([][]string, 0, len(tokens)) - for _, t := range tokens { - rows = append(rows, []string{ - sty.render(sty.id, t.ID), - styleName(sty, t.Name), - sty.render(sty.value, fallback(t.Scope, placeholderDash)), - sty.render(sty.value, formatAuthDate(t.CreatedAt)), - styleLastUsed(sty, t.LastUsedAt, now), - styleExpires(sty, t.ExpiresAt, now), - }) - } - - renderAlignedTable(w, header, rows) -} - // renderAlignedTable writes header followed by rows in left-aligned columns, // sizing each column to its widest (possibly pre-styled) cell. Column widths -// use lipgloss.Width so ANSI escapes don't inflate the padding. Shared by the -// auth-status and auth-contexts tables. +// use lipgloss.Width so ANSI escapes don't inflate the padding. func renderAlignedTable(w io.Writer, header []string, rows [][]string) { widths := make([]int, len(header)) for i, h := range header { @@ -416,102 +347,6 @@ func writeRow(w io.Writer, cells []string, widths []int) { fmt.Fprintln(w) } -func styleName(sty authTableStyles, name string) string { - if name == "" { - return sty.render(sty.dim, placeholderDash) - } - return sty.render(sty.name, name) -} - -func styleLastUsed(sty authTableStyles, lastUsed *string, now time.Time) string { - if lastUsed == nil { - return sty.render(sty.dim, lastUsedNever) - } - return sty.render(sty.value, formatAuthLastUsed(lastUsed, now)) -} - -func styleExpires(sty authTableStyles, expiresAt string, now time.Time) string { - formatted := formatAuthDate(expiresAt) - switch classifyExpiresAt(expiresAt, now) { - case expiresExpired: - return sty.render(sty.expired, formatted) - case expiresSoon: - return sty.render(sty.warning, formatted) - case expiresNormal: - return sty.render(sty.value, formatted) - } - return sty.render(sty.value, formatted) -} - -func lastUsedSortKey(t api.Session) string { - if t.LastUsedAt == nil { - return "" - } - return *t.LastUsedAt -} - -// formatAuthDate renders an RFC3339 timestamp as YYYY-MM-DD in local time. -func formatAuthDate(s string) string { - if s == "" { - return placeholderDash - } - if ts, err := time.Parse(time.RFC3339, s); err == nil { - return ts.Local().Format("2006-01-02") - } - return s -} - -// formatAuthLastUsed renders a relative "last used" timestamp, with "yesterday" -// and absolute-date branches that the shared formatRelativeDuration helper -// doesn't cover. -func formatAuthLastUsed(s *string, now time.Time) string { - if s == nil || *s == "" { - return lastUsedNever - } - ts, err := time.Parse(time.RFC3339, *s) - if err != nil { - return *s - } - delta := now.Sub(ts) - switch { - case delta < 0, delta >= 30*24*time.Hour: - return ts.Local().Format("2006-01-02") - case delta >= 24*time.Hour && delta < 48*time.Hour: - return "yesterday" - default: - return formatRelativeDuration(delta) - } -} - -type expiresState int - -const ( - expiresNormal expiresState = iota - expiresSoon - expiresExpired -) - -// classifyExpiresAt classifies an RFC3339 expires-at relative to now. Used to -// color the EXPIRES column so tokens worth rotating stand out. -func classifyExpiresAt(s string, now time.Time) expiresState { - if s == "" { - return expiresNormal - } - ts, err := time.Parse(time.RFC3339, s) - if err != nil { - return expiresNormal - } - delta := ts.Sub(now) - switch { - case delta <= 0: - return expiresExpired - case delta < 7*24*time.Hour: - return expiresSoon - default: - return expiresNormal - } -} - func fallback(s, alt string) string { if strings.TrimSpace(s) == "" { return alt diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index 7622354c45..7497eb9963 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -7,7 +7,6 @@ import ( "net/http" "strings" "testing" - "time" "github.com/entireio/auth-go/sts" "github.com/entireio/auth-go/tokenmanager" @@ -16,6 +15,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/internal/coreapi" + "github.com/entireio/cli/internal/entireclient/contexts" ) const ( @@ -37,13 +37,16 @@ func okProfile(context.Context) (*authProfile, error) { }, nil } -// unusedList is a sessionLister that fails the test if called — used by the -// liveness tests where /me rejects the token before the list is reached. -func unusedList(t *testing.T) sessionLister { - return func(context.Context) ([]api.Session, error) { +// noContexts is a contextsProvider with nothing stored. +func noContexts() ([]*contexts.Context, string, error) { return nil, "", nil } + +// unusedProfile is a profileFetcher that fails the test if called — for the +// not-logged-in path, where the local token check short-circuits before /me. +func unusedProfile(t *testing.T) profileFetcher { + return func(context.Context) (*authProfile, error) { t.Helper() - t.Fatal("session list should not be called on the rejected-token path") - return nil, nil + t.Fatal("/me should not be called when no token is stored") + return nil, errors.New("unreachable") } } @@ -52,24 +55,10 @@ func TestRunAuthStatus_NotLoggedIn(t *testing.T) { store := newMockTokenStore() - profileCalled, listCalled := false, false - fetchProfile := func(context.Context) (*authProfile, error) { - profileCalled = true - return &authProfile{}, nil - } - list := func(context.Context) ([]api.Session, error) { - listCalled = true - return nil, nil - } - - var out, errOut bytes.Buffer - if err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, list, testBaseURL); err != nil { + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, store, unusedProfile(t), noContexts, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } - - if profileCalled || listCalled { - t.Fatal("no network calls expected when no token is stored") - } if !strings.Contains(out.String(), "Not logged in to "+testBaseURL) { t.Fatalf("output = %q, want 'Not logged in' message", out.String()) } @@ -81,15 +70,12 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - list := func(context.Context) ([]api.Session, error) { - return []api.Session{ - {ID: "a", Name: "laptop"}, - {ID: "b", Name: "ci"}, - }, nil + ctxs := func() ([]*contexts.Context, string, error) { + return []*contexts.Context{{Name: "us.auth.entire.io"}}, "us.auth.entire.io", nil } - var out, errOut bytes.Buffer - if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, store, okProfile, ctxs, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -103,11 +89,31 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { if !strings.Contains(got, "github/alice") { t.Fatalf("output = %q, want provider identity", got) } - if !strings.Contains(got, "Active sessions:") { - t.Fatalf("output = %q, want active-sessions heading", got) + if !strings.Contains(got, "us.auth.entire.io") { + t.Fatalf("output = %q, want active-context line", got) } - if !strings.Contains(got, "laptop") || !strings.Contains(got, "ci") { - t.Fatalf("output = %q, want session rows", got) + // Status must no longer list server-side sessions. + if strings.Contains(got, "Active sessions") { + t.Fatalf("output = %q, should not list server-side sessions", got) + } +} + +func TestRunAuthStatus_MultipleContextsHint(t *testing.T) { + t.Parallel() + + store := newMockTokenStore() + store.tokens[testBaseURL] = testAuthTok + + ctxs := func() ([]*contexts.Context, string, error) { + return []*contexts.Context{{Name: "a"}, {Name: "b"}, {Name: "c"}}, "a", nil + } + + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, store, okProfile, ctxs, testBaseURL); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "3 login contexts saved") { + t.Fatalf("output = %q, want multi-context hint", out.String()) } } @@ -123,11 +129,10 @@ func TestRunAuthStatus_TokenInvalid(t *testing.T) { return nil, &coreapi.ErrorModelStatusCode{StatusCode: http.StatusUnauthorized} } - var out, errOut bytes.Buffer - if err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, unusedList(t), testBaseURL); err != nil { + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, store, fetchProfile, noContexts, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out.String(), "no longer valid") { t.Fatalf("output = %q, want invalid-token message", out.String()) } @@ -136,11 +141,10 @@ func TestRunAuthStatus_TokenInvalid(t *testing.T) { } } -// TestRunAuthStatus_STSRejectionRendersInvalidMessage pins fix #2: in -// split-host setups, STS rejection happens before any HTTP call to the -// data API, so the friendly "Token in keychain ... is no longer valid" -// message has to fire on the auth-go sts package's wrapped string -// (no typed sentinel) as well as the data-API 401 case above. +// TestRunAuthStatus_STSRejectionRendersInvalidMessage: in split-host setups, +// STS rejection happens before /me resolves a bearer, surfacing as auth-go's +// wrapped string (no typed sentinel). isKeychainTokenRejected must still map +// it to the re-login hint. func TestRunAuthStatus_STSRejectionRendersInvalidMessage(t *testing.T) { t.Parallel() @@ -148,58 +152,44 @@ func TestRunAuthStatus_STSRejectionRendersInvalidMessage(t *testing.T) { store.tokens[testBaseURL] = testAuthTok fetchProfile := func(context.Context) (*authProfile, error) { - // Exact format auth-go's sts package emits for an invalid_grant - // 4xx (see internal/oauthhttp's readAPIError). Without the - // detection in isKeychainTokenRejected this would fall through - // to the generic "validate token: ..." error path and the user - // would see a raw STS string instead of the re-login hint. return nil, errors.New("token exchange: status 400: invalid_grant: subject_token expired") } - var out, errOut bytes.Buffer - if err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, unusedList(t), testBaseURL); err != nil { + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, store, fetchProfile, noContexts, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out.String(), "no longer valid") { t.Fatalf("output = %q, want invalid-token message", out.String()) } - if !strings.Contains(out.String(), "entire login") { - t.Fatalf("output = %q, want re-auth hint", out.String()) - } } -// TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage pins the -// other half of fix #2: the tokenmanager's preflight check returns -// auth.ErrNotLoggedIn when a stored core JWT's exp claim is in the -// past. The keyring read at the top of runAuthStatus still finds a -// non-empty entry, so the "Not logged in" branch doesn't fire — the -// helper has to route the wrapped sentinel to the same re-login hint. +// TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage: the tokenmanager's +// preflight returns auth.ErrNotLoggedIn for an expired core JWT. The keyring +// read still finds an entry, so the helper must route the wrapped sentinel to +// the re-login hint (and a string-only lookalike must NOT match). func TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage(t *testing.T) { t.Parallel() store := newMockTokenStore() store.tokens[testBaseURL] = testAuthTok - fetchProfile := func(context.Context) (*authProfile, error) { + stringOnly := func(context.Context) (*authProfile, error) { return nil, errors.New("fetch profile: " + auth.ErrNotLoggedIn.Error()) } - // errors.New above is intentionally string-only to defeat the - // detection — confirm the substring fallback alone isn't what's - // catching this case. The real production path wraps with %w. - fetchWithChain := func(context.Context) (*authProfile, error) { + wrapped := func(context.Context) (*authProfile, error) { return nil, &wrappedTestError{msg: "fetch profile", inner: auth.ErrNotLoggedIn} } // Sanity: string-only does NOT match (no sentinel chain). - var out1, errOut1 bytes.Buffer - if err := runAuthStatus(context.Background(), &out1, &errOut1, store, fetchProfile, unusedList(t), testBaseURL); err == nil { + var out1 bytes.Buffer + if err := runAuthStatus(context.Background(), &out1, store, stringOnly, noContexts, testBaseURL); err == nil { t.Fatal("string-only ErrNotLoggedIn should not match — keep the test honest") } // Real path: errors.Is sees the sentinel through the %w chain. - var out2, errOut2 bytes.Buffer - if err := runAuthStatus(context.Background(), &out2, &errOut2, store, fetchWithChain, unusedList(t), testBaseURL); err != nil { + var out2 bytes.Buffer + if err := runAuthStatus(context.Background(), &out2, store, wrapped, noContexts, testBaseURL); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(out2.String(), "no longer valid") { @@ -207,8 +197,7 @@ func TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage(t *testing.T) { } } -// wrappedTestError is a tiny stand-in for fmt.Errorf("...: %w", inner) — kept -// local so the test reads as "this is what production hands runAuthStatus". +// wrappedTestError is a tiny stand-in for fmt.Errorf("...: %w", inner). type wrappedTestError struct { msg string inner error @@ -227,8 +216,8 @@ func TestRunAuthStatus_ServerError(t *testing.T) { return nil, errors.New("connection refused") } - var out, errOut bytes.Buffer - err := runAuthStatus(context.Background(), &out, &errOut, store, fetchProfile, unusedList(t), testBaseURL) + var out bytes.Buffer + err := runAuthStatus(context.Background(), &out, store, fetchProfile, noContexts, testBaseURL) if err == nil { t.Fatal("expected error for non-401 failure") } @@ -237,173 +226,6 @@ func TestRunAuthStatus_ServerError(t *testing.T) { } } -// --- active-sessions table --------------------------------------------------- - -func TestRunAuthStatus_SessionsTablePrintsRows(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - lastUsed := "2026-04-01T12:00:00Z" - list := func(context.Context) ([]api.Session, error) { - return []api.Session{ - {ID: "fam-1", Name: "laptop", Scope: "cli", - CreatedAt: "2026-01-01T00:00:00Z", - ExpiresAt: "2027-01-01T00:00:00Z", - LastUsedAt: &lastUsed}, - {ID: "fam-2", Name: "ci", Scope: "cli", - CreatedAt: "2026-02-01T00:00:00Z", - ExpiresAt: "2027-01-01T00:00:00Z", - LastUsedAt: nil}, - }, nil - } - - var out, errOut bytes.Buffer - if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - output := out.String() - if !strings.Contains(output, "Active sessions:") { - t.Fatalf("output = %q, want active-sessions heading", output) - } - if !strings.Contains(output, "ID") || !strings.Contains(output, "NAME") { - t.Fatalf("output = %q, want table headers", output) - } - if !strings.Contains(output, "fam-1") || !strings.Contains(output, "laptop") { - t.Fatalf("output = %q, want first row", output) - } - if !strings.Contains(output, "fam-2") || !strings.Contains(output, "never") { - t.Fatalf("output = %q, want second row with 'never' last-used", output) - } - // fam-1 used recently, so it should sort before fam-2 in the table. - if strings.Index(output, "fam-1") > strings.Index(output, "fam-2") { - t.Fatalf("output = %q, want fam-1 before fam-2 (recent-first)", output) - } -} - -func TestRunAuthStatus_NoSessionsPrintsMessage(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - list := func(context.Context) ([]api.Session, error) { return nil, nil } - - var out, errOut bytes.Buffer - if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(out.String(), "Logged in to "+testBaseURL) { - t.Fatalf("output = %q, want 'Logged in' message", out.String()) - } - if !strings.Contains(out.String(), "No active sessions") { - t.Fatalf("output = %q, want 'No active sessions' message", out.String()) - } -} - -// TestRunAuthStatus_SessionsListFailureIsSoftWarning pins that once /me has -// confirmed the token, a sessions-list failure degrades to a stderr warning -// rather than failing the command — the user is still logged in. -func TestRunAuthStatus_SessionsListFailureIsSoftWarning(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - list := func(context.Context) ([]api.Session, error) { - return nil, errors.New("data API unreachable") - } - - var out, errOut bytes.Buffer - if err := runAuthStatus(context.Background(), &out, &errOut, store, okProfile, list, testBaseURL); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(out.String(), "Logged in to "+testBaseURL) { - t.Fatalf("stdout = %q, want 'Logged in' message", out.String()) - } - if !strings.Contains(errOut.String(), "could not list active sessions") { - t.Fatalf("stderr = %q, want sessions-list warning", errOut.String()) - } - if !strings.Contains(errOut.String(), "data API unreachable") { - t.Fatalf("stderr = %q, want underlying error", errOut.String()) - } -} - -func TestFormatAuthLastUsed_RelativeBuckets(t *testing.T) { - t.Parallel() - - now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) - - tests := map[string]struct { - input *string - want string - }{ - "nil": {nil, "never"}, - "just now": { - ptr(now.Add(-30 * time.Second).Format(time.RFC3339)), - "just now", - }, - "minutes ago": { - ptr(now.Add(-15 * time.Minute).Format(time.RFC3339)), - "15m ago", - }, - "hours ago": { - ptr(now.Add(-3 * time.Hour).Format(time.RFC3339)), - "3h ago", - }, - "yesterday": { - ptr(now.Add(-30 * time.Hour).Format(time.RFC3339)), - "yesterday", - }, - "days ago": { - ptr(now.Add(-5 * 24 * time.Hour).Format(time.RFC3339)), - "5d ago", - }, - "old absolute": { - ptr(now.Add(-90 * 24 * time.Hour).Format(time.RFC3339)), - now.Add(-90 * 24 * time.Hour).Local().Format("2006-01-02"), - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - if got := formatAuthLastUsed(tt.input, now); got != tt.want { - t.Errorf("formatAuthLastUsed(%v) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestClassifyExpiresAt_Buckets(t *testing.T) { - t.Parallel() - - now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) - - tests := map[string]struct { - input string - want expiresState - }{ - "empty": {"", expiresNormal}, - "expired": {now.Add(-time.Hour).Format(time.RFC3339), expiresExpired}, - "soon": {now.Add(3 * 24 * time.Hour).Format(time.RFC3339), expiresSoon}, - "normal": {now.Add(60 * 24 * time.Hour).Format(time.RFC3339), expiresNormal}, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - if got := classifyExpiresAt(tt.input, now); got != tt.want { - t.Errorf("classifyExpiresAt(%q) = %v, want %v", tt.input, got, tt.want) - } - }) - } -} - -func ptr(s string) *string { return &s } - // --- registration ----------------------------------------------------------- func TestAuthCmd_RegistersExpectedSubcommands(t *testing.T) { @@ -437,16 +259,15 @@ func TestAuthCmd_RegistersExpectedSubcommands(t *testing.T) { // tokenmanager.Manager via auth.SetManagerForTest and stub only the // STS wire call via SetExchangeForTest. That covers the audience- // matching logic the function-injection tests above can't reach -// (defaultListSessions / defaultRevokeAllSessions call resolveDataAPIToken -// directly, but unit tests for the surrounding flows inject fakes -// that bypass it). +// (defaultRevokeCurrentSession / defaultRevokeAllSessions call +// resolveAuthHostToken directly, but unit tests for the surrounding flows +// inject fakes that bypass it). -// authResolveTestIssuer is intentionally distinct from the default -// api.BaseURL() ("https://entire.io") so the manager's same-host -// shortcut is skipped and the STS-exchange path runs. +// authResolveTestIssuer is intentionally distinct from api.AuthBaseURL() so +// the manager's same-host shortcut is skipped and the STS-exchange path runs. const authResolveTestIssuer = "https://auth.resolve-test.example.com" -func TestResolveDataAPIToken_ScopesExchangeToDataAPIOrigin(t *testing.T) { +func TestResolveAuthHostToken_ScopesExchangeToAuthHostOrigin(t *testing.T) { // No t.Parallel: SetManagerForTest mutates package-level state in the // auth package. Concurrent tests in this package don't reach the real // auth.TokenForResource path (they inject lister/revoker fakes), so @@ -458,29 +279,28 @@ func TestResolveDataAPIToken_ScopesExchangeToDataAPIOrigin(t *testing.T) { var capturedResource string mgr := newResolveTestManager(t, store, func(_ context.Context, req sts.ExchangeRequest) (*tokens.TokenSet, error) { capturedResource = req.Resource - return &tokens.TokenSet{AccessToken: "exchanged-data-api-tok"}, nil + return &tokens.TokenSet{AccessToken: "exchanged-auth-host-tok"}, nil }) t.Cleanup(auth.SetManagerForTest(t, mgr)) - got, err := resolveDataAPIToken(t.Context()) + got, err := resolveAuthHostToken(t.Context()) if err != nil { - t.Fatalf("resolveDataAPIToken: %v", err) + t.Fatalf("resolveAuthHostToken: %v", err) } - if got != "exchanged-data-api-tok" { - t.Errorf("token = %q, want %q", got, "exchanged-data-api-tok") + if got != "exchanged-auth-host-tok" { + t.Errorf("token = %q, want %q", got, "exchanged-auth-host-tok") } - // The whole point of the helper: the resource handed to the STS - // exchange must be the data API's origin, not the auth host's - // origin and not the raw env var value. The default api.BaseURL() - // is "https://entire.io" and api.OriginOnly leaves it unchanged. - if capturedResource != "https://entire.io" { - t.Errorf("STS exchange Resource = %q, want %q (api.OriginOnly(api.BaseURL()))", - capturedResource, "https://entire.io") + // The whole point of the helper: when an exchange happens, the resource + // handed to STS must be the auth host's origin (where the session + // endpoints live), not the raw env-var value. + if want := api.OriginOnly(api.AuthBaseURL()); capturedResource != want { + t.Errorf("STS exchange Resource = %q, want %q (api.OriginOnly(api.AuthBaseURL()))", + capturedResource, want) } } -func TestResolveDataAPIToken_WrapsManagerError(t *testing.T) { +func TestResolveAuthHostToken_WrapsManagerError(t *testing.T) { store := newAuthMemStore() saveCoreToken(t, store, authResolveTestIssuer, "opaque-core-token") @@ -489,12 +309,12 @@ func TestResolveDataAPIToken_WrapsManagerError(t *testing.T) { }) t.Cleanup(auth.SetManagerForTest(t, mgr)) - _, err := resolveDataAPIToken(t.Context()) + _, err := resolveAuthHostToken(t.Context()) if err == nil { t.Fatal("expected error when exchange fails") } - if !strings.Contains(err.Error(), "resolve API token") { - t.Errorf("error = %v, want 'resolve API token' wrap prefix", err) + if !strings.Contains(err.Error(), "resolve auth-host token") { + t.Errorf("error = %v, want 'resolve auth-host token' wrap prefix", err) } if !strings.Contains(err.Error(), "simulated transport failure") { t.Errorf("error = %v, want underlying message preserved", err) @@ -538,7 +358,7 @@ func TestIsKeychainTokenRejected_AllShapes(t *testing.T) { } } -// --- helpers for resolveDataAPIToken tests ---------------------------------- +// --- helpers for resolveAuthHostToken tests --------------------------------- // authMemStore is an in-memory tokenstore.Store for tests that need a // real tokenmanager.Manager. Mirrors the private memStore in auth-go's diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index 54a64b8cfd..0b523c08bc 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -83,7 +83,7 @@ func promoteNextLogin(outW, errW io.Writer) { } func defaultRevokeCurrentSession(ctx context.Context) error { - token, err := resolveDataAPIToken(ctx) + token, err := resolveAuthHostToken(ctx) if err != nil { return err } @@ -97,7 +97,7 @@ func defaultRevokeCurrentSession(ctx context.Context) error { // session doesn't strand the rest. Cross-core revoke is out of scope — these // endpoints target api.AuthBaseURL()'s core only. func defaultRevokeAllSessions(ctx context.Context) error { - token, err := resolveDataAPIToken(ctx) + token, err := resolveAuthHostToken(ctx) if err != nil { return err } From c36b9bbf75f7165c62a89da7dea0b62cae550691 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:18:11 +0930 Subject: [PATCH 08/21] auth: burn all PAT machinery; sessions/logout target entire-core only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entire.io's ent_ personal-access-token surface (/api/v1/auth/tokens) is being sunset, and the CLI never used it for auth — it authenticates with the core JWT. Remove every trace from this repo: - Drop the dead Provider.AuthTokensPath field (and its /api/v1/auth/tokens values + tests); nothing references the entire.io PAT path anymore. - Rename the api.Client session plumbing off PAT-era naming: api/auth_tokens.go -> api/sessions.go, WithAuthTokensPath -> WithSessionsPath, authTokensPath -> sessionsPath, errAuthTokensPathUnset -> errSessionsPathUnset. - Scrub PAT / ent_ / personal-access-token mentions from comments. Session management (auth status liveness via /me, logout revocation) targets entire-core's /api/auth/tokens on the auth host (api.AuthBaseURL()) with the session-scoped core JWT — never entire.io's PAT endpoint, so the 400 from that endpoint cannot recur. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 5969a20f41e3 --- cmd/entire/cli/api/client.go | 26 ++++++-------- .../cli/api/{auth_tokens.go => sessions.go} | 35 +++++++++---------- .../{auth_tokens_test.go => sessions_test.go} | 24 ++++++------- cmd/entire/cli/auth.go | 11 +++--- cmd/entire/cli/auth/provider.go | 12 ------- cmd/entire/cli/auth/provider_test.go | 4 --- 6 files changed, 44 insertions(+), 68 deletions(-) rename cmd/entire/cli/api/{auth_tokens.go => sessions.go} (66%) rename cmd/entire/cli/api/{auth_tokens_test.go => sessions_test.go} (88%) diff --git a/cmd/entire/cli/api/client.go b/cmd/entire/cli/api/client.go index a2df83f40f..033541d456 100644 --- a/cmd/entire/cli/api/client.go +++ b/cmd/entire/cli/api/client.go @@ -22,24 +22,20 @@ type Client struct { httpClient *http.Client baseURL string - // authTokensPath is the base path for the auth-tokens management - // endpoints (list / revoke). Set via WithAuthTokensPath when the - // client targets the auth host. Empty for data-API-only clients; - // auth-tokens methods error out if called against an empty path. - authTokensPath string + // sessionsPath is the base path for entire-core's login-session + // endpoints (list / revoke / current). Set via WithSessionsPath when the + // client targets the auth host; empty otherwise, and the session methods + // error out if called against an empty path. + sessionsPath string } -// WithAuthTokensPath sets the base path used by ListSessions, -// RevokeCurrentSession, and RevokeSession. The path is supplied by the -// auth shim from auth.CurrentProvider().AuthTokensPath, which is the -// single source of truth for provider-version routing — the api -// package no longer reads ENTIRE_AUTH_PROVIDER_VERSION itself. +// WithSessionsPath sets the base path used by ListSessions, +// RevokeCurrentSession, and RevokeSession. Returns the receiver for chaining +// at construction: // -// Returns the receiver for chaining at construction: -// -// c := api.NewClientWithBaseURL(token, base).WithAuthTokensPath(p) -func (c *Client) WithAuthTokensPath(path string) *Client { - c.authTokensPath = path +// c := api.NewClientWithBaseURL(token, base).WithSessionsPath(p) +func (c *Client) WithSessionsPath(path string) *Client { + c.sessionsPath = path return c } diff --git a/cmd/entire/cli/api/auth_tokens.go b/cmd/entire/cli/api/sessions.go similarity index 66% rename from cmd/entire/cli/api/auth_tokens.go rename to cmd/entire/cli/api/sessions.go index 0552d42884..18f3eb794c 100644 --- a/cmd/entire/cli/api/auth_tokens.go +++ b/cmd/entire/cli/api/sessions.go @@ -8,10 +8,10 @@ import ( ) // Session is a single active login session — an OAuth refresh-token family — -// returned by the auth-tokens endpoint. One is created per `entire login`, -// across all of a user's devices. Plaintext token values are never returned by -// the server, only metadata. (The wire endpoint is historically named -// "tokens"; these rows are sessions, not personal access tokens.) +// returned by entire-core's session endpoint. One is created per +// `entire login`, across all of a user's devices. Plaintext token values are +// never returned by the server, only metadata. (The list envelope's wire key +// is "tokens"; the rows are sessions.) type Session struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -22,29 +22,26 @@ type Session struct { CreatedAt string `json:"created_at"` } -// SessionsResponse is the envelope returned by the list endpoint. The wire key -// stays "tokens" — that is the server's contract. +// SessionsResponse is the envelope returned by the list endpoint. type SessionsResponse struct { Sessions []Session `json:"tokens"` } -// errAuthTokensPathUnset surfaces when a session method is called on a -// Client that wasn't given a base path. Construct via -// NewClientWithBaseURL(...).WithAuthTokensPath(...) — the active path -// lives in cmd/entire/cli/auth.CurrentProvider().AuthTokensPath, the -// single source of truth for provider-version routing. -var errAuthTokensPathUnset = errors.New("api: auth-tokens path is unset (call (*Client).WithAuthTokensPath before list/revoke)") +// errSessionsPathUnset surfaces when a session method is called on a Client +// that wasn't given a base path. Construct via +// NewClientWithBaseURL(...).WithSessionsPath(...). +var errSessionsPathUnset = errors.New("api: sessions path is unset (call (*Client).WithSessionsPath before list/revoke)") -func (c *Client) authTokensBasePath() (string, error) { - if c.authTokensPath == "" { - return "", errAuthTokensPathUnset +func (c *Client) sessionsBasePath() (string, error) { + if c.sessionsPath == "" { + return "", errSessionsPathUnset } - return c.authTokensPath, nil + return c.sessionsPath, nil } // ListSessions returns the authenticated user's active login sessions. func (c *Client) ListSessions(ctx context.Context) ([]Session, error) { - base, err := c.authTokensBasePath() + base, err := c.sessionsBasePath() if err != nil { return nil, fmt.Errorf("list sessions: %w", err) } @@ -68,7 +65,7 @@ func (c *Client) ListSessions(ctx context.Context) ([]Session, error) { // RevokeCurrentSession revokes the login session this client is authenticating // with (the family the current bearer belongs to). func (c *Client) RevokeCurrentSession(ctx context.Context) error { - base, err := c.authTokensBasePath() + base, err := c.sessionsBasePath() if err != nil { return fmt.Errorf("revoke current session: %w", err) } @@ -86,7 +83,7 @@ func (c *Client) RevokeCurrentSession(ctx context.Context) error { // RevokeSession revokes the login session with the given id. func (c *Client) RevokeSession(ctx context.Context, id string) error { - base, err := c.authTokensBasePath() + base, err := c.sessionsBasePath() if err != nil { return fmt.Errorf("revoke session %s: %w", id, err) } diff --git a/cmd/entire/cli/api/auth_tokens_test.go b/cmd/entire/cli/api/sessions_test.go similarity index 88% rename from cmd/entire/cli/api/auth_tokens_test.go rename to cmd/entire/cli/api/sessions_test.go index dc76bc8530..e8b74e5e05 100644 --- a/cmd/entire/cli/api/auth_tokens_test.go +++ b/cmd/entire/cli/api/sessions_test.go @@ -23,7 +23,7 @@ func TestClient_RevokeCurrentSession_SendsDeleteWithBearer(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") + c := NewClient("tok").WithSessionsPath("/api/auth/tokens") c.baseURL = server.URL if err := c.RevokeCurrentSession(context.Background()); err != nil { @@ -33,8 +33,8 @@ func TestClient_RevokeCurrentSession_SendsDeleteWithBearer(t *testing.T) { if gotMethod != http.MethodDelete { t.Errorf("method = %q, want DELETE", gotMethod) } - if gotPath != "/api/v1/auth/tokens/current" { - t.Errorf("path = %q, want /api/v1/auth/tokens/current", gotPath) + if gotPath != "/api/auth/tokens/current" { + t.Errorf("path = %q, want /api/auth/tokens/current", gotPath) } if gotAuth != testBearerHeader { t.Errorf("Authorization = %q, want %q", gotAuth, testBearerHeader) @@ -51,7 +51,7 @@ func TestClient_RevokeCurrentSession_ReturnsHTTPErrorOn401(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") + c := NewClient("tok").WithSessionsPath("/api/auth/tokens") c.baseURL = server.URL err := c.RevokeCurrentSession(context.Background()) @@ -87,7 +87,7 @@ func TestClient_ListSessions_DecodesResponse(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") + c := NewClient("tok").WithSessionsPath("/api/auth/tokens") c.baseURL = server.URL tokens, err := c.ListSessions(context.Background()) @@ -98,8 +98,8 @@ func TestClient_ListSessions_DecodesResponse(t *testing.T) { if gotMethod != http.MethodGet { t.Errorf("method = %q, want GET", gotMethod) } - if gotPath != "/api/v1/auth/tokens" { - t.Errorf("path = %q, want /api/v1/auth/tokens", gotPath) + if gotPath != "/api/auth/tokens" { + t.Errorf("path = %q, want /api/auth/tokens", gotPath) } if gotAuth != testBearerHeader { t.Errorf("Authorization = %q, want %q", gotAuth, testBearerHeader) @@ -129,7 +129,7 @@ func TestClient_ListSessions_ReturnsHTTPErrorOn401(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") + c := NewClient("tok").WithSessionsPath("/api/auth/tokens") c.baseURL = server.URL _, err := c.ListSessions(context.Background()) @@ -155,7 +155,7 @@ func TestClient_RevokeSession_SendsDeleteWithEscapedID(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") + c := NewClient("tok").WithSessionsPath("/api/auth/tokens") c.baseURL = server.URL // Use an id that needs URL escaping to verify we don't blindly concat. @@ -166,10 +166,10 @@ func TestClient_RevokeSession_SendsDeleteWithEscapedID(t *testing.T) { if gotMethod != http.MethodDelete { t.Errorf("method = %q, want DELETE", gotMethod) } - if want := "/api/v1/auth/tokens/abc%2Fdef%201"; gotEscapedPath != want { + if want := "/api/auth/tokens/abc%2Fdef%201"; gotEscapedPath != want { t.Errorf("escaped path = %q, want %q", gotEscapedPath, want) } - if want := "/api/v1/auth/tokens/abc/def 1"; gotDecodedPath != want { + if want := "/api/auth/tokens/abc/def 1"; gotDecodedPath != want { t.Errorf("decoded path = %q, want %q", gotDecodedPath, want) } } @@ -184,7 +184,7 @@ func TestClient_RevokeSession_ReturnsErrorBody(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens") + c := NewClient("tok").WithSessionsPath("/api/auth/tokens") c.baseURL = server.URL err := c.RevokeSession(context.Background(), "missing") diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index f324ba1604..46f2ebba6b 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -17,11 +17,10 @@ import ( ) // coreSessionsPath is entire-core's login-session endpoint family -// (list / revoke / current). These are OAuth refresh-token families, served -// by the auth host (entire-core), NOT entire.io's `/api/v1/auth/tokens` — -// that path is entire.io's legacy `ent_` personal-access-token surface, which -// rejects entire-core JWTs. The CLI authenticates with a core JWT, so all -// session management goes to core. +// (list / revoke / current) on the auth host. Sessions are OAuth +// refresh-token families; the CLI authenticates against them with its core +// JWT. Session management must target the auth host (entire-core), never the +// data host. const coreSessionsPath = "/api/auth/tokens" // User-visible placeholder strings. lastUsedJustNow is consumed by @@ -73,7 +72,7 @@ func requireSecureBaseURL(insecureHTTPAuth bool) error { // routes require it). func newSessionsClient(token string) *api.Client { return api.NewClientWithBaseURL(token, api.AuthBaseURL()). - WithAuthTokensPath(coreSessionsPath) + WithSessionsPath(coreSessionsPath) } // resolveAuthHostToken returns a bearer scoped for the auth host (entire-core). diff --git a/cmd/entire/cli/auth/provider.go b/cmd/entire/cli/auth/provider.go index c263e0f8ea..c6b291026e 100644 --- a/cmd/entire/cli/auth/provider.go +++ b/cmd/entire/cli/auth/provider.go @@ -21,17 +21,11 @@ const ProviderVersionEnvVar = "ENTIRE_AUTH_PROVIDER_VERSION" // STS is never invoked, so v1.STSPath is left empty. v2 exposes a // dedicated STS path because it's used in split-host deployments // (e.g. us.auth.partial.to mints, partial.to consumes). -// -// AuthTokensPath is the base path for the auth-tokens management -// endpoint family (list / revoke). Routed at the api.Client layer via -// (*api.Client).WithAuthTokensPath so the provider table is the single -// source of truth — no env-var duplication between auth/ and api/. type Provider struct { ClientID string DeviceCodePath string TokenPath string STSPath string - AuthTokensPath string } var providers = map[string]Provider{ @@ -39,7 +33,6 @@ var providers = map[string]Provider{ ClientID: "entire-cli", DeviceCodePath: "/oauth/device/code", TokenPath: "/oauth/token", - AuthTokensPath: "/api/v1/auth/tokens", }, "v2": { //nolint:gosec // OAuth client_id and endpoint paths, not credentials // Matches an OIDC-standard auth server's discovery doc — confirmed @@ -51,11 +44,6 @@ var providers = map[string]Provider{ DeviceCodePath: "/device_authorization", TokenPath: "/oauth/token", STSPath: "/oauth/token", - // API token management lives on the data API (not the auth host). - // auth.go / logout.go pass api.AuthBaseURL() for the keyring key, - // but the AuthTokensPath calls should route to api.BaseURL() in - // split-host setups — see TODO in auth.go's newAuthHostAPIClient. - AuthTokensPath: "/api/v1/auth/tokens", }, } diff --git a/cmd/entire/cli/auth/provider_test.go b/cmd/entire/cli/auth/provider_test.go index 9b01a3cb1e..800056c817 100644 --- a/cmd/entire/cli/auth/provider_test.go +++ b/cmd/entire/cli/auth/provider_test.go @@ -36,9 +36,6 @@ func TestResolveProvider_V2(t *testing.T) { if got.STSPath != "/oauth/token" { t.Errorf("v2 STSPath = %q", got.STSPath) } - if got.AuthTokensPath != "/api/v1/auth/tokens" { - t.Errorf("v2 AuthTokensPath = %q", got.AuthTokensPath) - } } // effectiveProviderVersion tests cannot be t.Parallel (they use t.Setenv). @@ -110,7 +107,6 @@ func TestSetProviderForTest_Overrides(t *testing.T) { DeviceCodePath: "/custom/device", TokenPath: "/custom/token", STSPath: "/custom/sts", - AuthTokensPath: "/custom/tokens", } SetProviderForTest(t, custom) From 721ad68c16bcfa28cb128b1f194de29f2619a03c Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:39:32 +0930 Subject: [PATCH 09/21] auth: make `auth status` context-aware; hit /me on the active core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 " 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) Entire-Checkpoint: 1385b003c7a8 --- cmd/entire/cli/auth.go | 110 +++++++++++------ cmd/entire/cli/auth_context_test.go | 26 ++++ cmd/entire/cli/auth_test.go | 177 +++++++++++----------------- internal/coreapi/client.go | 26 ++++ 4 files changed, 197 insertions(+), 142 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 46f2ebba6b..042f4e4a34 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -119,6 +119,13 @@ func isKeychainTokenRejected(err error) bool { if errors.Is(err, auth.ErrNotLoggedIn) { return true } + // A 401 whose body isn't JSON (e.g. a gateway returning text/plain) fails + // the ogen typed decode, so it never becomes an ErrorModelStatusCode — it + // arrives as a decode error whose message carries "(code 401)". Match that + // so the user still gets the re-login hint, not a raw decode dump. + if strings.Contains(err.Error(), "code 401") { + return true + } return strings.Contains(err.Error(), "token exchange: status 4") } @@ -160,8 +167,15 @@ func newAuthStatusCmd() *cobra.Command { if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { return err } - return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), - auth.NewContextStore(), defaultFetchProfile, auth.Contexts, api.AuthBaseURL()) + target := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL()) + // We send the session token to target.coreURL; enforce TLS on it + // too (it may differ from AuthBaseURL when a context is active). + if !insecureHTTPAuth { + if err := api.RequireSecureURL(target.coreURL); err != nil { + return fmt.Errorf("context core URL check: %w", err) + } + } + return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), defaultFetchProfile, target) }, } addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) @@ -178,23 +192,57 @@ type authProfile struct { ProviderUserID string } -// profileFetcher fetches the logged-in user's profile via GET /me on the core -// API. Injected so status stays unit-testable without a live core. -type profileFetcher func(ctx context.Context) (*authProfile, error) +// profileFetcher fetches a user's profile via GET /me on coreURL, authenticated +// with token. Injected so status stays unit-testable without a live core. +type profileFetcher func(ctx context.Context, coreURL, token string) (*authProfile, error) // contextsProvider returns the stored login contexts and the active context -// name, for the local-context lines in `entire auth status`. Injected for -// testability; production wires auth.Contexts. +// name. Injected for testability; production wires auth.Contexts. type contextsProvider func() ([]*contexts.Context, string, error) -// defaultFetchProfile fetches the current user's profile from the core API's -// GET /me. It doubles as the liveness check for `entire auth status`: a 401 -// (or an expired login that can't be exchanged) means the stored token is no -// longer usable, which isKeychainTokenRejected maps to a re-login hint. -func defaultFetchProfile(ctx context.Context) (*authProfile, error) { - client, err := coreapi.New() +// statusTarget is the resolved core `entire auth status` should query: the +// active context's CoreURL + its session token, or (no active context) the +// configured AuthBaseURL + legacy keyring entry. +type statusTarget struct { + coreURL string + token string + activeContext string // "" when falling back to the legacy entry + totalContexts int +} + +// resolveStatusTarget picks the core + token for `entire auth status`. The +// active contexts.json context wins (so `auth use` retargets status onto that +// login server); otherwise it falls back to the legacy keyring entry keyed by +// the configured auth host. +func resolveStatusTarget(store tokenStore, listContexts contextsProvider, fallbackBaseURL string) statusTarget { + all, current, err := listContexts() + total := 0 + if err == nil { + total = len(all) + for _, c := range all { + if c.Name != current || c.CoreURL == "" { + continue + } + if tok, terr := auth.LoginTokenForContext(c); terr == nil && tok != "" { + return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total} + } + } + } + tok, gerr := store.GetToken(fallbackBaseURL) + if gerr != nil { + tok = "" // best-effort: a keyring read failure just reads as "no token" + } + return statusTarget{coreURL: fallbackBaseURL, token: tok, totalContexts: total} +} + +// defaultFetchProfile fetches a user's profile from coreURL's GET /me with the +// given bearer. It doubles as the liveness check for `entire auth status`: a +// 401 (or an expired login) means the token is no longer usable, which +// isKeychainTokenRejected maps to a re-login hint. +func defaultFetchProfile(ctx context.Context, coreURL, token string) (*authProfile, error) { + client, err := coreapi.NewWithBearer(coreURL, token) if err != nil { - return nil, fmt.Errorf("connect to Entire control plane: %w", err) + return nil, fmt.Errorf("connect to %s: %w", coreURL, err) } me, err := client.GetMe(ctx) if err != nil { @@ -213,44 +261,36 @@ func defaultFetchProfile(ctx context.Context) (*authProfile, error) { } // runAuthStatus reports auth state without listing server-side sessions: GET -// /me validates the token and supplies the profile header, and the active -// login context is read locally. (Session listing/revocation lives on -// entire-core and is reached only by logout — see newSessionsClient.) -func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, fetchProfile profileFetcher, listContexts contextsProvider, baseURL string) error { - token, err := store.GetToken(baseURL) - if err != nil { - return fmt.Errorf("read keychain: %w", err) - } - if token == "" { - fmt.Fprintf(w, "Not logged in to %s\n", baseURL) +// /me on the target core validates the token and supplies the profile header, +// and the active login context is shown locally. (Session listing/revocation +// lives on entire-core and is reached only by logout — see newSessionsClient.) +func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher, t statusTarget) error { + if t.token == "" { + fmt.Fprintf(w, "Not logged in to %s\n", t.coreURL) fmt.Fprintln(w, "Run 'entire login' to authenticate.") return nil } - profile, err := fetchProfile(ctx) + profile, err := fetchProfile(ctx, t.coreURL, t.token) if err != nil { if isKeychainTokenRejected(err) { - fmt.Fprintf(w, "Token in keychain for %s is no longer valid.\n", baseURL) + fmt.Fprintf(w, "Login for %s is no longer valid.\n", t.coreURL) fmt.Fprintln(w, "Run 'entire login' to re-authenticate.") return nil } return fmt.Errorf("validate token: %w", err) } - fmt.Fprintf(w, "Logged in to %s\n", baseURL) + fmt.Fprintf(w, "Logged in to %s\n", t.coreURL) writeProfileLines(w, profile) - - // Local context info is informational; a read failure shouldn't fail the - // command, so on error we just skip the context lines. - all, current, ctxErr := listContexts() - if ctxErr == nil && current != "" { - fmt.Fprintf(w, " %-9s %s\n", "Context:", current) + if t.activeContext != "" { + fmt.Fprintf(w, " %-9s %s\n", "Context:", t.activeContext) } fmt.Fprintf(w, " %-9s %s\n", "Token:", "stored in OS keychain") - if ctxErr == nil && len(all) > 1 { + if t.totalContexts > 1 { fmt.Fprintln(w) - fmt.Fprintf(w, "%d login contexts saved; run 'entire auth contexts' to list or 'entire auth use ' to switch.\n", len(all)) + fmt.Fprintf(w, "%d login contexts saved; run 'entire auth contexts' to list or 'entire auth use ' to switch.\n", t.totalContexts) } return nil } diff --git a/cmd/entire/cli/auth_context_test.go b/cmd/entire/cli/auth_context_test.go index cbd466d7a7..6b429f2c7e 100644 --- a/cmd/entire/cli/auth_context_test.go +++ b/cmd/entire/cli/auth_context_test.go @@ -13,6 +13,32 @@ import ( "github.com/entireio/cli/internal/entireclient/tokenstore" ) +// TestResolveStatusTarget_PrefersActiveContext pins the multi-core fix: status +// targets the active context's CoreURL + its session token, recording a real +// context and reading it back. +func TestResolveStatusTarget_PrefersActiveContext(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) + restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")) + t.Cleanup(restore) + + exp := time.Now().Add(time.Hour).Unix() + if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://eu.auth.entire.io","handle":"alice","exp":%d}`, exp)), "", true); err != nil { + t.Fatalf("record context: %v", err) + } + + got := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, "https://fallback.example.com") + if got.coreURL != "https://eu.auth.entire.io" { + t.Errorf("coreURL = %q, want the active context's CoreURL", got.coreURL) + } + if got.token == "" { + t.Error("token = empty, want the active context's session token") + } + if got.activeContext == "" { + t.Error("activeContext = empty, want the active context name") + } +} + // makeContextJWT builds a JWT-shaped token (non-"none" alg) carrying the // given claims, which is all RecordLoginContext needs. func makeContextJWT(t *testing.T, payloadJSON string) string { diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index 7497eb9963..0d9c2c3b57 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -15,19 +15,15 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/internal/coreapi" - "github.com/entireio/cli/internal/entireclient/contexts" -) - -const ( - testBaseURL = "https://entire.io" - testAuthTok = "tok" ) // --- status ----------------------------------------------------------------- +const testCoreURL = "https://eu.auth.entire.io" + // okProfile is a profileFetcher returning a fully-populated profile, for the // happy-path status tests. -func okProfile(context.Context) (*authProfile, error) { +func okProfile(context.Context, string, string) (*authProfile, error) { return &authProfile{ Handle: "alice", DisplayName: "Alice Smith", @@ -37,29 +33,30 @@ func okProfile(context.Context) (*authProfile, error) { }, nil } -// noContexts is a contextsProvider with nothing stored. -func noContexts() ([]*contexts.Context, string, error) { return nil, "", nil } - // unusedProfile is a profileFetcher that fails the test if called — for the -// not-logged-in path, where the local token check short-circuits before /me. +// not-logged-in path, where the empty-token check short-circuits before /me. func unusedProfile(t *testing.T) profileFetcher { - return func(context.Context) (*authProfile, error) { + return func(context.Context, string, string) (*authProfile, error) { t.Helper() - t.Fatal("/me should not be called when no token is stored") + t.Fatal("/me should not be called when there is no token") return nil, errors.New("unreachable") } } +// rejecting returns a profileFetcher that always fails with err. +func rejecting(err error) profileFetcher { + return func(context.Context, string, string) (*authProfile, error) { return nil, err } +} + func TestRunAuthStatus_NotLoggedIn(t *testing.T) { t.Parallel() - store := newMockTokenStore() - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, unusedProfile(t), noContexts, testBaseURL); err != nil { + target := statusTarget{coreURL: testCoreURL} // empty token + if err := runAuthStatus(context.Background(), &out, unusedProfile(t), target); err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out.String(), "Not logged in to "+testBaseURL) { + if !strings.Contains(out.String(), "Not logged in to "+testCoreURL) { t.Fatalf("output = %q, want 'Not logged in' message", out.String()) } } @@ -67,21 +64,16 @@ func TestRunAuthStatus_NotLoggedIn(t *testing.T) { func TestRunAuthStatus_LoggedIn(t *testing.T) { t.Parallel() - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - ctxs := func() ([]*contexts.Context, string, error) { - return []*contexts.Context{{Name: "us.auth.entire.io"}}, "us.auth.entire.io", nil - } + target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "eu.auth.entire.io", totalContexts: 1} var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, okProfile, ctxs, testBaseURL); err != nil { + if err := runAuthStatus(context.Background(), &out, okProfile, target); err != nil { t.Fatalf("unexpected error: %v", err) } got := out.String() - if !strings.Contains(got, "Logged in to "+testBaseURL) { - t.Fatalf("output = %q, want 'Logged in' message", got) + if !strings.Contains(got, "Logged in to "+testCoreURL) { + t.Fatalf("output = %q, want 'Logged in' to the active context's core", got) } if !strings.Contains(got, "Alice Smith") || !strings.Contains(got, "@alice") || !strings.Contains(got, "") { t.Fatalf("output = %q, want profile header (name/@handle/)", got) @@ -89,111 +81,84 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { if !strings.Contains(got, "github/alice") { t.Fatalf("output = %q, want provider identity", got) } - if !strings.Contains(got, "us.auth.entire.io") { + if !strings.Contains(got, "Context:") || !strings.Contains(got, "eu.auth.entire.io") { t.Fatalf("output = %q, want active-context line", got) } - // Status must no longer list server-side sessions. if strings.Contains(got, "Active sessions") { t.Fatalf("output = %q, should not list server-side sessions", got) } } -func TestRunAuthStatus_MultipleContextsHint(t *testing.T) { +// TestRunAuthStatus_QueriesActiveContextCore pins the multi-core fix: /me is +// called against the active context's core with that context's token, not a +// static AuthBaseURL. +func TestRunAuthStatus_QueriesActiveContextCore(t *testing.T) { t.Parallel() - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - ctxs := func() ([]*contexts.Context, string, error) { - return []*contexts.Context{{Name: "a"}, {Name: "b"}, {Name: "c"}}, "a", nil + var gotCoreURL, gotToken string + fetch := func(_ context.Context, coreURL, token string) (*authProfile, error) { + gotCoreURL, gotToken = coreURL, token + return &authProfile{Handle: "alice"}, nil } + target := statusTarget{coreURL: testCoreURL, token: "eu-session-tok", activeContext: "eu.auth.entire.io", totalContexts: 1} var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, okProfile, ctxs, testBaseURL); err != nil { + if err := runAuthStatus(context.Background(), &out, fetch, target); err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out.String(), "3 login contexts saved") { - t.Fatalf("output = %q, want multi-context hint", out.String()) + if gotCoreURL != testCoreURL { + t.Errorf("fetchProfile coreURL = %q, want %q", gotCoreURL, testCoreURL) } -} - -func TestRunAuthStatus_TokenInvalid(t *testing.T) { - t.Parallel() - - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - // A 401 from GET /me arrives as *coreapi.ErrorModelStatusCode, not - // api.HTTPError — exercise isKeychainTokenRejected's core-API branch. - fetchProfile := func(context.Context) (*authProfile, error) { - return nil, &coreapi.ErrorModelStatusCode{StatusCode: http.StatusUnauthorized} - } - - var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, fetchProfile, noContexts, testBaseURL); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(out.String(), "no longer valid") { - t.Fatalf("output = %q, want invalid-token message", out.String()) - } - if !strings.Contains(out.String(), "entire login") { - t.Fatalf("output = %q, want re-auth hint", out.String()) + if gotToken != "eu-session-tok" { + t.Errorf("fetchProfile token = %q, want the active context's token", gotToken) } } -// TestRunAuthStatus_STSRejectionRendersInvalidMessage: in split-host setups, -// STS rejection happens before /me resolves a bearer, surfacing as auth-go's -// wrapped string (no typed sentinel). isKeychainTokenRejected must still map -// it to the re-login hint. -func TestRunAuthStatus_STSRejectionRendersInvalidMessage(t *testing.T) { +func TestRunAuthStatus_MultipleContextsHint(t *testing.T) { t.Parallel() - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - fetchProfile := func(context.Context) (*authProfile, error) { - return nil, errors.New("token exchange: status 400: invalid_grant: subject_token expired") - } + target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "a", totalContexts: 3} var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, store, fetchProfile, noContexts, testBaseURL); err != nil { + if err := runAuthStatus(context.Background(), &out, okProfile, target); err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(out.String(), "no longer valid") { - t.Fatalf("output = %q, want invalid-token message", out.String()) + if !strings.Contains(out.String(), "3 login contexts saved") { + t.Fatalf("output = %q, want multi-context hint", out.String()) } } -// TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage: the tokenmanager's -// preflight returns auth.ErrNotLoggedIn for an expired core JWT. The keyring -// read still finds an entry, so the helper must route the wrapped sentinel to -// the re-login hint (and a string-only lookalike must NOT match). -func TestRunAuthStatus_ExpiredCoreTokenRendersInvalidMessage(t *testing.T) { +func TestRunAuthStatus_InvalidTokenShapes(t *testing.T) { t.Parallel() - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - stringOnly := func(context.Context) (*authProfile, error) { - return nil, errors.New("fetch profile: " + auth.ErrNotLoggedIn.Error()) - } - wrapped := func(context.Context) (*authProfile, error) { - return nil, &wrappedTestError{msg: "fetch profile", inner: auth.ErrNotLoggedIn} - } - - // Sanity: string-only does NOT match (no sentinel chain). - var out1 bytes.Buffer - if err := runAuthStatus(context.Background(), &out1, store, stringOnly, noContexts, testBaseURL); err == nil { - t.Fatal("string-only ErrNotLoggedIn should not match — keep the test honest") + cases := map[string]error{ + // 401 from GET /me as a typed core error. + "typed 401": &coreapi.ErrorModelStatusCode{StatusCode: http.StatusUnauthorized}, + // 401 whose body isn't JSON: ogen fails to decode and the status is + // only in the message string. This is the shape `auth status` hit in + // the wild against a cross-core mismatch. + "non-JSON 401": errors.New("decode response: default (code 401): unexpected Content-Type: text/plain"), + // STS rejection during a split-host exchange (no typed sentinel). + "sts 4xx": errors.New("token exchange: status 400: invalid_grant: subject_token expired"), + // Expired core JWT surfaces as a wrapped ErrNotLoggedIn. + "wrapped not-logged-in": &wrappedTestError{msg: "fetch profile", inner: auth.ErrNotLoggedIn}, } - // Real path: errors.Is sees the sentinel through the %w chain. - var out2 bytes.Buffer - if err := runAuthStatus(context.Background(), &out2, store, wrapped, noContexts, testBaseURL); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(out2.String(), "no longer valid") { - t.Fatalf("output = %q, want invalid-token message", out2.String()) + for name, fetchErr := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + target := statusTarget{coreURL: testCoreURL, token: "tok"} + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, rejecting(fetchErr), target); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "no longer valid") { + t.Fatalf("output = %q, want invalid-token message", out.String()) + } + if !strings.Contains(out.String(), "entire login") { + t.Fatalf("output = %q, want re-auth hint", out.String()) + } + }) } } @@ -209,15 +174,10 @@ func (e *wrappedTestError) Unwrap() error { return e.inner } func TestRunAuthStatus_ServerError(t *testing.T) { t.Parallel() - store := newMockTokenStore() - store.tokens[testBaseURL] = testAuthTok - - fetchProfile := func(context.Context) (*authProfile, error) { - return nil, errors.New("connection refused") - } + target := statusTarget{coreURL: testCoreURL, token: "tok"} var out bytes.Buffer - err := runAuthStatus(context.Background(), &out, store, fetchProfile, noContexts, testBaseURL) + err := runAuthStatus(context.Background(), &out, rejecting(errors.New("connection refused")), target) if err == nil { t.Fatal("expected error for non-401 failure") } @@ -338,6 +298,9 @@ func TestIsKeychainTokenRejected_AllShapes(t *testing.T) { "sts 400 invalid_grant": {errors.New("token exchange: status 400: invalid_grant: token expired"), true}, "sts 500": {errors.New("token exchange: status 500: server_error"), false}, "network error": {errors.New("dial tcp: i/o timeout"), false}, + // ogen decode failure on a non-JSON 401 body (the /me cross-core case). + "non-JSON 401 decode": {errors.New("decode response: default (code 401): unexpected Content-Type: text/plain"), true}, + "non-JSON 500 decode": {errors.New("decode response: default (code 500): unexpected Content-Type: text/plain"), false}, } // Confirm wrapped chains do propagate (the "wrapped ErrNotLoggedIn" diff --git a/internal/coreapi/client.go b/internal/coreapi/client.go index 1adeebaae6..430086faaf 100644 --- a/internal/coreapi/client.go +++ b/internal/coreapi/client.go @@ -43,6 +43,32 @@ func New() (*Client, error) { return client, nil } +// NewWithBearer returns a *Client targeting an explicit core origin with a +// fixed bearer token — no per-request resolution or STS exchange. Used when a +// command must hit a specific login server rather than the configured +// AuthBaseURL: e.g. `entire auth status` querying /me on the active context's +// core with that context's session token. +func NewWithBearer(coreBaseURL, token string) (*Client, error) { + base := strings.TrimRight(coreBaseURL, "/") + client, err := NewClient(base+apiBasePath, staticBearer{token: token}) + if err != nil { + return nil, fmt.Errorf("build core API client: %w", err) + } + return client, nil +} + +// staticBearer is a SecuritySource that returns a fixed bearer token. Same +// sessionAuth-skipping rationale as bearerSource. +type staticBearer struct{ token string } + +func (s staticBearer) BearerAuth(context.Context, OperationName) (BearerAuth, error) { + return BearerAuth{Token: s.token}, nil +} + +func (s staticBearer) SessionAuth(context.Context, OperationName) (SessionAuth, error) { + return SessionAuth{}, ogenerrors.ErrSkipClientSecurity +} + // bearerSource implements the generated SecuritySource, supplying the // logged-in user's bearer token for every request. The control plane // only uses bearerAuth from the CLI; the sessionAuth (browser cookie) From 35830c7986d46baabf00a3439f9ca5cb85c4a3c9 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:09:30 +0930 Subject: [PATCH 10/21] auth: show active sessions in `auth status`; logout targets same core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring back the sessions table now that it correctly lists entire-core login sessions (not entire.io PATs), so the effect of `logout` / `logout --all` is visible: - `auth status` lists the active sessions (NAME / CREATED / LAST USED / EXPIRES) on the active context's core, after the profile/context lines, with a hint tying the table to `logout` and `logout --all`. Best-effort: a listing failure is a soft note (liveness already confirmed via /me). - `logout` now revokes against the **active context's core** too (shared resolveStatusTarget), so it acts on exactly the sessions status shows — not a static AuthBaseURL. Fixes the same multi-core mismatch for logout. - newSessionsClient takes an explicit coreURL. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 8159eed97feb --- cmd/entire/cli/auth.go | 124 ++++++++++++++++++++++++++++++------ cmd/entire/cli/auth_test.go | 72 +++++++++++++++++++-- cmd/entire/cli/logout.go | 44 +++++++------ 3 files changed, 196 insertions(+), 44 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 042f4e4a34..63f75905f8 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "net/http" + "sort" "strings" + "time" "charm.land/lipgloss/v2" "github.com/entireio/cli/cmd/entire/cli/api" @@ -27,6 +29,7 @@ const coreSessionsPath = "/api/auth/tokens" // formatRelativeDuration in status.go. const ( placeholderDash = "-" + lastUsedNever = "never" lastUsedJustNow = "just now" ) @@ -64,15 +67,12 @@ func requireSecureBaseURL(insecureHTTPAuth bool) error { } // newSessionsClient builds an api.Client for entire-core's login-session -// endpoints (coreSessionsPath). It targets the auth host (api.AuthBaseURL()), -// since that's where the session/refresh-token families live; the supplied -// bearer must be the session-scoped login JWT, obtained via -// resolveAuthHostToken (a same-host resolution that returns the login token -// unchanged, preserving its entire:session scope — entire-core's session -// routes require it). -func newSessionsClient(token string) *api.Client { - return api.NewClientWithBaseURL(token, api.AuthBaseURL()). - WithSessionsPath(coreSessionsPath) +// endpoints (coreSessionsPath) on coreURL, authenticated with the +// session-scoped login JWT. coreURL is the active context's CoreURL (or the +// configured auth host when no context is active) — session management always +// targets a login server, never the data host. +func newSessionsClient(coreURL, token string) *api.Client { + return api.NewClientWithBaseURL(token, coreURL).WithSessionsPath(coreSessionsPath) } // resolveAuthHostToken returns a bearer scoped for the auth host (entire-core). @@ -175,7 +175,7 @@ func newAuthStatusCmd() *cobra.Command { return fmt.Errorf("context core URL check: %w", err) } } - return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), defaultFetchProfile, target) + return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), defaultFetchProfile, defaultListSessions, target) }, } addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) @@ -196,13 +196,19 @@ type authProfile struct { // with token. Injected so status stays unit-testable without a live core. type profileFetcher func(ctx context.Context, coreURL, token string) (*authProfile, error) +// sessionLister lists the active login sessions on coreURL (the user's +// refresh-token families). Injected for testability; production wires +// defaultListSessions. +type sessionLister func(ctx context.Context, coreURL, token string) ([]api.Session, error) + // contextsProvider returns the stored login contexts and the active context // name. Injected for testability; production wires auth.Contexts. type contextsProvider func() ([]*contexts.Context, string, error) -// statusTarget is the resolved core `entire auth status` should query: the -// active context's CoreURL + its session token, or (no active context) the -// configured AuthBaseURL + legacy keyring entry. +// statusTarget is the resolved core to act against: the active context's +// CoreURL + its session token, or (no active context) the configured +// AuthBaseURL + legacy keyring entry. Shared by `auth status` (profile + +// session list) and `logout` (revocation) so both hit the same login server. type statusTarget struct { coreURL string token string @@ -260,11 +266,16 @@ func defaultFetchProfile(ctx context.Context, coreURL, token string) (*authProfi return p, nil } -// runAuthStatus reports auth state without listing server-side sessions: GET -// /me on the target core validates the token and supplies the profile header, -// and the active login context is shown locally. (Session listing/revocation -// lives on entire-core and is reached only by logout — see newSessionsClient.) -func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher, t statusTarget) error { +// defaultListSessions lists the user's active login sessions on coreURL. +func defaultListSessions(ctx context.Context, coreURL, token string) ([]api.Session, error) { + return newSessionsClient(coreURL, token).ListSessions(ctx) //nolint:wrapcheck // ListSessions already wraps with action context +} + +// runAuthStatus reports auth state against the target core: GET /me validates +// the token and supplies the profile header, the active login context is shown +// locally, and the active sessions (refresh-token families) on that core are +// listed so the effect of `logout` / `logout --all` is visible. +func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher, listSessions sessionLister, t statusTarget) error { if t.token == "" { fmt.Fprintf(w, "Not logged in to %s\n", t.coreURL) fmt.Fprintln(w, "Run 'entire login' to authenticate.") @@ -288,6 +299,19 @@ func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher } fmt.Fprintf(w, " %-9s %s\n", "Token:", "stored in OS keychain") + // Active sessions on this core. The token is already known good, so a + // listing failure is non-fatal — note it and carry on. + sessions, serr := listSessions(ctx, t.coreURL, t.token) + switch { + case serr != nil: + fmt.Fprintf(w, "\n(could not list active sessions: %v)\n", serr) + case len(sessions) > 0: + sortSessionsByRecency(sessions) + fmt.Fprintf(w, "\nActive sessions (%d):\n", len(sessions)) + renderSessionsTable(w, newAuthTableStyles(w), sessions) + fmt.Fprintln(w, "\nRun 'entire logout' to end this session, or 'entire logout --all' to end all of them.") + } + if t.totalContexts > 1 { fmt.Fprintln(w) fmt.Fprintf(w, "%d login contexts saved; run 'entire auth contexts' to list or 'entire auth use ' to switch.\n", t.totalContexts) @@ -392,3 +416,67 @@ func fallback(s, alt string) string { } return s } + +// renderSessionsTable prints the active login sessions as an aligned table. +// No id column: there's no per-session CLI action (revoke-by-id is gone), so +// NAME/CREATED/LAST USED/EXPIRES is what's useful. +func renderSessionsTable(w io.Writer, sty authTableStyles, sessions []api.Session) { + header := []string{ + sty.render(sty.header, "NAME"), + sty.render(sty.header, "CREATED"), + sty.render(sty.header, "LAST USED"), + sty.render(sty.header, "EXPIRES"), + } + rows := make([][]string, 0, len(sessions)) + for _, s := range sessions { + rows = append(rows, []string{ + sty.render(sty.name, fallback(s.Name, placeholderDash)), + sty.render(sty.value, formatAuthDate(s.CreatedAt)), + sty.render(sty.value, formatLastUsed(s.LastUsedAt)), + sty.render(sty.value, formatAuthDate(s.ExpiresAt)), + }) + } + renderAlignedTable(w, header, rows) +} + +// sortSessionsByRecency orders sessions most-recently-used first, then most +// recently created, then by id — a fully specified order independent of the +// server's response ordering. +func sortSessionsByRecency(sessions []api.Session) { + sort.Slice(sessions, func(i, j int) bool { + li, lj := lastUsedSortKey(sessions[i]), lastUsedSortKey(sessions[j]) + if li != lj { + return li > lj + } + if sessions[i].CreatedAt != sessions[j].CreatedAt { + return sessions[i].CreatedAt > sessions[j].CreatedAt + } + return sessions[i].ID < sessions[j].ID + }) +} + +func lastUsedSortKey(s api.Session) string { + if s.LastUsedAt == nil { + return "" + } + return *s.LastUsedAt +} + +// formatAuthDate renders an RFC3339 timestamp as YYYY-MM-DD in local time, +// falling back to a dash (empty) or the raw value (unparseable). +func formatAuthDate(s string) string { + if s == "" { + return placeholderDash + } + if ts, err := time.Parse(time.RFC3339, s); err == nil { + return ts.Local().Format("2006-01-02") + } + return s +} + +func formatLastUsed(s *string) string { + if s == nil || *s == "" { + return lastUsedNever + } + return formatAuthDate(*s) +} diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index 0d9c2c3b57..2013a38fe6 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -48,12 +48,15 @@ func rejecting(err error) profileFetcher { return func(context.Context, string, string) (*authProfile, error) { return nil, err } } +// noSessions is a sessionLister returning an empty list (no table rendered). +func noSessions(context.Context, string, string) ([]api.Session, error) { return nil, nil } + func TestRunAuthStatus_NotLoggedIn(t *testing.T) { t.Parallel() var out bytes.Buffer target := statusTarget{coreURL: testCoreURL} // empty token - if err := runAuthStatus(context.Background(), &out, unusedProfile(t), target); err != nil { + if err := runAuthStatus(context.Background(), &out, unusedProfile(t), noSessions, target); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(out.String(), "Not logged in to "+testCoreURL) { @@ -67,7 +70,7 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "eu.auth.entire.io", totalContexts: 1} var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, okProfile, target); err != nil { + if err := runAuthStatus(context.Background(), &out, okProfile, noSessions, target); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -84,8 +87,63 @@ func TestRunAuthStatus_LoggedIn(t *testing.T) { if !strings.Contains(got, "Context:") || !strings.Contains(got, "eu.auth.entire.io") { t.Fatalf("output = %q, want active-context line", got) } + // noSessions returns an empty list, so no table is rendered. if strings.Contains(got, "Active sessions") { - t.Fatalf("output = %q, should not list server-side sessions", got) + t.Fatalf("output = %q, empty session list should render no table", got) + } +} + +func TestRunAuthStatus_RendersSessionsTable(t *testing.T) { + t.Parallel() + + target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "eu.auth.entire.io", totalContexts: 1} + lastUsed := "2026-05-01T00:00:00Z" + listSessions := func(_ context.Context, coreURL, token string) ([]api.Session, error) { + if coreURL != testCoreURL || token != "tok" { + t.Errorf("listSessions called with (%q, %q), want the active core+token", coreURL, token) + } + return []api.Session{ + {ID: "fam-1", Name: "OIDC login", CreatedAt: "2026-01-01T00:00:00Z", ExpiresAt: "2026-12-01T00:00:00Z", LastUsedAt: &lastUsed}, + {ID: "fam-2", Name: "OIDC login", CreatedAt: "2026-02-01T00:00:00Z", ExpiresAt: "2026-12-15T00:00:00Z"}, + }, nil + } + + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, okProfile, listSessions, target); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := out.String() + if !strings.Contains(got, "Active sessions (2):") { + t.Fatalf("output = %q, want active-sessions heading with count", got) + } + for _, want := range []string{"NAME", "CREATED", "LAST USED", "EXPIRES", "2026-01-01", "never"} { + if !strings.Contains(got, want) { + t.Fatalf("output = %q, want table to contain %q", got, want) + } + } + if !strings.Contains(got, "entire logout --all") { + t.Fatalf("output = %q, want logout hint tying the table to logout", got) + } +} + +func TestRunAuthStatus_SessionListFailureIsSoftNote(t *testing.T) { + t.Parallel() + + target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "eu.auth.entire.io", totalContexts: 1} + listSessions := func(context.Context, string, string) ([]api.Session, error) { + return nil, errors.New("sessions endpoint unreachable") + } + + var out bytes.Buffer + if err := runAuthStatus(context.Background(), &out, okProfile, listSessions, target); err != nil { + t.Fatalf("unexpected error: %v", err) // liveness already passed via /me + } + got := out.String() + if !strings.Contains(got, "Logged in to "+testCoreURL) { + t.Fatalf("output = %q, want still-logged-in", got) + } + if !strings.Contains(got, "could not list active sessions") { + t.Fatalf("output = %q, want soft note about the listing failure", got) } } @@ -103,7 +161,7 @@ func TestRunAuthStatus_QueriesActiveContextCore(t *testing.T) { target := statusTarget{coreURL: testCoreURL, token: "eu-session-tok", activeContext: "eu.auth.entire.io", totalContexts: 1} var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, fetch, target); err != nil { + if err := runAuthStatus(context.Background(), &out, fetch, noSessions, target); err != nil { t.Fatalf("unexpected error: %v", err) } if gotCoreURL != testCoreURL { @@ -120,7 +178,7 @@ func TestRunAuthStatus_MultipleContextsHint(t *testing.T) { target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "a", totalContexts: 3} var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, okProfile, target); err != nil { + if err := runAuthStatus(context.Background(), &out, okProfile, noSessions, target); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(out.String(), "3 login contexts saved") { @@ -149,7 +207,7 @@ func TestRunAuthStatus_InvalidTokenShapes(t *testing.T) { t.Parallel() target := statusTarget{coreURL: testCoreURL, token: "tok"} var out bytes.Buffer - if err := runAuthStatus(context.Background(), &out, rejecting(fetchErr), target); err != nil { + if err := runAuthStatus(context.Background(), &out, rejecting(fetchErr), noSessions, target); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(out.String(), "no longer valid") { @@ -177,7 +235,7 @@ func TestRunAuthStatus_ServerError(t *testing.T) { target := statusTarget{coreURL: testCoreURL, token: "tok"} var out bytes.Buffer - err := runAuthStatus(context.Background(), &out, rejecting(errors.New("connection refused")), target) + err := runAuthStatus(context.Background(), &out, rejecting(errors.New("connection refused")), noSessions, target) if err == nil { t.Fatal("expected error for non-401 failure") } diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index 0b523c08bc..f7f9123b8c 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -49,9 +49,23 @@ func newLogoutCmd() *cobra.Command { if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { return err } + // Revoke against the active context's core (matching what + // `auth status` lists), not a static AuthBaseURL. + target := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL()) + if !insecureHTTPAuth { + if err := api.RequireSecureURL(target.coreURL); err != nil { + return fmt.Errorf("context core URL check: %w", err) + } + } + revokeCurrent := func(ctx context.Context) error { + return revokeCurrentSession(ctx, target.coreURL, target.token) + } + revokeAll := func(ctx context.Context) error { + return revokeAllSessions(ctx, target.coreURL, target.token) + } outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr() if err := runLogout(cmd.Context(), outW, errW, - auth.NewContextStore(), defaultRevokeCurrentSession, defaultRevokeAllSessions, + auth.NewContextStore(), revokeCurrent, revokeAll, auth.RemoveCurrentContext, api.AuthBaseURL(), all); err != nil { return err } @@ -82,26 +96,18 @@ func promoteNextLogin(outW, errW io.Writer) { fmt.Fprintf(outW, "Now using %q (%d saved login(s) remain; run `entire logout` again to remove each).\n", next, len(all)) } -func defaultRevokeCurrentSession(ctx context.Context) error { - token, err := resolveAuthHostToken(ctx) - if err != nil { - return err - } - return newSessionsClient(token).RevokeCurrentSession(ctx) //nolint:wrapcheck // RevokeCurrentSession already wraps with action context +// revokeCurrentSession revokes the active session on coreURL (the family the +// bearer belongs to) — the default `entire logout`. +func revokeCurrentSession(ctx context.Context, coreURL, token string) error { + return newSessionsClient(coreURL, token).RevokeCurrentSession(ctx) //nolint:wrapcheck // RevokeCurrentSession already wraps with action context } -// defaultRevokeAllSessions revokes every active login session on the active -// core (the `entire logout --all` path). It resolves a data-API bearer once, -// lists the user's sessions, and deletes each by id. Best-effort across -// sessions: it attempts them all and returns the first failure, so one stuck -// session doesn't strand the rest. Cross-core revoke is out of scope — these -// endpoints target api.AuthBaseURL()'s core only. -func defaultRevokeAllSessions(ctx context.Context) error { - token, err := resolveAuthHostToken(ctx) - if err != nil { - return err - } - client := newSessionsClient(token) +// revokeAllSessions revokes every active login session on coreURL (the +// `entire logout --all` path): list the families, then delete each by id. +// Best-effort across sessions — it attempts them all and returns the first +// failure, so one stuck session doesn't strand the rest. +func revokeAllSessions(ctx context.Context, coreURL, token string) error { + client := newSessionsClient(coreURL, token) sessions, err := client.ListSessions(ctx) if err != nil { return fmt.Errorf("list sessions: %w", err) From 16fcb5d74cb382998bcddccd8bd5997f32d5f977 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:43:23 +0930 Subject: [PATCH 11/21] logout: stop double-wrapping session revoke errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revokeAllSessions wrapped ListSessions/RevokeSession failures with their own prefixes, but (*api.Client) already wraps both (incl. the session id), producing "list sessions: list sessions: …" / "revoke session X: revoke session X: …". Return the wrapped errors verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 009341d5e790 --- cmd/entire/cli/logout.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index f7f9123b8c..cda5c9d2ca 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -108,14 +108,16 @@ func revokeCurrentSession(ctx context.Context, coreURL, token string) error { // failure, so one stuck session doesn't strand the rest. func revokeAllSessions(ctx context.Context, coreURL, token string) error { client := newSessionsClient(coreURL, token) + // ListSessions and RevokeSession already wrap with their own action + // context (incl. the session id), so return their errors verbatim. sessions, err := client.ListSessions(ctx) if err != nil { - return fmt.Errorf("list sessions: %w", err) + return err //nolint:wrapcheck // ListSessions already wraps with "list sessions" } var firstErr error for _, s := range sessions { if err := client.RevokeSession(ctx, s.ID); err != nil && firstErr == nil { - firstErr = fmt.Errorf("revoke session %s: %w", s.ID, err) + firstErr = err } } return firstErr From 7d25012177aead509839c8ee9f1ed2bb706cdb05 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:43:42 +0930 Subject: [PATCH 12/21] logout: fix stale revokeCurrentFunc doc comment The comment claimed the impl resolves its own data-API bearer; the caller now resolves the active context's core URL + token and binds them into the closure. Describe the actual contract. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 2525dd1ceb9f --- cmd/entire/cli/logout.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index cda5c9d2ca..e008ad576d 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -19,10 +19,10 @@ type tokenStore interface { DeleteToken(baseURL string) error } -// revokeCurrentFunc revokes the CLI's current token server-side. The -// implementation resolves its own data-API bearer (same audience- -// matching rule as sessionLister); callers don't pass the keyring -// entry through. +// revokeCurrentFunc revokes the CLI's current login session server-side. +// The caller resolves the active context's core URL + bearer up-front and +// binds them into the closure, so the revocation hits the same core that +// `auth status` lists. type revokeCurrentFunc func(ctx context.Context) error // clearContextFunc removes the active contexts.json context (and its From b49905f22cd914f061ccd5a284b59eee5d8bc1e0 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:44:35 +0930 Subject: [PATCH 13/21] entire auth logout --everywhere Better name for the flag. Entire-Checkpoint: 0eb8618db00d --- cmd/entire/cli/auth.go | 4 ++-- cmd/entire/cli/auth_test.go | 2 +- cmd/entire/cli/logout.go | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 63f75905f8..d667578768 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -274,7 +274,7 @@ func defaultListSessions(ctx context.Context, coreURL, token string) ([]api.Sess // runAuthStatus reports auth state against the target core: GET /me validates // the token and supplies the profile header, the active login context is shown // locally, and the active sessions (refresh-token families) on that core are -// listed so the effect of `logout` / `logout --all` is visible. +// listed so the effect of `logout` / `logout --everywhere` is visible. func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher, listSessions sessionLister, t statusTarget) error { if t.token == "" { fmt.Fprintf(w, "Not logged in to %s\n", t.coreURL) @@ -309,7 +309,7 @@ func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher sortSessionsByRecency(sessions) fmt.Fprintf(w, "\nActive sessions (%d):\n", len(sessions)) renderSessionsTable(w, newAuthTableStyles(w), sessions) - fmt.Fprintln(w, "\nRun 'entire logout' to end this session, or 'entire logout --all' to end all of them.") + fmt.Fprintln(w, "\nRun 'entire logout' to end this session, or 'entire logout --everywhere' to end all of them.") } if t.totalContexts > 1 { diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index 2013a38fe6..a1667f0feb 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -121,7 +121,7 @@ func TestRunAuthStatus_RendersSessionsTable(t *testing.T) { t.Fatalf("output = %q, want table to contain %q", got, want) } } - if !strings.Contains(got, "entire logout --all") { + if !strings.Contains(got, "entire logout --everywhere") { t.Fatalf("output = %q, want logout hint tying the table to logout", got) } } diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index e008ad576d..7f364b53e1 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -33,7 +33,7 @@ type clearContextFunc func() error func newLogoutCmd() *cobra.Command { var insecureHTTPAuth bool - var all bool + var everywhere bool cmd := &cobra.Command{ Use: "logout", Short: "Log out of Entire", @@ -66,14 +66,14 @@ func newLogoutCmd() *cobra.Command { outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr() if err := runLogout(cmd.Context(), outW, errW, auth.NewContextStore(), revokeCurrent, revokeAll, - auth.RemoveCurrentContext, api.AuthBaseURL(), all); err != nil { + auth.RemoveCurrentContext, api.AuthBaseURL(), everywhere); err != nil { return err } promoteNextLogin(outW, errW) return nil }, } - cmd.Flags().BoolVar(&all, "all", false, "Also revoke every session on the active core server-side, not just the active one") + cmd.Flags().BoolVar(&everywhere, "everywhere", false, "Revoke every session server-side, not just the current one") addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) return cmd } @@ -103,7 +103,7 @@ func revokeCurrentSession(ctx context.Context, coreURL, token string) error { } // revokeAllSessions revokes every active login session on coreURL (the -// `entire logout --all` path): list the families, then delete each by id. +// `entire logout --everywhere` path): list the families, then delete each by id. // Best-effort across sessions — it attempts them all and returns the first // failure, so one stuck session doesn't strand the rest. func revokeAllSessions(ctx context.Context, coreURL, token string) error { From 14550a8eac75ea02dc811876283e89317c692ef9 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:42:13 +0930 Subject: [PATCH 14/21] logout: add --all to drain every saved login (context) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `entire logout --all` iterates all saved contexts, revokes each context's current session server-side against its own core (or every session on that core when combined with --everywhere), and removes each login locally — then clears the legacy keyring entry so the machine ends fully logged out. Per-context failures warn but never abort the sweep; local removal always proceeds. Adds auth.RemoveContext (named sibling of RemoveCurrentContext, sharing a keychain-cleanup helper). Also fixes the stale --all reference in the logout help left by the --all→--everywhere rename. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 51b6330bf003 --- cmd/entire/cli/auth/context_store.go | 43 +++++-- cmd/entire/cli/auth/contexts_test.go | 45 +++++++ cmd/entire/cli/logout.go | 103 +++++++++++++++- cmd/entire/cli/logout_test.go | 172 +++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/auth/context_store.go b/cmd/entire/cli/auth/context_store.go index 5ff2a782cf..ab952f4166 100644 --- a/cmd/entire/cli/auth/context_store.go +++ b/cmd/entire/cli/auth/context_store.go @@ -50,18 +50,45 @@ func RemoveCurrentContext() error { }); err != nil { return fmt.Errorf("remove current context: %w", err) } - // Best-effort keychain cleanup, sequenced off the context just removed. - // A missing entry is fine — the contexts.json removal above is what makes - // us "logged out". Both the access slot and its paired refresh slot must - // go: leaving the long-lived refresh token behind would let any later - // keyring-capable process mint fresh access tokens after logout. - if svc != "" && handle != "" { - _ = tokenstore.Delete(svc, handle) //nolint:errcheck // best-effort; contexts.json removal is the source of truth for logout - _ = tokenstore.Delete(tokenstore.RefreshService(svc), handle) //nolint:errcheck // best-effort; absent refresh slot is fine + deleteContextKeychain(svc, handle) + return nil +} + +// RemoveContext deletes the named context from contexts.json and its keyring +// tokens. A missing context is a no-op. Used by `logout --all` to drain every +// saved login. File.Delete clears current_context when name was the active +// one, so removing the current context this way also logs it out. +func RemoveContext(name string) error { + var svc, handle string + if err := contexts.Modify(contexts.DefaultConfigDir(), func(f *contexts.File) (bool, error) { + c := f.Find(name) + if c == nil { + return false, nil + } + svc, handle = c.KeychainService, c.Handle + f.Delete(name) + return true, nil + }); err != nil { + return fmt.Errorf("remove context %q: %w", name, err) } + deleteContextKeychain(svc, handle) return nil } +// deleteContextKeychain best-effort removes a context's keyring slots, +// sequenced off the context just removed from contexts.json. A missing entry +// is fine — the contexts.json removal is what makes us "logged out". Both the +// access slot and its paired refresh slot must go: leaving the long-lived +// refresh token behind would let any later keyring-capable process mint fresh +// access tokens after logout. +func deleteContextKeychain(svc, handle string) { + if svc == "" || handle == "" { + return + } + _ = tokenstore.Delete(svc, handle) //nolint:errcheck // best-effort; contexts.json removal is the source of truth for logout + _ = tokenstore.Delete(tokenstore.RefreshService(svc), handle) //nolint:errcheck // best-effort; absent refresh slot is fine +} + // SetCurrentContext makes name the active context. Returns an error when // no context with that name exists (a stale current pointer is a foot-gun). func SetCurrentContext(name string) error { diff --git a/cmd/entire/cli/auth/contexts_test.go b/cmd/entire/cli/auth/contexts_test.go index f2bba8a196..c5078b1f09 100644 --- a/cmd/entire/cli/auth/contexts_test.go +++ b/cmd/entire/cli/auth/contexts_test.go @@ -304,6 +304,51 @@ func TestRemoveCurrentContext_DoesNotSwitchToAnother(t *testing.T) { } } +func TestRemoveContext(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) + restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")) + t.Cleanup(restore) + + exp := time.Now().Add(time.Hour).Unix() + first, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":"https://a.example.com","handle":"alice","exp":%d}`, exp)), "entr_a", true) + if err != nil { + t.Fatalf("record a: %v", err) + } + active, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":"https://b.example.com","handle":"alice","exp":%d}`, exp)), "entr_b", true) + if err != nil { + t.Fatalf("record b: %v", err) + } + + // Remove the non-current context by name: it must disappear (both slots) + // while the active context and current_context pointer are untouched. + if err := RemoveContext(first); err != nil { + t.Fatalf("RemoveContext: %v", err) + } + f, err := contexts.Load(cfgDir) + if err != nil { + t.Fatalf("load: %v", err) + } + if f.Find(first) != nil { + t.Fatalf("context %q should have been removed", first) + } + if f.CurrentContext != active { + t.Fatalf("current_context = %q, want the untouched active context %q", f.CurrentContext, active) + } + svcA := tokenstore.CoreKeyringService("https://a.example.com") + if v, err := tokenstore.Get(svcA, "alice"); !errors.Is(err, tokenstore.ErrNotFound) { + t.Fatalf("access slot survived RemoveContext: value=%q err=%v", v, err) + } + if v, err := tokenstore.Get(tokenstore.RefreshService(svcA), "alice"); !errors.Is(err, tokenstore.ErrNotFound) { + t.Fatalf("refresh slot survived RemoveContext: value=%q err=%v", v, err) + } + + // Idempotent: removing a name that no longer exists is a no-op. + if err := RemoveContext(first); err != nil { + t.Fatalf("second RemoveContext: %v", err) + } +} + func TestSetCurrentContext(t *testing.T) { cfgDir := t.TempDir() t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index 7f364b53e1..31acd258ad 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -8,6 +8,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/internal/entireclient/contexts" "github.com/spf13/cobra" ) @@ -34,6 +35,7 @@ type clearContextFunc func() error func newLogoutCmd() *cobra.Command { var insecureHTTPAuth bool var everywhere bool + var all bool cmd := &cobra.Command{ Use: "logout", Short: "Log out of Entire", @@ -41,14 +43,33 @@ func newLogoutCmd() *cobra.Command { "By default this ends the active session only (server-side) and removes the\n" + "active login from this machine. Other saved logins (contexts) remain and can\n" + "still authenticate `git clone entire://…` against clusters fronted by their\n" + - "login server. Pass --all to additionally revoke every session on the active\n" + - "core server-side.\n\n" + - "After logging out, the next saved login (if any) becomes active, so running\n" + - "`entire logout` repeatedly drains every saved login in turn.", + "login server.\n\n" + + "Pass --everywhere to revoke every session on the active core server-side\n" + + "(all your devices), not just the current one.\n\n" + + "Pass --all to log out of every saved login (context) at once: each context's\n" + + "session is revoked server-side and the login is removed from this machine.\n" + + "Combine with --everywhere to revoke every session on every context's core.\n\n" + + "Without --all, logging out promotes the next saved login (if any) to active,\n" + + "so running `entire logout` repeatedly drains every saved login in turn.", RunE: func(cmd *cobra.Command, _ []string) error { if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { return err } + outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr() + + // Pick the per-target revocation: just the current session, or + // every session on that context's core when --everywhere is set. + revokeForTarget := revokeCurrentSession + if everywhere { + revokeForTarget = revokeAllSessions + } + + if all { + return runLogoutAll(cmd.Context(), outW, errW, auth.Contexts, + auth.LoginTokenForContext, revokeForTarget, auth.RemoveContext, + auth.NewContextStore(), api.AuthBaseURL(), insecureHTTPAuth) + } + // Revoke against the active context's core (matching what // `auth status` lists), not a static AuthBaseURL. target := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL()) @@ -63,7 +84,6 @@ func newLogoutCmd() *cobra.Command { revokeAll := func(ctx context.Context) error { return revokeAllSessions(ctx, target.coreURL, target.token) } - outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr() if err := runLogout(cmd.Context(), outW, errW, auth.NewContextStore(), revokeCurrent, revokeAll, auth.RemoveCurrentContext, api.AuthBaseURL(), everywhere); err != nil { @@ -74,6 +94,7 @@ func newLogoutCmd() *cobra.Command { }, } cmd.Flags().BoolVar(&everywhere, "everywhere", false, "Revoke every session server-side, not just the current one") + cmd.Flags().BoolVar(&all, "all", false, "Log out of every saved login (context), not just the active one") addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) return cmd } @@ -160,3 +181,75 @@ func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revo fmt.Fprintln(outW, "Logged out.") return nil } + +// revokeTargetFunc revokes sessions on a specific core. The two production +// implementations are revokeCurrentSession (just the bearer's own session) +// and revokeAllSessions (every session on that core); `logout --all` picks +// one based on --everywhere and applies it to each saved context's core. +type revokeTargetFunc func(ctx context.Context, coreURL, token string) error + +// runLogoutAll drains every saved login. For each context it revokes the +// session(s) on that context's own core (using its own bearer) and removes +// the login locally, then clears the legacy keyring entry. Per-context +// failures warn but never abort the sweep — one stuck login can't strand the +// rest, and local removal always proceeds so the CLI ends fully logged out. +// +// Dependencies are injected so the sweep is unit-testable without the real +// keyring or config dir: listContexts (auth.Contexts), tokenForContext +// (auth.LoginTokenForContext), revoke (revokeCurrentSession/revokeAllSessions), +// and removeContext (auth.RemoveContext). +func runLogoutAll(ctx context.Context, outW, errW io.Writer, + listContexts contextsProvider, + tokenForContext func(*contexts.Context) (string, error), + revoke revokeTargetFunc, + removeContext func(name string) error, + store tokenStore, baseURL string, insecureHTTPAuth bool, +) error { + all, _, err := listContexts() + if err != nil { + return fmt.Errorf("list saved logins: %w", err) + } + + removed := 0 + for _, c := range all { + token, terr := tokenForContext(c) + if terr != nil { + // Can't read this context's bearer — skip the server revoke but + // still drop it locally so it stops being reported as a login. + fmt.Fprintf(errW, "Warning: couldn't read token for %q; removing locally only: %v\n", c.Name, terr) + token = "" + } + if token != "" && c.CoreURL != "" && !insecureHTTPAuth { + if serr := api.RequireSecureURL(c.CoreURL); serr != nil { + // Never send a bearer over a non-TLS core; warn and skip the + // server revoke, but still remove the login locally. + fmt.Fprintf(errW, "Warning: skipping server-side revocation for %q: %v\n", c.Name, serr) + token = "" + } + } + if token != "" && c.CoreURL != "" { + if rerr := revoke(ctx, c.CoreURL, token); rerr != nil && !api.IsHTTPErrorStatus(rerr, http.StatusUnauthorized) { + fmt.Fprintf(errW, "Warning: server-side session revocation failed for %q: %v\n", c.Name, rerr) + } + } + if rerr := removeContext(c.Name); rerr != nil { + fmt.Fprintf(errW, "Warning: failed to remove saved login %q: %v\n", c.Name, rerr) + continue + } + removed++ + } + + // Clear the legacy keyring entry too so a pre-contexts login doesn't + // linger after a full logout. Best-effort: the contexts above are the + // source of truth for the logged-out state. + if derr := store.DeleteToken(baseURL); derr != nil { + fmt.Fprintf(errW, "Warning: failed to remove legacy auth token: %v\n", derr) + } + + if removed == 0 { + fmt.Fprintln(outW, "No saved logins to remove.") + } else { + fmt.Fprintf(outW, "Logged out of %d saved login(s).\n", removed) + } + return nil +} diff --git a/cmd/entire/cli/logout_test.go b/cmd/entire/cli/logout_test.go index 3fafca5f08..a779ab736f 100644 --- a/cmd/entire/cli/logout_test.go +++ b/cmd/entire/cli/logout_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/internal/entireclient/contexts" ) const testLogoutToken = "tok123" @@ -260,3 +261,174 @@ func TestLogoutCmd_IsRegistered(t *testing.T) { t.Fatal("logout command not registered on root") } } + +// makeLogoutContexts builds a contextsProvider returning the given contexts +// with no active marker — `logout --all` ignores which one is current. +func makeLogoutContexts(cs ...*contexts.Context) contextsProvider { + return func() ([]*contexts.Context, string, error) { return cs, "", nil } +} + +func TestRunLogoutAll_RevokesAndRemovesEachContext(t *testing.T) { + t.Parallel() + + provider := makeLogoutContexts( + &contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"}, + &contexts.Context{Name: "us", CoreURL: "https://us.auth.entire.io"}, + ) + tokens := map[string]string{"eu": "tok-eu", "us": "tok-us"} + tokenFor := func(c *contexts.Context) (string, error) { return tokens[c.Name], nil } + + revoked := map[string]string{} // coreURL -> token + revoke := func(_ context.Context, coreURL, token string) error { + revoked[coreURL] = token + return nil + } + removed := map[string]bool{} + remove := func(name string) error { removed[name] = true; return nil } + + store := newMockTokenStore() + var out, errOut bytes.Buffer + if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, store, "https://entire.io", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if revoked["https://eu.auth.entire.io"] != "tok-eu" || revoked["https://us.auth.entire.io"] != "tok-us" { + t.Fatalf("each context's session should be revoked against its own core+token, got %v", revoked) + } + if !removed["eu"] || !removed["us"] { + t.Fatalf("both contexts should be removed locally, got %v", removed) + } + if !store.deleted["https://entire.io"] { + t.Error("legacy keyring entry should be cleared on logout --all") + } + if !strings.Contains(out.String(), "Logged out of 2 saved login(s).") { + t.Fatalf("stdout = %q, want count of 2", out.String()) + } + if errOut.Len() != 0 { + t.Fatalf("stderr = %q, want empty", errOut.String()) + } +} + +func TestRunLogoutAll_NoContexts(t *testing.T) { + t.Parallel() + + revoke := func(context.Context, string, string) error { + t.Fatal("revoke should not run with no contexts") + return nil + } + remove := func(string) error { t.Fatal("remove should not run with no contexts"); return nil } + store := newMockTokenStore() + + var out, errOut bytes.Buffer + if err := runLogoutAll(context.Background(), &out, &errOut, makeLogoutContexts(), nil, revoke, remove, store, "https://entire.io", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "No saved logins to remove.") { + t.Fatalf("stdout = %q, want the empty-state message", out.String()) + } + if !store.deleted["https://entire.io"] { + t.Error("legacy keyring entry should still be cleared even with no contexts") + } +} + +func TestRunLogoutAll_RevokeFailureWarnsButContinues(t *testing.T) { + t.Parallel() + + provider := makeLogoutContexts( + &contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"}, + &contexts.Context{Name: "us", CoreURL: "https://us.auth.entire.io"}, + ) + tokenFor := func(*contexts.Context) (string, error) { return testLogoutToken, nil } + revoke := func(_ context.Context, coreURL, _ string) error { + if coreURL == "https://eu.auth.entire.io" { + return errors.New("connection refused") + } + return nil + } + removed := map[string]bool{} + remove := func(name string) error { removed[name] = true; return nil } + + var out, errOut bytes.Buffer + if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !removed["eu"] || !removed["us"] { + t.Fatalf("a server revoke failure must not strand local removal, got %v", removed) + } + if !strings.Contains(errOut.String(), `revocation failed for "eu"`) || !strings.Contains(errOut.String(), "connection refused") { + t.Fatalf("stderr = %q, want a warning naming the failed context", errOut.String()) + } + if !strings.Contains(out.String(), "Logged out of 2 saved login(s).") { + t.Fatalf("stdout = %q, want count of 2 despite the warning", out.String()) + } +} + +func TestRunLogoutAll_UnauthorizedRevokeIsSilent(t *testing.T) { + t.Parallel() + + provider := makeLogoutContexts(&contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"}) + tokenFor := func(*contexts.Context) (string, error) { return testLogoutToken, nil } + revoke := func(context.Context, string, string) error { + return &api.HTTPError{StatusCode: http.StatusUnauthorized, Message: "Not authenticated"} + } + remove := func(string) error { return nil } + + var out, errOut bytes.Buffer + if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if errOut.Len() != 0 { + t.Fatalf("stderr = %q, want empty: an already-invalid token is the desired state", errOut.String()) + } +} + +func TestRunLogoutAll_UnreadableTokenRemovesLocallyOnly(t *testing.T) { + t.Parallel() + + provider := makeLogoutContexts(&contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"}) + tokenFor := func(*contexts.Context) (string, error) { return "", errors.New("keyring locked") } + revokeCalled := false + revoke := func(context.Context, string, string) error { revokeCalled = true; return nil } + removed := false + remove := func(string) error { removed = true; return nil } + + var out, errOut bytes.Buffer + if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if revokeCalled { + t.Error("revoke should be skipped when the token can't be read") + } + if !removed { + t.Error("context should still be removed locally") + } + if !strings.Contains(errOut.String(), "removing locally only") { + t.Fatalf("stderr = %q, want the locally-only warning", errOut.String()) + } +} + +func TestRunLogoutAll_InsecureCoreSkipsRevoke(t *testing.T) { + t.Parallel() + + provider := makeLogoutContexts(&contexts.Context{Name: "local", CoreURL: "http://insecure.example.com"}) + tokenFor := func(*contexts.Context) (string, error) { return testLogoutToken, nil } + revokeCalled := false + revoke := func(context.Context, string, string) error { revokeCalled = true; return nil } + removed := false + remove := func(string) error { removed = true; return nil } + + var out, errOut bytes.Buffer + // insecureHTTPAuth=false: a plain-http core must not receive the bearer. + if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if revokeCalled { + t.Error("revoke should be skipped for a non-TLS core without --insecure-http-auth") + } + if !removed { + t.Error("context should still be removed locally") + } + if !strings.Contains(errOut.String(), "skipping server-side revocation") { + t.Fatalf("stderr = %q, want the insecure-skip warning", errOut.String()) + } +} From 289839f84e22866cff2ef8dadc8278bd73b275d8 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:01:16 +0930 Subject: [PATCH 15/21] logout: end-to-end test pinning the --all/--everywhere matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the real cobra logout command against two fake entire-core servers and asserts which revoke shape each context's core receives across all four quadrants: logout → active context, current session logout --everywhere → active context, all sessions logout --all → every context, current session each logout --all --everywhere → every context, all sessions each This pins the command-layer mapping (--everywhere → revoke-all per core) that the runLogout/runLogoutAll unit tests can't reach, since they inject the revoke func directly. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 7cfe9c936a83 --- cmd/entire/cli/logout_test.go | 132 ++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/cmd/entire/cli/logout_test.go b/cmd/entire/cli/logout_test.go index a779ab736f..5c04200483 100644 --- a/cmd/entire/cli/logout_test.go +++ b/cmd/entire/cli/logout_test.go @@ -4,12 +4,19 @@ import ( "bytes" "context" "errors" + "fmt" "net/http" + "net/http/httptest" + "path/filepath" "strings" + "sync" "testing" + "time" "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/internal/entireclient/contexts" + "github.com/entireio/cli/internal/entireclient/tokenstore" ) const testLogoutToken = "tok123" @@ -432,3 +439,128 @@ func TestRunLogoutAll_InsecureCoreSkipsRevoke(t *testing.T) { t.Fatalf("stderr = %q, want the insecure-skip warning", errOut.String()) } } + +// coreRecorder counts the session-endpoint calls a fake entire-core sees, so +// the flag-matrix test can assert exactly which revoke shape each context's +// core received. +type coreRecorder struct { + mu sync.Mutex + listCount int + deleteCurrent int + deleteByID []string +} + +func (r *coreRecorder) snapshot() (list, current, byID int) { + r.mu.Lock() + defer r.mu.Unlock() + return r.listCount, r.deleteCurrent, len(r.deleteByID) +} + +// newCoreServer stands up a fake entire-core that answers the three session +// endpoints logout uses: GET (list), DELETE /current, DELETE /. The list +// returns two sessions so --everywhere has something to delete per core. +func newCoreServer(t *testing.T) (*httptest.Server, *coreRecorder) { + t.Helper() + rec := &coreRecorder{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + defer rec.mu.Unlock() + switch { + case r.Method == http.MethodGet && r.URL.Path == coreSessionsPath: + rec.listCount++ + fmt.Fprint(w, `{"tokens":[{"id":"s1"},{"id":"s2"}]}`) + case r.Method == http.MethodDelete && r.URL.Path == coreSessionsPath+"/current": + rec.deleteCurrent++ + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, coreSessionsPath+"/"): + rec.deleteByID = append(rec.deleteByID, strings.TrimPrefix(r.URL.Path, coreSessionsPath+"/")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv, rec +} + +// seedTwoContexts records two login contexts pointing at two fake cores. The +// second (recB) is recorded with activate=true, so it is the *active* context +// — what a plain `logout` (no --all) targets. +func seedTwoContexts(t *testing.T) (recA, recB *coreRecorder) { + t.Helper() + cfgDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) + restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")) + t.Cleanup(restore) + + srvA, recA := newCoreServer(t) + srvB, recB := newCoreServer(t) + exp := time.Now().Add(time.Hour).Unix() + if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"alice","exp":%d}`, srvA.URL, exp)), "", true); err != nil { + t.Fatalf("seed context A: %v", err) + } + if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"bob","exp":%d}`, srvB.URL, exp)), "", true); err != nil { + t.Fatalf("seed context B: %v", err) + } + return recA, recB +} + +// execLogout runs the real cobra logout command with --insecure-http-auth +// (the fake cores are http loopback) plus the given flags. +func execLogout(t *testing.T, flags ...string) { + t.Helper() + cmd := newLogoutCmd() + cmd.SetArgs(append([]string{"--insecure-http-auth"}, flags...)) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + if err := cmd.Execute(); err != nil { + t.Fatalf("logout %v: %v (stderr=%q)", flags, err, errOut.String()) + } +} + +// TestLogoutCommand_FlagMatrix pins all four quadrants of the --all/--everywhere +// matrix end-to-end through the cobra command, asserting which revoke shape each +// context's core actually received. Process-global env + keyring backend, so no +// t.Parallel(); subtests run sequentially, each with fresh state. +func TestLogoutCommand_FlagMatrix(t *testing.T) { + t.Run("logout: active context, current session", func(t *testing.T) { + recA, recB := seedTwoContexts(t) + execLogout(t) + if l, c, b := recA.snapshot(); l+c+b != 0 { + t.Errorf("inactive context A should be untouched, got list=%d current=%d byID=%d", l, c, b) + } + if l, c, b := recB.snapshot(); l != 0 || c != 1 || b != 0 { + t.Errorf("active context B: want one current-session revoke, got list=%d current=%d byID=%d", l, c, b) + } + }) + + t.Run("--everywhere: active context, all sessions", func(t *testing.T) { + recA, recB := seedTwoContexts(t) + execLogout(t, "--everywhere") + if l, c, b := recA.snapshot(); l+c+b != 0 { + t.Errorf("inactive context A should be untouched, got list=%d current=%d byID=%d", l, c, b) + } + if l, c, b := recB.snapshot(); l != 1 || c != 0 || b != 2 { + t.Errorf("active context B: want list + 2 by-id revokes, got list=%d current=%d byID=%d", l, c, b) + } + }) + + t.Run("--all: every context, current session each", func(t *testing.T) { + recA, recB := seedTwoContexts(t) + execLogout(t, "--all") + for name, rec := range map[string]*coreRecorder{"A": recA, "B": recB} { + if l, c, b := rec.snapshot(); l != 0 || c != 1 || b != 0 { + t.Errorf("context %s: want one current-session revoke, got list=%d current=%d byID=%d", name, l, c, b) + } + } + }) + + t.Run("--all --everywhere: every context, all sessions each", func(t *testing.T) { + recA, recB := seedTwoContexts(t) + execLogout(t, "--all", "--everywhere") + for name, rec := range map[string]*coreRecorder{"A": recA, "B": recB} { + if l, c, b := rec.snapshot(); l != 1 || c != 0 || b != 2 { + t.Errorf("context %s: want list + 2 by-id revokes, got list=%d current=%d byID=%d", name, l, c, b) + } + } + }) +} From 5bc6e49909370184cfec4c2aa063912a988867d2 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:03:25 +0930 Subject: [PATCH 16/21] logout: rename --all to --all-contexts --all was ambiguous next to --everywhere (all devices vs all logins). --all-contexts says what it does: drain every saved login context. Updates the flag, help text, doc comments, and tests. Also corrects stale "--all" wording in TestRunLogout_AllRevokesAllSessions, which exercises the --everywhere path. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 446db5e8b03d --- cmd/entire/cli/auth/context_store.go | 6 +++--- cmd/entire/cli/logout.go | 19 ++++++++++--------- cmd/entire/cli/logout_test.go | 22 +++++++++++----------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/cmd/entire/cli/auth/context_store.go b/cmd/entire/cli/auth/context_store.go index ab952f4166..30a272d318 100644 --- a/cmd/entire/cli/auth/context_store.go +++ b/cmd/entire/cli/auth/context_store.go @@ -55,9 +55,9 @@ func RemoveCurrentContext() error { } // RemoveContext deletes the named context from contexts.json and its keyring -// tokens. A missing context is a no-op. Used by `logout --all` to drain every -// saved login. File.Delete clears current_context when name was the active -// one, so removing the current context this way also logs it out. +// tokens. A missing context is a no-op. Used by `logout --all-contexts` to +// drain every saved login. File.Delete clears current_context when name was +// the active one, so removing the current context this way also logs it out. func RemoveContext(name string) error { var svc, handle string if err := contexts.Modify(contexts.DefaultConfigDir(), func(f *contexts.File) (bool, error) { diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index 31acd258ad..5b79c3ca32 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -35,7 +35,7 @@ type clearContextFunc func() error func newLogoutCmd() *cobra.Command { var insecureHTTPAuth bool var everywhere bool - var all bool + var allContexts bool cmd := &cobra.Command{ Use: "logout", Short: "Log out of Entire", @@ -46,11 +46,12 @@ func newLogoutCmd() *cobra.Command { "login server.\n\n" + "Pass --everywhere to revoke every session on the active core server-side\n" + "(all your devices), not just the current one.\n\n" + - "Pass --all to log out of every saved login (context) at once: each context's\n" + - "session is revoked server-side and the login is removed from this machine.\n" + - "Combine with --everywhere to revoke every session on every context's core.\n\n" + - "Without --all, logging out promotes the next saved login (if any) to active,\n" + - "so running `entire logout` repeatedly drains every saved login in turn.", + "Pass --all-contexts to log out of every saved login (context) at once: each\n" + + "context's session is revoked server-side and the login is removed from this\n" + + "machine. Combine with --everywhere to revoke every session on every context's\n" + + "core.\n\n" + + "Without --all-contexts, logging out promotes the next saved login (if any) to\n" + + "active, so running `entire logout` repeatedly drains every saved login in turn.", RunE: func(cmd *cobra.Command, _ []string) error { if err := requireSecureBaseURL(insecureHTTPAuth); err != nil { return err @@ -64,7 +65,7 @@ func newLogoutCmd() *cobra.Command { revokeForTarget = revokeAllSessions } - if all { + if allContexts { return runLogoutAll(cmd.Context(), outW, errW, auth.Contexts, auth.LoginTokenForContext, revokeForTarget, auth.RemoveContext, auth.NewContextStore(), api.AuthBaseURL(), insecureHTTPAuth) @@ -94,7 +95,7 @@ func newLogoutCmd() *cobra.Command { }, } cmd.Flags().BoolVar(&everywhere, "everywhere", false, "Revoke every session server-side, not just the current one") - cmd.Flags().BoolVar(&all, "all", false, "Log out of every saved login (context), not just the active one") + cmd.Flags().BoolVar(&allContexts, "all-contexts", false, "Log out of every saved login (context), not just the active one") addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) return cmd } @@ -184,7 +185,7 @@ func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revo // revokeTargetFunc revokes sessions on a specific core. The two production // implementations are revokeCurrentSession (just the bearer's own session) -// and revokeAllSessions (every session on that core); `logout --all` picks +// and revokeAllSessions (every session on that core); `logout --all-contexts` picks // one based on --everywhere and applies it to each saved context's core. type revokeTargetFunc func(ctx context.Context, coreURL, token string) error diff --git a/cmd/entire/cli/logout_test.go b/cmd/entire/cli/logout_test.go index 5c04200483..2e5646680e 100644 --- a/cmd/entire/cli/logout_test.go +++ b/cmd/entire/cli/logout_test.go @@ -240,13 +240,13 @@ func TestRunLogout_AllRevokesAllSessions(t *testing.T) { } if currentCalled { - t.Error("--all should not call the current-session revoke") + t.Error("--everywhere should not call the current-session revoke") } if !allCalled { - t.Error("--all should call the revoke-all path") + t.Error("--everywhere should call the revoke-all path") } if !store.deleted["https://entire.io"] { - t.Fatal("local token should still be deleted under --all") + t.Fatal("local token should still be deleted under --everywhere") } if !strings.Contains(out.String(), "Logged out.") { t.Fatalf("stdout = %q, want to contain %q", out.String(), "Logged out.") @@ -270,7 +270,7 @@ func TestLogoutCmd_IsRegistered(t *testing.T) { } // makeLogoutContexts builds a contextsProvider returning the given contexts -// with no active marker — `logout --all` ignores which one is current. +// with no active marker — `logout --all-contexts` ignores which one is current. func makeLogoutContexts(cs ...*contexts.Context) contextsProvider { return func() ([]*contexts.Context, string, error) { return cs, "", nil } } @@ -306,7 +306,7 @@ func TestRunLogoutAll_RevokesAndRemovesEachContext(t *testing.T) { t.Fatalf("both contexts should be removed locally, got %v", removed) } if !store.deleted["https://entire.io"] { - t.Error("legacy keyring entry should be cleared on logout --all") + t.Error("legacy keyring entry should be cleared on logout --all-contexts") } if !strings.Contains(out.String(), "Logged out of 2 saved login(s).") { t.Fatalf("stdout = %q, want count of 2", out.String()) @@ -483,7 +483,7 @@ func newCoreServer(t *testing.T) (*httptest.Server, *coreRecorder) { // seedTwoContexts records two login contexts pointing at two fake cores. The // second (recB) is recorded with activate=true, so it is the *active* context -// — what a plain `logout` (no --all) targets. +// — what a plain `logout` (no --all-contexts) targets. func seedTwoContexts(t *testing.T) (recA, recB *coreRecorder) { t.Helper() cfgDir := t.TempDir() @@ -517,7 +517,7 @@ func execLogout(t *testing.T, flags ...string) { } } -// TestLogoutCommand_FlagMatrix pins all four quadrants of the --all/--everywhere +// TestLogoutCommand_FlagMatrix pins all four quadrants of the --all-contexts/--everywhere // matrix end-to-end through the cobra command, asserting which revoke shape each // context's core actually received. Process-global env + keyring backend, so no // t.Parallel(); subtests run sequentially, each with fresh state. @@ -544,9 +544,9 @@ func TestLogoutCommand_FlagMatrix(t *testing.T) { } }) - t.Run("--all: every context, current session each", func(t *testing.T) { + t.Run("--all-contexts: every context, current session each", func(t *testing.T) { recA, recB := seedTwoContexts(t) - execLogout(t, "--all") + execLogout(t, "--all-contexts") for name, rec := range map[string]*coreRecorder{"A": recA, "B": recB} { if l, c, b := rec.snapshot(); l != 0 || c != 1 || b != 0 { t.Errorf("context %s: want one current-session revoke, got list=%d current=%d byID=%d", name, l, c, b) @@ -554,9 +554,9 @@ func TestLogoutCommand_FlagMatrix(t *testing.T) { } }) - t.Run("--all --everywhere: every context, all sessions each", func(t *testing.T) { + t.Run("--all-contexts --everywhere: every context, all sessions each", func(t *testing.T) { recA, recB := seedTwoContexts(t) - execLogout(t, "--all", "--everywhere") + execLogout(t, "--all-contexts", "--everywhere") for name, rec := range map[string]*coreRecorder{"A": recA, "B": recB} { if l, c, b := rec.snapshot(); l != 1 || c != 0 || b != 2 { t.Errorf("context %s: want list + 2 by-id revokes, got list=%d current=%d byID=%d", name, l, c, b) From 5da9c131bc63223531c080103949b0a2ba936c3b Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:28:01 +0930 Subject: [PATCH 17/21] auth: disambiguate api session names as AuthSession The api package is generic, so its Session/ListSessions/etc. collided conceptually with the repo's agent/checkpoint session domain (strategy.Session). Prefix the auth surface with Auth: api.Session -> AuthSession, file sessions.go -> auth_sessions.go, and the cli-package helpers (newAuthSessionsClient, defaultListAuthSessions, revokeAllAuthSessions, etc.). JSON wire key stays "tokens". Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: cae0e9836123 --- cmd/entire/cli/api/{sessions.go => auth_sessions.go} | 0 cmd/entire/cli/api/{sessions_test.go => auth_sessions_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename cmd/entire/cli/api/{sessions.go => auth_sessions.go} (100%) rename cmd/entire/cli/api/{sessions_test.go => auth_sessions_test.go} (100%) diff --git a/cmd/entire/cli/api/sessions.go b/cmd/entire/cli/api/auth_sessions.go similarity index 100% rename from cmd/entire/cli/api/sessions.go rename to cmd/entire/cli/api/auth_sessions.go diff --git a/cmd/entire/cli/api/sessions_test.go b/cmd/entire/cli/api/auth_sessions_test.go similarity index 100% rename from cmd/entire/cli/api/sessions_test.go rename to cmd/entire/cli/api/auth_sessions_test.go From 5b21199d8eb20e1b0e830551927b59d268f07099 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:28:29 +0930 Subject: [PATCH 18/21] auth: rename session identifiers to AuthSession forms Apply the Auth-prefix renames across the moved files and call sites: api.Session -> AuthSession, ListSessions -> ListAuthSessions, etc., plus cli helpers (newAuthSessionsClient, defaultListAuthSessions, revokeAllAuthSessions, ...). JSON wire key stays "tokens". Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: c6c44b763a39 --- cmd/entire/cli/api/auth_sessions.go | 44 ++++++++++++------------ cmd/entire/cli/api/auth_sessions_test.go | 42 +++++++++++----------- cmd/entire/cli/api/client.go | 16 ++++----- cmd/entire/cli/auth.go | 42 +++++++++++----------- cmd/entire/cli/auth_test.go | 12 +++---- cmd/entire/cli/logout.go | 30 ++++++++-------- cmd/entire/cli/logout_test.go | 8 ++--- 7 files changed, 97 insertions(+), 97 deletions(-) diff --git a/cmd/entire/cli/api/auth_sessions.go b/cmd/entire/cli/api/auth_sessions.go index 18f3eb794c..58c619016d 100644 --- a/cmd/entire/cli/api/auth_sessions.go +++ b/cmd/entire/cli/api/auth_sessions.go @@ -7,12 +7,12 @@ import ( "net/url" ) -// Session is a single active login session — an OAuth refresh-token family — +// AuthSession is a single active login session — an OAuth refresh-token family — // returned by entire-core's session endpoint. One is created per // `entire login`, across all of a user's devices. Plaintext token values are // never returned by the server, only metadata. (The list envelope's wire key // is "tokens"; the rows are sessions.) -type Session struct { +type AuthSession struct { ID string `json:"id"` UserID string `json:"user_id"` Name string `json:"name"` @@ -22,26 +22,26 @@ type Session struct { CreatedAt string `json:"created_at"` } -// SessionsResponse is the envelope returned by the list endpoint. -type SessionsResponse struct { - Sessions []Session `json:"tokens"` +// AuthSessionsResponse is the envelope returned by the list endpoint. +type AuthSessionsResponse struct { + Sessions []AuthSession `json:"tokens"` } -// errSessionsPathUnset surfaces when a session method is called on a Client +// errAuthSessionsPathUnset surfaces when a session method is called on a Client // that wasn't given a base path. Construct via -// NewClientWithBaseURL(...).WithSessionsPath(...). -var errSessionsPathUnset = errors.New("api: sessions path is unset (call (*Client).WithSessionsPath before list/revoke)") +// NewClientWithBaseURL(...).WithAuthSessionsPath(...). +var errAuthSessionsPathUnset = errors.New("api: auth sessions path is unset (call (*Client).WithAuthSessionsPath before list/revoke)") -func (c *Client) sessionsBasePath() (string, error) { - if c.sessionsPath == "" { - return "", errSessionsPathUnset +func (c *Client) authSessionsBasePath() (string, error) { + if c.authSessionsPath == "" { + return "", errAuthSessionsPathUnset } - return c.sessionsPath, nil + return c.authSessionsPath, nil } -// ListSessions returns the authenticated user's active login sessions. -func (c *Client) ListSessions(ctx context.Context) ([]Session, error) { - base, err := c.sessionsBasePath() +// ListAuthSessions returns the authenticated user's active login sessions. +func (c *Client) ListAuthSessions(ctx context.Context) ([]AuthSession, error) { + base, err := c.authSessionsBasePath() if err != nil { return nil, fmt.Errorf("list sessions: %w", err) } @@ -55,17 +55,17 @@ func (c *Client) ListSessions(ctx context.Context) ([]Session, error) { return nil, fmt.Errorf("list sessions: %w", err) } - var out SessionsResponse + var out AuthSessionsResponse if err := DecodeJSON(resp, &out); err != nil { return nil, fmt.Errorf("list sessions: %w", err) } return out.Sessions, nil } -// RevokeCurrentSession revokes the login session this client is authenticating +// RevokeCurrentAuthSession revokes the login session this client is authenticating // with (the family the current bearer belongs to). -func (c *Client) RevokeCurrentSession(ctx context.Context) error { - base, err := c.sessionsBasePath() +func (c *Client) RevokeCurrentAuthSession(ctx context.Context) error { + base, err := c.authSessionsBasePath() if err != nil { return fmt.Errorf("revoke current session: %w", err) } @@ -81,9 +81,9 @@ func (c *Client) RevokeCurrentSession(ctx context.Context) error { return nil } -// RevokeSession revokes the login session with the given id. -func (c *Client) RevokeSession(ctx context.Context, id string) error { - base, err := c.sessionsBasePath() +// RevokeAuthSession revokes the login session with the given id. +func (c *Client) RevokeAuthSession(ctx context.Context, id string) error { + base, err := c.authSessionsBasePath() if err != nil { return fmt.Errorf("revoke session %s: %w", id, err) } diff --git a/cmd/entire/cli/api/auth_sessions_test.go b/cmd/entire/cli/api/auth_sessions_test.go index e8b74e5e05..8293c6d9e0 100644 --- a/cmd/entire/cli/api/auth_sessions_test.go +++ b/cmd/entire/cli/api/auth_sessions_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func TestClient_RevokeCurrentSession_SendsDeleteWithBearer(t *testing.T) { +func TestClient_RevokeCurrentAuthSession_SendsDeleteWithBearer(t *testing.T) { t.Parallel() var gotMethod, gotPath, gotAuth string @@ -23,11 +23,11 @@ func TestClient_RevokeCurrentSession_SendsDeleteWithBearer(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithSessionsPath("/api/auth/tokens") + c := NewClient("tok").WithAuthSessionsPath("/api/auth/tokens") c.baseURL = server.URL - if err := c.RevokeCurrentSession(context.Background()); err != nil { - t.Fatalf("RevokeCurrentSession() error = %v", err) + if err := c.RevokeCurrentAuthSession(context.Background()); err != nil { + t.Fatalf("RevokeCurrentAuthSession() error = %v", err) } if gotMethod != http.MethodDelete { @@ -41,7 +41,7 @@ func TestClient_RevokeCurrentSession_SendsDeleteWithBearer(t *testing.T) { } } -func TestClient_RevokeCurrentSession_ReturnsHTTPErrorOn401(t *testing.T) { +func TestClient_RevokeCurrentAuthSession_ReturnsHTTPErrorOn401(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -51,10 +51,10 @@ func TestClient_RevokeCurrentSession_ReturnsHTTPErrorOn401(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithSessionsPath("/api/auth/tokens") + c := NewClient("tok").WithAuthSessionsPath("/api/auth/tokens") c.baseURL = server.URL - err := c.RevokeCurrentSession(context.Background()) + err := c.RevokeCurrentAuthSession(context.Background()) if err == nil { t.Fatal("expected error for 401 response") } @@ -70,7 +70,7 @@ func TestClient_RevokeCurrentSession_ReturnsHTTPErrorOn401(t *testing.T) { } } -func TestClient_ListSessions_DecodesResponse(t *testing.T) { +func TestClient_ListAuthSessions_DecodesResponse(t *testing.T) { t.Parallel() var gotMethod, gotPath, gotAuth string @@ -87,12 +87,12 @@ func TestClient_ListSessions_DecodesResponse(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithSessionsPath("/api/auth/tokens") + c := NewClient("tok").WithAuthSessionsPath("/api/auth/tokens") c.baseURL = server.URL - tokens, err := c.ListSessions(context.Background()) + tokens, err := c.ListAuthSessions(context.Background()) if err != nil { - t.Fatalf("ListSessions() error = %v", err) + t.Fatalf("ListAuthSessions() error = %v", err) } if gotMethod != http.MethodGet { @@ -119,7 +119,7 @@ func TestClient_ListSessions_DecodesResponse(t *testing.T) { } } -func TestClient_ListSessions_ReturnsHTTPErrorOn401(t *testing.T) { +func TestClient_ListAuthSessions_ReturnsHTTPErrorOn401(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -129,10 +129,10 @@ func TestClient_ListSessions_ReturnsHTTPErrorOn401(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithSessionsPath("/api/auth/tokens") + c := NewClient("tok").WithAuthSessionsPath("/api/auth/tokens") c.baseURL = server.URL - _, err := c.ListSessions(context.Background()) + _, err := c.ListAuthSessions(context.Background()) if err == nil { t.Fatal("expected error for 401") } @@ -141,7 +141,7 @@ func TestClient_ListSessions_ReturnsHTTPErrorOn401(t *testing.T) { } } -func TestClient_RevokeSession_SendsDeleteWithEscapedID(t *testing.T) { +func TestClient_RevokeAuthSession_SendsDeleteWithEscapedID(t *testing.T) { t.Parallel() var gotMethod, gotEscapedPath, gotDecodedPath string @@ -155,12 +155,12 @@ func TestClient_RevokeSession_SendsDeleteWithEscapedID(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithSessionsPath("/api/auth/tokens") + c := NewClient("tok").WithAuthSessionsPath("/api/auth/tokens") c.baseURL = server.URL // Use an id that needs URL escaping to verify we don't blindly concat. - if err := c.RevokeSession(context.Background(), "abc/def 1"); err != nil { - t.Fatalf("RevokeSession() error = %v", err) + if err := c.RevokeAuthSession(context.Background(), "abc/def 1"); err != nil { + t.Fatalf("RevokeAuthSession() error = %v", err) } if gotMethod != http.MethodDelete { @@ -174,7 +174,7 @@ func TestClient_RevokeSession_SendsDeleteWithEscapedID(t *testing.T) { } } -func TestClient_RevokeSession_ReturnsErrorBody(t *testing.T) { +func TestClient_RevokeAuthSession_ReturnsErrorBody(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -184,10 +184,10 @@ func TestClient_RevokeSession_ReturnsErrorBody(t *testing.T) { })) defer server.Close() - c := NewClient("tok").WithSessionsPath("/api/auth/tokens") + c := NewClient("tok").WithAuthSessionsPath("/api/auth/tokens") c.baseURL = server.URL - err := c.RevokeSession(context.Background(), "missing") + err := c.RevokeAuthSession(context.Background(), "missing") if err == nil { t.Fatal("expected error for 404") } diff --git a/cmd/entire/cli/api/client.go b/cmd/entire/cli/api/client.go index 033541d456..daea111a0d 100644 --- a/cmd/entire/cli/api/client.go +++ b/cmd/entire/cli/api/client.go @@ -22,20 +22,20 @@ type Client struct { httpClient *http.Client baseURL string - // sessionsPath is the base path for entire-core's login-session - // endpoints (list / revoke / current). Set via WithSessionsPath when the + // authSessionsPath is the base path for entire-core's login-session + // endpoints (list / revoke / current). Set via WithAuthSessionsPath when the // client targets the auth host; empty otherwise, and the session methods // error out if called against an empty path. - sessionsPath string + authSessionsPath string } -// WithSessionsPath sets the base path used by ListSessions, -// RevokeCurrentSession, and RevokeSession. Returns the receiver for chaining +// WithAuthSessionsPath sets the base path used by ListAuthSessions, +// RevokeCurrentAuthSession, and RevokeAuthSession. Returns the receiver for chaining // at construction: // -// c := api.NewClientWithBaseURL(token, base).WithSessionsPath(p) -func (c *Client) WithSessionsPath(path string) *Client { - c.sessionsPath = path +// c := api.NewClientWithBaseURL(token, base).WithAuthSessionsPath(p) +func (c *Client) WithAuthSessionsPath(path string) *Client { + c.authSessionsPath = path return c } diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index d667578768..0c11efb117 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -18,12 +18,12 @@ import ( "github.com/spf13/cobra" ) -// coreSessionsPath is entire-core's login-session endpoint family +// coreAuthSessionsPath is entire-core's login-session endpoint family // (list / revoke / current) on the auth host. Sessions are OAuth // refresh-token families; the CLI authenticates against them with its core // JWT. Session management must target the auth host (entire-core), never the // data host. -const coreSessionsPath = "/api/auth/tokens" +const coreAuthSessionsPath = "/api/auth/tokens" // User-visible placeholder strings. lastUsedJustNow is consumed by // formatRelativeDuration in status.go. @@ -66,13 +66,13 @@ func requireSecureBaseURL(insecureHTTPAuth bool) error { return nil } -// newSessionsClient builds an api.Client for entire-core's login-session -// endpoints (coreSessionsPath) on coreURL, authenticated with the +// newAuthSessionsClient builds an api.Client for entire-core's login-session +// endpoints (coreAuthSessionsPath) on coreURL, authenticated with the // session-scoped login JWT. coreURL is the active context's CoreURL (or the // configured auth host when no context is active) — session management always // targets a login server, never the data host. -func newSessionsClient(coreURL, token string) *api.Client { - return api.NewClientWithBaseURL(token, coreURL).WithSessionsPath(coreSessionsPath) +func newAuthSessionsClient(coreURL, token string) *api.Client { + return api.NewClientWithBaseURL(token, coreURL).WithAuthSessionsPath(coreAuthSessionsPath) } // resolveAuthHostToken returns a bearer scoped for the auth host (entire-core). @@ -175,7 +175,7 @@ func newAuthStatusCmd() *cobra.Command { return fmt.Errorf("context core URL check: %w", err) } } - return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), defaultFetchProfile, defaultListSessions, target) + return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), defaultFetchProfile, defaultListAuthSessions, target) }, } addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth) @@ -196,10 +196,10 @@ type authProfile struct { // with token. Injected so status stays unit-testable without a live core. type profileFetcher func(ctx context.Context, coreURL, token string) (*authProfile, error) -// sessionLister lists the active login sessions on coreURL (the user's +// authSessionLister lists the active login sessions on coreURL (the user's // refresh-token families). Injected for testability; production wires -// defaultListSessions. -type sessionLister func(ctx context.Context, coreURL, token string) ([]api.Session, error) +// defaultListAuthSessions. +type authSessionLister func(ctx context.Context, coreURL, token string) ([]api.AuthSession, error) // contextsProvider returns the stored login contexts and the active context // name. Injected for testability; production wires auth.Contexts. @@ -266,16 +266,16 @@ func defaultFetchProfile(ctx context.Context, coreURL, token string) (*authProfi return p, nil } -// defaultListSessions lists the user's active login sessions on coreURL. -func defaultListSessions(ctx context.Context, coreURL, token string) ([]api.Session, error) { - return newSessionsClient(coreURL, token).ListSessions(ctx) //nolint:wrapcheck // ListSessions already wraps with action context +// defaultListAuthSessions lists the user's active login sessions on coreURL. +func defaultListAuthSessions(ctx context.Context, coreURL, token string) ([]api.AuthSession, error) { + return newAuthSessionsClient(coreURL, token).ListAuthSessions(ctx) //nolint:wrapcheck // ListSessions already wraps with action context } // runAuthStatus reports auth state against the target core: GET /me validates // the token and supplies the profile header, the active login context is shown // locally, and the active sessions (refresh-token families) on that core are // listed so the effect of `logout` / `logout --everywhere` is visible. -func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher, listSessions sessionLister, t statusTarget) error { +func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher, listSessions authSessionLister, t statusTarget) error { if t.token == "" { fmt.Fprintf(w, "Not logged in to %s\n", t.coreURL) fmt.Fprintln(w, "Run 'entire login' to authenticate.") @@ -306,9 +306,9 @@ func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher case serr != nil: fmt.Fprintf(w, "\n(could not list active sessions: %v)\n", serr) case len(sessions) > 0: - sortSessionsByRecency(sessions) + sortAuthSessionsByRecency(sessions) fmt.Fprintf(w, "\nActive sessions (%d):\n", len(sessions)) - renderSessionsTable(w, newAuthTableStyles(w), sessions) + renderAuthSessionsTable(w, newAuthTableStyles(w), sessions) fmt.Fprintln(w, "\nRun 'entire logout' to end this session, or 'entire logout --everywhere' to end all of them.") } @@ -417,10 +417,10 @@ func fallback(s, alt string) string { return s } -// renderSessionsTable prints the active login sessions as an aligned table. +// renderAuthSessionsTable prints the active login sessions as an aligned table. // No id column: there's no per-session CLI action (revoke-by-id is gone), so // NAME/CREATED/LAST USED/EXPIRES is what's useful. -func renderSessionsTable(w io.Writer, sty authTableStyles, sessions []api.Session) { +func renderAuthSessionsTable(w io.Writer, sty authTableStyles, sessions []api.AuthSession) { header := []string{ sty.render(sty.header, "NAME"), sty.render(sty.header, "CREATED"), @@ -439,10 +439,10 @@ func renderSessionsTable(w io.Writer, sty authTableStyles, sessions []api.Sessio renderAlignedTable(w, header, rows) } -// sortSessionsByRecency orders sessions most-recently-used first, then most +// sortAuthSessionsByRecency orders sessions most-recently-used first, then most // recently created, then by id — a fully specified order independent of the // server's response ordering. -func sortSessionsByRecency(sessions []api.Session) { +func sortAuthSessionsByRecency(sessions []api.AuthSession) { sort.Slice(sessions, func(i, j int) bool { li, lj := lastUsedSortKey(sessions[i]), lastUsedSortKey(sessions[j]) if li != lj { @@ -455,7 +455,7 @@ func sortSessionsByRecency(sessions []api.Session) { }) } -func lastUsedSortKey(s api.Session) string { +func lastUsedSortKey(s api.AuthSession) string { if s.LastUsedAt == nil { return "" } diff --git a/cmd/entire/cli/auth_test.go b/cmd/entire/cli/auth_test.go index a1667f0feb..9f248a0552 100644 --- a/cmd/entire/cli/auth_test.go +++ b/cmd/entire/cli/auth_test.go @@ -48,8 +48,8 @@ func rejecting(err error) profileFetcher { return func(context.Context, string, string) (*authProfile, error) { return nil, err } } -// noSessions is a sessionLister returning an empty list (no table rendered). -func noSessions(context.Context, string, string) ([]api.Session, error) { return nil, nil } +// noSessions is a authSessionLister returning an empty list (no table rendered). +func noSessions(context.Context, string, string) ([]api.AuthSession, error) { return nil, nil } func TestRunAuthStatus_NotLoggedIn(t *testing.T) { t.Parallel() @@ -98,11 +98,11 @@ func TestRunAuthStatus_RendersSessionsTable(t *testing.T) { target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "eu.auth.entire.io", totalContexts: 1} lastUsed := "2026-05-01T00:00:00Z" - listSessions := func(_ context.Context, coreURL, token string) ([]api.Session, error) { + listSessions := func(_ context.Context, coreURL, token string) ([]api.AuthSession, error) { if coreURL != testCoreURL || token != "tok" { t.Errorf("listSessions called with (%q, %q), want the active core+token", coreURL, token) } - return []api.Session{ + return []api.AuthSession{ {ID: "fam-1", Name: "OIDC login", CreatedAt: "2026-01-01T00:00:00Z", ExpiresAt: "2026-12-01T00:00:00Z", LastUsedAt: &lastUsed}, {ID: "fam-2", Name: "OIDC login", CreatedAt: "2026-02-01T00:00:00Z", ExpiresAt: "2026-12-15T00:00:00Z"}, }, nil @@ -130,7 +130,7 @@ func TestRunAuthStatus_SessionListFailureIsSoftNote(t *testing.T) { t.Parallel() target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "eu.auth.entire.io", totalContexts: 1} - listSessions := func(context.Context, string, string) ([]api.Session, error) { + listSessions := func(context.Context, string, string) ([]api.AuthSession, error) { return nil, errors.New("sessions endpoint unreachable") } @@ -277,7 +277,7 @@ func TestAuthCmd_RegistersExpectedSubcommands(t *testing.T) { // tokenmanager.Manager via auth.SetManagerForTest and stub only the // STS wire call via SetExchangeForTest. That covers the audience- // matching logic the function-injection tests above can't reach -// (defaultRevokeCurrentSession / defaultRevokeAllSessions call +// (revokeCurrentAuthSession / revokeAllAuthSessions call // resolveAuthHostToken directly, but unit tests for the surrounding flows // inject fakes that bypass it). diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index 5b79c3ca32..628b69b13c 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -60,9 +60,9 @@ func newLogoutCmd() *cobra.Command { // Pick the per-target revocation: just the current session, or // every session on that context's core when --everywhere is set. - revokeForTarget := revokeCurrentSession + revokeForTarget := revokeCurrentAuthSession if everywhere { - revokeForTarget = revokeAllSessions + revokeForTarget = revokeAllAuthSessions } if allContexts { @@ -80,10 +80,10 @@ func newLogoutCmd() *cobra.Command { } } revokeCurrent := func(ctx context.Context) error { - return revokeCurrentSession(ctx, target.coreURL, target.token) + return revokeCurrentAuthSession(ctx, target.coreURL, target.token) } revokeAll := func(ctx context.Context) error { - return revokeAllSessions(ctx, target.coreURL, target.token) + return revokeAllAuthSessions(ctx, target.coreURL, target.token) } if err := runLogout(cmd.Context(), outW, errW, auth.NewContextStore(), revokeCurrent, revokeAll, @@ -118,27 +118,27 @@ func promoteNextLogin(outW, errW io.Writer) { fmt.Fprintf(outW, "Now using %q (%d saved login(s) remain; run `entire logout` again to remove each).\n", next, len(all)) } -// revokeCurrentSession revokes the active session on coreURL (the family the +// revokeCurrentAuthSession revokes the active session on coreURL (the family the // bearer belongs to) — the default `entire logout`. -func revokeCurrentSession(ctx context.Context, coreURL, token string) error { - return newSessionsClient(coreURL, token).RevokeCurrentSession(ctx) //nolint:wrapcheck // RevokeCurrentSession already wraps with action context +func revokeCurrentAuthSession(ctx context.Context, coreURL, token string) error { + return newAuthSessionsClient(coreURL, token).RevokeCurrentAuthSession(ctx) //nolint:wrapcheck // RevokeCurrentSession already wraps with action context } -// revokeAllSessions revokes every active login session on coreURL (the +// revokeAllAuthSessions revokes every active login session on coreURL (the // `entire logout --everywhere` path): list the families, then delete each by id. // Best-effort across sessions — it attempts them all and returns the first // failure, so one stuck session doesn't strand the rest. -func revokeAllSessions(ctx context.Context, coreURL, token string) error { - client := newSessionsClient(coreURL, token) +func revokeAllAuthSessions(ctx context.Context, coreURL, token string) error { + client := newAuthSessionsClient(coreURL, token) // ListSessions and RevokeSession already wrap with their own action // context (incl. the session id), so return their errors verbatim. - sessions, err := client.ListSessions(ctx) + sessions, err := client.ListAuthSessions(ctx) if err != nil { return err //nolint:wrapcheck // ListSessions already wraps with "list sessions" } var firstErr error for _, s := range sessions { - if err := client.RevokeSession(ctx, s.ID); err != nil && firstErr == nil { + if err := client.RevokeAuthSession(ctx, s.ID); err != nil && firstErr == nil { firstErr = err } } @@ -184,8 +184,8 @@ func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revo } // revokeTargetFunc revokes sessions on a specific core. The two production -// implementations are revokeCurrentSession (just the bearer's own session) -// and revokeAllSessions (every session on that core); `logout --all-contexts` picks +// implementations are revokeCurrentAuthSession (just the bearer's own session) +// and revokeAllAuthSessions (every session on that core); `logout --all-contexts` picks // one based on --everywhere and applies it to each saved context's core. type revokeTargetFunc func(ctx context.Context, coreURL, token string) error @@ -197,7 +197,7 @@ type revokeTargetFunc func(ctx context.Context, coreURL, token string) error // // Dependencies are injected so the sweep is unit-testable without the real // keyring or config dir: listContexts (auth.Contexts), tokenForContext -// (auth.LoginTokenForContext), revoke (revokeCurrentSession/revokeAllSessions), +// (auth.LoginTokenForContext), revoke (revokeCurrentAuthSession/revokeAllAuthSessions), // and removeContext (auth.RemoveContext). func runLogoutAll(ctx context.Context, outW, errW io.Writer, listContexts contextsProvider, diff --git a/cmd/entire/cli/logout_test.go b/cmd/entire/cli/logout_test.go index 2e5646680e..83f25c0e04 100644 --- a/cmd/entire/cli/logout_test.go +++ b/cmd/entire/cli/logout_test.go @@ -466,13 +466,13 @@ func newCoreServer(t *testing.T) (*httptest.Server, *coreRecorder) { rec.mu.Lock() defer rec.mu.Unlock() switch { - case r.Method == http.MethodGet && r.URL.Path == coreSessionsPath: + case r.Method == http.MethodGet && r.URL.Path == coreAuthSessionsPath: rec.listCount++ fmt.Fprint(w, `{"tokens":[{"id":"s1"},{"id":"s2"}]}`) - case r.Method == http.MethodDelete && r.URL.Path == coreSessionsPath+"/current": + case r.Method == http.MethodDelete && r.URL.Path == coreAuthSessionsPath+"/current": rec.deleteCurrent++ - case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, coreSessionsPath+"/"): - rec.deleteByID = append(rec.deleteByID, strings.TrimPrefix(r.URL.Path, coreSessionsPath+"/")) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, coreAuthSessionsPath+"/"): + rec.deleteByID = append(rec.deleteByID, strings.TrimPrefix(r.URL.Path, coreAuthSessionsPath+"/")) default: w.WriteHeader(http.StatusNotFound) } From 73470fefba7b099dc6f122fe45a577ef03e1378f Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:28:40 +0930 Subject: [PATCH 19/21] release: capture git status before testing (shellcheck SC2312) Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 5b271c9e8439 --- mise-tasks/release | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mise-tasks/release b/mise-tasks/release index 57b0547462..ff128a500a 100755 --- a/mise-tasks/release +++ b/mise-tasks/release @@ -21,7 +21,8 @@ if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then fi # Ensure working tree is clean -if [ -n "$(git status --porcelain)" ]; then +status="$(git status --porcelain)" +if [ -n "$status" ]; then echo "Error: working tree is not clean, commit or stash changes first" exit 1 fi From 0caa415ae8afd42417cdecfb75d4ae8eaec12dbf Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:33:01 +0930 Subject: [PATCH 20/21] auth: say 'login server' not 'core' in user-facing text Rename the contexts-table column header to LOGIN SERVER, reword the logout --everywhere/--all-contexts help, and the TLS-check error so we never surface the internal 'core' term to users. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 0b31c2ff9960 --- cmd/entire/cli/auth.go | 2 +- cmd/entire/cli/auth_context.go | 2 +- cmd/entire/cli/auth_context_test.go | 2 +- cmd/entire/cli/logout.go | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 0c11efb117..4632f7bd5b 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -172,7 +172,7 @@ func newAuthStatusCmd() *cobra.Command { // too (it may differ from AuthBaseURL when a context is active). if !insecureHTTPAuth { if err := api.RequireSecureURL(target.coreURL); err != nil { - return fmt.Errorf("context core URL check: %w", err) + return fmt.Errorf("context login server URL check: %w", err) } } return runAuthStatus(cmd.Context(), cmd.OutOrStdout(), defaultFetchProfile, defaultListAuthSessions, target) diff --git a/cmd/entire/cli/auth_context.go b/cmd/entire/cli/auth_context.go index 7c6eb31b13..53518dfbed 100644 --- a/cmd/entire/cli/auth_context.go +++ b/cmd/entire/cli/auth_context.go @@ -107,7 +107,7 @@ func renderContextsTable(w io.Writer, all []*contexts.Context, current string) { "", // active marker sty.render(sty.header, "CONTEXT"), sty.render(sty.header, "HANDLE"), - sty.render(sty.header, "CORE URL"), + sty.render(sty.header, "LOGIN SERVER"), } rows := make([][]string, 0, len(all)) diff --git a/cmd/entire/cli/auth_context_test.go b/cmd/entire/cli/auth_context_test.go index 6b429f2c7e..0ad1adbc3c 100644 --- a/cmd/entire/cli/auth_context_test.go +++ b/cmd/entire/cli/auth_context_test.go @@ -73,7 +73,7 @@ func TestRunAuthContexts(t *testing.T) { t.Fatalf("runAuthContexts: %v", err) } got := out.String() - for _, hdr := range []string{"CONTEXT", "HANDLE", "CORE URL"} { + for _, hdr := range []string{"CONTEXT", "HANDLE", "LOGIN SERVER"} { if !strings.Contains(got, hdr) { t.Fatalf("listing = %q, want column header %q", got, hdr) } diff --git a/cmd/entire/cli/logout.go b/cmd/entire/cli/logout.go index 628b69b13c..923a39c150 100644 --- a/cmd/entire/cli/logout.go +++ b/cmd/entire/cli/logout.go @@ -44,12 +44,12 @@ func newLogoutCmd() *cobra.Command { "active login from this machine. Other saved logins (contexts) remain and can\n" + "still authenticate `git clone entire://…` against clusters fronted by their\n" + "login server.\n\n" + - "Pass --everywhere to revoke every session on the active core server-side\n" + + "Pass --everywhere to revoke every session on the active login server\n" + "(all your devices), not just the current one.\n\n" + "Pass --all-contexts to log out of every saved login (context) at once: each\n" + "context's session is revoked server-side and the login is removed from this\n" + "machine. Combine with --everywhere to revoke every session on every context's\n" + - "core.\n\n" + + "login server.\n\n" + "Without --all-contexts, logging out promotes the next saved login (if any) to\n" + "active, so running `entire logout` repeatedly drains every saved login in turn.", RunE: func(cmd *cobra.Command, _ []string) error { @@ -76,7 +76,7 @@ func newLogoutCmd() *cobra.Command { target := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL()) if !insecureHTTPAuth { if err := api.RequireSecureURL(target.coreURL); err != nil { - return fmt.Errorf("context core URL check: %w", err) + return fmt.Errorf("context login server URL check: %w", err) } } revokeCurrent := func(ctx context.Context) error { From f9c7d973876106da8a94a4ad1b0f986a7f887106 Mon Sep 17 00:00:00 2001 From: paul <423357+toothbrush@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:39:37 +0930 Subject: [PATCH 21/21] auth: say 'login server' not 'core' in two more user-facing errors repo-token exchange hint and git-remote-entire's trust-gate error both surfaced the internal 'core' term; reword to 'login server' and update the trust-gate test assertion. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: c7e0df0e441f --- cmd/entire/cli/auth/repo_token.go | 2 +- cmd/git-remote-entire/main.go | 2 +- cmd/git-remote-entire/main_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/auth/repo_token.go b/cmd/entire/cli/auth/repo_token.go index 0371107333..2f90fe6100 100644 --- a/cmd/entire/cli/auth/repo_token.go +++ b/cmd/entire/cli/auth/repo_token.go @@ -75,7 +75,7 @@ func SetRepoExchangeTransportForTest(rt http.RoundTripper) func() { func RepoScopedToken(ctx context.Context, clusterBaseURL, repoSlug, action string) (string, error) { provider := CurrentProvider() if strings.TrimSpace(provider.STSPath) == "" { - return "", errors.New("repo-scoped token exchange requires a v2 auth host (set ENTIRE_AUTH_BASE_URL to a core that exposes /oauth/token)") + return "", errors.New("repo-scoped token exchange requires a v2 auth host (set ENTIRE_AUTH_BASE_URL to a login server that exposes /oauth/token)") } loginJWT, err := LookupCurrentToken() diff --git a/cmd/git-remote-entire/main.go b/cmd/git-remote-entire/main.go index 515c695d3d..783d01dff4 100644 --- a/cmd/git-remote-entire/main.go +++ b/cmd/git-remote-entire/main.go @@ -279,7 +279,7 @@ func resolveEnvTokenCreds(ctx context.Context, envToken, clusterHost, clusterBas return nil, err //nolint:wrapcheck // ResolveClusterCores returns a user-facing discovery error } if !coreTrusted(coreURL, cores) { - return nil, fmt.Errorf("%s aud %q is not a trusted core for cluster %s (advertised: %s); the token belongs to a different cluster", + return nil, fmt.Errorf("%s aud %q is not a trusted login server for cluster %s (advertised: %s); the token belongs to a different cluster", auth.EnvTokenVar, coreURL, clusterHost, strings.Join(cores, ", ")) } debuglog.Printf("authenticating via %s; core=%s", auth.EnvTokenVar, coreURL) diff --git a/cmd/git-remote-entire/main_test.go b/cmd/git-remote-entire/main_test.go index d527d9a052..30890658e4 100644 --- a/cmd/git-remote-entire/main_test.go +++ b/cmd/git-remote-entire/main_test.go @@ -232,7 +232,7 @@ func TestResolveEnvTokenCreds_UntrustedAudAborts(t *testing.T) { if creds != nil { t.Fatal("expected nil creds when aud is untrusted") } - if !strings.Contains(err.Error(), "not a trusted core") { + if !strings.Contains(err.Error(), "not a trusted login server") { t.Fatalf("expected trust-gate error, got: %v", err) } }