Skip to content

Identify OAuth clients by their metadata document URL, from allowlisted hosts - #3228

Draft
jeremy wants to merge 10 commits into
oauth-token-digestsfrom
oauth-cimd
Draft

jeremy wants to merge 10 commits into
oauth-token-digestsfrom
oauth-cimd

Conversation

@jeremy

@jeremy jeremy commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Draft, held for Jeremy. Stacked on #3192 (oauth-token-digests). Part of the hosted Fizzy MCP plan (hosted-plan.md, "Then CIMD"), alongside the audience and token-exchange lanes.

Claude and ChatGPT connectors identify themselves by a Client ID Metadata Document: the client_id is the https URL of a JSON document they host (draft-ietf-oauth-client-id-metadata-document). This lets them connect without dynamic registration. It's ported from bc3's Oauth::ClientMetadata, and covers the recognized tier only.

Behaviour

  • Off unless configured. OAUTH_CIMD_ENABLED=true turns it on, and only while issuance is on (Oauth::Availability.metadata_documents_enabled?). The metadata adds client_id_metadata_document_supported: true only while it's on, as bc3's does (metadata_controller.rb#L107-L108). Without that member, clients fall back to DCR.

  • Allowlisted hosts only. OAUTH_CIMD_ALLOWED_HOSTS is a space-separated list of exact hosts, for production mcp.claude.ai claude.ai claude.com chatgpt.com, the same four as bc3. These are environment values, not credentials (bc3#13306).

    • A URL client_id on any other host is an unknown client and is never fetched.
    • bc3's open tier (any https host, with heightened warnings and a per-domain row quota) isn't ported.
    • Development and test enable it with the same four hosts.
    • Deploy (second commit): saas/config/deploy.yml env.clear sets OAUTH_CIMD_ALLOWED_HOSTS: "mcp.claude.ai claude.ai claude.com chatgpt.com" for every SaaS destination (Kamal merges destination env.clear over the base). That alone fetches nothing: it stays dark until OAUTH_CIMD_ENABLED=true, which no destination sets. Turning it on is Jeremy's call.
  • Identifiers are exact strings. Oauth::ClientMetadata::Url accepts:

    • https only, exact case;
    • a host and a path;
    • no query, fragment, userinfo or dot-segments;
    • at most 255 characters, the existing client_id width. Widening it would be a type change.

    oauth_clients.client_id's MySQL collation folds case. So Oauth::Client.find_presented compares the stored id exactly after the lookup, and a case variant of a provisioned URL names no client.

  • Two modes, since GETs read from a replica:

    • Consent GET: renders from a freshly fetched, unsaved client.
    • Authorization POST: provisions the row before any code is minted, as a public, untrusted, client_id_metadata_document client.
    • Deny: never registers a client. It uses the stored row when that row allows the redirect, and otherwise reads the document again without saving.
    • Token and revocation endpoints: only look rows up. A client site outage can't sign anyone out.
  • Document validation (Oauth::ClientMetadata::Document):

    • the client_id member must exactly equal the URL;
    • no client_secret* members;
    • the client is provisioned public (none), including when it prefers another method but lists none in token_endpoint_auth_methods_supported (ChatGPT does; the bc3#13578 lesson);
    • grant_types, if present, must include authorization_code; others are ignored;
    • redirect_uris is required, at most 10;
    • scopes narrow to read/write, and silence offers both;
    • the name is client_name, or the host.
  • Redirects: a metadata document client is held to the self-registered rules, https or loopback, and gets an error redirect before consent only to loopback (vetted_redirect?).

  • Consent: shows "Name set by the app at host".

  • Cleanup: metadata document rows left without a grant for 30 days are swept with DCR clients. The next authorization provisions them again.

  • Caching: the document's Cache-Control/Expires, clamped to between 5 minutes and 24 hours (default 1 hour). A stale row revalidates with If-None-Match, and a 304 extends it. Concurrent writers use a compare-and-swap on metadata_fetched_at, and a creation race adopts the winner through the unique index.

  • Fetch budgets, per person: 6 per URL per minute and 60 per host per hour, counted in Rails.cache under the signed-in identity, failing open. Deviation from bc3, whose budgets are global: a consent GET fetches without provisioning, so anyone signed in could spend a shared budget and refuse a client's first authorization to everyone. The fetcher's bulkhead and deadline bound Fizzy's fetches overall, and answer a retryable 503 rather than refusing the client. A client already provisioned that is over budget keeps its stored document.

  • Only a string names a client: a client_id[] array never reaches find_by as an IN list. A metadata document client is reachable only through its URL, so the switch, the exact comparison and the refresh always apply to it.

The new outbound fetch: SSRF defences

This is new egress from Fizzy to URLs a client names, limited to the allowlisted hosts. Oauth::ClientMetadata::Fetcher, ported from bc3:

  • Address: Surfguard resolves the host once under its strict :iana_special_use policy. That refuses private, loopback and link-local addresses, plus every IANA special-purpose range, including NAT64, AMT and AS112, which the default policy admits. An answer set that mixes public and blocked addresses is refused whole (Surfguard.resolve_public_ip). The connection is pinned to the address it admitted (ipaddr), so DNS rebinding can't move it.
  • No proxy: the proxy argument is an explicit nil, so HTTP(S)_PROXY can't reroute the fetch.
  • No redirects. TLS verifies the peer against the URL host.
  • Bounded:
    • one 3s deadline, DNS to last byte, backstopped by Timeout;
    • 5KB body, checked by both Content-Length and streaming;
    • 16KB wire budget over the status line, headers and framing, enforced in BudgetedIO#rbuf_fill;
    • identity encoding only, so no decompression bombs;
    • JSON content types only;
    • max_retries = 0;
    • at most 2 concurrent fetches per process.
  • Results: typed failures, never raises; a failure aborts the authorization (invalid_client_metadata, or 503 with Retry-After under pressure), and none is stored.
  • Kill switches: for a fetch incident, set OAUTH_CIMD_ENABLED to anything else, or block egress. Clients already authorized keep refreshing.

Migration (row 14 for the runbook)

20261019150000 adds to oauth_clients, which is empty in production, in one bulk ALTER:

Column Type
client_id_metadata_document boolean, default false, nullable
metadata_fetched_at datetime
metadata_expires_at datetime
metadata_etag string(191)

No foreign keys, no index, and no change to any existing column. The ALTER ran with an explicit ALGORITHM=INSTANT on MySQL 8.4 against the pre-migration table definition with a row in it.

Not ported from bc3, on purpose

  • Open tier and DomainQuota: not needed with an allowlist.
  • FetchLease, the cross-process site fetch slots: the bulkhead, the budgets and the deadline bound it. Revisit if the allowlist widens.
  • Quarantine (disable!): Fizzy has none. The allowlist and the switch are the containment.
  • Yabeda metrics: Report OAuth audit events and export OAuth metrics #3204 is a sibling branch; outcomes are logged for now.
  • The SQLite busy retry: single-writer SQLite installs get a 500 on a concurrent first provisioning instead of a retry.
  • The local-TLS integration test: BudgetedIO is tested against a scripted io; the on_connect seam is asserted to exist.
  • private_key_jwt and PAR: a document that offers only private_key_jwt is refused.

Expected conflicts with the sibling lanes

  • Audience lane (B):
    • app/controllers/oauth/authorizations_controller.rb: set_client and its new helpers, beside B's resource handling.
    • app/views/oauth/authorizations/new.html.erb: one elsif in the Application block, beside B's account picker.
    • app/models/oauth/client.rb: two scopes and three small methods.
  • Token-exchange lane (C): app/controllers/oauth/metadata_controller.rb, one member in the show hash; C changes grant_types_supported.
  • Both lanes:
    • db/schema.rb and db/schema_sqlite.rb: the version line and the oauth_clients block.
    • config/environments/production.rb: the OAuth block.
    • app/models/oauth/availability.rb: one method.

All of these are additive, so restacking should resolve by keeping both sides.

The coming DCR allowlist lane (DCR_ALLOWED_HTTPS_HOSTS, loopback, cursor/claude schemes) will share:

  • app/models/oauth/client.rb#validate_redirect_uri: this PR makes metadata document clients follow the self-registered redirect rule (self_registered?). If DCR's rule narrows to an allowlist, decide whether metadata document clients follow it too. Their redirects are already bound to an allowlisted document host, so they probably shouldn't.
  • config/environments/production.rb and saas/config/deploy.yml env.clear: neighbouring env lines.
  • app/models/oauth/availability.rb, if DCR gets its own switch.

Tests

On thelio at 67538b5a5 (before the review fixes; CI covers the head):

Suite Runs Failures Errors Skips
SaaS MySQL 8.4 2359 0 0 1
OSS SQLite 2191 0 0 5
OSS MySQL 8.4 2191 0 0 1

Rubocop is clean. The new tests run with outbound network disabled (WebMock.disable_net_connect!):

  • test/models/oauth/client_metadata/: url, document, fetcher, budgeted_io, resolver
  • test/integration/oauth_client_metadata_documents_test.rb
  • additions to test/models/oauth/client_test.rb

The case-variant test branches on adapter: MySQL's case-folding collation refuses the variant's row, while SQLite gives it a separate row. Either way the variant never resolves to the original client.

…ts only

Port bc3's Client ID Metadata Document support, recognized tier only: a
URL client_id from a host OAUTH_CIMD_ALLOWED_HOSTS names is fetched,
validated and provisioned as a public, untrusted client on the
authorization POST; the consent GET and denials never persist. Off
unless OAUTH_CIMD_ENABLED is true, and never ahead of issuance; the
metadata advertises client_id_metadata_document_supported only while on.

The fetch pins Surfguard's public address, ignores proxies, follows no
redirects, verifies TLS, and holds one 3s deadline, a 5KB body and a
16KB wire budget.
OAUTH_CIMD_ALLOWED_HOSTS alone fetches nothing: metadata documents stay
off until OAUTH_CIMD_ENABLED is true, which no destination sets.
@jeremy

jeremy commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 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-24T17:23:23.920767Z a235c32 Manual request
🔒 Security Review ✅ Completed 2026-10-24T17:24:51.924484Z a235c32 Manual request

Security findings

Advisory findings (1)

ℹ️ About Codex in GitHub

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

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

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

@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: 67538b5a5d

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.

@jeremy

jeremy commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

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

Reviewed commit: 67538b5a5d

ℹ️ About Codex security reviews in GitHub

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

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

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

Comment thread app/models/oauth/client_metadata/resolver.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: 67538b5a5d

ℹ️ About Codex in GitHub

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

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

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/controllers/oauth/authorizations_controller.rb Outdated
Comment thread app/models/oauth/client_metadata/fetcher.rb Outdated
…policy

A shared fetch budget was one anyone signed in could spend from the
consent screen, refusing a client's first authorization to everyone;
budgets are now per person, with the bulkhead bounding fetches overall.
Fetches resolve under Surfguard's :iana_special_use policy and refuse an
answer set mixing public and blocked addresses. A client_id array no
longer reaches find_by as an IN list, and a metadata document client is
reachable only through its URL.
@jeremy

jeremy commented Oct 23, 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: 1b74b969fa

ℹ️ 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 commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Status at 1b74b96. Draft, held for Jeremy.

* origin/oauth-token-digests:
  Refuse an OAuth parameter that arrives as a list where one value is meant
#3236's require_single_valued_parameters now refuses client_id[]= at authorize before any lookup, so set_client drops its own string check. A metadata document client is still reached only through its own URL. The array test now asserts the shared refusal with the switch off and on, and that no client query runs.
@jeremy

jeremy commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: a235c3282d

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

View security finding report

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

ℹ️ About Codex security reviews in GitHub

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

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

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

* origin/oauth-token-digests: (48 commits)
  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
  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
  Retry clearing old offline pages whenever the worker starts
  Clear offline pages rendered under an older rendering version
  Re-key every fragment and page ETag past crass 1.0.7
  Bump crass to 1.0.8 in Gemfile.saas.lock, and test rich text style scrubbing
  Bump crass to 1.0.8 for GHSA-pq4g-8rxf-4g29
  story accepted
  ...
* origin/oauth-token-digests:
  Filter the code parameter by its whole name, so code_challenge stays readable
* origin/oauth-token-digests:
  Redact retired refresh tokens from logged SQL
…ping partway

* origin/oauth-token-digests:
  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
* origin/oauth-token-digests:
  Answer 404 from a dark endpoint however many times it's asked
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.

1 participant