Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.10
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/basecamp-sdk/go v0.23.0
github.com/basecamp/basecamp-sdk/go v0.23.1-0.20261006222256-db402d937b74
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d
github.com/basecamp/surfguard/go v0.1.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg=
github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs=
github.com/basecamp/basecamp-sdk/go v0.23.0 h1:+QkXZjPKfTYcuEffQ+FlxdCWqs8RuA16v2nGDi9X3HQ=
github.com/basecamp/basecamp-sdk/go v0.23.0/go.mod h1:vdplxIcZMYSAIOvVm7AOIgLBUAPoJHESStTh9mF/ww4=
github.com/basecamp/basecamp-sdk/go v0.23.1-0.20261006222256-db402d937b74 h1:FEJli6E+/buVYRoFoFkE/r+ik6hHAyMmRT9zIavNypI=
github.com/basecamp/basecamp-sdk/go v0.23.1-0.20261006222256-db402d937b74/go.mod h1:vdplxIcZMYSAIOvVm7AOIgLBUAPoJHESStTh9mF/ww4=
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8=
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE=
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4=
Expand Down
20 changes: 10 additions & 10 deletions internal/auth/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import (
// and by every poll of anything automated that runs one. The token
// endpoint's refusals are therefore remembered with the credential and
// answered locally until the secret changes, or for as long as the server
// asked; see mintAgentCredential and agent_hold.go.
// asked; see mintAgentCredential and renewal_hold.go.
//
// The tokens come back bound to the agent with an RFC 8707 resource
// indicator of the form urn:bc:agent:<id>. Nothing here parses or requires
Expand Down Expand Up @@ -113,7 +113,7 @@ func (m *Manager) resolveAgentMint(creds *Credentials) (*agentMint, error) {
// A verdict the token endpoint already gave on these client
// credentials is answered here, before anything is resolved or sent —
// which is also what lets a report say the next command will not ask.
if err := m.heldMint(creds); err != nil {
if err := m.heldRenewal(creds); err != nil {
return nil, err
}

Expand Down Expand Up @@ -155,7 +155,7 @@ func (m *Manager) resolveAgentMint(creds *Credentials) (*agentMint, error) {
// reason), which the same secret may get past later.
//
// But a refusal is not forgotten either. The token endpoint's verdict is
// remembered on the stored credential (MintHold, agent_hold.go), and the
// remembered on the stored credential (RenewalHold, renewal_hold.go), and the
// next mint answers it locally, before any network I/O:
//
// - invalid_client refuses the secret, and no later state of the server
Expand Down Expand Up @@ -183,7 +183,7 @@ func (m *Manager) resolveAgentMint(creds *Credentials) (*agentMint, error) {
//
// The caller holds m.mu and the credential key's cross-process lock, which
// is what makes the hold's write safe against a concurrent login (see
// rememberMintHold).
// rememberRenewalHold).
func (m *Manager) mintAgentCredential(ctx context.Context, origin string, creds *Credentials) error {
mint, err := m.prepareAgentMint(creds)
if err != nil {
Expand All @@ -192,7 +192,7 @@ func (m *Manager) mintAgentCredential(ctx context.Context, origin string, creds
token, hold, err := m.mintAgentToken(ctx, mint)
if err != nil {
if hold != nil {
m.rememberMintHold(origin, hold)
m.rememberRenewalHold(origin, hold)
}
return err
}
Expand All @@ -211,7 +211,7 @@ func applyAgentToken(creds *Credentials, token *oauth.Token) {
creds.AccessToken = token.AccessToken
creds.RefreshToken = ""
// The client was just accepted, so whatever was held against it is over.
creds.MintHold = nil
creds.RenewalHold = nil
if token.Resource != "" {
creds.Resource = token.Resource
}
Expand Down Expand Up @@ -310,7 +310,7 @@ func agentTokenExpiry(token *oauth.Token) time.Time {
// body carries a client secret: a bounded response read, a refusal to treat
// a redirect as a hop, and RFC 6749 §5.2 error rendering in the shape the
// rest of this package already matches on ("token error: <code> - <text>").
func (m *Manager) mintAgentToken(ctx context.Context, mint *agentMint) (*oauth.Token, *MintHold, error) {
func (m *Manager) mintAgentToken(ctx context.Context, mint *agentMint) (*oauth.Token, *RenewalHold, error) {
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {mint.clientID},
Expand Down Expand Up @@ -460,7 +460,7 @@ var oauthErrorCodes = map[string]bool{
// It also returns the hold the refusal leaves, if it leaves one — the
// verdict the next mint will answer locally rather than ask for again (see
// mintHoldFor, and mintAgentCredential for what is held and for how long).
func (m *Manager) agentMintRefusal(resp *http.Response, body []byte, mint *agentMint) (*MintHold, error) {
func (m *Manager) agentMintRefusal(resp *http.Response, body []byte, mint *agentMint) (*RenewalHold, error) {
detail := fmt.Sprintf("the server answered HTTP %d", resp.StatusCode)
var errResp struct {
Error string `json:"error"`
Expand All @@ -485,7 +485,7 @@ func (m *Manager) agentMintRefusal(resp *http.Response, body []byte, mint *agent
}
now := m.now()
hold := mintHoldFor(mint, resp, detail, code, false, now)
if hold != nil && hold.Kind == mintHoldRateLimited {
if hold != nil && hold.Kind == renewalHoldRateLimited {
e := holdRateLimitError(hold, now, "minting an agent token: "+detail)
if named := retryAfterSeconds(resp.Header.Get("Retry-After"), now); named > holdWait(hold, now) {
// Held for the cap; what the server asked for is kept too,
Expand All @@ -512,7 +512,7 @@ func (m *Manager) agentMintRefusal(resp *http.Response, body []byte, mint *agent
if clientRefusalCodes[code] || bareUnauthorized {
refused := output.ErrAuth("Minting an agent token was refused (" + detail + ")")
refused.Cause = ErrAgentCredentialRefused
var hold *MintHold
var hold *RenewalHold
// Held only when the refusal named a known code or named nothing
// at all: a code this version does not know may mean something a
// held hour would get wrong, so it is asked again.
Expand Down
79 changes: 62 additions & 17 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"sync"
"time"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp/oauth"
"github.com/basecamp/cli/pkce"

Expand Down Expand Up @@ -104,7 +105,7 @@ type Manager struct {
Warnf func(format string, args ...any)

// clock is the time a remembered mint verdict is read and written
// against (agent_hold.go). Test seam; nil means time.Now.
// against (renewal_hold.go). Test seam; nil means time.Now.
clock func() time.Time

mu sync.Mutex
Expand Down Expand Up @@ -478,26 +479,23 @@ func (m *Manager) renewLocked(ctx context.Context, origin string, creds *Credent
return m.refreshLocked(ctx, origin, creds)
}

// invalidGrantPrefix is how the SDK's token exchanger renders an RFC 6749
// token-endpoint error: it returns the response as an untyped error, so the
// OAuth error code is recoverable only from the message. Coupled to
// basecamp-sdk oauth.Exchanger; a re-pin that types the error can replace
// the string match.
const invalidGrantPrefix = "token error: invalid_grant"
// localClockLayout is how a wait a person reads at a terminal is stated: the
// local wall clock, with its zone, so "until 4:40 PM PDT" needs no
// conversion.
const localClockLayout = "Jan 2 3:04 PM MST"

// invalidGrant reports whether a refresh was refused with invalid_grant —
// the refresh token is expired, revoked, or reused — and returns the
// server's error_description when it sent one.
// server's error_description when it sent one. Both are read off the SDK's
// typed refusal (basecamp.Error.OAuthError), never off the wording of its
// message: whether to delete a stored login is not a decision to hang on
// how an error happens to be phrased.
func invalidGrant(err error) (string, bool) {
rest, ok := strings.CutPrefix(err.Error(), invalidGrantPrefix)
switch {
case !ok:
return "", false
case rest == "":
return "", true
default:
return strings.CutPrefix(rest, " - ")
var refusal *basecamp.Error
if errors.As(err, &refusal) && refusal.OAuthError == "invalid_grant" {
return refusal.OAuthErrorDescription, true
}
return "", false
}

// forgetRefusedGrant deletes the stored credential only while it still
Expand Down Expand Up @@ -627,6 +625,16 @@ func (m *Manager) prepareRefresh(creds *Credentials) (oauth.RefreshRequest, *oau
if laneErr != nil {
return oauth.RefreshRequest{}, nil, laneErr
}

// A rate limit the token endpoint already put on this refresh token is
// answered last, once everything local has resolved and only the
// request is left — which is also what lets a report say the next
// command will not ask (renewal_hold.go). Nothing local can fail after
// it, so a stored hold never masks a credential that could not be
// refreshed anyway.
if err := m.heldRenewal(creds); err != nil {
return oauth.RefreshRequest{}, nil, err
}
exchanger := oauth.NewExchanger(laneClient)

req := oauth.RefreshRequest{
Expand All @@ -651,6 +659,9 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C

token, err := exchanger.Refresh(ctx, req)
if err != nil {
if held := m.holdRateLimitedRefresh(origin, creds, err); held != nil {
return held
}
desc, dead := invalidGrant(err)
if !dead {
return wrapOAuthError("token refresh failed", err)
Expand Down Expand Up @@ -703,10 +714,44 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C
// self-token sets its own, and leaving a stale one here would date the
// new expiry against the old token's lifetime.
creds.RenewAfter = 0
// The refresh was answered, so whatever was held against the old
// refresh token is over.
creds.RenewalHold = nil

return m.store.Save(origin, creds)
}

// deviceLoginRefusal is the error a device login refused with a 429 returns:
// what happened, and until when, on the reader's own clock. Basecamp's abuse
// block covers device authorization along with every other OAuth endpoint,
// so the way back in is closed by the same failures that locked the old
// login out — and "status 429" told a person neither that nor how long.
// Every other failure is returned as it came.
func (m *Manager) deviceLoginRefusal(err error) error {
var refusal *basecamp.Error
if !errors.As(err, &refusal) || refusal.HTTPStatus != http.StatusTooManyRequests {
return err
}
msg := "Basecamp is refusing sign-ins from this address for now"
if refusal.RetryAfter > 0 {
wait := time.Duration(refusal.RetryAfter) * time.Second
until := m.now().Add(wait).Local().Format(localClockLayout)
msg += fmt.Sprintf("; try again after %s (in %s)", until, expiresIn(wait))
} else {
msg += "; try again later"
}
e := output.ErrRateLimit(refusal.RetryAfter)
e.Message = msg
e.HTTPStatus = refusal.HTTPStatus
e.Hint = "Repeated failed sign-ins from this address cause this, and an old copy of a login causes them: " +
"another install, container or scheduled job still using a copied credentials.json. Stop it first, or the block starts again."
e.Cause = err
if refusal.RetryAfter > 0 {
e.Cause = errors.Join(retryAfterError(refusal.RetryAfter), err)
}
return e
}

// LoginResult holds the outcome of a successful login — Login()'s, or
// LoginClientCredentials()'s. Callers use this to determine the effective
// scope instead of their input.
Expand Down Expand Up @@ -1166,7 +1211,7 @@ func (m *Manager) loginDevice(ctx context.Context, credKey string, oauthCfg *oau
return nil, displayErr
}
if err != nil {
return nil, err
return nil, m.deviceLoginRefusal(err)
}

// The granted scope is whatever the server says it granted; fall back to
Expand Down
6 changes: 6 additions & 0 deletions internal/auth/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ type deviceAS struct {
metadata func() string
// deviceAuth renders the device-authorization response.
deviceAuth func() (status int, body string)
// deviceRetryAfter, when set, is the device-authorization response's
// Retry-After header.
deviceRetryAfter string
// token renders the nth (0-based) token poll response.
token func(call int) (status int, body string)
// revoke renders the nth (0-based) RFC 7009 revocation response.
Expand All @@ -58,6 +61,9 @@ func startDeviceAS(t *testing.T) *deviceAS {
as.mu.Unlock()
status, body := as.deviceAuth()
w.Header().Set("Content-Type", "application/json")
if as.deviceRetryAfter != "" {
w.Header().Set("Retry-After", as.deviceRetryAfter)
}
w.WriteHeader(status)
fmt.Fprint(w, body)
}
Expand Down
14 changes: 8 additions & 6 deletions internal/auth/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@ type Credentials struct {
// today except a minted agent self-token — means the default.
RenewAfter int64 `json:"renew_after,omitempty"`

// MintHold is the token endpoint's last refusal of an agent
// credential's client — a refused secret, or a rate limit — kept so
// the next mint answers it locally instead of asking again (see
// agent_hold.go). Nil for every other kind of credential, and for an
// agent whose last mint succeeded.
MintHold *MintHold `json:"mint_hold,omitempty"`
// RenewalHold is the token endpoint's last refusal of this
// credential's renewal, kept so the next renewal answers it locally
// instead of asking again (see renewal_hold.go): for an agent, a
// refused secret or a rate limit on its mint; for anyone else, a rate
// limit on the refresh. Nil once a renewal succeeds. Stored as
// "mint_hold", the name it had when only an agent's mint was held, so
// every version reads the others'.
RenewalHold *RenewalHold `json:"mint_hold,omitempty"`

// Issuer is the RFC 8414 issuer of the authorization server that minted
// a BC5 credential — where its metadata, and so its revocation endpoint,
Expand Down
Loading
Loading