Skip to content

Store tokens and client secrets as digests (D1 of 3: read by digest, plaintext fallback) - #3192

Merged
jeremy merged 31 commits into
mainfrom
oauth-token-digests
Oct 27, 2026
Merged

jeremy merged 31 commits into
mainfrom
oauth-token-digests

Conversation

@jeremy

@jeremy jeremy commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Reviewing note (2026-10-24): main was merged into this branch (da3f593f6, then Ruby 4 test fixes 52d49bc5a and two redaction commits 0fb6c23a0, 0cfe38111), so until the PRs below it land, the diff against oauth-delegate-resources also shows main's changes. To review this PR's own work, read the first-parent commits after 6fd18cd05 (git log --first-parent 6fd18cd05..oauth-token-digests).

Warning

This PR contains migrations. Don't merge it directly. It has to go through the beta integration branch (oauth-integration-2026-10-15) and be migrated by hand on beta before it lands. beta1 runs against fizzy_production (saas/config/database.yml ends beta: *production), so migrating on beta1 is migrating production. The rows to add to that branch's migration inventory are below; this PR doesn't push to the integration branch.

Before beta boots this code, OAUTH_TOKEN_PEPPER must exist in 1Password (account 23QPQDKZC5BKBIIG7UGT5GR5RM, vault Deploy, item Fizzy) as two fields: Production/OAUTH_TOKEN_PEPPER (beta's secrets read this one too) and Staging/OAUTH_TOKEN_PEPPER. A SaaS deploy without it fails kamal secrets fetch, and the app refuses to boot. See Deploy prerequisites.

Stacked on #3169 (train: #2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159 → #3163 → #3169 → this).

Implements step D1 of hashing tokens at rest. The plan has three deploys, so no token stops working at any point:

Step What it does Status
D1 (this PR) Add digest columns with unique indexes, then backfill. Read by digest, falling back to plaintext for personal access tokens and digesting them as it finds them. Personal access tokens are still written in plaintext too, for production main. OAuth credentials are digest-only. this PR
D2 Rerun the backfill. Stop writing plaintext and stop the fallback. Add token to ignored_columns. after D1 is deployed to production
D3 Drop identity_access_tokens.token. after D2 is deployed everywhere

What D1 does

TokenDigester (app/models/token_digester.rb)

  • Stores OpenSSL::HMAC.hexdigest("SHA256", pepper, raw): lowercase hex, byte-for-byte what bc3's Oauth::TokenDigester#digest computes. A test pins it to the published HMAC-SHA256 vector.
  • Version prefix for rotating the pepper later. A digest can be stored as "<n>$<hex>", and a bare digest is version 1. Lookups go through TokenDigester.digests(raw), one digest per pepper version in service, so a future v2 can be added without a flag day. Only v1 exists today.
  • Pepper:
    • SaaS reads OAUTH_TOKEN_PEPPER, named to mirror bc3's credentials.oauth.token_pepper (personal access tokens are digested under it too; in Fizzy they're OAuth grants of a first-party client after Issue personal access tokens from a first-party client, and backfill existing ones onto it #3205). Fizzy keeps no encrypted credentials file, so every SaaS secret, SECRET_KEY_BASE included, comes from the environment through .kamal/secrets; reading credentials first would add a second source for one value, and self-hosted installs need the variable either way. Outside development and test it's required, and must be at least 32 characters. The initializer checks at boot, so a deploy missing it fails before it serves anything. There's no fallback to a derived key, which would digest with a different key than every stored token, as bc3's verify_pepper! also refuses to. Asset precompilation (SECRET_KEY_BASE_DUMMY) skips the check.
    • OSS uses OAUTH_TOKEN_PEPPER when it's set. Otherwise it uses Rails.application.key_generator.generate_key("token_digest_pepper", 32), a key derived from SECRET_KEY_BASE. docs/docker-deployment.md now says so: on a self-hosted install, rotating SECRET_KEY_BASE invalidates every personal access token and connected app unless OAUTH_TOKEN_PEPPER is set separately.

identity_access_tokens

  • Schema. New columns token_digest and refresh_token_digest (string, limit 80), each with a unique index. The indexes are created in the schema migration, before any digest is written, so a duplicate fails loudly and no lookup ever scans the table. Before this, token had no index, and every Bearer request scanned all ~3,500 rows.
  • Backfill (BackfillTokenDigests).
    • It runs in Ruby, because MySQL and SQLite have no HMAC: batches of 500, with disable_ddl_transaction!.
    • Each row is written with update_all, so updated_at, the grant idle clock from Lapse OAuth grants left idle for 90 days #3161, doesn't move.
    • Duplicate plaintext tokens are refused before anything is written. The error names the row ids and never the tokens.
    • It's rerunnable: it touches only rows without a digest, skips columns that are already gone, and its down does nothing. db:migrate:redo:primary VERSION=20261015120100 runs it again.
  • Writes.
    • Personal access tokens get both token and token_digest, because production main still reads token while beta runs this.
    • OAuth access tokens get the digest only, and token stays NULL.
    • A refresh writes new digests and clears any plaintext.
    • Raw values exist only in memory, as virtual attributes (raw_token, raw_refresh_token), on the record that issued or rotated them.
  • Reads. find_by_raw_token looks up the digest first. If that misses, it falls back to personal.where(token_digest: nil).find_by(token: raw) and heals the row it finds: the digest goes to the primary, even when the lookup ran on the replica as GET requests do, via update_columns, so updated_at doesn't move.
    • Review caught the replica case. A test that runs the lookup under with_reading_role failed on MySQL with ActiveRecord::ReadOnlyError before the fix.
    • Prefixed tokens fall back too: Prefix newly issued access tokens, refresh tokens and personal access tokens #3163 issues fizzy_pat_ tokens without digests on beta today. A plaintext match is confirmed byte for byte, since MySQL compares token case-insensitively, so a differently cased token neither authenticates nor installs a wrong digest.
    • The fallback stays inside the caller's scope, so oauth.find_by_raw_token never matches a personal token.

OAuth-only plaintext columns: replaced now

These have never held production data, since the OAuth tables and columns have never served production traffic, and OAuth has never shipped on main, so no self-hosted install has them either. So they're replaced in this step rather than kept for two more deploys. That holds only while this PR ships in the same release as the rest of the OAuth stack (#2296 onward).

  • identity_access_tokens.refresh_token → refresh_token_digest. The plaintext column itself stays until Drop the plaintext personal access token column (D3 of 3: drop identity_access_tokens.token) #3203 (D3) drops it beside token: Identity::AccessToken ignores it, and the backfill empties it. Production main caches that table's columns and names every one on INSERT, so dropping it under main would fail its personal access token inserts until a restart (see the runbook notes).
  • oauth_retired_refresh_tokens is rebuilt keyed by digest: refresh_token_digest, successor_refresh_token_digest, and an encrypted successor_payload. Retired tokens exist only to recognize a replay, so they're dropped rather than converted. A replay of a token rotated before the migration is answered as an unknown token.
  • oauth_clients.client_secret → client_secret_digest.

Refresh replay grace with digests (Oauth::RetiredRefreshToken). A retry within 60s used to answer from the grant's plaintext tokens. Now each rotation stores its successor pair in the retired row, the way bc3's rotate_to! stores successor_raw_token:

  • The pair is encrypted with a MessageEncryptor key derived from secret_key_base (salt oauth_refresh_token_successor, as in bc3).
  • It's sealed with expires_in: GRACE, so it can't be decrypted after the window even if nothing blanks it.
  • It's blanked by Oauth::RetiredRefreshToken.forget_successors every 5 minutes (config/recurring.yml). The sweep looks back one day, so it stays an index range over recent rows rather than a scan of 90 days of retired tokens.
  • superseded? compares refresh_token_digest with successor_refresh_token_digest.
  • A retry whose pair is gone (blanked or undecryptable) inside the window is refused with invalid_grant "Refresh token no longer retryable", without revoking. That matches bc3's "Grace window token not available".

Client secrets.

  • The raw secret is returned only in the DCR creation response, as before, and it's now held only in memory (raw_client_secret).
  • It's verified by TokenDigester.verify, a constant-time compare of HMACs.
  • This deviates from bc3, which uses bcrypt (has_secure_password :secret). Fizzy only issues generated secrets, 257 bits of randomness since Prefix newly issued access tokens, refresh tokens and personal access tokens #3163 (about 140 before it), so a deliberately slow hash adds no guessing resistance that the entropy doesn't already give, and one keyed digest covers every credential. The deviation is recorded in docs/oauth/spec_conformance.md. That doc's "Secrets stored in plaintext … Deliberate" line is now Fixed, and a Token format row records the digests.

Personal access token: shown once

Creating a token used to redirect to /my/access_tokens/<signed id>, which showed the raw token to anyone who loaded it within 10 seconds. Now:

  • The token appears only in the create response itself: a turbo_stream that replaces the form, or an HTML 201 without Turbo.
  • That response, and the JSON one fizzy-cli uses, carries Cache-Control: no-store.
  • The show route and action are gone.
  • The JSON response shape is unchanged (id, token, description, permission, created_at).

Issuer without a trailing slash (Oauth::BaseController#oauth_issuer). The metadata issuer, the iss authorization response parameter, the Basic challenge's realm and the protected resource's authorization_servers were root_url, https://app.fizzy.do/. Clients compare issuers as exact strings (RFC 8414 §3.3), bc3's canonical_issuer_url is the bare origin, and fizzy-mcp-server trims the slash from the issuer it's configured with, so its startup check would have refused this metadata. They're now https://app.fizzy.do, still from the one method; the protected resource's own resource identifier is unchanged. A test checks authorization_servers is exactly the advertised issuer.

Deploy prerequisites

  1. Create OAUTH_TOKEN_PEPPER before anything running this code boots on beta, staging or production. Use at least 32 characters, e.g. bin/rails secret.
    • Production/OAUTH_TOKEN_PEPPER (the Production section of the Fizzy item in the Deploy vault): read by production and by every beta (saas/.kamal/secrets.beta, which secrets.beta1–4 link to, fetches Production/OAUTH_TOKEN_PEPPER). Beta and production must share it, because beta writes digests into production's database. Reading the one field from both, rather than copying it into the Beta section, makes sharing structural. Beta deployers already read this vault.
    • Staging/OAUTH_TOKEN_PEPPER: staging has its own database, so it gets its own pepper.
    • It must never change afterwards. Rotating it means adding a v2 pepper (see TokenDigester), not editing this one.
  2. Without it, kamal secrets fetch fails for that destination, and a container started without it refuses to boot (TokenDigester::MissingPepper).
  3. SECRET_KEY_BASE differs between beta and production (the Beta and Production sections). It only keys the 60-second successor payload, so a retry answered by the other deployment would get "no longer retryable" rather than revocation. They're different hosts, so in practice this never happens.

Migration inventory rows for the integration branch

For docs/oauth/integration-migrations.md on oauth-integration-2026-10-15. This PR doesn't change that branch. These rows extend its table, and its migrate:up/down lists, after #7:

# Version / file PR What it does Production table MySQL 8.4 DDL
8 20261015120000_add_token_digests.rb this change_table :identity_access_tokens, bulk: true: token_digest, refresh_token_digest (string 80), a unique index on each. add_column :oauth_clients, :client_secret_digest (string 80) identity_access_tokens: ~3,500 rows One ALTER, INPLACE LOCK=NONE. Every row starts NULL, so neither unique index can fail
9 20261015120100_backfill_token_digests.rb this Data only, no DDL. Refuses duplicate tokens first. Then one UPDATE … WHERE id = ? per row: token_digest for every token (OAuth rows also get token NULLed), refresh_token_digest (and refresh_token NULLed) and client_secret_digest where those columns still exist. updated_at doesn't move. disable_ddl_transaction!, batches of 500. down is a no-op identity_access_tokens Row locks only, ~3,500 single-row updates on the primary key, a few seconds
10 20261015120200_drop_plaintext_oauth_credentials.rb this Drop oauth_clients.client_secret. Drop and recreate oauth_retired_refresh_tokens keyed by digest, with successor_payload text. Leaves identity_access_tokens alone the empty oauth_* tables only, which main doesn't model DROP COLUMN is INSTANT. The oauth_* tables are empty in production

Notes for the runbook:

Verified on thelio against a dedicated MySQL 8.4 container (SaaS):

Tests

New:

  • test/models/token_digester_test.rb:
    • bc3 byte-compatibility against the HMAC-SHA256 vector.
    • Version prefix and multi-version lookup.
    • verify refuses an unknown version, a malformed prefix, blanks, nil and non-strings.
    • The pepper is read from OAUTH_TOKEN_PEPPER (the name the deploy configs and docs give; any other would quietly fall back to the derived key).
    • The pepper is required in SaaS beta, staging and production, and not in development, test or OSS. A short pepper is refused. The derived fallback is the key generator's.
  • test/models/identity/access_token_test.rb:
    • Personal tokens are stored digested plus plaintext. Grants and rotations store digests only.
    • Lookup is by digest, and an ordinary lookup issues no plaintext query.
    • Fallback and heal: an undigested personal token authenticates, gets its digest, keeps its updated_at, and the next lookup is digest-only.
    • The heal works from the replica.
    • Prefixed tokens skip the fallback, OAuth rows never fall back, and the fallback stays in scope.
    • Uniqueness of token_digest and refresh_token_digest.
  • test/migrations/backfill_token_digests_test.rb:
    • Personal tokens keep their plaintext, and grants' plaintext is cleared.
    • updated_at is untouched.
    • It's idempotent.
    • The backfilled token authenticates.
    • Duplicates are refused before any write, naming ids, not tokens.
  • test/models/oauth/retired_refresh_token_test.rb:
    • The retired row holds digests, and the successor pair only encrypted: no raw token appears in the payload.
    • The pair is readable at 59s and unreadable at 61s.
    • forget_successors blanks only past the window.
    • superseded? compares by digest.
    • A tampered payload reads as none.
  • OauthRefreshReplayTest: a retry inside the window whose pair is gone is refused without revoking. The existing Revoke the grant when a rotated refresh token is replayed, with a 60s retry grace #3160 tests (same pair at 59s, revoke at exactly 60s, superseded, lost race, and so on) pass unchanged in substance.
  • OauthFlowTest: a DCR-registered secret is stored only as its digest and authenticates with Basic. Oauth::ClientTest checks the digest on create and none for public clients.
  • My::AccessTokensControllerTest:
    • Reveal once: the HTML and turbo stream create responses carry the token and no-store.
    • The old show URL is 404, and the index doesn't contain the token.
    • JSON create carries no-store, and its token matches the stored digest.
  • Fixtures compute digests in ERB (TokenDigester.digest(...)).

Existing tests now use raw_token and raw_refresh_token for values held in memory, and compare digests where they used to reread plaintext. An assert_not exists?(token: …) would now pass vacuously, so each became exists?(token_digest: TokenDigester.digest(…)).

#3191 pins. Merged test/pin-shipped-client-auth into this branch locally (not pushed) and ran ShippedClientAuthenticationTest and the SaaS Admin::AuditsControllerTest: all pass, in OSS (8 tests) and SaaS (17 tests, auditor pins included). (The two pins the integration doc lists as failing are against #3158, which isn't below this PR.)

Suites (thelio, gated on exit status):

Suite Result
SaaS (MySQL 8.4) 2210 tests, 0 failures, 0 errors
OSS (SQLite) 2043 tests, 0 failures, 0 errors
OSS (MySQL 8.4, DATABASE_ADAPTER=mysql) 2043 tests, 0 failures, 0 errors

These ran at 239292edd. The head adds only the PR number to the conformance doc. System tests weren't run locally; CI runs them.

RuboCop, Brakeman and gitleaks are clean.

Not done here, on purpose

  • Client secrets have no fizzy_cs_ prefix yet. That's out of scope and still the follow-up Prefix newly issued access tokens, refresh tokens and personal access tokens #3163 names.
  • No data in the plaintext OAuth columns is carried over beyond the backfill: they have none in production.
  • Personal access token plaintext stays until D3. Production main reads only token until D1 is deployed there.
  • No new third party or personal-data flow. The pepper is a server-side secret in our existing 1Password vault.

jeremy added a commit that referenced this pull request Oct 15, 2026
@jeremy
jeremy marked this pull request as ready for review October 15, 2026 21:46
Copilot AI balanced review requested due to automatic review settings October 15, 2026 21:46
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 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-26T03:06:46.376459Z 4fa5587 Manual request
🔒 Security Review 🔄 Running since 2026-10-26T03:00:45.194248Z 4fa5587 New commits

Security findings

Advisory findings (1)

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

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.

🟡 Changes recommended

Token reveal caching and legacy-token digest healing need correction before approval.

2 open findings
What changed in this PR

Implements the first stage of digest-based credential storage in Fizzy’s OAuth stack while preserving compatibility with existing personal access tokens.

Changes:

  • Adds keyed digests, indexed lookups, legacy-token healing, and credential migrations.
  • Stores refresh-retry credentials encrypted and reveals new personal tokens only in creation responses.
  • Updates deployment secrets, documentation, and regression tests.

[!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
test/​models/​token_digester_test.rb Tests hashing, versions, and pepper requirements.
test/​models/​oauth/​retired_refresh_token_test.rb Tests encrypted successors and expiry.
test/​models/​oauth/​client_test.rb Verifies client-secret digests.
test/​models/​identity/​access_token_test.rb Tests digest storage, lookup, and healing.
test/​migrations/​backfill_token_digests_test.rb Tests backfill safety and repeatability.
test/​integration/​oauth_revocation_test.rb Updates revocation credential handling.
test/​integration/​oauth_refresh_replay_test.rb Tests digest-based replay handling.
test/​integration/​oauth_grant_reach_test.rb Uses in-memory bearer credentials.
test/​integration/​oauth_grant_idle_expiry_test.rb Updates idle-expiry assertions for digests.
test/​integration/​oauth_flow_test.rb Updates flows and tests secret authentication.
test/​integration/​oauth_delegate_resources_test.rb Updates delegate-resource credentials.
test/​integration/​oauth_client_secret_basic_test.rb Updates Basic authentication tests.
test/​integration/​oauth_availability_test.rb Updates availability tests for digests.
test/​fixtures/​oauth/​clients.yml Provides a client-secret digest.
test/​fixtures/​identity/​access_tokens.yml Adds personal-token digests.
test/​controllers/​my/​access_tokens_controller_test.rb Tests reveal responses and cache headers.
saas/​config/​deploy.staging.yml Exposes the staging pepper.
saas/​config/​deploy.production.yml Exposes the production pepper.
saas/​config/​deploy.beta.yml Exposes the beta pepper.
saas/​.kamal/​secrets.staging Fetches the staging pepper.
saas/​.kamal/​secrets.production Fetches the production pepper.
saas/​.kamal/​secrets.beta Shares production’s pepper with beta.
docs/​oauth/​spec_conformance.md Records digest-storage conformance decisions.
docs/​kamal-deployment.md Documents key-rotation consequences.
docs/​docker-deployment.md Explains independent pepper configuration.
db/​schema.rb Updates MySQL credential schema.
db/​schema_sqlite.rb Updates SQLite credential schema.
db/​migrate/​20261015120200_drop_plaintext_oauth_credentials.rb Replaces plaintext OAuth storage.
db/​migrate/​20261015120100_backfill_token_digests.rb Backfills existing credential digests.
db/​migrate/​20261015120000_add_token_digests.rb Adds digest columns and indexes.
config/​routes.rb Removes token-reveal GET routing.
config/​recurring.yml Schedules successor-payload cleanup.
config/​initializers/​token_digester.rb Validates the pepper at boot.
app/​views/​my/​access_tokens/​show.html.erb Removes the previous reveal page.
app/​views/​my/​access_tokens/​new.html.erb Adds the stream replacement target.
app/​views/​my/​access_tokens/​create.turbo_stream.erb Renders the token through a stream.
app/​views/​my/​access_tokens/​create.html.erb Adds the HTML creation response.
app/​views/​my/​access_tokens/​_reveal.html.erb Shares token-reveal markup.
app/​models/​token_digester.rb Implements versioned keyed digests.
app/​models/​oauth/​retired_refresh_token.rb Encrypts and expires retry credentials.
app/​models/​oauth/​client.rb Generates and verifies secret digests.
app/​models/​identity/​access_token.rb Implements digest lookup, healing, and rotation.
app/​controllers/​oauth/​tokens_controller.rb Uses digests and encrypted retry successors.
app/​controllers/​oauth/​revocations_controller.rb Finds revocable credentials by digest.
app/​controllers/​oauth/​clients_controller.rb Returns the in-memory registration secret.
app/​controllers/​my/​access_tokens_controller.rb Returns uncached, one-time reveal responses.

🧠 Review effort: Balanced


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

Comment thread app/views/my/access_tokens/_reveal.html.erb Outdated
Comment thread app/models/identity/access_token.rb

@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: 124ce8da02

ℹ️ 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 app/models/identity/access_token.rb Outdated
Comment thread db/migrate/20261015120100_backfill_token_digests.rb
Comment thread db/migrate/20261015120200_drop_plaintext_oauth_credentials.rb Outdated
@jeremy
jeremy requested a balanced review from Copilot October 15, 2026 22:05
@jeremy

jeremy commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

@codex review

@jeremy

jeremy commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

Review threads: 5 resolved (3 fixed, 2 declined with the reasoning in each thread).
Declined:

Head 86e7fd7: CI green. OSS SQLite, OSS MySQL and SaaS MySQL suites also pass locally. The two new fallback tests failed on MySQL against the previous model.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 86e7fd7ffd

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

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.

🔵 Needs a closer look

Shared-production migrations and mandatory pepper provisioning require human verification of deployment prerequisites and sequencing.

0 open findings

2 resolved since last review

🧠 Review effort: Balanced

jeremy added a commit that referenced this pull request Oct 17, 2026
@jeremy
jeremy force-pushed the oauth-token-digests branch from 86e7fd7 to 5cf35e0 Compare October 17, 2026 07:13
@jeremy
jeremy force-pushed the oauth-delegate-resources branch from ec809fe to 2fd0056 Compare October 17, 2026 07:13
@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Restacked onto #3169's 2fd0056 (the train is rebased on main). Conflicts with #3163's new fizzy_cs_ prefix and 44-character bodies were resolved in favor of both: generate_client_secret draws Identity::AccessToken.generate_token(:client_secret) into raw_client_secret and digests it, and the tests pin raw_* values at 44 characters. One new commit corrects the ledger's client-secret entropy to 257 bits. The PR body's "about 140 bits" now describes only secrets issued before #3163. Head 5cf35e0.

@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: 5cf35e0557

ℹ️ 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 docs/kamal-deployment.md Outdated
Comment thread app/views/my/access_tokens/_reveal.html.erb

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.

🔵 Needs a closer look

Destructive credential migrations against the shared beta/production database require human confirmation of pepper provisioning and migration sequencing.

No open issues 🎉

Review effort: Balanced

@jeremy

jeremy commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 0fb6c23a01

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@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: 0fb6c23a01

ℹ️ 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 app/views/pwa/service_worker.js.erb
Comment thread app/javascript/controllers/extras_controller.js
@jeremy

jeremy commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

Restack onto main for the crass 1.0.8 bump (#3226): merged main as da3f593, then 52d49bc and 0fb6c23 bring main's new tests and filters up to this branch.

CI is green on 0fb6c23. Held, not merged.

successor_payload is encrypted at rest but decrypts to a live successor token pair, and logs, exports and error reports are what the redaction guards. oauth_retired_refresh_tokens joins TABLES, its digest columns' not-secret entries go, and a test pins the payload and both digests out of the logged insert, lookup and blanking update.
@jeremy

jeremy commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 0cfe381119

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

@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 Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 0cfe381119

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread saas/app/models/identity/reviewer_sign_in.rb Outdated
… stopping partway

* origin/oauth-delegate-resources:
  Make this branch's migrations safe to run again after stopping partway
  Make the OAuth migrations safe to run again after stopping partway
MySQL commits each DDL statement on its own, so a migration of several statements that stops partway leaves the first ones applied and its version unrecorded, and running it again fails on the first one. Each step now skips itself when it's already done (if_not_exists, if_exists). The DDL they emit is unchanged, except where noted in the migration.
@jeremy

jeremy commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 9fd6aac557

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

* origin/oauth-delegate-resources:
  Answer 404 from a dark endpoint however many times it's asked

# Conflicts:
#	app/controllers/oauth/clients_controller.rb
#	app/controllers/oauth/tokens_controller.rb
@jeremy

jeremy commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 56ceee4daf

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

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 56ceee4daf

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

* tp/oauth-delegate-resources:
  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)

# Conflicts:
#	config/application.rb
#	config/routes.rb
#	saas/.kamal/secrets.beta
#	saas/.kamal/secrets.production
#	saas/.kamal/secrets.staging
#	test/models/account/data_transfer/record_set_test.rb
* tp/oauth-delegate-resources:
  Bring main's tests up to this branch: Ruby 4 and the access token finder

# Conflicts:
#	test/lib/rails_ext/active_record_sql_redaction_test.rb
* tp/oauth-delegate-resources:
  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
@jeremy

jeremy commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 4fa5587fbf

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@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: 4fa5587fbf

ℹ️ 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 app/controllers/oauth/base_controller.rb
* tp/oauth-delegate-resources:
  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

# Conflicts:
#	lib/rails_ext/active_record_sql_redaction.rb
* tp/oauth-delegate-resources:
  Redact retired refresh tokens from logged SQL

# Conflicts:
#	lib/rails_ext/active_record_sql_redaction.rb
* tp/oauth-delegate-resources:
  Filter the code parameter by its whole name, so code_challenge stays readable
* tp/oauth-delegate-resources:
  Read a replayed refresh token's grant past the query cache
Base automatically changed from oauth-delegate-resources to main October 27, 2026 06:28
@jeremy
jeremy merged commit a3c0575 into main Oct 27, 2026
17 of 18 checks passed
@jeremy
jeremy deleted the oauth-token-digests branch October 27, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants