From 9e4215594a266c348bfebc077f3bf33bf61c34e3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 18:31:14 -0700 Subject: [PATCH 01/16] Make auth status say who you are, and --check ask the server `auth status` answered "Authenticated (scope: full)" plus a few keys: no identity, account, server, storage backend, or expiry timestamp, and no remedy when nothing was stored. Finding out which account a shell addressed as whom meant `me` (a network round-trip) or reading the credential file. The JSON data gains base_url, account_id, user_email, expires_at, refreshable, and storage, keeping every existing key. A terminal gets prose: a "Logged in to as (user )" headline, then profile, account, access, source, token expiry, and storage; an expired token that cannot refresh names the login to run. Not logged in keeps exit 0 and authenticated:false for scripts, with the login command as the hint. Terminal versus envelope follows the output writer's effective format, so a piped status stays JSON. --check makes one authenticated request, the authorization lookup `me` makes, and reports whether the server still accepts the token as "valid"; a 401 is the rejected verdict (still exit 0), a fault that is no verdict is returned as itself. Without the flag nothing is fetched. --- .surface | 1 + install.md | 2 +- internal/commands/auth.go | 282 ++++++++++++++++++------ internal/commands/auth_status_test.go | 301 ++++++++++++++++++++++++++ skills/basecamp/SKILL.md | 3 +- 5 files changed, 526 insertions(+), 63 deletions(-) create mode 100644 internal/commands/auth_status_test.go diff --git a/.surface b/.surface index 276ad001a..d24af0f97 100644 --- a/.surface +++ b/.surface @@ -2047,6 +2047,7 @@ FLAG basecamp auth revoke --verbose type=count FLAG basecamp auth status --account type=string FLAG basecamp auth status --agent type=bool FLAG basecamp auth status --cache-dir type=string +FLAG basecamp auth status --check type=bool FLAG basecamp auth status --count type=bool FLAG basecamp auth status --help type=bool FLAG basecamp auth status --hints type=bool diff --git a/install.md b/install.md index ae9e0e2ca..990fe5b50 100644 --- a/install.md +++ b/install.md @@ -118,7 +118,7 @@ This opens browser OAuth. Grant access when prompted. **Verify:** ```bash basecamp auth status -# Expected: Authenticated (BC3 OAuth may show "Authenticated (scope: read)") +# Expected: Logged in to https://3.basecampapi.com as you@example.com (user ) ``` --- diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 65a0955e9..59ab7afb4 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -57,87 +57,247 @@ func newAuthLogoutCmd() *cobra.Command { } func newAuthStatusCmd() *cobra.Command { - return &cobra.Command{ + var check bool + + cmd := &cobra.Command{ Use: "status", - Short: "Show authentication status", - Long: "Display the current authentication status and token information.", + Short: "Show who you are logged in as", + Long: `Show the active credential: who it authenticates as, which server and +account it addresses, its access level and source, when the token expires, +and where it is stored. + +Nothing is fetched unless --check is given, which makes one authenticated +request (the same authorization lookup "basecamp me" makes) and reports +whether the server still accepts the token: "valid" in the JSON data. + +Exits 0 whether or not you are logged in; scripts read "authenticated" from +the JSON envelope. When nothing is stored, the summary names the login +command to run.`, RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) if app == nil { return fmt.Errorf("app not initialized") } - credKey := app.Auth.CredentialKey() - - // Check if using BASECAMP_TOKEN environment variable - if envToken := os.Getenv("BASECAMP_TOKEN"); envToken != "" { - result := map[string]any{ - "authenticated": true, - "source": "BASECAMP_TOKEN", - } - if app.Config.ActiveProfile != "" { - result["profile"] = app.Config.ActiveProfile - } - return app.OK(result, output.WithSummary("Authenticated via BASECAMP_TOKEN env var")) - } - - if !app.Auth.IsAuthenticated() { - result := map[string]any{ - "authenticated": false, - } - if app.Config.ActiveProfile != "" { - result["profile"] = app.Config.ActiveProfile - } - return app.OK(result, output.WithSummary("Not authenticated")) - } - - // Get stored credentials info - store := app.Auth.GetStore() - creds, err := store.Load(credKey) + report, err := authStatusReport(app) if err != nil { return err } - - // Suppress scope for Launchpad (scopes are not supported) - effectiveScope := creds.Scope - if creds.OAuthType == "launchpad" { - effectiveScope = "" + if check && report.data["authenticated"] == true { + if err := report.checkWithServer(cmd.Context(), app); err != nil { + return err + } } - source := "oauth" - if creds.Source != "" { - source = creds.Source + if !humanOutput(app) { + opts := []output.ResponseOption{output.WithSummary(report.summary)} + if report.hint != "" { + opts = append(opts, output.WithBreadcrumbs(output.Breadcrumb{ + Action: "login", Cmd: app.Auth.LoginCommand(), Description: "Log in to Basecamp", + })) + } + return app.OK(report.data, opts...) } - status := map[string]any{ - "authenticated": true, - "source": source, - "oauth_type": creds.OAuthType, + + w := cmd.OutOrStdout() + r := output.NewRendererWithTheme(w, false, tui.ResolveTheme(tui.DetectDark())) + headline := r.Summary + if report.data["authenticated"] == true { + headline = r.Success } - if effectiveScope != "" { - status["scope"] = effectiveScope + fmt.Fprintln(w, headline.Render(report.summary)) + for _, line := range report.details { + fmt.Fprintln(w, r.Muted.Render(" "+line)) } - if app.Config.ActiveProfile != "" { - status["profile"] = app.Config.ActiveProfile + if report.hint != "" { + fmt.Fprintln(w, r.Data.Render(" "+report.hint)) } + return nil + }, + } - if creds.UserID != "" { - status["user_id"] = creds.UserID - } + cmd.Flags().BoolVar(&check, "check", false, "Ask the server whether the stored token is still accepted (one authenticated request)") - // Token expiration - if creds.ExpiresAt > 0 { - expiresIn := time.Until(time.Unix(creds.ExpiresAt, 0)) - status["expires_in"] = expiresIn.Round(time.Second).String() - status["expired"] = expiresIn < 0 - } + return cmd +} - summary := "Authenticated" - if effectiveScope != "" { - summary += fmt.Sprintf(" (scope: %s)", effectiveScope) - } +// checkWithServer makes the one authenticated request --check promises and +// records the verdict. An auth-class failure — the server refused the token, +// or the credential could not be refreshed — is the "rejected" answer, not +// an error: that is what the caller asked. Anything else (the server could +// not be reached, a fault) is returned as itself, since no verdict was had. +func (s *authStatus) checkWithServer(ctx context.Context, app *appctx.App) error { + endpoint, err := app.Auth.AuthorizationEndpoint(ctx) + if err != nil { + return err + } + _, err = app.SDK.Authorization().GetInfo(ctx, &basecamp.GetInfoOptions{Endpoint: endpoint, FilterProduct: "bc3"}) + switch { + case err == nil: + s.data["valid"] = true + s.details = append(s.details, "Token: valid (checked just now)") + case output.AsError(err).Code == output.CodeAuth: + s.data["valid"] = false + s.details = append(s.details, "Token: rejected by the server") + s.hint = app.Auth.LoginHint() + default: + return convertSDKError(err) + } + return nil +} - return app.OK(status, output.WithSummary(summary)) - }, +// humanOutput reports whether the command's output is read by a person: the +// styled or markdown renderer, which the output writer resolves from flags, +// config, and whether stdout is a terminal. Anything else — JSON, quiet, +// a pipe — is a machine consumer that gets the envelope. +func humanOutput(app *appctx.App) bool { + format := app.Output.EffectiveFormat() + return format == output.FormatStyled || format == output.FormatMarkdown +} + +// authStatus is what `auth status` learned about the active credential: +// the envelope data, and the same facts as prose for a terminal. +type authStatus struct { + data map[string]any + summary string + details []string + hint string +} + +// authStatusReport inspects the active credential without touching the +// network. A BASECAMP_TOKEN session never reaches the credential store, so +// it neither pays the keyring probe nor reports another credential's +// identity as its own. +func authStatusReport(app *appctx.App) (*authStatus, error) { + baseURL := config.NormalizeBaseURL(app.Config.BaseURL) + profile := app.Config.ActiveProfile + account := app.Config.AccountID + + report := &authStatus{data: map[string]any{"base_url": baseURL}} + if profile != "" { + report.data["profile"] = profile + } + if account != "" { + report.data["account_id"] = account + } + where := []string{} + if profile != "" { + where = append(where, "Profile: "+profile) + } + if account != "" { + where = append(where, "Account: "+account) + } + + if os.Getenv("BASECAMP_TOKEN") != "" { + report.data["authenticated"] = true + report.data["source"] = "BASECAMP_TOKEN" + report.data["storage"] = "env" + report.summary = "Logged in to " + baseURL + " via BASECAMP_TOKEN" + report.details = []string{strings.Join(append(where, "Source: BASECAMP_TOKEN", "Storage: env"), " · ")} + return report, nil + } + + if !app.Auth.IsAuthenticated() { + report.data["authenticated"] = false + report.summary = "Not logged in to " + baseURL + report.hint = app.Auth.LoginHint() + return report, nil + } + + store := app.Auth.GetStore() + creds, err := store.Load(app.Auth.CredentialKey()) + if err != nil { + return nil, err + } + + // Launchpad ignores scope; its tokens are read-write. + scope := creds.Scope + if creds.OAuthType == "launchpad" { + scope = "" + } + source := "oauth" + if creds.Source != "" { + source = creds.Source + } + storage := "file" + if store.UsingKeyring() { + storage = "keyring" + } + refreshable := creds.RefreshToken != "" + + report.data["authenticated"] = true + report.data["source"] = source + report.data["oauth_type"] = creds.OAuthType + report.data["refreshable"] = refreshable + report.data["storage"] = storage + if scope != "" { + report.data["scope"] = scope + } + if creds.UserID != "" { + report.data["user_id"] = creds.UserID + } + if creds.UserEmail != "" { + report.data["user_email"] = creds.UserEmail + } + + report.summary = "Logged in to " + baseURL + if email := richtext.SanitizeSingleLine(creds.UserEmail); email != "" { + report.summary += " as " + email + } + if creds.UserID != "" { + report.summary += " (user " + creds.UserID + ")" + } + + if scope != "" { + where = append(where, "Access: "+scope) + } + sourceLabel := source + if creds.OAuthType != "" { + sourceLabel += " (" + creds.OAuthType + ")" + } + where = append(where, "Source: "+sourceLabel) + report.details = append(report.details, strings.Join(where, " · ")) + + expiry := "no expiry reported" + if creds.ExpiresAt > 0 { + expiresAt := time.Unix(creds.ExpiresAt, 0) + expiresIn := time.Until(expiresAt) + report.data["expires_at"] = expiresAt.UTC().Format(time.RFC3339) + report.data["expires_in"] = expiresIn.Round(time.Second).String() + report.data["expired"] = expiresIn < 0 + switch { + case expiresIn >= 0 && refreshable: + expiry = "expires in " + coarseDuration(expiresIn) + ", refreshes automatically" + case expiresIn >= 0: + expiry = "expires in " + coarseDuration(expiresIn) + case refreshable: + expiry = "expired, will refresh on next use" + default: + expiry = "expired" + report.hint = app.Auth.LoginHint() + } + } + report.details = append(report.details, "Token: "+expiry+" · Storage: "+storage) + + return report, nil +} + +// coarseDuration renders a duration at the precision a person reads an +// expiry at: seconds under a minute, minutes under an hour, hours and +// minutes under two days, days beyond. +func coarseDuration(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 48*time.Hour: + if m := int(d.Minutes()) % 60; m != 0 { + return fmt.Sprintf("%dh %dm", int(d.Hours()), m) + } + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) } } diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go new file mode 100644 index 000000000..2fde6cff6 --- /dev/null +++ b/internal/commands/auth_status_test.go @@ -0,0 +1,301 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/auth" + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// statusEnvelope is the JSON envelope `auth status` writes. +type statusEnvelope struct { + OK bool `json:"ok"` + Data map[string]any `json:"data"` + Summary string `json:"summary"` + Breadcrumbs []struct { + Action string `json:"action"` + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` +} + +// runAuthStatus executes `auth status` on app and returns the parsed JSON +// envelope. Breadcrumbs are kept so the login remedy can be asserted on. +func runAuthStatus(t *testing.T, app *appctx.App, buf *bytes.Buffer) statusEnvelope { + t.Helper() + app.Flags.Hints = true + require.NoError(t, executeProfileCommand(newAuthStatusCmd(), app)) + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + return envelope +} + +func statusTestConfig(t *testing.T) *config.Config { + t.Helper() + return &config.Config{ + BaseURL: "https://3.basecampapi.com/", + AccountID: "999", + CacheDir: t.TempDir(), + Sources: map[string]string{}, + } +} + +func TestAuthStatusReportsTheWholeCredential(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + app, buf := setupProfileTestApp(t, statusTestConfig(t)) + expiresAt := time.Now().Add(42 * time.Minute).Truncate(time.Second) + require.NoError(t, app.Auth.GetStore().Save("https://3.basecampapi.com", &auth.Credentials{ + AccessToken: "tok", + RefreshToken: "ref", + OAuthType: "bc5", + TokenEndpoint: "https://3.basecamp.com/oauth/tokens", + Scope: "full", + UserID: "12345", + UserEmail: "jeremy@example.com", + Resource: "urn:bc:account:999", + ExpiresAt: expiresAt.Unix(), + })) + + envelope := runAuthStatus(t, app, buf) + + assert.True(t, envelope.OK) + assert.Equal(t, "Logged in to https://3.basecampapi.com as jeremy@example.com (user 12345)", envelope.Summary) + assert.Equal(t, true, envelope.Data["authenticated"]) + assert.Equal(t, "https://3.basecampapi.com", envelope.Data["base_url"]) + assert.Equal(t, "999", envelope.Data["account_id"]) + assert.Equal(t, "jeremy@example.com", envelope.Data["user_email"]) + assert.Equal(t, "12345", envelope.Data["user_id"]) + assert.Equal(t, "oauth", envelope.Data["source"]) + assert.Equal(t, "bc5", envelope.Data["oauth_type"]) + assert.Equal(t, "full", envelope.Data["scope"]) + assert.Equal(t, expiresAt.UTC().Format(time.RFC3339), envelope.Data["expires_at"]) + assert.Equal(t, false, envelope.Data["expired"]) + assert.Equal(t, true, envelope.Data["refreshable"]) + assert.Equal(t, "file", envelope.Data["storage"], "BASECAMP_NO_KEYRING puts the credential in a file") + assert.NotContains(t, envelope.Data, "profile") + assert.Empty(t, envelope.Breadcrumbs, "a live credential needs no remedy") + + report, err := authStatusReport(app) + require.NoError(t, err) + assert.Equal(t, []string{ + "Account: 999 · Access: full · Source: oauth (bc5)", + "Token: expires in 41m, refreshes automatically · Storage: file", + }, report.details) + assert.Empty(t, report.hint) +} + +func TestAuthStatusExpiredButRefreshable(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + cfg := statusTestConfig(t) + cfg.ActiveProfile = "work" + app, buf := setupProfileTestApp(t, cfg) + require.NoError(t, app.Auth.GetStore().Save("profile:work", &auth.Credentials{ + AccessToken: "tok", + RefreshToken: "ref", + OAuthType: "launchpad", + Scope: "read", + ExpiresAt: time.Now().Add(-time.Hour).Unix(), + })) + + envelope := runAuthStatus(t, app, buf) + + assert.Equal(t, "Logged in to https://3.basecampapi.com", envelope.Summary) + assert.Equal(t, true, envelope.Data["expired"]) + assert.Equal(t, true, envelope.Data["refreshable"]) + assert.Equal(t, "work", envelope.Data["profile"]) + assert.NotContains(t, envelope.Data, "scope", "Launchpad has no scopes") + assert.Empty(t, envelope.Breadcrumbs, "a refreshable token renews itself on the next command") + + report, err := authStatusReport(app) + require.NoError(t, err) + assert.Equal(t, []string{ + "Profile: work · Account: 999 · Source: oauth (launchpad)", + "Token: expired, will refresh on next use · Storage: file", + }, report.details) + assert.Empty(t, report.hint) +} + +func TestAuthStatusExpiredImportedTokenNamesTheLogin(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + cfg := statusTestConfig(t) + cfg.ActiveProfile = "bot" + app, buf := setupProfileTestApp(t, cfg) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "tok", + OAuthType: "bc5", + Scope: "full", + Source: auth.CredentialSourceToken, + UserEmail: "bot@example.com", + ExpiresAt: time.Now().Add(-time.Minute).Unix(), + })) + + envelope := runAuthStatus(t, app, buf) + + assert.Equal(t, "token", envelope.Data["source"]) + assert.Equal(t, true, envelope.Data["expired"]) + assert.Equal(t, false, envelope.Data["refreshable"]) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp auth login -P bot", envelope.Breadcrumbs[0].Cmd) + + report, err := authStatusReport(app) + require.NoError(t, err) + assert.Equal(t, "Token: expired · Storage: file", report.details[1]) + assert.Equal(t, "Run: basecamp auth login -P bot", report.hint) +} + +func TestAuthStatusNotLoggedIn(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + cfg := statusTestConfig(t) + cfg.ActiveProfile = "work" + app, buf := setupProfileTestApp(t, cfg) + + envelope := runAuthStatus(t, app, buf) + + assert.True(t, envelope.OK, "scripts rely on exit 0 and authenticated:false") + assert.Equal(t, false, envelope.Data["authenticated"]) + assert.Equal(t, "https://3.basecampapi.com", envelope.Data["base_url"]) + assert.Equal(t, "work", envelope.Data["profile"]) + assert.Equal(t, "Not logged in to https://3.basecampapi.com", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "login", envelope.Breadcrumbs[0].Action) + assert.Equal(t, "basecamp auth login -P work", envelope.Breadcrumbs[0].Cmd) +} + +func TestAuthStatusEnvToken(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "bc_at_env") + app, buf := setupProfileTestApp(t, statusTestConfig(t)) + + envelope := runAuthStatus(t, app, buf) + + assert.Equal(t, true, envelope.Data["authenticated"]) + assert.Equal(t, "BASECAMP_TOKEN", envelope.Data["source"]) + assert.Equal(t, "env", envelope.Data["storage"]) + assert.Equal(t, "999", envelope.Data["account_id"]) + assert.Equal(t, "Logged in to https://3.basecampapi.com via BASECAMP_TOKEN", envelope.Summary) + assert.NotContains(t, buf.String(), "bc_at_env", "the token itself is never printed") +} + +// TestAuthStatusHumanOutput: a terminal gets the prose, not the envelope — +// the headline, the detail lines, and the remedy when there is one. +func TestAuthStatusHumanOutput(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + cfg := statusTestConfig(t) + cfg.ActiveProfile = "bot" + app, _ := setupProfileTestApp(t, cfg) + app.Output = output.New(output.Options{Format: output.FormatStyled, Writer: &bytes.Buffer{}}) + + runHuman := func() string { + cmd := newAuthStatusCmd() + cmd.SetContext(appctx.WithApp(context.Background(), app)) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + require.NoError(t, cmd.Execute()) + return out.String() + } + + assert.Equal(t, "Not logged in to https://3.basecampapi.com\n Run: basecamp auth login -P bot\n", runHuman()) + + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "tok", + RefreshToken: "ref", + OAuthType: "bc5", + Scope: "full", + UserID: "12345", + UserEmail: "jeremy@example.com", + ExpiresAt: time.Now().Add(3*time.Hour + 5*time.Minute).Unix(), + })) + assert.Equal(t, "Logged in to https://3.basecampapi.com as jeremy@example.com (user 12345)\n"+ + " Profile: bot · Account: 999 · Access: full · Source: oauth (bc5)\n"+ + " Token: expires in 3h 4m, refreshes automatically · Storage: file\n", runHuman()) +} + +func TestCoarseDuration(t *testing.T) { + assert.Equal(t, "42s", coarseDuration(42*time.Second)) + assert.Equal(t, "42m", coarseDuration(42*time.Minute+30*time.Second)) + assert.Equal(t, "2h", coarseDuration(2*time.Hour)) + assert.Equal(t, "2h 5m", coarseDuration(2*time.Hour+5*time.Minute)) + assert.Equal(t, "3d", coarseDuration(80*time.Hour)) +} + +// checkedStatus runs `auth status --check` against the login identity +// server with the given stored access token and returns the envelope. +func checkedStatus(t *testing.T, storedToken string, authorizationStatus int) (statusEnvelope, error) { + t.Helper() + srv := startLoginIdentityServer(t, "live-tok") + srv.authorizationStatus = authorizationStatus + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + app.Flags.Hints = true + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: storedToken, + OAuthType: "bc5", + Scope: "full", + Source: auth.CredentialSourceToken, + ExpiresAt: time.Now().Add(time.Hour).Unix(), + })) + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + if err := cmd.Execute(); err != nil { + return statusEnvelope{}, err + } + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + return envelope, nil +} + +// TestAuthStatusCheckAcceptedToken: --check makes the authorization lookup +// with the stored token and reports the server's acceptance. +func TestAuthStatusCheckAcceptedToken(t *testing.T) { + envelope, err := checkedStatus(t, "live-tok", 0) + require.NoError(t, err) + assert.Equal(t, true, envelope.Data["authenticated"]) + assert.Equal(t, true, envelope.Data["valid"]) + assert.Empty(t, envelope.Breadcrumbs) +} + +// TestAuthStatusCheckRejectedToken: a 401 is the "rejected" verdict, still +// exit 0, with the login named as the remedy. +func TestAuthStatusCheckRejectedToken(t *testing.T) { + envelope, err := checkedStatus(t, "stale-tok", 0) + require.NoError(t, err) + assert.Equal(t, true, envelope.Data["authenticated"], "the credential is still stored") + assert.Equal(t, false, envelope.Data["valid"]) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp auth login -P bot", envelope.Breadcrumbs[0].Cmd) +} + +// TestAuthStatusCheckServerFault: a fault that is not a verdict on the token +// is returned as itself rather than reported as valid or rejected. +func TestAuthStatusCheckServerFault(t *testing.T) { + _, err := checkedStatus(t, "live-tok", 503) + require.Error(t, err) + assert.NotEqual(t, output.CodeAuth, output.AsError(err).Code) +} + +// TestAuthStatusWithoutCheckMakesNoRequest: the default is offline. +func TestAuthStatusWithoutCheckMakesNoRequest(t *testing.T) { + srv := startLoginIdentityServer(t, "live-tok") + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "live-tok", OAuthType: "bc5", Scope: "full", ExpiresAt: time.Now().Add(time.Hour).Unix(), + })) + + envelope := runAuthStatus(t, app, buf) + assert.Equal(t, true, envelope.Data["authenticated"]) + assert.NotContains(t, envelope.Data, "valid") + assert.Empty(t, srv.seenPaths(), "no request without --check") +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 1a3485ae2..4cced2ec5 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -1387,7 +1387,8 @@ leave the skill only and surface the per-agent `basecamp setup ` commands. **Authentication errors:** ```bash -basecamp auth status # Check auth +basecamp auth status # Who you are logged in as, where, token expiry (no request made) +basecamp auth status --check # Also ask the server whether the stored token is still accepted basecamp auth login # Re-authenticate basecamp auth login --scope full # Full access (the default; ignored by Launchpad) basecamp auth login --scope read # Read-only access (ignored by Launchpad) From a4d0eef5fc1621c814e42daae93abeb2a38dad04 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 18:50:47 -0700 Subject: [PATCH 02/16] Build the status report after --check, and harden the terminal path Review of the status command found the report built before --check's request, so a refresh (or a forgotten dead credential) the request caused was not what the fields described; the check now goes first and the report is built from what is stored afterwards. The login remedy moves from a breadcrumb, which --hints controls, to the envelope's notice, and a rejected BASECAMP_TOKEN is told to unset or replace the variable rather than to log in. The terminal path sanitizes every line at the sink, honors --styled when redirected, and prints the --stats footer. A token with nothing to refresh with is reported expired once inside the refresh window, where every command already refuses it; the BASECAMP_TOKEN branch reports refreshable: false; and a bc_at_ environment token resolves the authorization document at the resource origin, as a stored BC5 credential does. --- internal/auth/auth.go | 4 +- internal/auth/auth_test.go | 14 +++ internal/commands/auth.go | 102 +++++++++++++++------ internal/commands/auth_status_test.go | 125 +++++++++++++++++++++++--- 4 files changed, 203 insertions(+), 42 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b3ec7acfb..d37e8597e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1465,7 +1465,9 @@ func (m *Manager) AuthorizationEndpoint(ctx context.Context) (string, error) { // BASECAMP_TOKEN wins — match AccessToken() precedence (auth.go line 75). if envToken := os.Getenv("BASECAMP_TOKEN"); envToken != "" { if strings.HasPrefix(envToken, bc3TokenPrefix) { - return config.NormalizeBaseURL(m.cfg.BaseURL) + "/authorization.json", nil + // The same origin-level document a stored BC5 credential asks + // for: a pathful base URL must not turn it into /api/v1/... + return m.AuthorizationEndpointFor(oauthTypeBC5) } lpURL, err := m.launchpadURL() if err != nil { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 99077d3fb..9665f6685 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -2285,3 +2285,17 @@ func TestLoginLaunchpad_RemoteTranscriptSaysWhyWhenTheHostChose(t *testing.T) { assert.NotContains(t, out, "Not opening a browser") }) } + +// TestAuthorizationEndpoint_EnvBC3TokenUsesTheOrigin: a bc_at_ environment +// token asks the same origin-level /authorization.json a stored BC5 +// credential does, even when the base URL carries a path. +func TestAuthorizationEndpoint_EnvBC3TokenUsesTheOrigin(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "bc_at_env") + cfg := config.Default() + cfg.BaseURL = "https://3.basecampapi.com/api/v1" + m := &Manager{cfg: cfg, store: newTestStore(t, t.TempDir())} + + endpoint, err := m.AuthorizationEndpoint(context.Background()) + require.NoError(t, err) + assert.Equal(t, "https://3.basecampapi.com/authorization.json", endpoint) +} diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 59ab7afb4..85763ff2e 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -79,38 +79,55 @@ command to run.`, return fmt.Errorf("app not initialized") } + // The server check goes first: the request may refresh the stored + // credential, or forget a dead one, and the report must describe + // what is stored afterwards. + var verdict *checkVerdict + if check && (os.Getenv("BASECAMP_TOKEN") != "" || app.Auth.IsAuthenticated()) { + v, err := checkWithServer(cmd.Context(), app) + if err != nil { + return err + } + verdict = v + } report, err := authStatusReport(app) if err != nil { return err } - if check && report.data["authenticated"] == true { - if err := report.checkWithServer(cmd.Context(), app); err != nil { - return err - } + if verdict != nil { + report.record(app, verdict) } if !humanOutput(app) { opts := []output.ResponseOption{output.WithSummary(report.summary)} if report.hint != "" { - opts = append(opts, output.WithBreadcrumbs(output.Breadcrumb{ - Action: "login", Cmd: app.Auth.LoginCommand(), Description: "Log in to Basecamp", - })) + opts = append(opts, output.WithNotice(report.hint)) } return app.OK(report.data, opts...) } + // A direct terminal sink: every line carries config and stored + // values, so each is reduced to one terminal-safe line first, as + // the envelope renderer would. w := cmd.OutOrStdout() - r := output.NewRendererWithTheme(w, false, tui.ResolveTheme(tui.DetectDark())) + r := output.NewRendererWithTheme(w, app.Flags.Styled, tui.ResolveTheme(tui.DetectDark())) headline := r.Summary if report.data["authenticated"] == true { headline = r.Success } - fmt.Fprintln(w, headline.Render(report.summary)) + fmt.Fprintln(w, headline.Render(richtext.SanitizeSingleLine(report.summary))) for _, line := range report.details { - fmt.Fprintln(w, r.Muted.Render(" "+line)) + fmt.Fprintln(w, r.Muted.Render(" "+richtext.SanitizeSingleLine(line))) } if report.hint != "" { - fmt.Fprintln(w, r.Data.Render(" "+report.hint)) + fmt.Fprintln(w, r.Data.Render(" "+richtext.SanitizeSingleLine(report.hint))) + } + if app.Flags.Stats && !app.Flags.NoStats && app.Collector != nil { + stats := app.Collector.Summary() + if parts := stats.FormatParts(); len(parts) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, r.Muted.Render(strings.Join(parts, " · "))) + } } return nil }, @@ -121,29 +138,51 @@ command to run.`, return cmd } -// checkWithServer makes the one authenticated request --check promises and -// records the verdict. An auth-class failure — the server refused the token, -// or the credential could not be refreshed — is the "rejected" answer, not -// an error: that is what the caller asked. Anything else (the server could -// not be reached, a fault) is returned as itself, since no verdict was had. -func (s *authStatus) checkWithServer(ctx context.Context, app *appctx.App) error { +// checkVerdict is the server's answer to --check: whether it accepted the +// token the CLI would send right now. +type checkVerdict struct { + valid bool +} + +// checkWithServer makes the one authenticated request --check promises. An +// auth-class failure — the server refused the token, or the credential could +// not be refreshed — is the "rejected" verdict, not an error: that is what +// the caller asked. Anything else (the server could not be reached, a fault) +// is returned as itself, since no verdict was had. +func checkWithServer(ctx context.Context, app *appctx.App) (*checkVerdict, error) { endpoint, err := app.Auth.AuthorizationEndpoint(ctx) if err != nil { - return err + return nil, err } _, err = app.SDK.Authorization().GetInfo(ctx, &basecamp.GetInfoOptions{Endpoint: endpoint, FilterProduct: "bc3"}) switch { case err == nil: - s.data["valid"] = true - s.details = append(s.details, "Token: valid (checked just now)") + return &checkVerdict{valid: true}, nil case output.AsError(err).Code == output.CodeAuth: - s.data["valid"] = false - s.details = append(s.details, "Token: rejected by the server") - s.hint = app.Auth.LoginHint() + return &checkVerdict{valid: false}, nil default: - return convertSDKError(err) + return nil, convertSDKError(err) + } +} + +// envTokenRejectedHint is the remedy when the server refuses BASECAMP_TOKEN: +// every request sends the environment token ahead of any stored login, so +// logging in would change nothing. +const envTokenRejectedHint = "BASECAMP_TOKEN is set and the server rejected it; unset it, or export a token the server accepts" + +// record adds the server's verdict to the report. +func (s *authStatus) record(app *appctx.App, v *checkVerdict) { + s.data["valid"] = v.valid + if v.valid { + s.details = append(s.details, "Token: valid (checked just now)") + return + } + s.details = append(s.details, "Token: rejected by the server") + if os.Getenv("BASECAMP_TOKEN") != "" { + s.hint = envTokenRejectedHint + } else { + s.hint = app.Auth.LoginHint() } - return nil } // humanOutput reports whether the command's output is read by a person: the @@ -192,6 +231,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { report.data["authenticated"] = true report.data["source"] = "BASECAMP_TOKEN" report.data["storage"] = "env" + report.data["refreshable"] = false report.summary = "Logged in to " + baseURL + " via BASECAMP_TOKEN" report.details = []string{strings.Join(append(where, "Source: BASECAMP_TOKEN", "Storage: env"), " · ")} return report, nil @@ -262,16 +302,22 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { if creds.ExpiresAt > 0 { expiresAt := time.Unix(creds.ExpiresAt, 0) expiresIn := time.Until(expiresAt) + // A token with nothing to refresh with is refused inside the refresh + // window, so from the CLI's side it is already expired there. + expired := expiresIn < 0 || (!refreshable && expiresIn <= auth.RefreshWindow) report.data["expires_at"] = expiresAt.UTC().Format(time.RFC3339) report.data["expires_in"] = expiresIn.Round(time.Second).String() - report.data["expired"] = expiresIn < 0 + report.data["expired"] = expired switch { - case expiresIn >= 0 && refreshable: + case !expired && refreshable: expiry = "expires in " + coarseDuration(expiresIn) + ", refreshes automatically" - case expiresIn >= 0: + case !expired: expiry = "expires in " + coarseDuration(expiresIn) case refreshable: expiry = "expired, will refresh on next use" + case expiresIn >= 0: + expiry = "expires in " + coarseDuration(expiresIn) + ", inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry" + report.hint = app.Auth.LoginHint() default: expiry = "expired" report.hint = app.Auth.LoginHint() diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 2fde6cff6..f1e0a41c8 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "strings" "testing" "time" @@ -21,6 +22,7 @@ type statusEnvelope struct { OK bool `json:"ok"` Data map[string]any `json:"data"` Summary string `json:"summary"` + Notice string `json:"notice"` Breadcrumbs []struct { Action string `json:"action"` Cmd string `json:"cmd"` @@ -28,10 +30,10 @@ type statusEnvelope struct { } // runAuthStatus executes `auth status` on app and returns the parsed JSON -// envelope. Breadcrumbs are kept so the login remedy can be asserted on. +// envelope. Hints stay off: the login remedy must not depend on them. func runAuthStatus(t *testing.T, app *appctx.App, buf *bytes.Buffer) statusEnvelope { t.Helper() - app.Flags.Hints = true + app.Flags.Hints = false require.NoError(t, executeProfileCommand(newAuthStatusCmd(), app)) var envelope statusEnvelope require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) @@ -81,7 +83,7 @@ func TestAuthStatusReportsTheWholeCredential(t *testing.T) { assert.Equal(t, true, envelope.Data["refreshable"]) assert.Equal(t, "file", envelope.Data["storage"], "BASECAMP_NO_KEYRING puts the credential in a file") assert.NotContains(t, envelope.Data, "profile") - assert.Empty(t, envelope.Breadcrumbs, "a live credential needs no remedy") + assert.Empty(t, envelope.Notice, "a live credential needs no remedy") report, err := authStatusReport(app) require.NoError(t, err) @@ -112,7 +114,7 @@ func TestAuthStatusExpiredButRefreshable(t *testing.T) { assert.Equal(t, true, envelope.Data["refreshable"]) assert.Equal(t, "work", envelope.Data["profile"]) assert.NotContains(t, envelope.Data, "scope", "Launchpad has no scopes") - assert.Empty(t, envelope.Breadcrumbs, "a refreshable token renews itself on the next command") + assert.Empty(t, envelope.Notice, "a refreshable token renews itself on the next command") report, err := authStatusReport(app) require.NoError(t, err) @@ -142,8 +144,7 @@ func TestAuthStatusExpiredImportedTokenNamesTheLogin(t *testing.T) { assert.Equal(t, "token", envelope.Data["source"]) assert.Equal(t, true, envelope.Data["expired"]) assert.Equal(t, false, envelope.Data["refreshable"]) - require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, "basecamp auth login -P bot", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) report, err := authStatusReport(app) require.NoError(t, err) @@ -164,9 +165,8 @@ func TestAuthStatusNotLoggedIn(t *testing.T) { assert.Equal(t, "https://3.basecampapi.com", envelope.Data["base_url"]) assert.Equal(t, "work", envelope.Data["profile"]) assert.Equal(t, "Not logged in to https://3.basecampapi.com", envelope.Summary) - require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, "login", envelope.Breadcrumbs[0].Action) - assert.Equal(t, "basecamp auth login -P work", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "Run: basecamp auth login -P work", envelope.Notice, "the remedy does not depend on --hints") + assert.Empty(t, envelope.Breadcrumbs) } func TestAuthStatusEnvToken(t *testing.T) { @@ -178,6 +178,7 @@ func TestAuthStatusEnvToken(t *testing.T) { assert.Equal(t, true, envelope.Data["authenticated"]) assert.Equal(t, "BASECAMP_TOKEN", envelope.Data["source"]) assert.Equal(t, "env", envelope.Data["storage"]) + assert.Equal(t, false, envelope.Data["refreshable"]) assert.Equal(t, "999", envelope.Data["account_id"]) assert.Equal(t, "Logged in to https://3.basecampapi.com via BASECAMP_TOKEN", envelope.Summary) assert.NotContains(t, buf.String(), "bc_at_env", "the token itself is never printed") @@ -233,7 +234,6 @@ func checkedStatus(t *testing.T, storedToken string, authorizationStatus int) (s srv := startLoginIdentityServer(t, "live-tok") srv.authorizationStatus = authorizationStatus app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) - app.Flags.Hints = true require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ AccessToken: storedToken, OAuthType: "bc5", @@ -264,7 +264,7 @@ func TestAuthStatusCheckAcceptedToken(t *testing.T) { require.NoError(t, err) assert.Equal(t, true, envelope.Data["authenticated"]) assert.Equal(t, true, envelope.Data["valid"]) - assert.Empty(t, envelope.Breadcrumbs) + assert.Empty(t, envelope.Notice) } // TestAuthStatusCheckRejectedToken: a 401 is the "rejected" verdict, still @@ -274,8 +274,7 @@ func TestAuthStatusCheckRejectedToken(t *testing.T) { require.NoError(t, err) assert.Equal(t, true, envelope.Data["authenticated"], "the credential is still stored") assert.Equal(t, false, envelope.Data["valid"]) - require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, "basecamp auth login -P bot", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) } // TestAuthStatusCheckServerFault: a fault that is not a verdict on the token @@ -299,3 +298,103 @@ func TestAuthStatusWithoutCheckMakesNoRequest(t *testing.T) { assert.NotContains(t, envelope.Data, "valid") assert.Empty(t, srv.seenPaths(), "no request without --check") } + +// TestAuthStatusCheckRejectedEnvToken: a login cannot replace what +// BASECAMP_TOKEN sends, so the remedy names the variable. +func TestAuthStatusCheckRejectedEnvToken(t *testing.T) { + srv := startLoginIdentityServer(t, "bc_at_live") + app, buf := loginTestApp(t, srv, &config.Config{}) + t.Setenv("BASECAMP_TOKEN", "bc_at_stale") + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, "BASECAMP_TOKEN", envelope.Data["source"]) + assert.Equal(t, false, envelope.Data["valid"]) + assert.Contains(t, envelope.Notice, "BASECAMP_TOKEN is set") + assert.NotContains(t, envelope.Notice, "auth login") +} + +// TestAuthStatusCheckReportsTheRefreshedCredential: the check's request +// refreshes an expired credential first, and the report describes the +// credential as it is stored afterwards, not the one the command started +// with. +func TestAuthStatusCheckReportsTheRefreshedCredential(t *testing.T) { + srv := startLoginIdentityServer(t, "dev-tok") + srv.srv.Config.Handler = deviceGrantThen(t, srv.srv.Config.Handler) + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "stale-tok", + RefreshToken: "stale-ref", + OAuthType: "bc5", + TokenEndpoint: srv.srv.URL + "/oauth/tokens", + Scope: "full", + ExpiresAt: time.Now().Add(-time.Hour).Unix(), + })) + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, true, envelope.Data["valid"]) + assert.Equal(t, false, envelope.Data["expired"], "the report is built after the refresh") + assert.Empty(t, envelope.Notice) + creds, err := app.Auth.GetStore().Load("profile:bot") + require.NoError(t, err) + assert.Equal(t, "dev-tok", creds.AccessToken) +} + +// TestAuthStatusNonRefreshableTokenInsideTheRefreshWindow: a token with +// nothing to refresh with is refused by every command once it is inside +// the refresh window, so status calls it expired there too. +func TestAuthStatusNonRefreshableTokenInsideTheRefreshWindow(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + cfg := statusTestConfig(t) + cfg.ActiveProfile = "bot" + app, buf := setupProfileTestApp(t, cfg) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "tok", + OAuthType: "bc5", + Scope: "full", + Source: auth.CredentialSourceToken, + ExpiresAt: time.Now().Add(2 * time.Minute).Unix(), + })) + + envelope := runAuthStatus(t, app, buf) + assert.Equal(t, true, envelope.Data["expired"]) + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) + + report, err := authStatusReport(app) + require.NoError(t, err) + assert.Contains(t, report.details[1], "inside the 5m the CLI keeps clear of expiry") +} + +// TestAuthStatusHumanOutputSanitizesValues: config and stored values reach +// the terminal only as single, control-free lines. +func TestAuthStatusHumanOutputSanitizesValues(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + cfg := statusTestConfig(t) + cfg.AccountID = "999\x1b[31m\nfake" + app, _ := setupProfileTestApp(t, cfg) + app.Output = output.New(output.Options{Format: output.FormatStyled, Writer: &bytes.Buffer{}}) + require.NoError(t, app.Auth.GetStore().Save("https://3.basecampapi.com", &auth.Credentials{ + AccessToken: "tok", OAuthType: "bc5", Scope: "full", UserEmail: "a@example.com\x1b[0m", + })) + + cmd := newAuthStatusCmd() + cmd.SetContext(appctx.WithApp(context.Background(), app)) + var out bytes.Buffer + cmd.SetOut(&out) + require.NoError(t, cmd.Execute()) + assert.NotContains(t, out.String(), "\x1b") + assert.Equal(t, 3, strings.Count(out.String(), "\n"), "no injected line breaks") +} From 2a5a5c8db6ba0747f616b863c3d36afd932ab534 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 19:35:07 -0700 Subject: [PATCH 03/16] Call a token inside the refresh window expired, and keep Markdown literal The refresh-window line still read "expires in" while the field said expired; it now says expired and explains why. Markdown output goes through the envelope renderer again so --md stays literal on a terminal. The help text and install guide describe the output as it is. --- install.md | 2 +- internal/commands/auth.go | 17 ++++++++--------- internal/commands/auth_status_test.go | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/install.md b/install.md index 990fe5b50..c2afcd2d3 100644 --- a/install.md +++ b/install.md @@ -118,7 +118,7 @@ This opens browser OAuth. Grant access when prompted. **Verify:** ```bash basecamp auth status -# Expected: Logged in to https://3.basecampapi.com as you@example.com (user ) +# Expected: a line starting "Logged in to https://3.basecampapi.com" (your email and user id follow when known) ``` --- diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 85763ff2e..d8249f408 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -71,8 +71,8 @@ request (the same authorization lookup "basecamp me" makes) and reports whether the server still accepts the token: "valid" in the JSON data. Exits 0 whether or not you are logged in; scripts read "authenticated" from -the JSON envelope. When nothing is stored, the summary names the login -command to run.`, +the JSON envelope. When nothing is stored, the output names the login +command to run (the envelope's "notice").`, RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) if app == nil { @@ -185,13 +185,12 @@ func (s *authStatus) record(app *appctx.App, v *checkVerdict) { } } -// humanOutput reports whether the command's output is read by a person: the -// styled or markdown renderer, which the output writer resolves from flags, -// config, and whether stdout is a terminal. Anything else — JSON, quiet, -// a pipe — is a machine consumer that gets the envelope. +// humanOutput reports whether the command's output is the styled terminal +// renderer, which the output writer resolves from flags, config, and whether +// stdout is a terminal. Everything else — JSON, quiet, a pipe, and Markdown, +// which must stay literal and portable — goes through the envelope. func humanOutput(app *appctx.App) bool { - format := app.Output.EffectiveFormat() - return format == output.FormatStyled || format == output.FormatMarkdown + return app.Output.EffectiveFormat() == output.FormatStyled } // authStatus is what `auth status` learned about the active credential: @@ -316,7 +315,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { case refreshable: expiry = "expired, will refresh on next use" case expiresIn >= 0: - expiry = "expires in " + coarseDuration(expiresIn) + ", inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry" + expiry = "expired (" + coarseDuration(expiresIn) + " left, inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry, and no refresh token)" report.hint = app.Auth.LoginHint() default: expiry = "expired" diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index f1e0a41c8..1a56d6e11 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -375,6 +375,7 @@ func TestAuthStatusNonRefreshableTokenInsideTheRefreshWindow(t *testing.T) { report, err := authStatusReport(app) require.NoError(t, err) + assert.True(t, strings.HasPrefix(report.details[1], "Token: expired ("), report.details[1]) assert.Contains(t, report.details[1], "inside the 5m the CLI keeps clear of expiry") } @@ -398,3 +399,24 @@ func TestAuthStatusHumanOutputSanitizesValues(t *testing.T) { assert.NotContains(t, out.String(), "\x1b") assert.Equal(t, 3, strings.Count(out.String(), "\n"), "no injected line breaks") } + +// TestAuthStatusMarkdownStaysLiteral: --md output goes through the envelope's +// Markdown renderer, never the terminal prose path. +func TestAuthStatusMarkdownStaysLiteral(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + cfg := statusTestConfig(t) + cfg.ActiveProfile = "bot" + app, _ := setupProfileTestApp(t, cfg) + md := &bytes.Buffer{} + app.Output = output.New(output.Options{Format: output.FormatMarkdown, Writer: md}) + + cmd := newAuthStatusCmd() + cmd.SetContext(appctx.WithApp(context.Background(), app)) + var out bytes.Buffer + cmd.SetOut(&out) + require.NoError(t, cmd.Execute()) + assert.Empty(t, out.String(), "nothing is written to the terminal path") + assert.Contains(t, md.String(), "Not logged in to https://3.basecampapi.com") + assert.Contains(t, md.String(), "Run: basecamp auth login -P bot") + assert.NotContains(t, md.String(), "\x1b") +} From f496b61e66cb1fc2b82ba9fb5686ef790618d553 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 19:43:58 -0700 Subject: [PATCH 04/16] Return status write failures and say which token --check checks The terminal path dropped every Fprintln error, so a full disk could leave partial output and exit 0; the first write failure is returned now, as the envelope path does. The --check help and the skill said the stored token was checked, but BASECAMP_TOKEN takes precedence, so they now say the active token. --- internal/commands/auth.go | 19 ++++++++++++------- skills/basecamp/SKILL.md | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index d8249f408..795e6828d 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -68,7 +68,8 @@ and where it is stored. Nothing is fetched unless --check is given, which makes one authenticated request (the same authorization lookup "basecamp me" makes) and reports -whether the server still accepts the token: "valid" in the JSON data. +whether the server accepts the token the CLI would send — BASECAMP_TOKEN +when it is set, otherwise the stored login: "valid" in the JSON data. Exits 0 whether or not you are logged in; scripts read "authenticated" from the JSON envelope. When nothing is stored, the output names the login @@ -115,25 +116,29 @@ command to run (the envelope's "notice").`, if report.data["authenticated"] == true { headline = r.Success } - fmt.Fprintln(w, headline.Render(richtext.SanitizeSingleLine(report.summary))) + lines := []string{headline.Render(richtext.SanitizeSingleLine(report.summary))} for _, line := range report.details { - fmt.Fprintln(w, r.Muted.Render(" "+richtext.SanitizeSingleLine(line))) + lines = append(lines, r.Muted.Render(" "+richtext.SanitizeSingleLine(line))) } if report.hint != "" { - fmt.Fprintln(w, r.Data.Render(" "+richtext.SanitizeSingleLine(report.hint))) + lines = append(lines, r.Data.Render(" "+richtext.SanitizeSingleLine(report.hint))) } if app.Flags.Stats && !app.Flags.NoStats && app.Collector != nil { stats := app.Collector.Summary() if parts := stats.FormatParts(); len(parts) > 0 { - fmt.Fprintln(w) - fmt.Fprintln(w, r.Muted.Render(strings.Join(parts, " · "))) + lines = append(lines, "", r.Muted.Render(strings.Join(parts, " · "))) + } + } + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + return err } } return nil }, } - cmd.Flags().BoolVar(&check, "check", false, "Ask the server whether the stored token is still accepted (one authenticated request)") + cmd.Flags().BoolVar(&check, "check", false, "Ask the server whether the active token (BASECAMP_TOKEN, else the stored login) is accepted (one authenticated request)") return cmd } diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 4cced2ec5..20a688290 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -1388,7 +1388,7 @@ leave the skill only and surface the per-agent `basecamp setup ` commands. **Authentication errors:** ```bash basecamp auth status # Who you are logged in as, where, token expiry (no request made) -basecamp auth status --check # Also ask the server whether the stored token is still accepted +basecamp auth status --check # Also ask the server whether the active token (BASECAMP_TOKEN, else the stored login) is accepted basecamp auth login # Re-authenticate basecamp auth login --scope full # Full access (the default; ignored by Launchpad) basecamp auth login --scope read # Read-only access (ignored by Launchpad) From 0e29fbcf4b08117339760fb7a5dfdac9be65447f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 19:52:04 -0700 Subject: [PATCH 05/16] Answer --check with valid: false when there is nothing to send With no environment token and no stored credential the check was skipped and the output carried no valid field, indistinguishable from status without --check. It now answers valid: false and says there is no token to check, without claiming a request was made. --- internal/commands/auth.go | 8 +++++++- internal/commands/auth_status_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 795e6828d..55040794b 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -95,8 +95,14 @@ command to run (the envelope's "notice").`, if err != nil { return err } - if verdict != nil { + switch { + case verdict != nil: report.record(app, verdict) + case check: + // Nothing to send: the contract still answers "valid", + // without claiming a request was made. + report.data["valid"] = false + report.details = append(report.details, "Token: none to check") } if !humanOutput(app) { diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 1a56d6e11..3927ba4db 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -420,3 +420,28 @@ func TestAuthStatusMarkdownStaysLiteral(t *testing.T) { assert.Contains(t, md.String(), "Run: basecamp auth login -P bot") assert.NotContains(t, md.String(), "\x1b") } + +// TestAuthStatusCheckWithNothingStored: --check with no credential makes no +// request but still answers the contract: valid is false and the login is +// the remedy. +func TestAuthStatusCheckWithNothingStored(t *testing.T) { + srv := startLoginIdentityServer(t, "live-tok") + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, false, envelope.Data["authenticated"]) + assert.Equal(t, false, envelope.Data["valid"]) + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) + assert.Empty(t, srv.seenPaths(), "nothing to send, so no request") + + report, err := authStatusReport(app) + require.NoError(t, err) + assert.NotContains(t, strings.Join(report.details, "\n"), "rejected") +} From 61e0e0521163ad0b2abb3542fb436706c613c687 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 20:02:41 -0700 Subject: [PATCH 06/16] Tell a token the CLI cannot send apart from one the server rejected --check produces the token before the request, so a credential with nothing to refresh with inside the refresh window, or a refresh the token endpoint refused, is reported as a token that could not be sent rather than as the server's rejection; a refused refresh also replaces the offline line's promise to refresh on next use. A refresh token counts as refreshable only where a refresh can run: not for the removed bc3 flow, and not for a BC5 credential without its token endpoint. --- internal/commands/auth.go | 42 ++++++++-- internal/commands/auth_status_test.go | 106 ++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 55040794b..f8d133247 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -153,11 +153,16 @@ command to run (the envelope's "notice").`, // token the CLI would send right now. type checkVerdict struct { valid bool + // sent is whether the authorization request was made at all; when the + // credential could not produce a token to send (nothing to refresh + // with, or a refresh the token endpoint refused), reason says why. + sent bool + reason string } // checkWithServer makes the one authenticated request --check promises. An // auth-class failure — the server refused the token, or the credential could -// not be refreshed — is the "rejected" verdict, not an error: that is what +// not produce one — is the "rejected" verdict, not an error: that is what // the caller asked. Anything else (the server could not be reached, a fault) // is returned as itself, since no verdict was had. func checkWithServer(ctx context.Context, app *appctx.App) (*checkVerdict, error) { @@ -165,12 +170,23 @@ func checkWithServer(ctx context.Context, app *appctx.App) (*checkVerdict, error if err != nil { return nil, err } + // The token is produced first, so a refusal that never reaches the + // authorization server — no refresh token inside the refresh window, a + // refresh the token endpoint turned down — is reported as that, not as + // the server's answer. The request's own token lookup then finds it + // stored. + if _, err := app.Auth.AccessToken(ctx); err != nil { + if e := output.AsError(err); e.Code == output.CodeAuth { + return &checkVerdict{valid: false, reason: e.Message}, nil + } + return nil, err + } _, err = app.SDK.Authorization().GetInfo(ctx, &basecamp.GetInfoOptions{Endpoint: endpoint, FilterProduct: "bc3"}) switch { case err == nil: - return &checkVerdict{valid: true}, nil + return &checkVerdict{valid: true, sent: true}, nil case output.AsError(err).Code == output.CodeAuth: - return &checkVerdict{valid: false}, nil + return &checkVerdict{valid: false, sent: true}, nil default: return nil, convertSDKError(err) } @@ -188,7 +204,18 @@ func (s *authStatus) record(app *appctx.App, v *checkVerdict) { s.details = append(s.details, "Token: valid (checked just now)") return } - s.details = append(s.details, "Token: rejected by the server") + // A refresh the check just had refused makes the offline line's promise + // false; the verdict replaces it rather than sitting beside it. + for i, line := range s.details { + if line == "Token: expired, will refresh on next use · Storage: "+s.storage { + s.details[i] = "Token: expired, and the refresh was refused · Storage: " + s.storage + } + } + if v.sent { + s.details = append(s.details, "Token: rejected by the server") + } else { + s.details = append(s.details, "Token: none could be sent ("+v.reason+")") + } if os.Getenv("BASECAMP_TOKEN") != "" { s.hint = envTokenRejectedHint } else { @@ -211,6 +238,7 @@ type authStatus struct { summary string details []string hint string + storage string } // authStatusReport inspects the active credential without touching the @@ -273,7 +301,11 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { if store.UsingKeyring() { storage = "keyring" } - refreshable := creds.RefreshToken != "" + // A refresh token alone is not a refresh: the removed bc3 development + // flow's grants cannot be redeemed, and a BC5 credential without its + // token endpoint has nowhere to send one. + refreshable := creds.RefreshToken != "" && creds.OAuthType != "bc3" && !(creds.OAuthType == "bc5" && creds.TokenEndpoint == "") + report.storage = storage report.data["authenticated"] = true report.data["source"] = source diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 3927ba4db..13c6a6770 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "net/http" "strings" "testing" "time" @@ -206,13 +208,14 @@ func TestAuthStatusHumanOutput(t *testing.T) { assert.Equal(t, "Not logged in to https://3.basecampapi.com\n Run: basecamp auth login -P bot\n", runHuman()) require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ - AccessToken: "tok", - RefreshToken: "ref", - OAuthType: "bc5", - Scope: "full", - UserID: "12345", - UserEmail: "jeremy@example.com", - ExpiresAt: time.Now().Add(3*time.Hour + 5*time.Minute).Unix(), + AccessToken: "tok", + RefreshToken: "ref", + OAuthType: "bc5", + TokenEndpoint: "https://3.basecamp.com/oauth/tokens", + Scope: "full", + UserID: "12345", + UserEmail: "jeremy@example.com", + ExpiresAt: time.Now().Add(3*time.Hour + 5*time.Minute).Unix(), })) assert.Equal(t, "Logged in to https://3.basecampapi.com as jeremy@example.com (user 12345)\n"+ " Profile: bot · Account: 999 · Access: full · Source: oauth (bc5)\n"+ @@ -445,3 +448,92 @@ func TestAuthStatusCheckWithNothingStored(t *testing.T) { require.NoError(t, err) assert.NotContains(t, strings.Join(report.details, "\n"), "rejected") } + +// TestAuthStatusLegacyBC3IsNotRefreshable: the removed bc3 development flow +// cannot redeem its refresh tokens, so a stored one does not count. +func TestAuthStatusLegacyBC3IsNotRefreshable(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + app, buf := setupProfileTestApp(t, statusTestConfig(t)) + require.NoError(t, app.Auth.GetStore().Save("https://3.basecampapi.com", &auth.Credentials{ + AccessToken: "tok", RefreshToken: "ref", OAuthType: "bc3", Scope: "read", + TokenEndpoint: "https://example.com/token", ExpiresAt: time.Now().Add(-time.Hour).Unix(), + })) + + envelope := runAuthStatus(t, app, buf) + assert.Equal(t, false, envelope.Data["refreshable"]) + assert.Equal(t, true, envelope.Data["expired"]) + assert.Equal(t, "Run: basecamp auth login", envelope.Notice) +} + +// TestAuthStatusCheckWithNothingToSend: a token the CLI refuses to send +// (inside the refresh window with nothing to refresh with) is reported as +// that, not as the server's rejection, and no request is made. +func TestAuthStatusCheckWithNothingToSend(t *testing.T) { + srv := startLoginIdentityServer(t, "live-tok") + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "live-tok", OAuthType: "bc5", Scope: "full", Source: auth.CredentialSourceToken, + ExpiresAt: time.Now().Add(2 * time.Minute).Unix(), + })) + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, false, envelope.Data["valid"]) + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) + assert.Empty(t, srv.seenPaths(), "no token could be sent, so no request") + + report, err := authStatusReport(app) + require.NoError(t, err) + report.record(app, &checkVerdict{valid: false, reason: "No refresh token available"}) + joined := strings.Join(report.details, "\n") + assert.Contains(t, joined, "Token: none could be sent (No refresh token available)") + assert.NotContains(t, joined, "rejected by the server") +} + +// TestAuthStatusCheckRefusedRefreshReplacesThePromise: a Launchpad +// credential whose refresh the token endpoint refuses is kept, and the +// report must not still promise it will refresh on next use. +func TestAuthStatusCheckRefusedRefreshReplacesThePromise(t *testing.T) { + srv := startLoginIdentityServer(t, "live-tok") + inner := srv.srv.Config.Handler + srv.srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/authorization/token" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"invalid_grant"}`) + return + } + inner.ServeHTTP(w, r) + }) + t.Setenv("BASECAMP_LAUNCHPAD_URL", srv.srv.URL) + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "stale-tok", RefreshToken: "stale-ref", OAuthType: "launchpad", + TokenEndpoint: srv.srv.URL + "/authorization/token", ExpiresAt: time.Now().Add(-time.Hour).Unix(), + })) + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, false, envelope.Data["valid"]) + assert.Equal(t, true, envelope.Data["authenticated"], "a Launchpad credential is kept after a refused refresh") + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) + + report, err := authStatusReport(app) + require.NoError(t, err) + report.record(app, &checkVerdict{valid: false, reason: "Your session has expired or was revoked"}) + joined := strings.Join(report.details, "\n") + assert.Contains(t, joined, "expired, and the refresh was refused") + assert.NotContains(t, joined, "will refresh on next use") +} From 451a3d19205e8fc8d68b340c059e3ce3a15de46b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 20:04:34 -0700 Subject: [PATCH 07/16] Spell the refreshable rule as staticcheck reads it --- internal/commands/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index f8d133247..69112a17e 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -304,7 +304,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { // A refresh token alone is not a refresh: the removed bc3 development // flow's grants cannot be redeemed, and a BC5 credential without its // token endpoint has nowhere to send one. - refreshable := creds.RefreshToken != "" && creds.OAuthType != "bc3" && !(creds.OAuthType == "bc5" && creds.TokenEndpoint == "") + refreshable := creds.RefreshToken != "" && creds.OAuthType != "bc3" && (creds.OAuthType != "bc5" || creds.TokenEndpoint != "") report.storage = storage report.data["authenticated"] = true From 46fa7039b4ac862086dfd9d83dad348948266047 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 17:11:14 -0700 Subject: [PATCH 08/16] Keep the summary's email verbatim and say why a token will not refresh The envelope stores the summary as given and sanitizes at terminal sinks, so sanitizing the email while building it left JSON output with an altered value; the sanitizer now only decides whether there is an email to name. A legacy bc3 credential, or a BC5 one without its token endpoint, holds a refresh token the CLI will not redeem; inside the refresh window the report called that "no refresh token". It now names the actual reason. --- internal/commands/auth.go | 22 +++++++-- internal/commands/auth_status_test.go | 68 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 69112a17e..de6d3d861 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -303,8 +303,18 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { } // A refresh token alone is not a refresh: the removed bc3 development // flow's grants cannot be redeemed, and a BC5 credential without its - // token endpoint has nowhere to send one. - refreshable := creds.RefreshToken != "" && creds.OAuthType != "bc3" && (creds.OAuthType != "bc5" || creds.TokenEndpoint != "") + // token endpoint has nowhere to send one. The reason is what the report + // gives when it leaves a still-live token unusable. + var notRefreshable string + switch { + case creds.RefreshToken == "": + notRefreshable = "no refresh token" + case creds.OAuthType == "bc3": + notRefreshable = "a refresh token from a removed development flow that cannot be redeemed" + case creds.OAuthType == "bc5" && creds.TokenEndpoint == "": + notRefreshable = "no token endpoint to refresh at" + } + refreshable := notRefreshable == "" report.storage = storage report.data["authenticated"] = true @@ -322,9 +332,11 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { report.data["user_email"] = creds.UserEmail } + // The summary is stored verbatim, as the envelope contract has it; + // sanitizing only decides whether the email is displayable at all. report.summary = "Logged in to " + baseURL - if email := richtext.SanitizeSingleLine(creds.UserEmail); email != "" { - report.summary += " as " + email + if richtext.SanitizeSingleLine(creds.UserEmail) != "" { + report.summary += " as " + creds.UserEmail } if creds.UserID != "" { report.summary += " (user " + creds.UserID + ")" @@ -358,7 +370,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { case refreshable: expiry = "expired, will refresh on next use" case expiresIn >= 0: - expiry = "expired (" + coarseDuration(expiresIn) + " left, inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry, and no refresh token)" + expiry = "expired (" + coarseDuration(expiresIn) + " left, inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry, and " + notRefreshable + ")" report.hint = app.Auth.LoginHint() default: expiry = "expired" diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 13c6a6770..640e9585c 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -537,3 +537,71 @@ func TestAuthStatusCheckRefusedRefreshReplacesThePromise(t *testing.T) { assert.Contains(t, joined, "expired, and the refresh was refused") assert.NotContains(t, joined, "will refresh on next use") } + +// TestAuthStatusSummaryKeepsTheRawEmail: the envelope stores the summary +// verbatim and terminal sinks sanitize at render time, so JSON output must +// carry the stored email as it is; sanitizing only decides whether there +// is anything displayable to name. +func TestAuthStatusSummaryKeepsTheRawEmail(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + app, buf := setupProfileTestApp(t, statusTestConfig(t)) + require.NoError(t, app.Auth.GetStore().Save("https://3.basecampapi.com", &auth.Credentials{ + AccessToken: "tok", OAuthType: "bc5", Scope: "full", UserEmail: "a@example.com\x1b[0m", + })) + + envelope := runAuthStatus(t, app, buf) + assert.Equal(t, "Logged in to https://3.basecampapi.com as a@example.com\x1b[0m", envelope.Summary) + assert.Equal(t, "a@example.com\x1b[0m", envelope.Data["user_email"]) + + require.NoError(t, app.Auth.GetStore().Save("https://3.basecampapi.com", &auth.Credentials{ + AccessToken: "tok", OAuthType: "bc5", Scope: "full", UserEmail: "\x1b[0m\x07", + })) + report, err := authStatusReport(app) + require.NoError(t, err) + assert.Equal(t, "Logged in to https://3.basecampapi.com", report.summary, "an email with nothing displayable is not named") +} + +// TestAuthStatusNamesWhyTheTokenWillNotRefresh: a credential that holds a +// refresh token the CLI will not redeem is not one with "no refresh token"; +// inside the refresh window the report says which it is. +func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { + t.Setenv("BASECAMP_TOKEN", "") + for name, tc := range map[string]struct { + creds auth.Credentials + reason string + }{ + "without a refresh token": { + creds: auth.Credentials{OAuthType: "bc5", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, + reason: "and no refresh token)", + }, + "legacy bc3": { + creds: auth.Credentials{OAuthType: "bc3", RefreshToken: "ref", TokenEndpoint: "https://example.com/token"}, + reason: "and a refresh token from a removed development flow that cannot be redeemed)", + }, + "bc5 without its token endpoint": { + creds: auth.Credentials{OAuthType: "bc5", RefreshToken: "ref"}, + reason: "and no token endpoint to refresh at)", + }, + } { + t.Run(name, func(t *testing.T) { + app, buf := setupProfileTestApp(t, statusTestConfig(t)) + creds := tc.creds + creds.AccessToken = "tok" + creds.Scope = "full" + creds.ExpiresAt = time.Now().Add(2 * time.Minute).Unix() + require.NoError(t, app.Auth.GetStore().Save("https://3.basecampapi.com", &creds)) + + envelope := runAuthStatus(t, app, buf) + assert.Equal(t, false, envelope.Data["refreshable"]) + assert.Equal(t, true, envelope.Data["expired"]) + assert.Equal(t, "Run: basecamp auth login", envelope.Notice) + + report, err := authStatusReport(app) + require.NoError(t, err) + assert.True(t, strings.HasSuffix(report.details[1], tc.reason+" · Storage: file"), report.details[1]) + if tc.creds.RefreshToken != "" { + assert.NotContains(t, report.details[1], "no refresh token") + } + }) + } +} From c8639c51f89c8abce31897a8449d01e3f520c738 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 17:19:32 -0700 Subject: [PATCH 09/16] Say the refresh failed, not that it was refused The --check verdict replaces the offline promise for every auth-class failure the token lookup hits, and a half-configured OAuth client fails before any request reaches the token endpoint; the line after names the actual failure. --- internal/commands/auth.go | 8 +++++--- internal/commands/auth_status_test.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index de6d3d861..70c214f3f 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -204,11 +204,13 @@ func (s *authStatus) record(app *appctx.App, v *checkVerdict) { s.details = append(s.details, "Token: valid (checked just now)") return } - // A refresh the check just had refused makes the offline line's promise - // false; the verdict replaces it rather than sitting beside it. + // A refresh the check just watched fail makes the offline line's promise + // false; the verdict replaces it rather than sitting beside it. The + // failure is named on the line after, and is not always the endpoint's + // refusal: a half-configured OAuth client fails before any request. for i, line := range s.details { if line == "Token: expired, will refresh on next use · Storage: "+s.storage { - s.details[i] = "Token: expired, and the refresh was refused · Storage: " + s.storage + s.details[i] = "Token: expired, and the refresh failed · Storage: " + s.storage } } if v.sent { diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 640e9585c..4fccebd0e 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -534,7 +534,7 @@ func TestAuthStatusCheckRefusedRefreshReplacesThePromise(t *testing.T) { require.NoError(t, err) report.record(app, &checkVerdict{valid: false, reason: "Your session has expired or was revoked"}) joined := strings.Join(report.details, "\n") - assert.Contains(t, joined, "expired, and the refresh was refused") + assert.Contains(t, joined, "Token: expired, and the refresh failed · Storage: file\nToken: none could be sent (Your session has expired or was revoked)") assert.NotContains(t, joined, "will refresh on next use") } From 292ec5243a6dbee6ff7e1c5b1fa4fc70d822d600 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 17:25:16 -0700 Subject: [PATCH 10/16] Derive refreshability from the refusals a refresh would actually make The status report's rule for whether a stored refresh token counts lived apart from the refresh itself, and missed one of its pre-request refusals: a persisted token endpoint the CLI will not post to. The rule now lives in package auth beside the refresh, with that check included. --- internal/auth/auth.go | 21 +++++++++++++++++++++ internal/auth/auth_test.go | 23 +++++++++++++++++++++++ internal/commands/auth.go | 17 ++++------------- internal/commands/auth_status_test.go | 4 ++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d37e8597e..a70d3e071 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -336,6 +336,27 @@ func (m *Manager) forgetRefusedGrant(origin, refusedToken string) (rotated bool) return false } +// RefreshRefusal is why a refresh of creds would be refused before anything +// is sent — no refresh token, a grant from the removed bc3 development flow, +// a BC5 credential without its token endpoint, or a stored endpoint the CLI +// will not post to — or "" when a refresh would be attempted. It is the +// pre-request part of refreshCredential, for a report that must say what +// the next command will do without doing it. +func RefreshRefusal(creds *Credentials) string { + switch { + case creds.RefreshToken == "": + return "no refresh token" + case creds.OAuthType == "bc3": + return "a refresh token from a removed development flow that cannot be redeemed" + case creds.OAuthType == oauthTypeBC5 && creds.TokenEndpoint == "": + return "no token endpoint to refresh at" + case creds.TokenEndpoint != "" && requireSecureOAuthEndpoint("token endpoint", creds.TokenEndpoint) != nil: + return "a stored token endpoint the CLI will not send a refresh to" + default: + return "" + } +} + // refreshLocked rotates the stored credential under the manager lock. The // credential is the active profile's, so whatever auth-class failure the // refresh hits — an unusable stored endpoint, a half-configured OAuth diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 9665f6685..8bdeca9a6 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -2299,3 +2299,26 @@ func TestAuthorizationEndpoint_EnvBC3TokenUsesTheOrigin(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://3.basecampapi.com/authorization.json", endpoint) } + +// TestRefreshRefusal: the pre-request refusals refreshCredential makes, as a +// report can state them without sending anything. +func TestRefreshRefusal(t *testing.T) { + for name, tc := range map[string]struct { + creds Credentials + want string + }{ + "launchpad with a refresh token": {Credentials{OAuthType: "launchpad", RefreshToken: "ref"}, ""}, + "bc5 with its token endpoint": {Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, ""}, + "loopback endpoint for development": {Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "http://localhost:3000/oauth/tokens"}, ""}, + "no refresh token": {Credentials{OAuthType: "bc5", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, "no refresh token"}, + "legacy bc3": {Credentials{OAuthType: "bc3", RefreshToken: "ref", TokenEndpoint: "https://example.com/token"}, "a refresh token from a removed development flow that cannot be redeemed"}, + "bc5 without its token endpoint": {Credentials{OAuthType: "bc5", RefreshToken: "ref"}, "no token endpoint to refresh at"}, + "endpoint carrying userinfo": {Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "https://user@evil.example/oauth/tokens"}, "a stored token endpoint the CLI will not send a refresh to"}, + "plain http endpoint off loopback": {Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "http://launchpad.example/authorization/token"}, "a stored token endpoint the CLI will not send a refresh to"}, + "endpoint with an undialable port": {Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://host:70000/token"}, "a stored token endpoint the CLI will not send a refresh to"}, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, RefreshRefusal(&tc.creds)) + }) + } +} diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 70c214f3f..144578474 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -303,19 +303,10 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { if store.UsingKeyring() { storage = "keyring" } - // A refresh token alone is not a refresh: the removed bc3 development - // flow's grants cannot be redeemed, and a BC5 credential without its - // token endpoint has nowhere to send one. The reason is what the report - // gives when it leaves a still-live token unusable. - var notRefreshable string - switch { - case creds.RefreshToken == "": - notRefreshable = "no refresh token" - case creds.OAuthType == "bc3": - notRefreshable = "a refresh token from a removed development flow that cannot be redeemed" - case creds.OAuthType == "bc5" && creds.TokenEndpoint == "": - notRefreshable = "no token endpoint to refresh at" - } + // A refresh token alone is not a refresh: the reason the CLI would refuse + // one before sending anything is what the report gives when that leaves + // a still-live token unusable. + notRefreshable := auth.RefreshRefusal(creds) refreshable := notRefreshable == "" report.storage = storage diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 4fccebd0e..818ef7f11 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -582,6 +582,10 @@ func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { creds: auth.Credentials{OAuthType: "bc5", RefreshToken: "ref"}, reason: "and no token endpoint to refresh at)", }, + "stored token endpoint the CLI will not post to": { + creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://user@evil.example/authorization/token"}, + reason: "and a stored token endpoint the CLI will not send a refresh to)", + }, } { t.Run(name, func(t *testing.T) { app, buf := setupProfileTestApp(t, statusTestConfig(t)) From 7e9f89cbccde429754f37ce029afab0b6b1b9893 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 17:38:08 -0700 Subject: [PATCH 11/16] Ask the refresh itself what it would refuse The status report kept its own list of the checks a refresh makes before sending anything, and each review found one it lacked; the latest was an OAuth client half-configured in the environment. The refresh's pre-request half is now its own step, and the report runs that step on a copy of the credential, so the two cannot disagree. --- internal/auth/auth.go | 55 ++++++++++++++------------- internal/auth/auth_test.go | 44 ++++++++++++++------- internal/commands/auth.go | 10 ++--- internal/commands/auth_status_test.go | 30 ++++++++++----- 4 files changed, 84 insertions(+), 55 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a70d3e071..51b852c39 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -336,25 +336,16 @@ func (m *Manager) forgetRefusedGrant(origin, refusedToken string) (rotated bool) return false } -// RefreshRefusal is why a refresh of creds would be refused before anything -// is sent — no refresh token, a grant from the removed bc3 development flow, -// a BC5 credential without its token endpoint, or a stored endpoint the CLI -// will not post to — or "" when a refresh would be attempted. It is the -// pre-request part of refreshCredential, for a report that must say what +// RefreshRefusal is the error a refresh of creds would fail with before +// anything is sent — no refresh token, a grant from the removed bc3 +// development flow, a missing or unusable token endpoint, a half-configured +// OAuth client — or nil when a refresh would be attempted. It runs the same +// preparation the refresh does, on a copy, for a report that must say what // the next command will do without doing it. -func RefreshRefusal(creds *Credentials) string { - switch { - case creds.RefreshToken == "": - return "no refresh token" - case creds.OAuthType == "bc3": - return "a refresh token from a removed development flow that cannot be redeemed" - case creds.OAuthType == oauthTypeBC5 && creds.TokenEndpoint == "": - return "no token endpoint to refresh at" - case creds.TokenEndpoint != "" && requireSecureOAuthEndpoint("token endpoint", creds.TokenEndpoint) != nil: - return "a stored token endpoint the CLI will not send a refresh to" - default: - return "" - } +func (m *Manager) RefreshRefusal(creds *Credentials) error { + prepared := *creds + _, _, err := m.prepareRefresh(&prepared) + return err } // refreshLocked rotates the stored credential under the manager lock. The @@ -366,9 +357,12 @@ func (m *Manager) refreshLocked(ctx context.Context, origin string, creds *Crede return m.hintLogin(m.refreshCredential(ctx, origin, creds)) } -func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *Credentials) error { +// prepareRefresh is the half of a refresh that sends nothing: it migrates +// the credential's missing fields in place, checks what it holds, and +// resolves the client and lane the request would go out through. +func (m *Manager) prepareRefresh(creds *Credentials) (oauth.RefreshRequest, *oauth.Exchanger, error) { if creds.RefreshToken == "" { - return m.errAuth("No refresh token available") + return oauth.RefreshRequest{}, nil, m.errAuth("No refresh token available") } // Migrate old credentials missing OAuthType @@ -379,11 +373,11 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C // Migrate old credentials missing TokenEndpoint if creds.TokenEndpoint == "" { if creds.OAuthType == "bc3" || creds.OAuthType == oauthTypeBC5 { - return m.errAuth("Stored credentials are missing their token endpoint and cannot be refreshed") + return oauth.RefreshRequest{}, nil, m.errAuth("Stored credentials are missing their token endpoint and cannot be refreshed") } lpURL, lpErr := m.launchpadURL() if lpErr != nil { - return lpErr + return oauth.RefreshRequest{}, nil, lpErr } creds.TokenEndpoint = lpURL + "/authorization/token" } @@ -396,7 +390,7 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C // empty-host, or opaque/malformed https forms, so apply the same strict // check used for the other OAuth endpoints before any POST. if err := requireSecureOAuthEndpoint("token endpoint", tokenEndpoint); err != nil { - return err + return oauth.RefreshRequest{}, nil, err } // Resolve client credentials for the refresh request @@ -405,14 +399,14 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C case "bc3": // DCR-era development flow, removed. Its per-install dynamic clients // can't be resolved anymore, so the refresh token is unusable. - return m.errAuth("Stored credentials are from a removed development flow and cannot be refreshed") + return oauth.RefreshRequest{}, nil, m.errAuth("Stored credentials are from a removed development flow and cannot be refreshed") case oauthTypeBC5: // Pre-registered public client: no secret. clientID = bc5ClientID default: // Launchpad (or old credentials defaulted to launchpad) if envCreds, err := resolveClientCredentials(func(string) {}); err != nil { - return err + return oauth.RefreshRequest{}, nil, err } else if envCreds != nil { clientID = envCreds.ClientID clientSecret = envCreds.ClientSecret @@ -431,7 +425,7 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C laneClient, laneErr = m.bc5Client() } if laneErr != nil { - return laneErr + return oauth.RefreshRequest{}, nil, laneErr } exchanger := oauth.NewExchanger(laneClient) @@ -446,6 +440,15 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C UseLegacyFormat: creds.OAuthType == oauthTypeLaunchpad, } + return req, exchanger, nil +} + +func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *Credentials) error { + req, exchanger, err := m.prepareRefresh(creds) + if err != nil { + return err + } + token, err := exchanger.Refresh(ctx, req) if err != nil { desc, dead := invalidGrant(err) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 8bdeca9a6..84c37801b 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -2300,25 +2300,41 @@ func TestAuthorizationEndpoint_EnvBC3TokenUsesTheOrigin(t *testing.T) { assert.Equal(t, "https://3.basecampapi.com/authorization.json", endpoint) } -// TestRefreshRefusal: the pre-request refusals refreshCredential makes, as a -// report can state them without sending anything. +// TestRefreshRefusal: the refusals a refresh makes before sending anything, +// as a report can state them without a request — and without the migration +// a real refresh writes into the credential. func TestRefreshRefusal(t *testing.T) { + t.Setenv("BASECAMP_OAUTH_CLIENT_ID", "") + t.Setenv("BASECAMP_OAUTH_CLIENT_SECRET", "") + t.Setenv("BASECAMP_LAUNCHPAD_URL", "") + m := NewManager(&config.Config{BaseURL: "https://3.basecampapi.com"}, http.DefaultClient) for name, tc := range map[string]struct { - creds Credentials - want string + creds Credentials + clientID string + want string }{ - "launchpad with a refresh token": {Credentials{OAuthType: "launchpad", RefreshToken: "ref"}, ""}, - "bc5 with its token endpoint": {Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, ""}, - "loopback endpoint for development": {Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "http://localhost:3000/oauth/tokens"}, ""}, - "no refresh token": {Credentials{OAuthType: "bc5", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, "no refresh token"}, - "legacy bc3": {Credentials{OAuthType: "bc3", RefreshToken: "ref", TokenEndpoint: "https://example.com/token"}, "a refresh token from a removed development flow that cannot be redeemed"}, - "bc5 without its token endpoint": {Credentials{OAuthType: "bc5", RefreshToken: "ref"}, "no token endpoint to refresh at"}, - "endpoint carrying userinfo": {Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "https://user@evil.example/oauth/tokens"}, "a stored token endpoint the CLI will not send a refresh to"}, - "plain http endpoint off loopback": {Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "http://launchpad.example/authorization/token"}, "a stored token endpoint the CLI will not send a refresh to"}, - "endpoint with an undialable port": {Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://host:70000/token"}, "a stored token endpoint the CLI will not send a refresh to"}, + "launchpad with a refresh token": {creds: Credentials{OAuthType: "launchpad", RefreshToken: "ref"}}, + "bc5 with its token endpoint": {creds: Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}}, + "loopback endpoint for development": {creds: Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "http://localhost:3000/oauth/tokens"}}, + "no refresh token": {creds: Credentials{OAuthType: "bc5", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, want: "No refresh token available"}, + "legacy bc3": {creds: Credentials{OAuthType: "bc3", RefreshToken: "ref", TokenEndpoint: "https://example.com/token"}, want: "Stored credentials are from a removed development flow and cannot be refreshed"}, + "bc5 without its token endpoint": {creds: Credentials{OAuthType: "bc5", RefreshToken: "ref"}, want: "Stored credentials are missing their token endpoint and cannot be refreshed"}, + "endpoint carrying userinfo": {creds: Credentials{OAuthType: "bc5", RefreshToken: "ref", TokenEndpoint: "https://user@evil.example/oauth/tokens"}, want: "invalid token endpoint"}, + "plain http endpoint off loopback": {creds: Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "http://launchpad.example/authorization/token"}, want: "invalid token endpoint"}, + "endpoint with an undialable port": {creds: Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://host:70000/token"}, want: "invalid token endpoint"}, + "half-configured OAuth client": {creds: Credentials{OAuthType: "launchpad", RefreshToken: "ref"}, clientID: "only-the-id", want: "BASECAMP_OAUTH_CLIENT_SECRET is required when BASECAMP_OAUTH_CLIENT_ID is set"}, } { t.Run(name, func(t *testing.T) { - assert.Equal(t, tc.want, RefreshRefusal(&tc.creds)) + t.Setenv("BASECAMP_OAUTH_CLIENT_ID", tc.clientID) + before := tc.creds + err := m.RefreshRefusal(&tc.creds) + assert.Equal(t, before, tc.creds, "the caller's credential is left as stored") + if tc.want == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + } }) } } diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 144578474..96d22ed95 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -303,11 +303,11 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { if store.UsingKeyring() { storage = "keyring" } - // A refresh token alone is not a refresh: the reason the CLI would refuse - // one before sending anything is what the report gives when that leaves + // A refresh token alone is not a refresh: what the CLI would refuse one + // for before sending anything is what the report gives when that leaves // a still-live token unusable. - notRefreshable := auth.RefreshRefusal(creds) - refreshable := notRefreshable == "" + refusal := app.Auth.RefreshRefusal(creds) + refreshable := refusal == nil report.storage = storage report.data["authenticated"] = true @@ -363,7 +363,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { case refreshable: expiry = "expired, will refresh on next use" case expiresIn >= 0: - expiry = "expired (" + coarseDuration(expiresIn) + " left, inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry, and " + notRefreshable + ")" + expiry = "expired (" + coarseDuration(expiresIn) + " left, inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry, and the refresh would be refused: " + output.AsError(refusal).Message + ")" report.hint = app.Auth.LoginHint() default: expiry = "expired" diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 818ef7f11..d30fb4301 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -562,32 +562,42 @@ func TestAuthStatusSummaryKeepsTheRawEmail(t *testing.T) { } // TestAuthStatusNamesWhyTheTokenWillNotRefresh: a credential that holds a -// refresh token the CLI will not redeem is not one with "no refresh token"; -// inside the refresh window the report says which it is. +// refresh token the CLI will not send is not one with "no refresh token"; +// inside the refresh window the report gives the refusal the next command's +// refresh would make. func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { t.Setenv("BASECAMP_TOKEN", "") + t.Setenv("BASECAMP_OAUTH_CLIENT_ID", "") + t.Setenv("BASECAMP_OAUTH_CLIENT_SECRET", "") for name, tc := range map[string]struct { - creds auth.Credentials - reason string + creds auth.Credentials + clientID string + reason string }{ "without a refresh token": { creds: auth.Credentials{OAuthType: "bc5", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, - reason: "and no refresh token)", + reason: "No refresh token available", }, "legacy bc3": { creds: auth.Credentials{OAuthType: "bc3", RefreshToken: "ref", TokenEndpoint: "https://example.com/token"}, - reason: "and a refresh token from a removed development flow that cannot be redeemed)", + reason: "Stored credentials are from a removed development flow and cannot be refreshed", }, "bc5 without its token endpoint": { creds: auth.Credentials{OAuthType: "bc5", RefreshToken: "ref"}, - reason: "and no token endpoint to refresh at)", + reason: "Stored credentials are missing their token endpoint and cannot be refreshed", }, "stored token endpoint the CLI will not post to": { creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://user@evil.example/authorization/token"}, - reason: "and a stored token endpoint the CLI will not send a refresh to)", + reason: "invalid token endpoint \"https://user@evil.example/authorization/token\": must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", + }, + "half-configured OAuth client": { + creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://launchpad.37signals.com/authorization/token"}, + clientID: "only-the-id", + reason: "BASECAMP_OAUTH_CLIENT_SECRET is required when BASECAMP_OAUTH_CLIENT_ID is set", }, } { t.Run(name, func(t *testing.T) { + t.Setenv("BASECAMP_OAUTH_CLIENT_ID", tc.clientID) app, buf := setupProfileTestApp(t, statusTestConfig(t)) creds := tc.creds creds.AccessToken = "tok" @@ -602,9 +612,9 @@ func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { report, err := authStatusReport(app) require.NoError(t, err) - assert.True(t, strings.HasSuffix(report.details[1], tc.reason+" · Storage: file"), report.details[1]) + assert.True(t, strings.HasSuffix(report.details[1], "the CLI keeps clear of expiry, and the refresh would be refused: "+tc.reason+") · Storage: file"), report.details[1]) if tc.creds.RefreshToken != "" { - assert.NotContains(t, report.details[1], "no refresh token") + assert.NotContains(t, report.details[1], "No refresh token") } }) } From 2293e38118e161e7b79654d59518026f8695c01d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 22:25:33 -0700 Subject: [PATCH 12/16] Take back the refresh promise whichever way it was made --- internal/commands/auth.go | 31 ++++++++++++++----- internal/commands/auth_status_test.go | 43 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 96d22ed95..63676bab3 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -204,13 +204,16 @@ func (s *authStatus) record(app *appctx.App, v *checkVerdict) { s.details = append(s.details, "Token: valid (checked just now)") return } - // A refresh the check just watched fail makes the offline line's promise - // false; the verdict replaces it rather than sitting beside it. The - // failure is named on the line after, and is not always the endpoint's - // refusal: a half-configured OAuth client fails before any request. - for i, line := range s.details { - if line == "Token: expired, will refresh on next use · Storage: "+s.storage { - s.details[i] = "Token: expired, and the refresh failed · Storage: " + s.storage + // No token to send means the refresh the offline line counted on was + // tried and failed, so the promise is taken back rather than left beside + // the verdict. The failure is named on the line after, and is not always + // the endpoint's refusal: a half-configured OAuth client fails before + // any request. + if !v.sent && s.refreshPromise != "" { + for i, line := range s.details { + if line == s.tokenLine(s.refreshPromise) { + s.details[i] = s.tokenLine(s.refreshFailed) + } } } if v.sent { @@ -241,6 +244,16 @@ type authStatus struct { details []string hint string storage string + // refreshPromise is the expiry phrase written on the strength of a + // refresh succeeding, and refreshFailed what replaces it once --check + // has watched that refresh fail; both empty when nothing was promised. + refreshPromise, refreshFailed string +} + +// tokenLine is the report's expiry line: the phrase, and where the +// credential lives. +func (s *authStatus) tokenLine(expiry string) string { + return "Token: " + expiry + " · Storage: " + s.storage } // authStatusReport inspects the active credential without touching the @@ -358,10 +371,12 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { switch { case !expired && refreshable: expiry = "expires in " + coarseDuration(expiresIn) + ", refreshes automatically" + report.refreshPromise, report.refreshFailed = expiry, "expires in "+coarseDuration(expiresIn)+", and the refresh failed" case !expired: expiry = "expires in " + coarseDuration(expiresIn) case refreshable: expiry = "expired, will refresh on next use" + report.refreshPromise, report.refreshFailed = expiry, "expired, and the refresh failed" case expiresIn >= 0: expiry = "expired (" + coarseDuration(expiresIn) + " left, inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry, and the refresh would be refused: " + output.AsError(refusal).Message + ")" report.hint = app.Auth.LoginHint() @@ -370,7 +385,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { report.hint = app.Auth.LoginHint() } } - report.details = append(report.details, "Token: "+expiry+" · Storage: "+storage) + report.details = append(report.details, report.tokenLine(expiry)) return report, nil } diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index d30fb4301..f564d52c4 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -538,6 +538,49 @@ func TestAuthStatusCheckRefusedRefreshReplacesThePromise(t *testing.T) { assert.NotContains(t, joined, "will refresh on next use") } +// TestAuthStatusCheckRefusedRefreshInsideTheWindowReplacesThePromise: a +// still-live Launchpad token inside the refresh window is refreshed on +// use, so when --check watches that refresh fail the report must not keep +// saying it refreshes automatically. +func TestAuthStatusCheckRefusedRefreshInsideTheWindowReplacesThePromise(t *testing.T) { + srv := startLoginIdentityServer(t, "live-tok") + inner := srv.srv.Config.Handler + srv.srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/authorization/token" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"invalid_grant"}`) + return + } + inner.ServeHTTP(w, r) + }) + t.Setenv("BASECAMP_LAUNCHPAD_URL", srv.srv.URL) + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "stale-tok", RefreshToken: "stale-ref", OAuthType: "launchpad", + TokenEndpoint: srv.srv.URL + "/authorization/token", ExpiresAt: time.Now().Add(3 * time.Minute).Unix(), + })) + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, false, envelope.Data["valid"]) + assert.Equal(t, false, envelope.Data["expired"], "the token itself is still live") + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) + + report, err := authStatusReport(app) + require.NoError(t, err) + report.record(app, &checkVerdict{valid: false, reason: "Your session has expired or was revoked"}) + joined := strings.Join(report.details, "\n") + assert.Contains(t, joined, "Token: expires in 2m, and the refresh failed · Storage: file\nToken: none could be sent (Your session has expired or was revoked)") + assert.NotContains(t, joined, "refreshes automatically") +} + // TestAuthStatusSummaryKeepsTheRawEmail: the envelope stores the summary // verbatim and terminal sinks sanitize at render time, so JSON output must // carry the stored email as it is; sanitizing only decides whether there From 828e13573265b258cf1b4dca831ae49146ec0efe Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 22:58:14 -0700 Subject: [PATCH 13/16] Name the client environment, not a login, when the pair is half set --- internal/auth/auth.go | 17 ++++++++-- internal/auth/auth_test.go | 10 +++--- internal/commands/auth.go | 25 ++++++++++++--- internal/commands/auth_status_test.go | 45 ++++++++++++++++++++++++++- 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 51b852c39..362888cfb 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1257,6 +1257,11 @@ func launchpadClientCredentials(log func(string)) (*ClientCredentials, error) { }, nil } +// ClientEnvHint is the remedy for a half-set BASECAMP_OAUTH_CLIENT_ID and +// BASECAMP_OAUTH_CLIENT_SECRET pair. Logging in reads the same pair and +// fails the same way, so the login is no remedy; the environment is. +const ClientEnvHint = "Set both BASECAMP_OAUTH_CLIENT_ID and BASECAMP_OAUTH_CLIENT_SECRET, or unset both to use the built-in client" + // resolveClientCredentials reads OAuth client credentials from environment // variables BASECAMP_OAUTH_CLIENT_ID and BASECAMP_OAUTH_CLIENT_SECRET. // Both must be set together. Returns nil, nil when neither is set. @@ -1268,16 +1273,24 @@ func resolveClientCredentials(log func(string)) (*ClientCredentials, error) { return nil, nil } if clientID == "" { - return nil, output.ErrAuth("BASECAMP_OAUTH_CLIENT_ID is required when BASECAMP_OAUTH_CLIENT_SECRET is set") + return nil, errClientEnv("BASECAMP_OAUTH_CLIENT_ID is required when BASECAMP_OAUTH_CLIENT_SECRET is set") } if clientSecret == "" { - return nil, output.ErrAuth("BASECAMP_OAUTH_CLIENT_SECRET is required when BASECAMP_OAUTH_CLIENT_ID is set") + return nil, errClientEnv("BASECAMP_OAUTH_CLIENT_SECRET is required when BASECAMP_OAUTH_CLIENT_ID is set") } log("Using custom OAuth client credentials from BASECAMP_OAUTH_CLIENT_ID/SECRET") return &ClientCredentials{ClientID: clientID, ClientSecret: clientSecret}, nil } +// errClientEnv is an auth_required error whose remedy is the client +// environment, not a login. +func errClientEnv(msg string) *output.Error { + e := output.ErrAuth(msg) + e.Hint = ClientEnvHint + return e +} + // isSecureEndpointURL reports whether u uses a scheme safe for OAuth endpoints // derived from the server-controlled discovery document: https, or http only on // loopback for local development. The URL must also be absolute with a hostname — diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 84c37801b..2c62a6500 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -575,6 +575,7 @@ func TestResolveClientCredentials(t *testing.T) { if tt.wantErrMsg != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErrMsg) + assert.Equal(t, ClientEnvHint, output.AsError(err).Hint, "logging in reads the same pair, so it is no remedy") return } require.NoError(t, err) @@ -2151,10 +2152,11 @@ func TestRefresh_UnsafeTokenEndpointHintsTheProfile(t *testing.T) { assert.Equal(t, "old-ref", creds.RefreshToken) } -// TestRefresh_HalfConfiguredClientHintsTheProfile: a Launchpad refresh +// TestRefresh_HalfConfiguredClientNamesTheEnvironment: a Launchpad refresh // with only one of the OAuth client variables set fails before any request, -// and that failure also names the profile. -func TestRefresh_HalfConfiguredClientHintsTheProfile(t *testing.T) { +// and since the profile's login reads the same pair, the remedy it names is +// the environment. +func TestRefresh_HalfConfiguredClientNamesTheEnvironment(t *testing.T) { t.Setenv("BASECAMP_OAUTH_CLIENT_ID", "custom-id") t.Setenv("BASECAMP_OAUTH_CLIENT_SECRET", "") m, _ := profiledRefresh(t, &Credentials{ @@ -2167,7 +2169,7 @@ func TestRefresh_HalfConfiguredClientHintsTheProfile(t *testing.T) { require.ErrorAs(t, err, &cliErr) assert.Equal(t, output.CodeAuth, cliErr.Code) assert.Contains(t, cliErr.Message, "BASECAMP_OAUTH_CLIENT_SECRET is required") - assert.Equal(t, "Run: basecamp auth login -P work", cliErr.Hint) + assert.Equal(t, ClientEnvHint, cliErr.Hint) } // TestSetUserIdentity_EmptyValuesAreOmissions: an authorization document diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 63676bab3..00b2bafc8 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -158,6 +158,20 @@ type checkVerdict struct { // with, or a refresh the token endpoint refused), reason says why. sent bool reason string + // remedy is what the refusal itself said to do about it, when that is + // something other than logging in. + remedy string +} + +// remedyFor is the refusal's own remedy when it names one beyond the default +// login — a client environment to fix — and the active profile's login +// otherwise, which is what every refusal about the credential itself comes +// down to. +func remedyFor(app *appctx.App, refusal error) string { + if e := output.AsError(refusal); e.Hint != "" && e.Hint != output.DefaultAuthHint { + return e.Hint + } + return app.Auth.LoginHint() } // checkWithServer makes the one authenticated request --check promises. An @@ -177,7 +191,7 @@ func checkWithServer(ctx context.Context, app *appctx.App) (*checkVerdict, error // stored. if _, err := app.Auth.AccessToken(ctx); err != nil { if e := output.AsError(err); e.Code == output.CodeAuth { - return &checkVerdict{valid: false, reason: e.Message}, nil + return &checkVerdict{valid: false, reason: e.Message, remedy: remedyFor(app, err)}, nil } return nil, err } @@ -221,9 +235,12 @@ func (s *authStatus) record(app *appctx.App, v *checkVerdict) { } else { s.details = append(s.details, "Token: none could be sent ("+v.reason+")") } - if os.Getenv("BASECAMP_TOKEN") != "" { + switch { + case os.Getenv("BASECAMP_TOKEN") != "": s.hint = envTokenRejectedHint - } else { + case v.remedy != "": + s.hint = v.remedy + default: s.hint = app.Auth.LoginHint() } } @@ -379,7 +396,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) { report.refreshPromise, report.refreshFailed = expiry, "expired, and the refresh failed" case expiresIn >= 0: expiry = "expired (" + coarseDuration(expiresIn) + " left, inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry, and the refresh would be refused: " + output.AsError(refusal).Message + ")" - report.hint = app.Auth.LoginHint() + report.hint = remedyFor(app, refusal) default: expiry = "expired" report.hint = app.Auth.LoginHint() diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index f564d52c4..8fb821d20 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -581,6 +581,43 @@ func TestAuthStatusCheckRefusedRefreshInsideTheWindowReplacesThePromise(t *testi assert.NotContains(t, joined, "refreshes automatically") } +// TestAuthStatusCheckHalfConfiguredClientNamesTheEnvironment: a refresh +// the check could not make because only one of BASECAMP_OAUTH_CLIENT_ID and +// BASECAMP_OAUTH_CLIENT_SECRET is set is not cured by logging in, which +// reads the same pair; the remedy is the environment, and nothing is sent. +func TestAuthStatusCheckHalfConfiguredClientNamesTheEnvironment(t *testing.T) { + srv := startLoginIdentityServer(t, "live-tok") + t.Setenv("BASECAMP_LAUNCHPAD_URL", srv.srv.URL) + t.Setenv("BASECAMP_OAUTH_CLIENT_ID", "only-the-id") + t.Setenv("BASECAMP_OAUTH_CLIENT_SECRET", "") + app, buf := loginTestApp(t, srv, &config.Config{ActiveProfile: "bot"}) + require.NoError(t, app.Auth.GetStore().Save("profile:bot", &auth.Credentials{ + AccessToken: "live-tok", RefreshToken: "ref", OAuthType: "launchpad", + TokenEndpoint: srv.srv.URL + "/authorization/token", ExpiresAt: time.Now().Add(2 * time.Minute).Unix(), + })) + + cmd := newAuthStatusCmd() + cmd.SetArgs([]string{"--check"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetOut(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var envelope statusEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String()) + assert.Equal(t, false, envelope.Data["valid"]) + assert.Equal(t, auth.ClientEnvHint, envelope.Notice) + assert.Empty(t, srv.seenPaths(), "the refusal is made before any request") + + report, err := authStatusReport(app) + require.NoError(t, err) + assert.Equal(t, auth.ClientEnvHint, report.hint, "the offline report gives the same remedy") + verdict, err := checkWithServer(context.Background(), app) + require.NoError(t, err) + report.record(app, verdict) + assert.Equal(t, auth.ClientEnvHint, report.hint) + assert.Contains(t, strings.Join(report.details, "\n"), "Token: none could be sent (BASECAMP_OAUTH_CLIENT_SECRET is required when BASECAMP_OAUTH_CLIENT_ID is set)") +} + // TestAuthStatusSummaryKeepsTheRawEmail: the envelope stores the summary // verbatim and terminal sinks sanitize at render time, so JSON output must // carry the stored email as it is; sanitizing only decides whether there @@ -616,6 +653,7 @@ func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { creds auth.Credentials clientID string reason string + hint string }{ "without a refresh token": { creds: auth.Credentials{OAuthType: "bc5", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, @@ -637,6 +675,7 @@ func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://launchpad.37signals.com/authorization/token"}, clientID: "only-the-id", reason: "BASECAMP_OAUTH_CLIENT_SECRET is required when BASECAMP_OAUTH_CLIENT_ID is set", + hint: auth.ClientEnvHint, }, } { t.Run(name, func(t *testing.T) { @@ -651,7 +690,11 @@ func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { envelope := runAuthStatus(t, app, buf) assert.Equal(t, false, envelope.Data["refreshable"]) assert.Equal(t, true, envelope.Data["expired"]) - assert.Equal(t, "Run: basecamp auth login", envelope.Notice) + hint := tc.hint + if hint == "" { + hint = "Run: basecamp auth login" + } + assert.Equal(t, hint, envelope.Notice, "the remedy is the login unless the refusal names a better one") report, err := authStatusReport(app) require.NoError(t, err) From 91e9c70968eb58de40cd6b85e447cd0f7e81ab29 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 23:04:03 -0700 Subject: [PATCH 14/16] Mask a stored endpoint's password when refusing it --- internal/auth/auth.go | 13 ++++++++++--- internal/auth/auth_test.go | 17 +++++++++++++++++ internal/commands/auth_status_test.go | 4 ++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 362888cfb..9a7cd9b4a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1326,14 +1326,21 @@ func isSecureEndpointURL(u *url.URL) bool { // requireSecureOAuthEndpoint parses and validates a server-controlled OAuth // endpoint URL with isSecureEndpointURL, returning an auth-class error naming -// the endpoint when it fails. +// the endpoint when it fails. The endpoint is echoed with any password +// masked, and not at all when it does not parse: the error reaches status +// output and transcripts, and a stored endpoint is exactly where a secret +// in userinfo would sit. func requireSecureOAuthEndpoint(name, endpoint string) error { u, err := url.Parse(endpoint) if err != nil { - return output.ErrAuth(fmt.Sprintf("invalid %s %q: %v", name, endpoint, err)) + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return output.ErrAuth(fmt.Sprintf("invalid %s: %v", name, err)) } if !isSecureEndpointURL(u) { - return output.ErrAuth(fmt.Sprintf("invalid %s %q: must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", name, endpoint)) + return output.ErrAuth(fmt.Sprintf("invalid %s %q: must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", name, u.Redacted())) } return nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 2c62a6500..1e9378366 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -590,6 +590,23 @@ func TestResolveClientCredentials(t *testing.T) { } } +// TestRequireSecureOAuthEndpoint_DoesNotEchoSecrets: the refusal names the +// endpoint so the reader can find it in the store, but a password in its +// userinfo is masked, and an endpoint that does not parse is not echoed +// at all, since the message reaches status output and transcripts. +func TestRequireSecureOAuthEndpoint_DoesNotEchoSecrets(t *testing.T) { + err := requireSecureOAuthEndpoint("token endpoint", "https://client:s3cret@evil.example/token") + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid token endpoint "https://client:xxxxx@evil.example/token": must be`) + assert.NotContains(t, err.Error(), "s3cret") + + err = requireSecureOAuthEndpoint("token endpoint", "https://client:s3cret@evil.example:port/token") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid token endpoint: invalid port") + assert.NotContains(t, err.Error(), "s3cret") + assert.NotContains(t, err.Error(), "evil.example") +} + func TestBuildAuthURL_UsesResolvedRedirectURI(t *testing.T) { m := &Manager{cfg: config.Default(), httpClient: http.DefaultClient} opts := &LoginOptions{RedirectURI: "http://localhost:9999/my-callback"} diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 8fb821d20..4d42d731d 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -671,6 +671,10 @@ func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://user@evil.example/authorization/token"}, reason: "invalid token endpoint \"https://user@evil.example/authorization/token\": must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", }, + "stored token endpoint carrying a secret": { + creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://client:s3cret@evil.example/authorization/token"}, + reason: "invalid token endpoint \"https://client:xxxxx@evil.example/authorization/token\": must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", + }, "half-configured OAuth client": { creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://launchpad.37signals.com/authorization/token"}, clientID: "only-the-id", From 2247c27469143f1c9fa7866d7fdf9320de35b4cf Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 23:38:01 -0700 Subject: [PATCH 15/16] Send the check the token it just produced --- internal/commands/auth.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 00b2bafc8..a7cfc9b37 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -189,13 +189,19 @@ func checkWithServer(ctx context.Context, app *appctx.App) (*checkVerdict, error // refresh the token endpoint turned down — is reported as that, not as // the server's answer. The request's own token lookup then finds it // stored. - if _, err := app.Auth.AccessToken(ctx); err != nil { + token, err := app.Auth.AccessToken(ctx) + if err != nil { if e := output.AsError(err); e.Code == output.CodeAuth { return &checkVerdict{valid: false, reason: e.Message, remedy: remedyFor(app, err)}, nil } return nil, err } - _, err = app.SDK.Authorization().GetInfo(ctx, &basecamp.GetInfoOptions{Endpoint: endpoint, FilterProduct: "bc3"}) + // The request sends exactly the token just produced. The SDK's own + // lookup would produce it again, and a token crossing the refresh-window + // boundary between the two could fail there, locally, and be reported + // as the server's refusal. + client := app.SDKClientFor(&basecamp.StaticTokenProvider{Token: token}) + _, err = client.Authorization().GetInfo(ctx, &basecamp.GetInfoOptions{Endpoint: endpoint, FilterProduct: "bc3"}) switch { case err == nil: return &checkVerdict{valid: true, sent: true}, nil From 0e3ab69951d2b4be513bfd66e1913d97fa51d632 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 23:38:01 -0700 Subject: [PATCH 16/16] Mask a stored endpoint's userinfo whole when refusing it --- internal/auth/auth.go | 7 +++++-- internal/auth/auth_test.go | 15 +++++++++++---- internal/commands/auth_status_test.go | 4 ++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9a7cd9b4a..b1f469266 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1326,7 +1326,7 @@ func isSecureEndpointURL(u *url.URL) bool { // requireSecureOAuthEndpoint parses and validates a server-controlled OAuth // endpoint URL with isSecureEndpointURL, returning an auth-class error naming -// the endpoint when it fails. The endpoint is echoed with any password +// the endpoint when it fails. The endpoint is echoed with its userinfo // masked, and not at all when it does not parse: the error reaches status // output and transcripts, and a stored endpoint is exactly where a secret // in userinfo would sit. @@ -1340,7 +1340,10 @@ func requireSecureOAuthEndpoint(name, endpoint string) error { return output.ErrAuth(fmt.Sprintf("invalid %s: %v", name, err)) } if !isSecureEndpointURL(u) { - return output.ErrAuth(fmt.Sprintf("invalid %s %q: must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", name, u.Redacted())) + if u.User != nil { + u.User = url.User("xxxxx") + } + return output.ErrAuth(fmt.Sprintf("invalid %s %q: must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", name, u.String())) } return nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 1e9378366..42a656d2b 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -591,13 +591,20 @@ func TestResolveClientCredentials(t *testing.T) { } // TestRequireSecureOAuthEndpoint_DoesNotEchoSecrets: the refusal names the -// endpoint so the reader can find it in the store, but a password in its -// userinfo is masked, and an endpoint that does not parse is not echoed -// at all, since the message reaches status output and transcripts. +// endpoint so the reader can find it in the store, but its userinfo is +// masked whole — a secret can sit in the username as well as the password — +// and an endpoint that does not parse is not echoed at all, since the +// message reaches status output and transcripts. func TestRequireSecureOAuthEndpoint_DoesNotEchoSecrets(t *testing.T) { err := requireSecureOAuthEndpoint("token endpoint", "https://client:s3cret@evil.example/token") require.Error(t, err) - assert.Contains(t, err.Error(), `invalid token endpoint "https://client:xxxxx@evil.example/token": must be`) + assert.Contains(t, err.Error(), `invalid token endpoint "https://xxxxx@evil.example/token": must be`) + assert.NotContains(t, err.Error(), "s3cret") + assert.NotContains(t, err.Error(), "client") + + err = requireSecureOAuthEndpoint("token endpoint", "https://s3cret@evil.example/token") + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid token endpoint "https://xxxxx@evil.example/token": must be`) assert.NotContains(t, err.Error(), "s3cret") err = requireSecureOAuthEndpoint("token endpoint", "https://client:s3cret@evil.example:port/token") diff --git a/internal/commands/auth_status_test.go b/internal/commands/auth_status_test.go index 4d42d731d..51d6d9c4d 100644 --- a/internal/commands/auth_status_test.go +++ b/internal/commands/auth_status_test.go @@ -669,11 +669,11 @@ func TestAuthStatusNamesWhyTheTokenWillNotRefresh(t *testing.T) { }, "stored token endpoint the CLI will not post to": { creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://user@evil.example/authorization/token"}, - reason: "invalid token endpoint \"https://user@evil.example/authorization/token\": must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", + reason: "invalid token endpoint \"https://xxxxx@evil.example/authorization/token\": must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", }, "stored token endpoint carrying a secret": { creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://client:s3cret@evil.example/authorization/token"}, - reason: "invalid token endpoint \"https://client:xxxxx@evil.example/authorization/token\": must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", + reason: "invalid token endpoint \"https://xxxxx@evil.example/authorization/token\": must be an absolute https URL (or http on loopback) with a hostname, no userinfo, and a valid port", }, "half-configured OAuth client": { creds: auth.Credentials{OAuthType: "launchpad", RefreshToken: "ref", TokenEndpoint: "https://launchpad.37signals.com/authorization/token"},