Skip to content

Remember the token endpoint's refusal of an agent's secret - #787

Open
jeremy wants to merge 1 commit into
mainfrom
remember-agent-mint-refusals
Open

jeremy wants to merge 1 commit into
mainfrom
remember-agent-mint-refusals

Conversation

@jeremy

@jeremy jeremy commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

What happened in production

An agent's secret was rotated on the server. After that, a process on the owner's machine kept sending grant_type=client_credentials with the old secret to POST /oauth/tokens, about six times a minute for more than twelve hours: roughly 350 requests an hour, all using a secret that could never work again. bc3 answered 401 invalid_client. That tripped its abuse tracker, which blocks the address for five minutes, so most of those requests got 429 with Retry-After. That works out to about 340 × 429 and 12 × 401 an hour.

Root cause

An agent credential renews by minting a new token with its client secret, and each process mints on its own. mintAgentCredential intentionally forgot every failed mint and left the stored credential "exactly as it was, so the next command tries again". agentMintRefusal already sorted refusals correctly, but nothing saved the result. So every CLI invocation minted again, and so did every poll of a connector that shells out to basecamp or runs basecamp connect. A 429's Retry-After was lost the same way.

The fix

The token endpoint's verdict is now saved on the stored credential as Credentials.MintHold (see internal/auth/agent_hold.go). The next mint checks it in resolveAgentMint and fails locally without making a request.

Response Held Why
invalid_client Until a login stores a different secret (never re-checked) bc3's Oauth::AgentClients.rotate! replaces the secret digest, disconnect! clears it, and quarantine (Oauth::Client::Disabling) can't be undone. No later server state makes that secret valid again, and each retry counts against the abuse tracker.
invalid_grant, or a 401/403 with no error code 1 hour, then one retry In handle_client_credentials_grant, bc3's invalid_grant means "Agent is no longer active", which reactivating the account undoes. bc3 always names its refusals, so an unnamed 401/403 came from something else (a proxy or WAF) and doesn't prove the secret is dead.
429 Until Retry-After (60s if absent, at most 30m since retryAfter caps what it parses) Treated as a retryable rate_limit that says when the hold ends.
5xx, network errors, anything else Not held Retried by the next command, as before.
  • The error the caller gets is the same one the refusal produced, with the same --with-client-credentials remedy. It adds that the refusal is remembered, and when it will be retried if it will be.
  • Scoped to the secret. Each hold stores a fingerprint of the client id and secret it applies to (a labelled, truncated SHA-256, never the secret itself). A credential holding a different secret ignores the hold. Logging in again with a new secret writes a fresh credential with no hold. A successful mint clears the hold.
  • Concurrency. The hold is written while holding m.mu and the credential key's cross-process lock, which logins also take. It is written against a fresh read, and only if that read still has the refused secret. On a host where locking isn't possible, a login that stored a new secret mid-refusal therefore can't be overwritten.
  • Fails open. A hold whose expiry is more than an hour away (from a clock that stepped back or a damaged record), or a hold kind this version doesn't know, is ignored. Ignoring it just means the mint is attempted, which is the old behavior.
  • Login isn't held. LoginClientCredentials and the connect handover don't record holds: nothing is stored yet, and a login is a single attempt, not a loop.
  • auth status. It already asks RefreshRefusal, which now reports the hold. An expired agent credential shows "expired, and the renewal would be refused: …" with the agent-login hint. The JSON has a new renewal_refused field.

basecamp connect (in-process)

I checked this but didn't change anything. The connector gets every token through managerTokens → Manager.AccessToken. The intake feed treats a failed token (the mint's ErrAuth) as unrecoverable, and runPart then cancels the whole run. So the connector already stops on its first refused mint. Before this change, each restart (systemd Restart=always, RestartSec=5, within its start limit) sent the dead secret again. Now a restart fails locally without a request.

