Skip to content

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

Open
jeremy wants to merge 12 commits into
oauth-delegate-resourcesfrom
oauth-token-digests
Open

jeremy wants to merge 12 commits into
oauth-delegate-resourcesfrom
oauth-token-digests

Conversation

@jeremy

@jeremy jeremy commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

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, TOKEN_DIGEST_PEPPER must exist in 1Password, Deploy/Fizzy vault, on the Production item (beta's secrets read it from there too) and on the Staging item. 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 TOKEN_DIGEST_PEPPER. 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 TOKEN_DIGEST_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 TOKEN_DIGEST_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.
  • 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).

Deploy prerequisites

  1. Create TOKEN_DIGEST_PEPPER before anything running this code boots on beta, staging or production. Use at least 32 characters, e.g. bin/rails secret.
    • Production item (Deploy/Fizzy vault): read by production and by every beta (saas/.kamal/secrets.beta, which secrets.beta1–4 link to, fetches Production/TOKEN_DIGEST_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 item, makes sharing structural. Beta deployers already read this vault.
    • Staging item: 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 items). 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 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 identity_access_tokens.refresh_token and its unique index. Drop oauth_clients.client_secret. Drop and recreate oauth_retired_refresh_tokens keyed by digest, with successor_payload text identity_access_tokens, plus the empty oauth_* tables DROP INDEX and DROP COLUMN are online. The oauth_* tables are empty in production

Notes for the runbook:

  • Undo by version, newest first: 20261015120200, 20261015120100 (no-op), 20261015120000. Undoing Bump rails from 16d8b82 to 16f1222 #10 restores empty plaintext columns and an empty retired-token table, not the old values. Undoing Bump rails from 16d8b82 to 4fa5681 #8 drops the digests, and the personal access tokens still hold their plaintext.
  • Run Bump rails from 16d8b82 to 4fa5681 #8 to Bump rails from 16d8b82 to 16f1222 #10 in the same session as Bump rails from 16d8b82 to de4d874 #1 to Spike running PostgreSQL in app container #7. Production main runs with partial_inserts off (load_defaults 8.1), so a process names every column it knows on INSERT. A production process that boots after Bump rails from 16d8b82 to 6045f25 #3 has added refresh_token, and is still running when Bump rails from 16d8b82 to 16f1222 #10 drops it, would fail to create personal access tokens. If production deployed or restarted in between, restart it after Bump rails from 16d8b82 to 16f1222 #10.
  • Prefixed personal access tokens. A fizzy_pat_ token written without a digest is never found. Only pre-D1 stack code writes those: the integration branch without this PR, deployed to beta. So merge this PR into the integration branch before its migrations run on beta1. If anything else wrote such tokens after the backfill, rerun it with bin/rails db:migrate:redo:primary VERSION=20261015120100.
  • Production main keeps writing personal access tokens without digests until D1 reaches production. Each is digested the first time it's used, from beta or, after D1 lands, from production. D2 starts by rerunning the backfill for any never used.
  • Beta's recurring.yml runs only Solid Queue maintenance, so forget_successors doesn't run from beta. OAuth is dark there, and the payload expires cryptographically anyway.

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 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-17T10:35:30.091907Z 73f186d New commits
ℹ️ 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 Outdated

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

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

ℹ️ 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 config/routes.rb
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 09:43
jeremy added a commit that referenced this pull request Oct 17, 2026
@jeremy
jeremy force-pushed the oauth-delegate-resources branch from 7bf26ec to ec7441a Compare October 17, 2026 09:58
@jeremy
jeremy force-pushed the oauth-token-digests branch from dcb914c to 3a2bb79 Compare October 17, 2026 09:58
Copilot stopped reviewing on behalf of jeremy due to an error October 17, 2026 10:04

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

Credential migrations against the shared production database require human confirmation of pepper provisioning and manual rollout sequencing.

No open issues 🎉

Review effort: Balanced

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 credential migrations require human verification of pepper provisioning and the prescribed manual rollout, alongside correction of weakened authentication tests.

1 open issue

Review effort: Balanced

Comment thread app/models/identity/access_token.rb
jeremy added 12 commits October 17, 2026 03:32
Step 1 of 3 toward hashing credentials at rest. Access tokens, refresh
tokens and client secrets are stored as HMAC-SHA256 digests (TokenDigester,
byte-compatible with bc3's Oauth::TokenDigester) and looked up through a
unique index on the digest.

Personal access tokens are still written in plaintext too, because
production code that reads only plaintext shares the database while beta
runs this. A personal access token without a digest is found by plaintext
once and given its digest; prefixed tokens never fall back. OAuth access
tokens, refresh tokens and client secrets are digest-only, and their
plaintext columns go. A refresh retry within the grace window is answered
from an encrypted copy of the successor pair that expires with the window
and is swept soon after.

A new personal access token is shown only in the response that creates
it, which isn't cached, rather than on a page that can be loaded again.

SaaS deployments read the pepper from TOKEN_DIGEST_PEPPER and refuse to
boot without it. Beta reads production's, since it writes production's
database. Elsewhere it falls back to a key derived from secret_key_base.
Kamal hands a container only the secrets config/deploy.yml names, so a
pepper added to .kamal/secrets alone never reached the app. The sample
config doesn't list it, since a deploy whose secrets lack a listed one
fails, and most self-hosted installs keep the derived pepper.
…h form

The revealed token is temporary, so the snapshot taken on leaving held
neither it nor the form it replaced, and Back restored an empty page.
…digests

A grant keeps no plaintext, so three pins sent an empty Bearer token and
passed as invalid_token without reaching the expiry, dark-server and
scope checks they're named for.
@jeremy
jeremy force-pushed the oauth-token-digests branch from 82cdd19 to 73f186d Compare October 17, 2026 10:32
@jeremy
jeremy force-pushed the oauth-delegate-resources branch from ec7441a to 5d41998 Compare October 17, 2026 10:32
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 10:50

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

Security-sensitive credential changes and shared-production migrations require human verification of database routing, migration sequencing, and pepper provisioning.

1 open issue
1 resolved since last review

Review effort: Balanced

# replica, as GET requests read. update_columns leaves updated_at alone.
def find_and_digest_undigested(raw)
personal.where(token_digest: nil, token: raw).detect { |candidate| candidate.token == raw }&.tap do |access_token|
ApplicationRecord.with_writing_role { access_token.update_columns token_digest: TokenDigester.digest(raw) }
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