Skip to content

Answer API authentication failures with a Bearer challenge - #3158

Merged
jeremy merged 11 commits into
mainfrom
oauth-bearer-challenge
Oct 27, 2026
Merged

jeremy merged 11 commits into
mainfrom
oauth-bearer-challenge

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3082 (train: #2296 → #3080 → #3081 → #3082 → this → #3160 → …; #3160 was restacked onto this on 2026-10-17, so the line is linear). The gap predates the train, but #3081's hourly expiry makes it load-bearing, so it rides on the tip.

Problem

Every bearer failure got Rails' default WWW-Authenticate: Token realm="Application", with no error code. JSON requests that sent no credentials were redirected to the sign-in page. RFC 6750 §3 says challenges MUST use the Bearer scheme, including when credentials are missing. A read-only token used on a write got the same 401 as a dead token. An OAuth client would then refresh, get another read token (a refresh can't widen scope), and loop.

Change

Request Before After
Unknown, expired, revoked or dark-server OAuth token 401 Token realm 401 Bearer … error="invalid_token"
Valid read-only OAuth grant, non-GET/HEAD 401 Token realm 403 Bearer … error="insufficient_scope", scope="write"
Valid read-only personal access token, non-GET/HEAD 401 Token realm 401 Bearer … error="insufficient_scope", scope="write" (status unchanged for shipped clients)
JSON, no credentials 302 to sign-in 302, unchanged (see D6 below)
Bearer header on an HTML request 401 Token realm 401 Bearer … error="invalid_request"
bearer <token> (lowercase) 302 to sign-in authenticates (RFC 9110 §11.1)
Basic …Bearer… treated as bearer not bearer
  • resource_metadata (RFC 9728 §5.1) is built from the same root_url(script_name: nil) as the metadata's resource. It is included only while Oauth::Availability.acceptance_enabled?, so a dark server never points at a URL that 404s.
  • Identity.find_by_permissable_access_token is replaced by Identity::AccessToken.find_honored, which leaves the method check to the controller. That way a dead token and a token with too little scope get different answers. Expiry and the availability gate still come first: an expired read token on a write is invalid_token, not insufficient_scope.
  • The scheme gate parses the scheme with the same AUTH_SCHEME_REGEX Rails uses, so the gate and the parser agree. When DPoP lands, it widens this predicate.

D6: shipped clients keep their answers (decided 2026-10-17)

The native apps and fizzy-cli rely on two of the old answers, and #3191 pins both: a read-only personal access token writing gets 401, and a JSON request with no credentials gets a 302 to sign in. So only an OAuth grant gets the 403. A credential-less JSON request keeps the 302, because nothing in it tells a shipped client from an OAuth one. That 302 is a recorded deviation from RFC 6750 §3 (rubric DS-3; docs/oauth/spec_conformance.md, docs/oauth.md on #3193). Hosted OAuth clients discover Fizzy's authorization server through fizzy-mcp-server's own 401 + resource_metadata and Fizzy's /.well-known metadata, so the deviation costs them little. Exit: once the oldest supported native app and fizzy-cli handle a 401 challenge there. It also keeps @rails/request.js (card autosave) and the direct uploader on the redirect they handle.

Unchanged

  • Personal access tokens stay identity-wide and never expire.
  • Non-JSON requests are still refused for bearer auth, except on ActiveStorage, where bearer auth is allowed as before.
  • Session-cookie JSON still works, because resume_session runs first.
  • The require_account redirect for untenanted paths is untouched.

Tests

test/controllers/api_test.rb asserts the status and exact challenge for each row above. It also checks that the advertised resource_metadata URL resolves to metadata whose resource is the root URL, and that the parameter is absent while OAuth is dark. A read-only OAuth grant's write gets 403; a read-only personal access token's write keeps its 401 with the insufficient_scope challenge; a credential-less JSON request is redirected. #3191's pins pass unchanged on the train. API docs: docs/api/README.md.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 04:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 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-26T04:34:57.278708Z 8f18e37 New commits
🔒 Security Review ✅ Completed 2026-10-26T04:35:05.804553Z 8f18e37 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Convergence on head edefabb:

  • Tests: green on OSS and SaaS, MySQL and SQLite. edefabb changes only tests: two SaaS My::DevicesController cases sent JSON with no session cookie and expected the sign-in redirect, and now expect the 401 challenge. The native apps register devices after signing in, so they carry the cookie, and a stale cookie still gets the redirect.
  • Codex: reviewed 30eaeaa with no findings (👍). The only change since then is edefabb.
  • Copilot: didn't review because the review quota was reached. Only a human can request a re-review.
  • Security (gem audit) is red, but not because of this PR. The failure is rubyzip 3.3.0, CVE-2026-85396 / GHSA-47m2-wp7j-p9vc, an advisory published between the train tip's run (green at 04:17Z) and this one. main already has rubyzip 3.7.0. The train's base oauth branch is behind main, so all four train heads will fail the same check on their next run. It clears when the train is brought up to main, which isn't this PR's job.

Judgment calls to check:

  • A JSON request with no session cookie that isn't XHR now gets a 401 challenge. One with a session cookie, even a stale one, or sent over XHR still gets the 302. That split protects @rails/request.js autosave, which would otherwise navigate to the WWW-Authenticate value. The catch: an API client that sends X-Requested-With: XMLHttpRequest with no credentials still gets the redirect.
  • A Bearer header on an HTML request keeps its 401 status and now carries error="invalid_request". RFC 6750 §3.1 says invalid_request SHOULD be a 400, but I kept the existing tested 401.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Rebased onto #2296's 54eb952, which fixes the client's access_tokens foreign key and drops a stray schema.rb foreign key. No conflicts, and nothing else changed.

jeremy added a commit that referenced this pull request Oct 6, 2026
Requirement-by-requirement status for the OAuth train (#2296, #3080,
#3081, #3082) and the PRs above it (#3158, #3159): what conforms, which
commit fixed what, what the hosted plan schedules, the deliberate
postures, and the decisions still open.
jeremy added a commit that referenced this pull request Oct 6, 2026
Requirement-by-requirement status for the OAuth train (#2296, #3080,
#3081, #3082) and the PRs above it (#3158, #3159): what conforms, which
commit fixed what, what the hosted plan schedules, the deliberate
postures, and the decisions still open.
@jeremy
jeremy force-pushed the oauth-confidential-clients branch from 69b77e7 to d071ad7 Compare October 6, 2026 10:21
@jeremy
jeremy force-pushed the oauth-bearer-challenge branch 2 times, most recently from 3c9d777 to 2f49f2e Compare October 6, 2026 10:28
@jeremy
jeremy force-pushed the oauth-confidential-clients branch 2 times, most recently from 8947b42 to bd3a9e9 Compare October 6, 2026 10:32
@jeremy
jeremy force-pushed the oauth-bearer-challenge branch from 2f49f2e to 255eb60 Compare October 6, 2026 10:32
@jeremy
jeremy force-pushed the oauth-confidential-clients branch from bd3a9e9 to f501ced Compare October 6, 2026 10:48
@jeremy
jeremy force-pushed the oauth-bearer-challenge branch from 255eb60 to 7612d15 Compare October 6, 2026 10:49
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Restacked onto #3082's new head (Jeremy's decision f). It had been sitting on an old #3082 head that predated client_secret_basic and the revocation changes. It replayed cleanly, its own commits are unchanged, and the full suite passes on top of #3082's decisions (a) and (c). One CI run failed only in BoardKeyboardFocusTest, a Capybara focus flake unrelated to this PR, and passed on rerun.

CI is green.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 7612d15f3f

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

@jeremy
jeremy force-pushed the oauth-bearer-challenge branch from 7612d15 to fa1f70e Compare October 6, 2026 11:16
@jeremy
jeremy force-pushed the oauth-confidential-clients branch 2 times, most recently from 9cc10ea to ed4f2cd Compare October 6, 2026 11:30
@jeremy
jeremy force-pushed the oauth-bearer-challenge branch 3 times, most recently from b7d0ea4 to 27cf43f Compare October 6, 2026 11:48
jeremy added a commit that referenced this pull request Oct 17, 2026
…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.
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 09:42
Copilot AI dismissed their stale review, a newer Copilot review was requested October 17, 2026 09:42

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.

🟢 Approved

No blocking issues remain; only a nonblocking PR-description clarification is needed.

1 open issue

Review effort: Balanced

Comment thread docs/api/README.md
jeremy added a commit that referenced this pull request Oct 17, 2026
…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.
jeremy added a commit that referenced this pull request Oct 17, 2026
…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.
jeremy added a commit that referenced this pull request Oct 17, 2026
…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.
jeremy added a commit that referenced this pull request Oct 17, 2026
…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.
jeremy added a commit that referenced this pull request Oct 17, 2026
…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.
jeremy added a commit that referenced this pull request Oct 17, 2026
* commit '600d347762495665b35528d449e913c94c59b920':
  Keep shipped clients' answers: 401 for a read-only PAT, sign-in for no credentials
  Expect the Bearer challenge for unauthenticated device registration in SaaS
  Answer API authentication failures with a Bearer challenge
jeremy added a commit that referenced this pull request Oct 17, 2026
…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.
jeremy added a commit that referenced this pull request Oct 18, 2026
…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.
…er stopping partway

* origin/oauth-confidential-clients:
  Make the OAuth migrations safe to run again after stopping partway
* origin/oauth-confidential-clients:
  Answer 404 from a dark endpoint however many times it's asked
* tp/oauth-confidential-clients: (60 commits)
  Name v0.2.0 in the pin's comment
  Pin fizzy-mcp-server v0.2.0
  Refuse a join code that was reset while it was being redeemed
  Filter presigned storage signatures from logs
  Keep the reviewer's memberships in reach, and print the member audit escaped
  Import an export that lacks a column this database has only just gained
  List the reviewer accounts' members when signing the reviewer out
  Bump thruster from 0.1.26 to 0.1.27 (#3199)
  Give the asset build a placeholder direct-upload credential
  Anchor the stubbed storage host pattern
  Sign direct uploads with a write-only storage credential
  Lock the reviewer's email address, so a code holder can't move its membership out of revocation's reach
  Use Ruby's GC defaults and log job memory (#3238)
  Pin the fizzy_mcp accessory to fizzy-mcp-server v0.1.0 by digest
  Scope webhook dispatch by account as well as board
  Test the reviewer audit against the structured log line production writes
  Revoke on the email alone, and record each use in the request's structured log
  Remove the reviewer's access tokens and passkeys on sign-out, so a code holder can't keep a way back in
  Wire the reviewer sign-in into production from Deploy/Fizzy
  Close the reviewer's live sockets on sign-out, and keep the identity id out of Sentry
  ...
* tp/oauth-confidential-clients:
  Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-confidential-clients:
  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

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

ℹ️ 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
Comment thread app/controllers/concerns/authentication.rb
* tp/oauth-confidential-clients:
  Redact OAuth client statements from logged SQL, now that a client has a secret
@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. Keep them coming!

Reviewed commit: aa77436f81

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

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-confidential-clients:
  Filter the code parameter by its whole name, so code_challenge stays readable
Base automatically changed from oauth-confidential-clients to main October 27, 2026 06:26
@jeremy
jeremy merged commit 8837331 into main Oct 27, 2026
14 checks passed
@jeremy
jeremy deleted the oauth-bearer-challenge branch October 27, 2026 06:27
kheubaum pushed a commit to kheubaum/fizzy that referenced this pull request Oct 27, 2026
Requirement-by-requirement status for the OAuth train (basecamp#2296, basecamp#3080,
basecamp#3081, basecamp#3082) and the PRs above it (basecamp#3158, basecamp#3159): what conforms, which
commit fixed what, what the hosted plan schedules, the deliberate
postures, and the decisions still open.
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