Skip to content

Add OAuth 2.1 /internal endpoint (Sentry SSO via Cloudflare Access)#2

Open
sergical wants to merge 6 commits into
mainfrom
feat/oauth-cowork
Open

Add OAuth 2.1 /internal endpoint (Sentry SSO via Cloudflare Access)#2
sergical wants to merge 6 commits into
mainfrom
feat/oauth-cowork

Conversation

@sergical

@sergical sergical commented Apr 10, 2026

Copy link
Copy Markdown
Member

What this introduces

Today the Worker exposes a single endpoint, /mcp — bring-your-own-key, each caller passes their own Plausible API key as a Bearer token.

This adds a second endpoint, /internal, an OAuth 2.1 server for managed connectors (Cowork, Claude.ai) that can't send a custom header. /mcp is unchanged.

How /internal differs from the existing /mcp model

  • The Worker runs its own OAuth 2.1 server (@cloudflare/workers-oauth-provider) and federates login to Cloudflare Access as the upstream OIDC provider (Sentry Google SSO).
  • Only @sentry.io identities are admitted; queries run against one shared, server-side Plausible key, so connector users never handle a key (vs. /mcp where the user's own key is the credential).
  • Identity is verified by reusing the Access id_token JWKS validator (cf-access.ts).

Security posture of the new auth surface

  • RS256-only token validation (alg-confusion guard); case-insensitive @sentry.io gate
  • S256-only PKCE (plain disabled)
  • Token lifetimes bounded — access 1h, refresh 24h — so access can't be reused long after offboarding
  • No plaintext PII in the unencrypted grant store: the OAuth userId is a hash and no email is stored in metadata; the email lives only in the encrypted props, used for Sentry attribution
  • CIMD (URL client_ids) enabled with DCR /register kept as fallback; global_fetch_strictly_public set (CIMD requirement + SSRF guard)
  • Sentry: sendDefaultPii: false, and Authorization / cookie / Cf-Access-Jwt-Assertion scrubbed from spans
  • Per-IP rate limiting and security headers (HSTS, X-Frame-Options, etc.) on all responses

Other effects on the codebase

  • Worker files (worker.ts, env.ts, access-handler.ts, workers-oauth-utils.ts) are now type-checked via pnpm typecheck (tsconfig.worker.json); previously they were transpiled without type-checking.
  • @modelcontextprotocol/sdk is pinned to 1.28.0 so it resolves to a single copy (matching what agents@0.8.7 ships), required for the worker type-check to pass.
  • This branch also carries the pnpm migration (identical to Switch package manager from Bun to pnpm #6, since it forks pre-pnpm main); that portion collapses when either PR merges.

Requires before deploy (Cloudflare dashboard, not code)

Register the Access OIDC app (app-specific CF_ACCESS_AUD, @sentry.io SSO policy), create OAUTH_KV, set the secrets listed in the README, then run the end-to-end Cowork connect test.

Verification

Worker type-check + pnpm build clean · 68 tests pass · dependency-review + CodeQL + warden green.

@sergical sergical marked this pull request as ready for review April 10, 2026 23:32
Comment thread wrangler.toml Outdated
Comment thread src/auth-handler.ts Outdated
Comment thread src/worker.ts Outdated
Comment thread src/worker.ts Outdated
@sergical sergical changed the title Add OAuth 2.1 for Cowork connector support Replace custom OAuth with Cloudflare Access for /internal auth May 25, 2026
sergical and others added 2 commits May 25, 2026 16:29
Adds /internal as an OAuth-protected MCP endpoint for Claude Desktop/Cowork
users, authenticated via Google SSO restricted to @sentry.io emails. Uses
a shared team Plausible API key for OAuth users.

- New src/auth-handler.ts: Google OAuth flow with @sentry.io domain check
- worker.ts: OAuthProvider wraps /internal (OAuth) while /mcp stays as
  direct Bearer token for CLI users (backward compatible)
- wrangler.toml: KV namespace for OAuth state, pinned dev port
- New dependency: @cloudflare/workers-oauth-provider

Deploy requires: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, PLAUSIBLE_API_KEY
secrets and a KV namespace for OAUTH_KV.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap internalApiHandler and defaultHandler with Sentry.withSentry()
since OAuthProvider export can't be wrapped directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sergical sergical force-pushed the feat/oauth-cowork branch from 532c54f to 59f8e3f Compare May 25, 2026 20:30
@sergical sergical changed the title Replace custom OAuth with Cloudflare Access for /internal auth Add Cloudflare Access auth for /internal and harden Sentry config May 25, 2026
Comment thread src/worker.ts Outdated
Comment thread src/worker.ts Outdated
@sergical sergical force-pushed the feat/oauth-cowork branch from 59f8e3f to c32fa9e Compare May 25, 2026 20:33
Comment thread src/worker.ts Outdated
@sergical sergical force-pushed the feat/oauth-cowork branch from c32fa9e to da1ebe0 Compare May 25, 2026 20:37
Comment thread src/cf-access.ts Outdated
Comment thread src/worker.ts Outdated
@sergical sergical force-pushed the feat/oauth-cowork branch from da1ebe0 to d05ca83 Compare May 25, 2026 20:47
Comment thread src/cf-access.ts Outdated
@sergical sergical force-pushed the feat/oauth-cowork branch from d05ca83 to bf7b33e Compare May 25, 2026 20:55
Comment thread src/cf-access.ts Outdated
Comment thread src/cf-access.ts Outdated
Comment thread .gitignore Outdated
@sergical sergical force-pushed the feat/oauth-cowork branch from bf7b33e to 391f7af Compare May 25, 2026 22:45
Comment thread src/cf-access.ts Outdated
@sergical sergical force-pushed the feat/oauth-cowork branch from 391f7af to 86b69f3 Compare May 26, 2026 02:15
Comment thread src/cf-access.ts
@sergical sergical force-pushed the feat/oauth-cowork branch 2 times, most recently from 8b9b7f9 to b22eb5b Compare May 26, 2026 14:22
Comment thread src/cf-access.ts
@sergical sergical force-pushed the feat/oauth-cowork branch from b22eb5b to d53c4d2 Compare May 26, 2026 14:59
Comment thread src/worker.ts Outdated
Per security review (SEC-1266), simplify auth by dropping the custom
OAuth provider in favor of Cloudflare Access. /internal validates
Cf-Access-Jwt-Assertion with signature, audience, and @sentry.io check.
/mcp remains unchanged for direct Bearer token users.

- Delete auth-handler.ts (Google SSO flow)
- Remove @cloudflare/workers-oauth-provider, OAUTH_KV, Google creds
- Fix sendDefaultPii: false + beforeSendSpan scrubber for auth headers
- Add security headers (X-Frame-Options, HSTS, Referrer-Policy)
- Add Sentry.setUser for attribution on /internal calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sergical sergical force-pushed the feat/oauth-cowork branch from d53c4d2 to 2c5b563 Compare May 26, 2026 16:34
sergical and others added 2 commits June 1, 2026 10:57
The previous /internal implementation validated a Cf-Access-Jwt-Assertion
header injected by Cloudflare Access acting as a reverse proxy. That model
cannot serve OAuth 2.1 MCP clients (Cowork, Claude.ai connectors): it has no
discovery chain — no WWW-Authenticate: Bearer resource_metadata, no
/.well-known/oauth-protected-resource, no DCR — so managed connectors can't
authenticate against it.

Switch /internal to the documented Cloudflare pattern: the Worker itself runs
@cloudflare/workers-oauth-provider (serving discovery, /token, /register, and
the protected resource) and federates login to Cloudflare Access registered as
an upstream SaaS/OIDC application. The Access id_token is verified by reusing
the existing cf-access.ts JWKS verifier (aud = OIDC client_id, iss = team
domain, email gated to @sentry.io). Queries run against the shared server-side
PLAUSIBLE_API_KEY.

/mcp is unchanged — bring-your-own-key via Authorization: Bearer for
header-capable clients (Claude Code, Cursor, MCP Inspector).

- src/access-handler.ts: /authorize consent + /callback Access federation
- src/workers-oauth-utils.ts: vendored CSRF/state/PKCE/consent helpers from
  Cloudflare's reference demo
- src/env.ts: Env/Props types
- wrangler.toml: OAUTH_KV namespace + Access secret documentation
- worker/env/access-handler/workers-oauth-utils excluded from tsc (Workers
  types), type-checked at bundle time by wrangler

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- package.json: tsc/tsx/node scripts (no more `bun run`), drop @types/bun,
  add tsx + @types/node, pin packageManager to pnpm@11.1.3
- Replace bun.lock with pnpm-lock.yaml; add pnpm-workspace.yaml to allow
  esbuild (tsx/vitest) and @sentry/cli post-install build scripts
- Update README, CONTRIBUTING, CLAUDE.md, and evals/run.ts shebang to pnpm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sergical sergical changed the title Add Cloudflare Access auth for /internal and harden Sentry config Add OAuth 2.1 /internal endpoint (Sentry SSO via Cloudflare Access) + pnpm Jun 13, 2026
Comment thread src/cf-access.ts
);
if (!valid) return null;

const payload = JSON.parse(base64UrlDecode(parts[1])) as AccessJwtPayload;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

JWT verifier throws uncaught

Medium Severity

verifyCloudflareAccessJwt uses atob and JSON.parse on JWT segments without catching failures, so malformed tokens can throw instead of returning null. The OAuth /callback path calls this on upstream id_token values, so bad or adversarial tokens can surface as 500 responses rather than a controlled 403.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f99394. Configure here.

Comment thread src/access-handler.ts
state.oauthReqInfo,
env.OAUTH_KV,
env.COOKIE_ENCRYPTION_KEY,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval POST trusts tampered state

Medium Severity

On POST /authorize, the handler rebuilds the upstream OAuth flow from oauthReqInfo embedded in a base64 form state field instead of re-parsing the original authorization request. A modified hidden field (e.g. with a valid CSRF cookie) can store a different client or redirect in KV and complete authorization for a client the user did not see on the consent screen.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f99394. Configure here.

Comment thread src/worker.ts
baseUrl: env.PLAUSIBLE_BASE_URL,
defaultSiteId: env.PLAUSIBLE_DEFAULT_SITE_ID,
recordPii: true,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Internal MCP records span PII

Medium Severity

The /internal handler passes recordPii: true into createServer, enabling Sentry MCP input/output recording on the shared-key endpoint while global Sentry config sets sendDefaultPii: false and scrubs auth headers. Tool arguments and results can still land in Sentry spans, undermining the hardening goal for employee analytics queries.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f99394. Configure here.

@sergical sergical force-pushed the feat/oauth-cowork branch from 3178cb9 to 5a58292 Compare June 13, 2026 00:50
Security review follow-ups (SEC-1266):
- cf-access: reject non-RS256 tokens (alg-confusion guard); case-insensitive
  @sentry.io check
- worker: scrub Cf-Access-Jwt-Assertion from Sentry spans; S256-only PKCE
  (allowPlainPKCE=false), bounded token TTLs (access 1h, refresh 24h) to close
  the offboarding reuse gap, and CIMD enabled with DCR kept as fallback
- access-handler: keep plaintext PII out of the unencrypted grant store — hash
  the OAuth userId and drop the email metadata label; the email stays only in
  the encrypted props for Sentry attribution
- wrangler: add global_fetch_strictly_public compat flag (required by CIMD,
  also blocks SSRF via a malicious client_id URL)

Dependencies / tooling:
- Type-check the worker files via tsconfig.worker.json + `pnpm typecheck`
  (previously excluded from tsc and only transpiled, so unchecked)
- Pin @modelcontextprotocol/sdk to 1.28.0 (the version agents@0.8.7 already
  uses) so it resolves to a single copy, fixing the worker type-check. Avoids
  bumping `agents`, which would pull a brand-new, policy-blocked release and a
  large unused dep tree.
- Override esbuild to >=0.28.1 (transitive via tsx + vitest) to clear a
  high-severity advisory (GHSA-gv7w-rqvm-qjhr) flagged by dependency-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sergical sergical force-pushed the feat/oauth-cowork branch from 5a58292 to 4a2cf5c Compare June 13, 2026 00:53

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4a2cf5c. Configure here.

Comment thread src/access-handler.ts
});
if (errResponse) {
return errResponse;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

OAuth state deleted on failed callback

Medium Severity

On /callback, validateOAuthState runs (and deletes the KV entry) before checking whether Access returned an authorization code or before upstream token exchange and identity verification succeed. A denied login, missing code, token error, or failed JWT check consumes the one-time state, so the user cannot retry without restarting the whole MCP OAuth flow.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4a2cf5c. Configure here.

@sergical sergical changed the title Add OAuth 2.1 /internal endpoint (Sentry SSO via Cloudflare Access) + pnpm Add OAuth 2.1 /internal endpoint (Sentry SSO via Cloudflare Access) Jun 13, 2026
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