Skip to content

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

Open
jeremy wants to merge 10 commits into
oauth-token-digestsfrom
oauth-pats-as-grants
Open

jeremy wants to merge 10 commits into
oauth-token-digestsfrom
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-19T18:51:26.065854Z d9c68dc Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@jeremy

jeremy commented Oct 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

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 shared-production token migrations require human confirmation of the mandated beta migration, deployment, and rollback sequence.

No open issues 🎉

Review effort: Balanced

jeremy added a commit that referenced this pull request Oct 17, 2026
@jeremy
jeremy force-pushed the oauth-pats-as-grants branch from 19ae6c0 to 59d6546 Compare October 17, 2026 21:41
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 21:51

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 require human confirmation of manual beta validation and rollout and rollback ordering.

No open issues 🎉

Review effort: Balanced

jeremy added 10 commits October 17, 2026 22:40
… forms as personal

A personal access token becomes a one-off access token of the
fizzy-personal-access client, as bc3's are of bc-personal-access. The
client is never self-registered, so the sweep passes it by, and it is
listed for no grant type, so the authorization and token endpoints refuse
it. Every rule that read "no client" as "personal" now reads either form:
the personal and oauth scopes, honoring while OAuth is dark, idle lapse,
expiry and refresh tokens, the token prefix and plaintext, revocation by
possession, Connected Apps, the OAuth grant fence, and webhook and push
attribution. Client-less tokens stay personal for production code that
still writes them.
…o it

The client is created by migration, so self-hosted installs that migrate
get it too, and on first use where the schema was loaded instead. The
backfill only sets oauth_client_id, in batches with update_all: token
strings, digests, reach and non-expiry stay, updated_at doesn't move, and
nothing is re-issued. It reruns over client-less tokens written since.
Down returns the tokens to the client-less form before removing the client.

The digest backfill, which is rerun for tokens production writes in
plaintext only, now keeps the plaintext of client-backed personal tokens
as well.
Losing the race at the uniqueness validation raised RecordInvalid, which
create_or_find_by! doesn't recover, and losing it at the unique index
retried a find on every attribute, JSON columns included. Both now find
the issuer by client_id.
…it takes

#3158 answers a read-only grant's write with 403 and keeps 401 for
personal tokens, telling them apart by whether they have a client. A
token of the personal access client has one, so it reads the token's
connected app instead.
#3159's lock_client no longer reloads the row, so the guard reads the
client_id in the database rather than one changed in memory but not
saved.
…a heal

A personal token's push subscription has no app, so it delivers while
OAuth is dark, and outside the primary datacenter an undigested personal
token of either form authenticates without being healed.
A request that looked the issuer up before down removed it could issue a
token after its batch was cleared, leaving it naming a client that's
gone, which every version reads as an OAuth grant. The issuer now goes
first and the batches repeat until no token names it. Down is for after
code that doesn't know the issuer is deployed.
…nce no code knows it

Deleting the issuer first and repeating the batches only narrowed the
window for a token issued alongside down: issuance takes no lock, and code
that knows the issuer makes it again on first use. Down's precondition is
what closes it. With that settled, clearing the tokens before removing the
issuer makes an interrupted down safe to retry, since the issuer is still
there to find.
@jeremy
jeremy force-pushed the oauth-pats-as-grants branch from 59d6546 to d9c68dc Compare October 18, 2026 05:56
@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.

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 production-shared data migrations require human confirmation of beta migration execution and deployment/rollback sequencing.

No open issues 🎉

Review effort: Balanced

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

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