Skip to content

fix: use opaque state parameter in OAuth authorization flow - #41

Merged
mattzcarey merged 4 commits into
mainfrom
fix/oauth-state-parameter
Jun 16, 2026
Merged

fix: use opaque state parameter in OAuth authorization flow#41
mattzcarey merged 4 commits into
mainfrom
fix/oauth-state-parameter

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replace the base64-encoded downstream AuthRequest in the upstream Cloudflare OAuth state parameter with an opaque UUID token.
  • Keep the full authorization request and PKCE verifier only in the existing server-side KV record.
  • Validate callback state as a UUID before accessing KV.
  • Temporarily accept the legacy base64-JSON state shape so in-flight authorization flows survive deployment; the fallback is bounded, UUID-validated, and marked with a removal TODO after the 600-second state TTL.
  • Make the internal consent-form state transport UTF-8-safe.
  • Add end-to-end security and regression coverage for session binding, replay, malformed input, Unicode, and large downstream state.

Why

The complete downstream authorization request is already stored by createOAuthState() under a random UUID with a 10-minute TTL. Sending a second copy through the upstream Cloudflare authorization URL is redundant. The callback currently decodes that copy only to recover the UUID and load the authoritative KV record.

Using the UUID directly keeps the upstream URL bounded regardless of downstream client metadata or client state, avoids unnecessarily forwarding downstream client details to the upstream authorization server, and follows OAuth's definition of state as an opaque value.

The original PR was motivated by a 5,120-byte authorization endpoint limit observed at the time. That exact threshold is no longer reproducible, but the structural issue remains: client-controlled fields are duplicated and base64-amplified in the URL. Current examples with 78 scopes are roughly:

  • short downstream state: ~3.9 KB before, ~1.7 KB after;
  • 1 KiB downstream state: ~5.3 KB before, ~1.7 KB after;
  • 2 KiB downstream state: ~6.7 KB before, ~1.7 KB after.

The previous Latin-1-only consent state encoding also threw on valid Unicode client state. The internal consent transport now encodes JSON as UTF-8 before base64 encoding.

Security properties

The change preserves and tests the existing protections:

  • Opaque random state: generated with crypto.randomUUID().
  • Server-side state: the original AuthRequest and PKCE verifier remain in KV.
  • Short expiry: the KV state record expires after 600 seconds.
  • Session binding: a SHA-256 hash of the UUID is stored in an HttpOnly; Secure; SameSite=Lax cookie and verified on callback.
  • Single use: the KV state record is deleted after successful validation.
  • PKCE: S256 behavior is unchanged.
  • Input validation: malformed and oversized callback state values are rejected as invalid_request before KV access.
  • Downstream state preservation: the MCP client's original state remains in the stored AuthRequest and is returned by completeAuthorization().

Test coverage

  • Full OAuth flow completes and returns the downstream client state.
  • Upstream Cloudflare state is an opaque UUID, not a serialized AuthRequest.
  • Unicode downstream client state survives the complete flow unchanged.
  • An 8 KiB downstream state does not increase the upstream authorization URL.
  • Missing state is rejected.
  • Unknown/expired state is rejected.
  • An in-flight legacy base64-JSON state completes during deployment.
  • Malformed state is rejected before KV access.
  • Oversized callback state is rejected before KV access.
  • Missing session-binding cookie is rejected.
  • State bound to a different browser session is rejected.
  • Replay after successful authorization is rejected.
  • Full repository gate passes: format, lint, typecheck, and 263 tests across 16 files.

Maintenance

Rebased onto current main before updating the implementation and tests.

@mattzcarey
mattzcarey force-pushed the fix/oauth-state-parameter branch from 1b8b85f to 627140b Compare June 9, 2026 16:29
Replace base64-encoded AuthRequest in the OAuth state parameter with
an opaque UUID token. The full AuthRequest is already stored in KV
via createOAuthState() — embedding it in the URL was redundant and
caused authorization URLs to exceed Cloudflare's size limits when
combined with CIMD client IDs or many scopes.

Security is unchanged: state is still validated via KV lookup +
SHA-256 session cookie binding + single-use deletion.
Update OAuth tests for the opaque-state-token contract:
- cloudflare-auth.test.ts: getAuthorizationURL now takes stateToken and
  passes it through verbatim (no base64 AuthRequest).
- oauth-routes.test.ts full-flow: assert the state forwarded to Cloudflare
  is opaque, not a base64-encoded AuthRequest.
- oauth-routes.test.ts reject case: use a plain opaque token now that state
  is the KV lookup key directly.
@mattzcarey
mattzcarey force-pushed the fix/oauth-state-parameter branch from 627140b to 2484de5 Compare June 16, 2026 19:55
@mattzcarey
mattzcarey merged commit 019abcf into main Jun 16, 2026
5 checks passed
@mattzcarey
mattzcarey deleted the fix/oauth-state-parameter branch June 16, 2026 20:19
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