Repository navigation
Prefix newly issued access tokens, refresh tokens and personal access tokens - #3163
Conversation
343886b to
ba7d5cb
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
ba7d5cb to
d1c4bde
Compare
afc9599 to
fc3f2da
Compare
d1c4bde to
6d21161
Compare
fc3f2da to
98e7112
Compare
6d21161 to
030f8cc
Compare
98e7112 to
9ec1432
Compare
a446e74 to
c4b4c50
Compare
|
New PR for Jeremy's decision (d), stacked on #3159.
Rebased with the train. CI is green. @codex review |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
c4b4c50 to
f6bd906
Compare
3684478 to
342dc11
Compare
f6bd906 to
eef7b5c
Compare
342dc11 to
3e41255
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eef7b5c132
ℹ️ 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".
3e41255 to
4c1d4d5
Compare
1d47144 to
f2d3acf
Compare
4c1d4d5 to
92776e5
Compare
… tokens New tokens start with a prefix that names their kind: - fizzy_pat_ for personal access tokens - fizzy_at_ for OAuth access tokens - fizzy_rt_ for OAuth refresh tokens A prefix lets a person, a log scrubber or a secret scanner recognize a Fizzy credential on sight. The random part is unchanged: 24 base58 characters from generate_unique_secure_token. Tokens issued before this have no prefix and still work, since every lookup matches the whole string. A grant issued before this gets prefixed tokens at its next rotation. Personal access tokens keep the token they were issued with. This follows Basecamp, which prefixes the tokens it issues (bc_at_, bc_rt_).
…se58 generate_unique_secure_token calls SecureRandom.base58, an ActiveSupport extension that only has_secure_token requires. In a lazily loaded process (the dev server, a console) where this was the first secure-token model touched, minting or rotating a token could raise NoMethodError. Ruby's own SecureRandom.alphanumeric with the same base58 alphabet keeps the format and drops the dependency.
Client secrets now carry fizzy_cs_, as bc3's carry bc_cs_, so secret scanning can recognize them too. Every newly issued token and secret has 44 base58 characters after its prefix, 257 bits, where 24 gave about 140: at least bc3's 32 bytes. Earlier credentials keep working, since every lookup matches the whole string.
7e22445 to
918f256
Compare
* commit '918f2566ddca90348a5eced35e8bb2da7155febf': Prefix client secrets, and draw every new credential from 256 bits Draw token bodies through Ruby's SecureRandom, not ActiveSupport's base58 Prefix newly issued access tokens, refresh tokens and personal access tokens
* commit '4cfbbd2ec21b0b4bd7d0e17c16bb8ba86cd4ea4e': Authenticate an undigested token outside the primary datacenter without healing it Send grants' raw tokens in the challenge pins, which sent none since digests Present the read-only grant pin's token from memory, as digests require Present and compare the bare-Basic pin's credentials as digests require Keep the token form page out of Turbo's cache, so Back fetches a fresh form Tell Kamal operators to list OAUTH_TOKEN_PEPPER in env.secret too Present the superseded-replay pin's refresh token from memory, as digests require Count a client secret's randomness as #3163 now draws it Fall back to plaintext for prefixed tokens too, and only on an exact match Keep a revealed token out of Turbo's snapshot cache Point the conformance record at #3192 Write a healed token's digest to the primary, even when it was found on the replica Store tokens and client secrets as digests, reading by digest first
* commit '09523ade5f33a3a6e2f933b061fbb18822d98524': Advertise the issuer without a trailing slash, as bc3 does Authenticate an undigested token outside the primary datacenter without healing it Send grants' raw tokens in the challenge pins, which sent none since digests Present the read-only grant pin's token from memory, as digests require Present and compare the bare-Basic pin's credentials as digests require Keep the token form page out of Turbo's cache, so Back fetches a fresh form Tell Kamal operators to list OAUTH_TOKEN_PEPPER in env.secret too Present the superseded-replay pin's refresh token from memory, as digests require Count a client secret's randomness as #3163 now draws it Fall back to plaintext for prefixed tokens too, and only on an exact match Keep a revealed token out of Turbo's snapshot cache Point the conformance record at #3192 Write a healed token's digest to the primary, even when it was found on the replica Store tokens and client secrets as digests, reading by digest first
* commit '617edb87b': Keep identity_access_tokens.refresh_token until it can go with token Advertise the issuer without a trailing slash, as bc3 does Authenticate an undigested token outside the primary datacenter without healing it Send grants' raw tokens in the challenge pins, which sent none since digests Present the read-only grant pin's token from memory, as digests require Present and compare the bare-Basic pin's credentials as digests require Keep the token form page out of Turbo's cache, so Back fetches a fresh form Tell Kamal operators to list OAUTH_TOKEN_PEPPER in env.secret too Present the superseded-replay pin's refresh token from memory, as digests require Count a client secret's randomness as #3163 now draws it Fall back to plaintext for prefixed tokens too, and only on an exact match Keep a revealed token out of Turbo's snapshot cache Point the conformance record at #3192 Write a healed token's digest to the primary, even when it was found on the replica Store tokens and client secrets as digests, reading by digest first
…ing partway * origin/oauth-client-sweep: Make the OAuth migrations safe to run again after stopping partway
* origin/oauth-client-sweep: Answer 404 from a dark endpoint however many times it's asked
* tp/oauth-client-sweep: (60 commits) Name v0.2.0 in the pin's comment Pin fizzy-mcp-server v0.2.0 Refuse a join code that was reset while it was being redeemed Filter presigned storage signatures from logs Keep the reviewer's memberships in reach, and print the member audit escaped Import an export that lacks a column this database has only just gained List the reviewer accounts' members when signing the reviewer out Bump thruster from 0.1.26 to 0.1.27 (#3199) Give the asset build a placeholder direct-upload credential Anchor the stubbed storage host pattern Sign direct uploads with a write-only storage credential Lock the reviewer's email address, so a code holder can't move its membership out of revocation's reach Use Ruby's GC defaults and log job memory (#3238) Pin the fizzy_mcp accessory to fizzy-mcp-server v0.1.0 by digest Scope webhook dispatch by account as well as board Test the reviewer audit against the structured log line production writes Revoke on the email alone, and record each use in the request's structured log Remove the reviewer's access tokens and passkeys on sign-out, so a code holder can't keep a way back in Wire the reviewer sign-in into production from Deploy/Fizzy Close the reviewer's live sockets on sign-out, and keep the identity id out of Sentry ...
* tp/oauth-client-sweep: Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-client-sweep: Pin the load balancers to kamal-proxy v0.10.2, and say why configure-lb-mcp.sh still checks Give the load balancer the real client IP
* tp/oauth-client-sweep: Bring main's SQL redaction tests up to bearer lookup by find_honored Redact OAuth client statements from logged SQL, now that a client has a secret
* tp/oauth-client-sweep: Redact retired refresh tokens from logged SQL
* tp/oauth-client-sweep: Filter the code parameter by its whole name, so code_challenge stays readable
* tp/oauth-client-sweep: Read a replayed refresh token's grant past the query cache
Stacked on #3159, the tip of the OAuth train (#2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159).
New tokens now start with a prefix that says what kind of token they are:
fizzy_pat_fizzy_at_fizzy_rt_With a prefix, a person, a log scrubber or a secret scanner can tell a Fizzy credential on sight, and which kind it is. The random part is 44 base58 characters (the alphabet
has_secure_tokenuses, drawn through Ruby'sSecureRandom.alphanumeric), 257 bits, at least bc3's 32 bytes (rubric TK-1). It was 24 characters, about 140 bits, until 2026-10-17; credentials issued earlier keep working, since lookups match the whole string.Existing tokens keep working. Tokens issued before this change have no prefix. Every lookup still matches the whole string: Bearer authentication, refresh, revocation and replay detection. A grant issued before this gets prefixed tokens the next time it rotates. A personal access token keeps the token it was issued with until someone revokes it.
How Basecamp and HEY do it
Basecamp (bc3) defines its prefixes in
Oauth::TokenGenerator::PREFIXES(app/models/oauth/token_generator.rb):bc_at_for access tokens, including personal access tokens, which are OAuth access tokens issued by thebc-personal-accessclientbc_rt_refresh tokens,bc_code_authorization codes,bc_dc_device codesbc_cs_client secrets, but only the ones issued by its rake task (agent client secrets are unprefixedbase58(43))bc_iat_initial access tokensThe random part is
SecureRandom.hex(32)with no checksum, and bc3 stores HMAC digests rather than the raw tokens. It routes requests by prefix:bc_at_goes to bc3's own OAuth tokens and anything else to the legacy Launchpad tokens.HEY (haystack) doesn't use prefixes. Its OAuth access and refresh tokens are signed
MessageVerifierpayloads, and its client uid and secret are plainhas_secure_tokenvalues.Secret scanning: neither app is registered with GitHub's secret-scanning partner program. Neither repo has an alert endpoint or a pattern file for one.
Fizzy uses a separate
fizzy_pat_prefix for personal access tokens, unlike bc3. That keeps the kind visible on sight, and it stays meaningful if personal access tokens are later rebuilt on OAuth (see #3164).GitHub secret-scanning partner registration: worth pursuing later, not now
Not in this PR
Client secrets carry
fizzy_cs_too (added 2026-10-17 for rubric TK-1, as bc3's rake-issued secrets carrybc_cs_), drawn by the same generator:Identity::AccessToken.generate_token(:client_secret).Tests
Identity::AccessTokenTest: each kind gets its prefix and the base58 body. A grant issued before prefixes rotates into prefixed tokens.OauthFlowTest: the code exchange returns prefixed tokens. An unprefixed grant still authenticates against the API and refreshes into prefixed tokens. An unprefixed personal access token still authenticates.OauthRevocationTest: unprefixed personal and OAuth tokens still revoke.