Admission, outbox and membership only log token failures and back off; they now get a cheap local failure too. One gap remains: a feed sitting idle on its websocket may not notice for a while. Canceling the run from a wrapper around managerTokens would close that gap, but it isn't needed to stop the retry storm, so it's left out.

A related problem I found but didn't fix: the feed also treats a 429 or 5xx from the token endpoint as unrecoverable, so one throttled mint ends the connector run. The hold keeps the restart loop cheap, but the right fix belongs in the feed's error classification, in a separate change.

Tests

internal/auth/agent_hold_test.go, run against a local token endpoint that counts requests, with the Manager's clock as a test seam:

  • A 401 invalid_client means the next mints make zero requests: in the same process, 30 days later, and from a new Manager. The remembered error carries the agent remedy and never the secret.
  • A different secret mints normally, and the success clears the hold.
  • A 429 with Retry-After: 300 means no request at +299s and one at +300s. The success clears the hold. Also tested: no Retry-After, a very large value, the HTTP-date form, and 0.
  • 5xx responses and a proxy's bare 400 hold nothing and are retried.
  • invalid_grant and bare 401/403 get one retry per hour, a second refusal holds again, and a later success clears the hold.
  • A stale refusal is never written over a new secret.
  • A hold too far in the future, or of an unknown kind, is ignored.
  • auth status reports the remembered refusal and its remedy without calling the token endpoint.

I checked that the tests catch the bug they target: with the hold check disabled, the refusal, rate-limit and recheck tests fail, and with the fingerprint check removed, the stale-write test fails. make lint is clean and go test ./... passes on Linux.


Summary by cubic

Stops agent credentials from repeatedly presenting a secret the token endpoint has refused. Previously a rotated secret was sent again on every poll, tripping the abuse tracker; now the refusal is stored with the credential and answered locally.

Remembered refusals

  • invalid_client is held until a login stores a different secret.
  • invalid_grant and bare 401/403 are held an hour, then tried once.
  • 429 uses Retry-After (60s default, capped at an hour).
  • 5xx and network errors are not held and are retried as before.

Implementation notes

  • The hold is scoped to a fingerprint of client id and secret, so a new secret ignores the old hold.
  • A successful mint clears the hold; login is never held.
  • auth status reports the remembered refusal and its remedy.

Written for commit dec3e4d. Summary will update on new commits.

Review in cubic

An agent credential renews by minting with its client secret, and every
process mints for itself. Nothing remembered a refusal, so once a secret
was rotated server-side, anything that polled through the CLI presented
the dead secret on every poll, indefinitely: in production, one agent's
rotated secret was sent about 350 times an hour for more than twelve
hours, nearly all of it answered 429 by the abuse tracker the first 401s
had tripped.

The verdict is now kept on the stored credential (MintHold) and answered
locally, before any network I/O, for the client credentials it was about:

- invalid_client: held until a login stores a different secret. bc3's
  rotate! and disconnect! replace or clear the digest and a quarantine is
  one-way, so nothing makes that secret good again.
- invalid_grant (bc3's "Agent is no longer active") and a bare 401/403:
  held an hour, then tried once, so a reactivated account recovers on
  its own.
- 429: held until Retry-After (60s default, capped), answered as a
  retryable rate limit.
- 5xx, network: nothing held, retried as before.

A successful mint clears the hold, and the hold names a fingerprint of
the client id and secret, so a new secret is never held to the old one's
refusal. The write happens under the credential key's lock, against a
fresh read, and only while that read still holds the refused secret.

auth status reports the remembered refusal and when it lifts.
Copilot AI balanced review requested due to automatic review settings September 25, 2026 12:13
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-25T12:19:19.060891Z dec3e4d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) auth OAuth authentication labels Sep 25, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The unlocked fallback still permits a concurrent login to be overwritten, and malformed rate-limit holds can block renewal indefinitely.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity · 2 Low severity

Open (4)
What changed in this PR

Persists agent token-mint refusals to prevent repeated invalid credential requests and exposes renewal failures through auth status.

Changes:

  • Adds credential-scoped mint holds for refusals and rate limits.
  • Clears holds after successful renewal or credential replacement.
  • Adds auth-status reporting and tests for hold behavior.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
internal/​commands/​auth.go Reports agent renewal refusals.
internal/​commands/​auth_status_agent_test.go Tests auth-status refusal reporting.
internal/​auth/​keyring.go Persists mint holds with credentials.
internal/​auth/​auth.go Adds a testable clock.
internal/​auth/​agent.go Records and clears mint holds.
internal/​auth/​agent_test.go Updates refusal test setup.
internal/​auth/​agent_hold.go Implements hold creation, validation, and persistence.
internal/​auth/​agent_hold_test.go Tests refusal and rate-limit hold behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +209 to +213
if current.OAuthType != oauthTypeAgent || agentClientFingerprint(current.ClientID, current.ClientSecret) != hold.Client {
return
}
current.MintHold = hold
if err := m.store.Save(origin, current); err != nil {
Comment on lines +169 to +174
if hold.Until != 0 {
until := time.Unix(hold.Until, 0)
if !now.Before(until) || until.Sub(now) > maxAgentMintHold {
return nil
}
}
Comment thread internal/auth/agent.go
Comment on lines +163 to +164
// - 429 is held until its Retry-After (a minute when it gives none, at
// most an hour), and answered in the meantime as a rate limit.
Comment on lines +136 to +145
assert.Equal(t, 1, calls, "the report asked the token endpoint")
assert.Contains(t, buf.String(), "the refusal is remembered")
assert.Contains(t, buf.String(), "invalid_client")

var envelope struct {
Notice string `json:"notice"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope), buf.String())
assert.Contains(t, envelope.Notice, "--with-client-credentials")
assert.NotContains(t, buf.String(), "rotated-away")

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dec3e4d2d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

wait = defaultAgentRateLimitHold
}
hold.Kind = mintHoldRateLimited
hold.Until = now.Add(min(wait, maxAgentMintHold)).Unix()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the full Retry-After duration

For a delta-seconds header, converting now.Add(wait) directly to Unix seconds truncates the deadline. If a Retry-After: 300 response is processed at 12:00:00.999, the persisted hold expires at 12:05:00.000, so another mint can be sent almost one second before the server's requested delay has elapsed and potentially trigger another 429. Preserve subsecond precision or round the deadline up when storing it.

Useful? React with 👍 / 👎.

Comment on lines +169 to +172
if hold.Until != 0 {
until := time.Unix(hold.Until, 0)
if !now.Before(until) || until.Sub(now) > maxAgentMintHold {
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject rate-limit holds without a deadline

If a stored rate_limited hold is damaged or written without until, hold.Until == 0 skips this expiry check and the later rate-limit branch returns a local refusal based on the Unix epoch on every invocation. Such a credential can therefore never mint again without being replaced, contrary to the stated fail-open handling for damaged holds. Treat a zero deadline as invalid for rate_limited holds before returning an error.

Useful? React with 👍 / 👎.

Comment on lines +209 to +213
if current.OAuthType != oauthTypeAgent || agentClientFingerprint(current.ClientID, current.ClientSecret) != hold.Client {
return
}
current.MintHold = hold
if err := m.store.Save(origin, current); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the fingerprint check and hold write atomic

On a host where the per-credential lock cannot be created and execution deliberately falls through unlocked, a concurrent login can save a new secret after this fingerprint comparison but before Save. This function then writes the previously loaded credential back, restoring the refused secret and overwriting the newly minted token—the exact stale-write case the fresh read is intended to prevent. The comparison and update need to occur in one atomic store operation, or the write must be skipped when that guarantee is unavailable.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth OAuth authentication commands CLI command implementations tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants