Skip to content

Hold a rate-limited refresh, explain a blocked device login, and read invalid_grant by its code - #854

Merged
jeremy merged 9 commits into
mainfrom
auth-refusal-holds
Oct 7, 2026
Merged

jeremy merged 9 commits into
mainfrom
auth-refusal-holds

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

What

  • A 429 on refresh is held on the login. When a refresh comes back 429, the CLI stores a hold on the login until the Retry-After, using the same hold an agent's mint already keeps. Every later process (another shell, the next run of a scheduled job) answers it locally, without a request. The hold:
    • is capped at MaxServerWait, like the agent's;
    • names the refresh token it was set for, so a login another process has rotated, or a fresh login, isn't held;
    • is cleared by a successful refresh;
    • shows up in auth status under renewal_refused.
  • A device login refused with 429 explains itself. Instead of device authorization failed with status 429, it says: "Basecamp is refusing sign-ins from this address for now; try again after Oct 6 4:40 PM PDT (in 2h)". The hint says that an old copy of a login somewhere else keeps the block going.
  • invalid_grant is read from the SDK's typed refusal (OAuthError, OAuthErrorDescription), not by matching "token error: invalid_grant" in the message. Deleting a stored login no longer depends on how an error is worded.
  • Rename only: MintHold is now RenewalHold (in its own commit, so it can be skipped in review). The stored key stays mint_hold, so old and new versions read each other's holds.

Why

A user locked themselves out. Two containers held copies of one login, and the second refresh revoked it. A scheduled job then retried every two minutes, and bc3's abuse tracker escalated to a 4-hour block on every OAuth endpoint for basecamp-cli from that address. That included device authorization, so they couldn't sign in again either, and all they saw was "status 429". 0.12.0 already forgets a login after its first invalid_grant. This covers the rest:

  • Don't resend into a block that answers every request with a 429.
  • When the way back in is blocked, say so, and say until when.

A request made while a block is active is answered 429 without being counted against the address. So the 30-minute cap costs one request per cap, it never escalates the block, and the CLI notices if the block lifts early.

Depends on

basecamp/basecamp-sdk#972, pinned here at db402d93 (its last code change; later commits there touch only tests) with make bump-sdk REF=db402d937b7482fd6894249613c713a1ca89edd6. That SDK PR is itself stacked on basecamp/basecamp-sdk#971, so the pin is an unreleased pseudo-version until both merge, the same way the pin was held during the event-feed work. The vendored MCP model was re-synced from that commit (provenance only, no model change).

Testing

New tests in internal/auth/refusal_test.go. Each failed before the change:

  • TestInvalidGrant_ReadsTheTypedCode: the typed code is detected, and text that only looks like it isn't.

  • TestRefresh_RateLimitIsHeldOnTheLogin: one request; then a held answer with the remaining wait and nothing sent; then a fresh request once the wait is over.

  • TestRefresh_RateLimitHoldIsCappedButSaysWhatTheServerAsked: a 4-hour Retry-After is held for 30 minutes, and the message gives the server's own deadline.

  • TestRefreshRefusal_ReportsTheHold and TestRefresh_SuccessClearsTheHold.

  • TestRefresh_HoldIsForTheRefusedToken: guard only. It passed before as well, because nothing was held then.

  • TestLoginDevice_RateLimitSaysUntilWhen and TestLoginDevice_RateLimitWithoutRetryAfter.

  • make check passes


Summary by cubic

Holds a rate-limited refresh on the stored login until its Retry-After, so every later process — another shell, the next run of a scheduled job — answers it locally instead of resending a doomed request. This stops a scheduled job with a rotated-away login from hammering Basecamp's abuse block, which can answer every OAuth request from an address for up to a day.

Also makes two related failures readable: a device login refused with a 429 now says "Basecamp is refusing sign-ins from this address for now; try again after …" instead of "device authorization failed with status 429", and invalid_grant is read off the SDK's typed refusal rather than matched in the error message's wording.

Changes

  • Held refreshes are capped at MaxServerWait, named to the refresh token they were set for, cleared by a successful refresh, and reported in auth status under renewal_refused.
  • A stored hold is answered only after the local checks and the token lane are built, so it never masks a credential that could not refresh anyway.
  • The SDK's typed refusal stays in a held refresh's cause, so its request id and OAuth error still reach the error envelope.
  • MintHold is renamed RenewalHold; the stored key stays mint_hold so old and new versions read each other's holds.
  • The SDK is pinned to an unreleased head, with the vendored MCP model and Nix vendor hash re-synced to it, so token-endpoint refusals are typed (basecamp/basecamp-sdk #972).

Written for commit 7d1cc71. Summary will update on new commits.

Review in cubic Turn on auto-fix

jeremy added 5 commits October 6, 2026 14:48
The hold was written for an agent's mint, and the next change has a
refresh keep one too: a 429 on refresh is held on the login exactly as a
429 on a mint is. The type, its status report, and the file are renamed
for what they now cover. The stored key stays "mint_hold", so a hold an
earlier version wrote is still read, and one this version writes is still
read by an earlier one.

No behavior changes here.
… read invalid_grant by its code

A refresh answered 429 was reported and forgotten, so the next command
sent it again. A scheduled job running every two minutes into Basecamp's
abuse block — which answers every OAuth request from the address for up
to a day once enough refreshes have failed — sent one doomed refresh per
run, for hours. The 429 is now held on the stored login until its
Retry-After, the same hold an agent's mint already keeps, so every later
process answers it locally. It is capped at MaxServerWait like the
agent's; a request inside the block is answered without being counted
against the address, so the cap costs one request and notices a block
lifted early. A hold names the refresh token it was given for, so a login
another process has rotated, or a fresh one, is not held; a successful
refresh clears it, and `auth status` reports it.

A device login refused 429 said "device authorization failed with status
429". It now says Basecamp is refusing sign-ins from this address, until
when on the reader's clock, and that an old copy of a login elsewhere is
what keeps the block going.

invalid_grant is read off the SDK's typed refusal (OAuthError) instead of
the text of its message, so deleting a stored login no longer hangs on how
an error is worded.
Copilot AI balanced review requested due to automatic review settings October 6, 2026 22:15
@jeremy
jeremy requested a review from a team as a code owner October 6, 2026 22:15
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 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-10-06T22:51:27.794757Z 7d1cc71 Manual request
ℹ️ 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.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@github-actions github-actions Bot added commands CLI command implementations sdk SDK wrapper and provenance tests Tests (unit and e2e) auth OAuth authentication deps labels Oct 6, 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 was unable to review this pull request because the user who requested the review has reached their quota limit.

@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: f267ed4cb6

ℹ️ 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".

Comment thread internal/auth/renewal_hold.go

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 14 files

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread internal/auth/renewal_hold.go
Comment thread internal/auth/auth.go Outdated
Copilot AI balanced review requested due to automatic review settings October 6, 2026 22:24

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 22:25

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 22:30

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

@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: eaa48a2af2

ℹ️ 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".

Comment thread internal/auth/auth.go Outdated
Copilot AI balanced review requested due to automatic review settings October 6, 2026 22:47
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 7d1cc71a64

ℹ️ 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".

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Review summary at 7d1cc71a

  • Threads: none open. Every finding was fixed in-scope, with a test that failed before the fix:
    • A held refresh keeps the SDK refusal in its cause.
    • The hold no longer masks a local failure. That finding came up twice, the second time from the lane client, so I settled it with one rule: the hold is answered last, when only the request is left.
  • Codex: no major issues on 7d1cc71a.
  • Copilot: didn't review. Every request hit the requester's quota limit.
  • CI: green on 7d1cc71a, including the Nix flake build with the refreshed vendorHash.
  • make check: passes at 7d1cc71a.
  • SDK pin: an unreleased pseudo-version of Carry a token refusal's OAuth error and Retry-After, and type device authorization's 429 basecamp-sdk#972 at db402d93, which is stacked on Classify token-endpoint refusals by OAuth error code, not by 401 basecamp-sdk#971. Once those merge and an SDK release is cut, re-pin with make bump-sdk REF=<tag>, then re-sync the MCP model and refresh the vendorHash.

@jeremy
jeremy added this pull request to stack #859 October 7, 2026 03:50
@jeremy
jeremy merged commit 0c23248 into main Oct 7, 2026
40 of 41 checks passed
@jeremy
jeremy deleted the auth-refusal-holds branch October 7, 2026 03:50
jeremy added a commit that referenced this pull request Oct 7, 2026
#854 renamed MintHold and its helpers to RenewalHold after this branch
was cut, so the session mint and its tests named things that no longer
exist.
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 deps sdk SDK wrapper and provenance tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants