diff --git a/.surface b/.surface index 276ad001..d24af0f9 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 ae9e0e2c..c2afcd2d 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: a line starting "Logged in to https://3.basecampapi.com" (your email and user id follow when known) ``` --- diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b3ec7acf..b1f46926 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -336,6 +336,18 @@ func (m *Manager) forgetRefusedGrant(origin, refusedToken string) (rotated bool) return false } +// 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 (m *Manager) RefreshRefusal(creds *Credentials) error { + prepared := *creds + _, _, err := m.prepareRefresh(&prepared) + return err +} + // 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 @@ -345,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 @@ -358,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" } @@ -375,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 @@ -384,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 @@ -410,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) @@ -425,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) @@ -1233,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. @@ -1244,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 — @@ -1289,14 +1326,24 @@ 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 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. 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)) + 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 } @@ -1465,7 +1512,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 99077d3f..42a656d2 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) @@ -589,6 +590,30 @@ func TestResolveClientCredentials(t *testing.T) { } } +// TestRequireSecureOAuthEndpoint_DoesNotEchoSecrets: the refusal names the +// 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://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") + 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"} @@ -2151,10 +2176,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 +2193,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 @@ -2285,3 +2311,56 @@ 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) +} + +// 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 + clientID string + want string + }{ + "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) { + 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 65a0955e..a7cfc9b3 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -57,87 +57,378 @@ 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 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 +command to run (the envelope's "notice").`, 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 + // 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 } - return app.OK(result, output.WithSummary("Not authenticated")) + verdict = v } - - // Get stored credentials info - store := app.Auth.GetStore() - creds, err := store.Load(credKey) + report, err := authStatusReport(app) if err != nil { return err } + 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") + } - // Suppress scope for Launchpad (scopes are not supported) - effectiveScope := creds.Scope - if creds.OAuthType == "launchpad" { - effectiveScope = "" + if !humanOutput(app) { + opts := []output.ResponseOption{output.WithSummary(report.summary)} + if report.hint != "" { + opts = append(opts, output.WithNotice(report.hint)) + } + return app.OK(report.data, opts...) } - source := "oauth" - if creds.Source != "" { - source = creds.Source + // 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, app.Flags.Styled, tui.ResolveTheme(tui.DetectDark())) + headline := r.Summary + if report.data["authenticated"] == true { + headline = r.Success } - status := map[string]any{ - "authenticated": true, - "source": source, - "oauth_type": creds.OAuthType, + lines := []string{headline.Render(richtext.SanitizeSingleLine(report.summary))} + for _, line := range report.details { + lines = append(lines, r.Muted.Render(" "+richtext.SanitizeSingleLine(line))) } - if effectiveScope != "" { - status["scope"] = effectiveScope + if report.hint != "" { + lines = append(lines, r.Data.Render(" "+richtext.SanitizeSingleLine(report.hint))) } - if app.Config.ActiveProfile != "" { - status["profile"] = app.Config.ActiveProfile + if app.Flags.Stats && !app.Flags.NoStats && app.Collector != nil { + stats := app.Collector.Summary() + if parts := stats.FormatParts(); len(parts) > 0 { + lines = append(lines, "", r.Muted.Render(strings.Join(parts, " · "))) + } } - - if creds.UserID != "" { - status["user_id"] = creds.UserID + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } } + return nil + }, + } - // 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 - } + 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 +} + +// checkVerdict is the server's answer to --check: whether it accepted the +// 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 + // 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 +// auth-class failure — the server refused the token, or the credential could +// 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) { + endpoint, err := app.Auth.AuthorizationEndpoint(ctx) + 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. + 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 + } + // 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 + case output.AsError(err).Code == output.CodeAuth: + return &checkVerdict{valid: false, sent: true}, nil + default: + return nil, convertSDKError(err) + } +} - summary := "Authenticated" - if effectiveScope != "" { - summary += fmt.Sprintf(" (scope: %s)", effectiveScope) +// 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 + } + // 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 { + s.details = append(s.details, "Token: rejected by the server") + } else { + s.details = append(s.details, "Token: none could be sent ("+v.reason+")") + } + switch { + case os.Getenv("BASECAMP_TOKEN") != "": + s.hint = envTokenRejectedHint + case v.remedy != "": + s.hint = v.remedy + default: + s.hint = app.Auth.LoginHint() + } +} - return app.OK(status, output.WithSummary(summary)) - }, +// 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 { + return app.Output.EffectiveFormat() == output.FormatStyled +} + +// 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 + 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 +// 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.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 + } + + 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" + } + // 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. + refusal := app.Auth.RefreshRefusal(creds) + refreshable := refusal == nil + report.storage = storage + + 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 + } + + // 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 richtext.SanitizeSingleLine(creds.UserEmail) != "" { + report.summary += " as " + creds.UserEmail + } + 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) + // 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"] = expired + 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 = remedyFor(app, refusal) + default: + expiry = "expired" + report.hint = app.Auth.LoginHint() + } + } + report.details = append(report.details, report.tokenLine(expiry)) + + 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 00000000..51d6d9c4 --- /dev/null +++ b/internal/commands/auth_status_test.go @@ -0,0 +1,711 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "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"` + Notice string `json:"notice"` + Breadcrumbs []struct { + Action string `json:"action"` + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` +} + +// runAuthStatus executes `auth status` on app and returns the parsed JSON +// 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 = false + 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.Notice, "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.Notice, "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"]) + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) + + 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) + 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) { + 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, 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") +} + +// 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", + 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"+ + " 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"}) + 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.Notice) +} + +// 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"]) + assert.Equal(t, "Run: basecamp auth login -P bot", envelope.Notice) +} + +// 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") +} + +// 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.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") +} + +// 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") +} + +// 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") +} + +// 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") +} + +// 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, "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") +} + +// 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") +} + +// 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 +// 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 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 + clientID string + reason string + hint string + }{ + "without a refresh token": { + creds: auth.Credentials{OAuthType: "bc5", TokenEndpoint: "https://3.basecamp.com/oauth/tokens"}, + reason: "No refresh token available", + }, + "legacy bc3": { + creds: auth.Credentials{OAuthType: "bc3", RefreshToken: "ref", TokenEndpoint: "https://example.com/token"}, + 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: "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: "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://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", + reason: "BASECAMP_OAUTH_CLIENT_SECRET is required when BASECAMP_OAUTH_CLIENT_ID is set", + hint: auth.ClientEnvHint, + }, + } { + 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" + 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"]) + 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) + 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") + } + }) + } +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 1a3485ae..20a68829 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 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)