Skip to content

Issue personal access tokens from a first-party client, and backfill existing ones onto it - #3205

Merged
jeremy merged 24 commits into
mainfrom
oauth-pats-as-grants
Oct 27, 2026
Merged

jeremy merged 24 commits into
mainfrom
oauth-pats-as-grants

Conversation

@jeremy

@jeremy jeremy commented Oct 17, 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.

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

Implements #3164 steps 1 to 3: a personal access token becomes a one-off access token of a first-party client, fizzy-personal-access, the way bc3's are of bc-personal-access. Rubric checks LG-1 and LG-2 (bc3 doc/oauth/rubric.md, branch oauth-standard):

  • LG-1: "A PAT is a degenerate grant: an internal *-personal-access client, and a one-off access token in the same digest-stored table. It is shown once, can be revoked, and is listed with connected apps. Identity-wide reach is rechecked live against membership."
  • LG-2: "Migrating existing PATs keeps their reach, their non-expiry and their token string. Re-digest them in place; never re-issue."

LG-1 is met; its "listed with connected apps" is settled as personal access tokens staying on their own page (below). LG-2 is met. No token is re-issued or re-digested: the backfill sets only oauth_client_id, and #3192 already digested every token in place.

The issuer (Oauth::Client)

  • PERSONAL_ACCESS_CLIENT_ID = "fizzy-personal-access", named "Personal Access Tokens". It is trusted, public (none), has scopes read write, and has no redirect URIs. It's the only client exempt from the redirect requirement.
  • It isn't dynamically registered, so the Sweep self-registered OAuth clients left without a grant for 30 days #3159 sweep (Oauth::Client.stale, which takes only dynamically_registered clients) never picks it up. A test ages it a year and sweeps.
  • It's listed for no grant type, so it fails closed. GRANT_TYPES = %w[ authorization_code refresh_token ] is now an allowlist that both endpoints check (#grant_types, #allows_grant_type?), and the issuer's list is empty, as bc3's grant_types: [] is.
    • The authorization endpoint refuses it with unauthorized_client (set_client).
    • The token endpoint refuses it with unauthorized_client, for any grant type, before looking up a code or refresh token (validate_client_grant_type).
    • Before this, nothing in those paths could have minted for it anyway: it has no redirect URIs, and find_by_refresh_token is OAuth-scoped. The allowlist makes the refusal explicit instead of incidental.
  • Its client_id can't be claimed by anyone else. It contains a hyphen, and generated client_ids (has_secure_token, base58) never do.
  • It can't be destroyed. A before_destroy, prepended so it runs ahead of Sweep self-registered OAuth clients left without a grant for 30 days #3159's lock_client and of the cascade, aborts on the stored client_id, so an unsaved change in memory doesn't get past it. Destroying it would cascade (dependent: :destroy) to every client-backed personal access token on the install.
  • Provisioning.
    • By migration, so SaaS and self-hosted installs that migrate both get it.
    • On first use, through Oauth::Client.personal_access_issuer (create_or_find_by! on the unique client_id index). A fresh install loads the schema rather than running migrations, so this covers it.

Minting (My::AccessTokensController#create) now passes oauth_client: Oauth::Client.personal_access_issuer. The JSON response shape fizzy-cli reads is unchanged: id token description permission created_at, and a test pins the key set.

Every "no client means personal" check, for both forms

A personal access token is now "no client, or the personal access client". The client-less form stays fully supported: production main doesn't know the issuer, keeps writing client-less tokens until this train ships, and shares the database with beta.

Check Where Now
personal / oauth scopes access_token.rb#L33 personal = nil or the issuer (subquery). oauth excludes both forms
honored?: personal tokens stay honored while OAuth is dark #L157 oauth_client_id.nil? || acceptance_enabled? || personal? || pilot. The client is only loaded while OAuth is dark, as before
lapsed scope and lapsed?: personal tokens never lapse #L37, #L187 Both go through oauth
Expiry and refresh-token callback: personal tokens get neither #L67 if: :oauth?
Token prefix (fizzy_pat_) and the D1 plaintext kept for production main set_token personal?
D1 plaintext fallback and heal find_and_digest_undigested Already used personal, so it now covers both forms
Revocation by possession revocable_token Already used personal. The issuer has no secret and oauth.where(oauth_client: issuer) is empty, so naming it as client_id revokes nothing extra
Connected Apps grouping and disconnect My::ConnectedAppsController#oauth_tokens, users/show Already used oauth, so personal tokens stay off Connected Apps, and DELETE /my/connected_apps/<issuer id> is a 404
A read-only token's write (#3158, D6): an app's grant gets 403 insufficient_scope, a personal token keeps the 401 shipped clients have always had authentication.rb#L108 access_token.connected_app ? :forbidden : :unauthorized. #3158 keyed it on oauth_client_id?, which would have given a token of the personal access client the 403
The OAuth grant fence (disallow_oauth_grants): personal tokens may still manage personal tokens (fizzy-cli), the join code and so on authentication.rb#L51 Current.access_token&.oauth?. Not listed in #3164. Missing it would have answered 403 to fizzy-cli creating tokens with a client-backed token
Webhook created_via and push claim_for: what a personal token sets up belongs to no app webhook.rb, Users::PushSubscriptionsController Current.access_token&.connected_app, which is nil for a personal token. Not listed in #3164. Missing it would have marked a CLI's webhooks as "via Personal Access Tokens", and tied its push subscriptions to the issuer, making them undeliverable (deliverable_to only takes clients with an oauth grant)
Grant touch (#3159 retention clock) and app-plumbing teardown on destroy touch_oauth_client, end_app_plumbing if: :oauth?. The issuer has no retention period, so its row isn't written on every personal token's create and destroy
The D1 digest backfill, when rerun (the runbook reruns it for tokens production writes in plaintext only) 20261015120100_backfill_token_digests.rb Kept plaintext only for client-less rows, so a rerun after this backfill would have cleared the plaintext production main reads from any client-backed token that had no digest yet. It now keeps it for the issuer's tokens too

The last three rows weren't in #3164's list. I found them by rg-ing for oauth_client_id, oauth_client, .personal and .oauth across app, lib, config and saas. Nothing in saas/ reads token kinds.

Migration

  • 20261016120000_create_personal_access_client.rb:
    • up inserts the issuer unless it exists.
    • down sets oauth_client_id back to NULL on the issuer's tokens, in batches with update_all, then deletes the row. Every version of the code reads that client-less form as personal. The issuer stays until every token is handed back, so a down that stops partway can be rerun (tested). Run it only once no code with this PR is serving: that code issues the issuer's tokens without a lock and recreates the issuer on first use, so no ordering inside down could keep up with it.
  • 20261016120100_backfill_personal_access_client.rb:
    • where(oauth_client_id: nil).in_batches(of: 500).update_all(oauth_client_id: issuer.id), with disable_ddl_transaction!.
    • Only oauth_client_id changes. token, token_digest, permission, expires_at (NULL) and updated_at all stay.
    • It refuses to run if the issuer is missing.
    • Its down is a no-op, so db:migrate:redo:primary VERSION=20261016120100 reruns it. That picks up client-less tokens production main writes in the meantime.
  • Both are data-only, and portable. They use migration-local models, and the UUID primary-key default applies to them. db/schema.rb and db/schema_sqlite.rb change only their version.

Not in this PR, on purpose

  • The "no client" branch stays, and oauth_client_id stays nullable (Implement PATs in terms of OAuth (as Basecamp does) #3164 step 4). Production main keeps writing client-less tokens until this train is deployed there. Dropping the branch and adding NOT NULL is a later PR, after the backfill has been rerun once production runs this code.
  • permission doesn't become a scope string, PATs don't get bc3's 10-year TTL (they keep no expiry, per LG-2), and they keep the fizzy_pat_ prefix rather than sharing fizzy_at_ the way bc3's share bc_at_ (Implement PATs in terms of OAuth (as Basecamp does) #3164 step 5).

Decisions

Both open questions were decided for the OAuth program on 2026-10-17. Jeremy can overrule either.

  1. Personal access tokens stay on My → Personal access tokens, not Connected Apps. LG-1 allows either placement, and moving them would only be churn. This is bc3's placement too. Connected Apps excludes them, issuer included, and disconnecting the issuer there is a 404 (tested).
  2. They keep the fizzy_pat_ prefix. TK-1 wants an app-specific prefix for secret scanning, and fizzy_pat_ is what Prefix newly issued access tokens, refresh tokens and personal access tokens #3163 shipped and what fizzy-cli and scanners already recognise.

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 #3192's #10:

# Version / file PR What it does Production table MySQL 8.4 DDL
11 20261016120000_create_personal_access_client.rb this Data only, no DDL. Inserts one oauth_clients row, client_id fizzy-personal-access, unless it exists. down sets the issuer's tokens' oauth_client_id back to NULL (batches of 500, update_all), then deletes the row; rerunnable if interrupted. Only once no code with this PR is serving oauth_clients (one INSERT), and on down identity_access_tokens One single-row INSERT. On down, row locks only
12 20261016120100_backfill_personal_access_client.rb this Data only, no DDL. UPDATE identity_access_tokens SET oauth_client_id = ? WHERE oauth_client_id IS NULL AND id …, in batches of 500, with disable_ddl_transaction!. updated_at doesn't move. down is a no-op identity_access_tokens: ~3,500 rows, almost all personal Row locks only: ~8 batched UPDATEs by primary key, a second or two

Notes for the runbook:

  • Undo by version, newest first: 20261016120100 (no-op), then 20261016120000, which returns every personal token to the client-less form and removes the issuer. Deploy code without this PR first: code with it keeps issuing the issuer's tokens, and recreates the issuer on first use. No token string, digest or updated_at changes in either direction.
  • Merge this PR into the integration branch before Bump rails from 16d8b82 to 99fd4c0 #11 and Bump rails from 16d8b82 to 36b2dbf #12 run on beta1, and don't deploy integration-branch code without it afterwards. Train code below this PR reads any token with a client as an OAuth grant. It would refuse a backfilled personal token while OAuth is dark, and its idle-lapse sweep, wherever it runs, would delete every backfilled token idle for 90 days. Production main is unaffected: it doesn't know oauth_client_id. Beta's recurring.yml runs only Solid Queue maintenance.
  • Rerun Bump rails from 16d8b82 to 36b2dbf #12 before the follow-up that drops the "no client" branch, once production runs this code: bin/rails db:migrate:redo:primary VERSION=20261016120100. Rerunning it is harmless at any time.
  • D2's rerun of 20261015120100 is safe after Bump rails from 16d8b82 to 36b2dbf #12. That's what the change to that file is for, and a test covers it.

Verified on thelio, with this branch's code:

  • SQLite (OSS) and a dedicated MySQL 8.4 container (SaaS, MIGRATE=1). Starting from a loaded schema with both versions migrated down, I seeded a client-less personal access token (write, updated_at 2026-01-01) and an OAuth grant. Then: up, down, down, up, redo of Bump rails from 16d8b82 to 36b2dbf #12, and redo of Store tokens and client secrets as digests (D1 of 3: read by digest, plaintext fallback) #3192's 20261015120100.
  • After each step I checked that:
    • the issuer is present or absent as expected, and the token's oauth_client_id is the issuer or NULL;
    • the token string and digest are unchanged, with no expiry and no refresh token;
    • permission and updated_at are unchanged;
    • the token is personal? and authenticates for POST;
    • the grant is untouched.
  • Every check passed, and the MySQL dump of db/schema.rb came out identical.

Tests

  • test/integration/personal_access_token_forms_test.rb runs every rule once per form, client-less and client-backed:
    • personal and no app's;
    • fizzy_pat_ prefix, plaintext kept, no expiry and no refresh token;
    • honored while OAuth is dark;
    • never lapses, even past the idle limit with the sweep run;
    • revoked by whoever presents it: with no client, beside another client's client_id, or naming the issuer;
    • no refresh at the token endpoint;
    • manages personal access tokens over Bearer;
    • listed with personal access tokens and not under Connected Apps, where disconnecting the issuer is a 404;
    • revoked from the tokens page;
    • a webhook it sets up is attributed to the person alone;
    • a push subscription it registers belongs to no app, stays deliverable, even while OAuth is dark, and outlives the token;
    • a read-only token's write is a 401 with insufficient_scope and scope="write", not a grant's 403;
    • it reaches an account only while its user is active there, rechecked per request (LG-1);
    • the D1 plaintext fallback heals it, and outside the primary datacenter authenticates it without healing;
    • it never touches the issuer's updated_at.
    • Also: the page-issued token is the issuer's (with the JSON key set pinned), and both endpoints refuse the issuer with unauthorized_client.
  • Oauth::ClientTest:
    • the issuer is the provisioned client, and is provisioned on first use when missing;
    • it is found, not duplicated or refused, when a concurrent first use provisioned it, whether that use won at the uniqueness validation or at the unique index;
    • it has no grant types, and other clients have exactly the code and refresh grants;
    • it has no redirect to send a code to;
    • only it may go without redirect URIs;
    • it can't be destroyed, so its tokens can't cascade away, not even with unsaved changes to its client_id;
    • the sweep never takes it.
  • test/migrations/personal_access_client_test.rb:
    • provisions the issuer once;
    • the backfill keeps string, digest, reach, non-expiry and updated_at, and leaves grants alone;
    • it reruns over tokens written client-less since;
    • it refuses to run before the issuer exists;
    • the down restores the client-less form without moving updated_at, and a down that stops partway finds the issuer again and finishes;
    • a rerun of the D1 digest backfill keeps a client-backed token's plaintext.

Each guard is pinned by a failing test. I reverted each of the 16 changes on its own: honored?, the lapsed scope, lapsed?, the expiry callback, the prefix, the plaintext, the personal scope, the oauth scope, connected_app, the issuer touch, oauth_grant?, the grant-type allowlist, the redirect exemption, the destroy guard, the digest-backfill fix and minting with the issuer. Each revert made at least one of the new tests fail: 16 of 16. The three guards added since (webhook attribution on create, push subscriptions recording their app on insert, and the read-only write's 401) each break their test when reverted too.

#3191 pins. I merged test/pin-shipped-client-auth into this branch locally (not pushed) and ran ShippedClientAuthenticationTest and the SaaS Admin::AuditsControllerTest. All pass: OSS SQLite with 8 tests and SaaS MySQL 8.4 with 17 tests, both exit 0.

Suites (thelio, run as test.yml does: RAILS_ENV=test bin/rails db:setup test, against a dedicated MySQL 8.4 container, PARALLEL_WORKERS=12 because thelio was at load ~100 and the default 32 workers timed out on MySQL, gated on exit status), at e0d3ea389 (later commits add only the PR number to the conformance doc and, from review, the issuer's first-use race fix with two tests; a rebase onto the third cascade at 2bf7e87ea, then onto dcb914ceb, which carries #3158, and onto 3a2bb7953, where #3159's lock_client no longer reloads, so the destroy guard reads the stored client_id. For #3158, the read-only-write status now reads connected_app, and two tests use #3158's find_honored. GitHub CI covers the head):

Suite Result Exit
SaaS (MySQL 8.4) 2317 tests, 0 failures, 0 errors, 1 skip 0
OSS (SQLite) 2149 tests, 0 failures, 0 errors, 5 skips 0
OSS (MySQL 8.4, DATABASE_ADAPTER=mysql) 2149 tests, 0 failures, 0 errors, 1 skip 0

RuboCop (937 files), Brakeman (--exit-on-warn) and gitleaks are clean. I didn't run system tests locally; CI runs them.

No new third party or personal-data flow.

Copilot AI balanced review requested due to automatic review settings October 17, 2026 07:46
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 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-26T06:14:10.867407Z 45cd879 New commits
🔒 Security Review ✅ Completed 2026-10-26T06:15:19.245574Z 45cd879 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.

@jeremy

jeremy commented Oct 17, 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.

🟡 Changes recommended

Concurrent first-use issuer provisioning can fail valid personal access token creation requests.

1 open issue
What changed in this PR

Moves personal access tokens onto a first-party OAuth client while preserving existing token strings, permissions, and non-expiry.

Changes:

  • Provisions the issuer and backfills existing tokens.
  • Supports both token forms while blocking OAuth grants for the issuer.
  • Adds compatibility tests and updates conformance documentation.

[!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/​oauth/​client_test.rb Tests issuer provisioning and protections.
test/​migrations/​personal_access_client_test.rb Tests backfill, rollback, and plaintext preservation.
test/​integration/​personal_access_token_forms_test.rb Tests compatibility of both token forms.
test/​fixtures/​oauth/​clients.yml Adds the personal-access issuer fixture.
docs/​oauth/​spec_conformance.md Documents issuer behavior and compatibility.
db/​schema.rb Advances MySQL schema version.
db/​schema_sqlite.rb Advances SQLite schema version.
db/​migrate/​20261016120100_backfill_personal_access_client.rb Assigns existing tokens to the issuer.
db/​migrate/​20261016120000_create_personal_access_client.rb Provisions the issuer with rollback support.
db/​migrate/​20261015120100_backfill_token_digests.rb Preserves client-backed personal tokens’ plaintext.
app/​models/​webhook.rb Excludes personal-token issuer attribution.
app/​models/​oauth/​client.rb Defines and protects the first-party issuer.
app/​models/​identity/​access_token.rb Recognizes both personal-token forms.
app/​controllers/​users/​push_subscriptions_controller.rb Keeps personal subscriptions independent of apps.
app/​controllers/​oauth/​tokens_controller.rb Enforces allowed grant types.
app/​controllers/​oauth/​revocations_controller.rb Updates revocation comments only.
app/​controllers/​oauth/​authorizations_controller.rb Rejects authorization for the issuer.
app/​controllers/​my/​access_tokens_controller.rb Issues tokens through the first-party client.
app/​controllers/​concerns/​authentication.rb Distinguishes personal tokens from OAuth grants.

Review effort: Balanced

Comment thread app/models/oauth/client.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: 0b536a6e84

ℹ️ 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 db/migrate/20261016120100_backfill_personal_access_client.rb
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 08:00
@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: f63f3067c4

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

Authentication changes and manual migrations against the shared production database require human verification of integration-branch deployment sequencing.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Review threads: 2 resolved (1 fixed, 1 declined with the reasoning in the thread).
Declined: #3205 (comment): defer the backfill for rolling deploys. No running code misreads the backfilled rows. Production main has no oauth_client_id, and the train ships as one release. Beta is covered by the runbook note in the PR body.
Open for a decision: the two product calls in the PR body, for @jeremy. Should personal access tokens be listed in Connected Apps (LG-1; this PR keeps today's placement), and should they keep the fizzy_pat_ prefix?

@jeremy
jeremy force-pushed the oauth-token-digests branch from 1ef16f3 to 2bf7e87 Compare October 17, 2026 08:22
jeremy added a commit that referenced this pull request Oct 17, 2026
@jeremy
jeremy force-pushed the oauth-pats-as-grants branch from f63f306 to 1b38841 Compare October 17, 2026 08:26
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 08:26
@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 1b38841d2e

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

The authentication backfill affects shared production data and requires coordinated integration-branch deployment and manual beta migration verification.

No open issues 🎉

Review effort: Balanced

@jeremy

jeremy commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

@codex review

@jeremy

jeremy commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@jeremy

jeremy commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

Restacked onto #3192 617edb87b ("Keep identity_access_tokens.refresh_token until it can go with token"), now at d9c68dc3e. The rebase resolved one conflict, in the backfill's header comment. On thelio: rubocop clean; SaaS (MySQL), OSS (SQLite) and OSS (MySQL) suites pass, 0 failures, 0 errors. CI is green and Copilot reviewed this head with no open issues. Codex didn't review it: two requests returned "You have reached your Codex usage limits for code reviews". Re-request it once the limit resets.

@jeremy

jeremy commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: d9c68dc3e7

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

…ping partway

* origin/oauth-token-digests: (54 commits)
  Make this branch's migrations safe to run again after stopping partway
  Make this branch's migrations safe to run again after stopping partway
  Make the OAuth migrations safe to run again after stopping partway
  Redact retired refresh tokens from logged SQL
  Filter the code parameter by its whole name, so code_challenge stays readable
  Bring main's tests up to this branch: Ruby 4, webhook attribution, token digests
  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
  Refuse an OAuth parameter that arrives as a list where one value is meant
  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
  End the reviewer's sessions on rotation, so the old code's sessions don't outlive it
  Refuse a code that normalizes to nothing, not just a blank one
  Let the directory reviewer sign in with a fixed code, SaaS only and off unless configured
  Refuse docker-dev's shared default port in a git worktree
  Ruby 4.0.9 (#3234)
  Retry the offline clear only in an active worker
  ...
* origin/oauth-token-digests:
  Answer 404 from a dark endpoint however many times it's asked
jeremy added a commit that referenced this pull request Oct 26, 2026
…anch

OAUTH_STAFF_ONLY=true limits OAuth to staff, and the production, staging
and beta deploy configs set it.

Resolved against #3205: personal access tokens now come from a
first-party client, so the gate exempts them by personal? rather than by
a missing client. A test covers a token issued that way.
* tp/oauth-token-digests:
  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)
…main in order

* tp/oauth-audit-metrics:
  Remember a code redeemed before redemptions were recorded, once it's replayed
  Report a grace-window retry whose scope is refused as a replay
  Point the ledger at #3206
  Count no family revocation for a personal access token deleted with its identity
  Record each redeemed authorization code, so one can't mint again once its grant is gone
  Name an event's identity only from its grant or explicitly, and label errors only by the client that asked
  Label an OAuth error with the client that asked
  Record AU-1 and OB-1 in the conformance record
  Log OAuth events to the structured log and count them in Yabeda
  Report OAuth lifecycle events, and announce errors, throttling and timing

# Conflicts:
#	app/models/oauth/client.rb
#	db/schema.rb
#	db/schema_sqlite.rb
…nal access client

A token issued by the personal access client has an oauth_client_id, so the audit's "no client means pat" rule labelled new tokens by their client. Label by personal? and personal_access? instead.
Its creation and revocation were told apart from a grant's by oauth_client_id?, which a token the personal access client issued has. Ask oauth? instead, as the rest of the model does.
* tp/oauth-audit-metrics:
  Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-audit-metrics:
  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-audit-metrics:
  Report a refresh only once its rotation commits
@jeremy
jeremy changed the base branch from oauth-token-digests to oauth-audit-metrics October 26, 2026 03:24
jeremy added a commit that referenced this pull request Oct 26, 2026
…in order

* tp/oauth-pats-as-grants:
  Hand the issuer's tokens back before removing it, and run down only once no code knows it
  Remove the personal access issuer before handing its tokens back
  Pin both personal token forms to the train's dark delivery and replica heal
  Refuse to destroy the personal access issuer by its stored client_id
  Keep a read-only personal access token's write a 401, whichever form it takes
  Find the personal access issuer a concurrent first use provisioned
  Point the conformance record at #3205
  Record personal access tokens as tokens of a first-party client
  Provision the personal access client and backfill existing tokens onto it
  Issue personal access tokens from a first-party client, and read both forms as personal
  Remember a code redeemed before redemptions were recorded, once it's replayed
  Report a grace-window retry whose scope is refused as a replay
  Point the ledger at #3206
  Count no family revocation for a personal access token deleted with its identity
  Record each redeemed authorization code, so one can't mint again once its grant is gone
  Name an event's identity only from its grant or explicitly, and label errors only by the client that asked
  Label an OAuth error with the client that asked
  Record AU-1 and OB-1 in the conformance record
  Log OAuth events to the structured log and count them in Yabeda
  Report OAuth lifecycle events, and announce errors, throttling and timing

# Conflicts:
#	app/controllers/oauth/authorizations_controller.rb
#	db/schema.rb
#	db/schema_sqlite.rb
* tp/oauth-audit-metrics:
  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-audit-metrics:
  Redact retired refresh tokens from logged SQL
@jeremy

jeremy commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 5657701e1b

ℹ️ 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: 5657701e1b

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-audit-metrics:
  Filter the code parameter by its whole name, so code_challenge stays readable
* tp/oauth-audit-metrics:
  Count the redeemed-code sweep's failures, and report a client sweep once it commits
  Read a replayed refresh token's grant past the query cache
* tp/oauth-audit-metrics:
  Name a replayed code's identity when no grant is left, and report each refresh with its own permission
Base automatically changed from oauth-audit-metrics to main October 27, 2026 06:29
@jeremy
jeremy merged commit 48a43c4 into main Oct 27, 2026
14 checks passed
@jeremy
jeremy deleted the oauth-pats-as-grants branch October 27, 2026 06:29
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