Skip to content

Commit 9df866f

Browse files
committed
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.
1 parent 25da4db commit 9df866f

4 files changed

Lines changed: 203 additions & 42 deletions

File tree

‎internal/auth/auth.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,7 +1320,9 @@ func (m *Manager) AuthorizationEndpoint(ctx context.Context) (string, error) {
13201320
// BASECAMP_TOKEN wins — match AccessToken() precedence (auth.go line 75).
13211321
if envToken := os.Getenv("BASECAMP_TOKEN"); envToken != "" {
13221322
if strings.HasPrefix(envToken, bc3TokenPrefix) {
1323-
return config.NormalizeBaseURL(m.cfg.BaseURL) + "/authorization.json", nil
1323+
// The same origin-level document a stored BC5 credential asks
1324+
// for: a pathful base URL must not turn it into /api/v1/...
1325+
return m.AuthorizationEndpointFor(oauthTypeBC5)
13241326
}
13251327
lpURL, err := m.launchpadURL()
13261328
if err != nil {

‎internal/auth/auth_test.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2086,3 +2086,17 @@ func TestRefresh_InvalidGrantOnLaunchpadKeepsTheCredential(t *testing.T) {
20862086
require.NoError(t, loadErr, "a Launchpad refusal is not proof the grant is dead")
20872087
assert.Equal(t, "old-ref", creds.RefreshToken)
20882088
}
2089+
2090+
// TestAuthorizationEndpoint_EnvBC3TokenUsesTheOrigin: a bc_at_ environment
2091+
// token asks the same origin-level /authorization.json a stored BC5
2092+
// credential does, even when the base URL carries a path.
2093+
func TestAuthorizationEndpoint_EnvBC3TokenUsesTheOrigin(t *testing.T) {
2094+
t.Setenv("BASECAMP_TOKEN", "bc_at_env")
2095+
cfg := config.Default()
2096+
cfg.BaseURL = "https://3.basecampapi.com/api/v1"
2097+
m := &Manager{cfg: cfg, store: newTestStore(t, t.TempDir())}
2098+
2099+
endpoint, err := m.AuthorizationEndpoint(context.Background())
2100+
require.NoError(t, err)
2101+
assert.Equal(t, "https://3.basecampapi.com/authorization.json", endpoint)
2102+
}

‎internal/commands/auth.go‎

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -75,38 +75,55 @@ command to run.`,
7575
return fmt.Errorf("app not initialized")
7676
}
7777

78+
// The server check goes first: the request may refresh the stored
79+
// credential, or forget a dead one, and the report must describe
80+
// what is stored afterwards.
81+
var verdict *checkVerdict
82+
if check && (os.Getenv("BASECAMP_TOKEN") != "" || app.Auth.IsAuthenticated()) {
83+
v, err := checkWithServer(cmd.Context(), app)
84+
if err != nil {
85+
return err
86+
}
87+
verdict = v
88+
}
7889
report, err := authStatusReport(app)
7990
if err != nil {
8091
return err
8192
}
82-
if check && report.data["authenticated"] == true {
83-
if err := report.checkWithServer(cmd.Context(), app); err != nil {
84-
return err
85-
}
93+
if verdict != nil {
94+
report.record(app, verdict)
8695
}
8796

8897
if !humanOutput(app) {
8998
opts := []output.ResponseOption{output.WithSummary(report.summary)}
9099
if report.hint != "" {
91-
opts = append(opts, output.WithBreadcrumbs(output.Breadcrumb{
92-
Action: "login", Cmd: app.Auth.LoginCommand(), Description: "Log in to Basecamp",
93-
}))
100+
opts = append(opts, output.WithNotice(report.hint))
94101
}
95102
return app.OK(report.data, opts...)
96103
}
97104

105+
// A direct terminal sink: every line carries config and stored
106+
// values, so each is reduced to one terminal-safe line first, as
107+
// the envelope renderer would.
98108
w := cmd.OutOrStdout()
99-
r := output.NewRendererWithTheme(w, false, tui.ResolveTheme(tui.DetectDark()))
109+
r := output.NewRendererWithTheme(w, app.Flags.Styled, tui.ResolveTheme(tui.DetectDark()))
100110
headline := r.Summary
101111
if report.data["authenticated"] == true {
102112
headline = r.Success
103113
}
104-
fmt.Fprintln(w, headline.Render(report.summary))
114+
fmt.Fprintln(w, headline.Render(richtext.SanitizeSingleLine(report.summary)))
105115
for _, line := range report.details {
106-
fmt.Fprintln(w, r.Muted.Render(" "+line))
116+
fmt.Fprintln(w, r.Muted.Render(" "+richtext.SanitizeSingleLine(line)))
107117
}
108118
if report.hint != "" {
109-
fmt.Fprintln(w, r.Data.Render(" "+report.hint))
119+
fmt.Fprintln(w, r.Data.Render(" "+richtext.SanitizeSingleLine(report.hint)))
120+
}
121+
if app.Flags.Stats && !app.Flags.NoStats && app.Collector != nil {
122+
stats := app.Collector.Summary()
123+
if parts := stats.FormatParts(); len(parts) > 0 {
124+
fmt.Fprintln(w)
125+
fmt.Fprintln(w, r.Muted.Render(strings.Join(parts, " · ")))
126+
}
110127
}
111128
return nil
112129
},
@@ -117,29 +134,51 @@ command to run.`,
117134
return cmd
118135
}
119136

120-
// checkWithServer makes the one authenticated request --check promises and
121-
// records the verdict. An auth-class failure — the server refused the token,
122-
// or the credential could not be refreshed — is the "rejected" answer, not
123-
// an error: that is what the caller asked. Anything else (the server could
124-
// not be reached, a fault) is returned as itself, since no verdict was had.
125-
func (s *authStatus) checkWithServer(ctx context.Context, app *appctx.App) error {
137+
// checkVerdict is the server's answer to --check: whether it accepted the
138+
// token the CLI would send right now.
139+
type checkVerdict struct {
140+
valid bool
141+
}
142+
143+
// checkWithServer makes the one authenticated request --check promises. An
144+
// auth-class failure — the server refused the token, or the credential could
145+
// not be refreshed — is the "rejected" verdict, not an error: that is what
146+
// the caller asked. Anything else (the server could not be reached, a fault)
147+
// is returned as itself, since no verdict was had.
148+
func checkWithServer(ctx context.Context, app *appctx.App) (*checkVerdict, error) {
126149
endpoint, err := app.Auth.AuthorizationEndpoint(ctx)
127150
if err != nil {
128-
return err
151+
return nil, err
129152
}
130153
_, err = app.SDK.Authorization().GetInfo(ctx, &basecamp.GetInfoOptions{Endpoint: endpoint, FilterProduct: "bc3"})
131154
switch {
132155
case err == nil:
133-
s.data["valid"] = true
134-
s.details = append(s.details, "Token: valid (checked just now)")
156+
return &checkVerdict{valid: true}, nil
135157
case output.AsError(err).Code == output.CodeAuth:
136-
s.data["valid"] = false
137-
s.details = append(s.details, "Token: rejected by the server")
138-
s.hint = app.Auth.LoginHint()
158+
return &checkVerdict{valid: false}, nil
139159
default:
140-
return convertSDKError(err)
160+
return nil, convertSDKError(err)
161+
}
162+
}
163+
164+
// envTokenRejectedHint is the remedy when the server refuses BASECAMP_TOKEN:
165+
// every request sends the environment token ahead of any stored login, so
166+
// logging in would change nothing.
167+
const envTokenRejectedHint = "BASECAMP_TOKEN is set and the server rejected it; unset it, or export a token the server accepts"
168+
169+
// record adds the server's verdict to the report.
170+
func (s *authStatus) record(app *appctx.App, v *checkVerdict) {
171+
s.data["valid"] = v.valid
172+
if v.valid {
173+
s.details = append(s.details, "Token: valid (checked just now)")
174+
return
175+
}
176+
s.details = append(s.details, "Token: rejected by the server")
177+
if os.Getenv("BASECAMP_TOKEN") != "" {
178+
s.hint = envTokenRejectedHint
179+
} else {
180+
s.hint = app.Auth.LoginHint()
141181
}
142-
return nil
143182
}
144183

145184
// humanOutput reports whether the command's output is read by a person: the
@@ -188,6 +227,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) {
188227
report.data["authenticated"] = true
189228
report.data["source"] = "BASECAMP_TOKEN"
190229
report.data["storage"] = "env"
230+
report.data["refreshable"] = false
191231
report.summary = "Logged in to " + baseURL + " via BASECAMP_TOKEN"
192232
report.details = []string{strings.Join(append(where, "Source: BASECAMP_TOKEN", "Storage: env"), " · ")}
193233
return report, nil
@@ -258,16 +298,22 @@ func authStatusReport(app *appctx.App) (*authStatus, error) {
258298
if creds.ExpiresAt > 0 {
259299
expiresAt := time.Unix(creds.ExpiresAt, 0)
260300
expiresIn := time.Until(expiresAt)
301+
// A token with nothing to refresh with is refused inside the refresh
302+
// window, so from the CLI's side it is already expired there.
303+
expired := expiresIn < 0 || (!refreshable && expiresIn <= auth.RefreshWindow)
261304
report.data["expires_at"] = expiresAt.UTC().Format(time.RFC3339)
262305
report.data["expires_in"] = expiresIn.Round(time.Second).String()
263-
report.data["expired"] = expiresIn < 0
306+
report.data["expired"] = expired
264307
switch {
265-
case expiresIn >= 0 && refreshable:
308+
case !expired && refreshable:
266309
expiry = "expires in " + coarseDuration(expiresIn) + ", refreshes automatically"
267-
case expiresIn >= 0:
310+
case !expired:
268311
expiry = "expires in " + coarseDuration(expiresIn)
269312
case refreshable:
270313
expiry = "expired, will refresh on next use"
314+
case expiresIn >= 0:
315+
expiry = "expires in " + coarseDuration(expiresIn) + ", inside the " + coarseDuration(auth.RefreshWindow) + " the CLI keeps clear of expiry"
316+
report.hint = app.Auth.LoginHint()
271317
default:
272318
expiry = "expired"
273319
report.hint = app.Auth.LoginHint()

0 commit comments

Comments
 (0)