Add OAuth 2.1 /internal endpoint (Sentry SSO via Cloudflare Access)#2
Add OAuth 2.1 /internal endpoint (Sentry SSO via Cloudflare Access)#2sergical wants to merge 6 commits into
Conversation
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>
532c54f to
59f8e3f
Compare
59f8e3f to
c32fa9e
Compare
c32fa9e to
da1ebe0
Compare
da1ebe0 to
d05ca83
Compare
d05ca83 to
bf7b33e
Compare
bf7b33e to
391f7af
Compare
391f7af to
86b69f3
Compare
8b9b7f9 to
b22eb5b
Compare
b22eb5b to
d53c4d2
Compare
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>
d53c4d2 to
2c5b563
Compare
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>
| ); | ||
| if (!valid) return null; | ||
|
|
||
| const payload = JSON.parse(base64UrlDecode(parts[1])) as AccessJwtPayload; |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 1f99394. Configure here.
| state.oauthReqInfo, | ||
| env.OAUTH_KV, | ||
| env.COOKIE_ENCRYPTION_KEY, | ||
| ); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 1f99394. Configure here.
| baseUrl: env.PLAUSIBLE_BASE_URL, | ||
| defaultSiteId: env.PLAUSIBLE_DEFAULT_SITE_ID, | ||
| recordPii: true, | ||
| }); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 1f99394. Configure here.
3178cb9 to
5a58292
Compare
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>
5a58292 to
4a2cf5c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ 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.
| }); | ||
| if (errResponse) { | ||
| return errResponse; | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 4a2cf5c. Configure here.


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./mcpis unchanged.How
/internaldiffers from the existing/mcpmodel@cloudflare/workers-oauth-provider) and federates login to Cloudflare Access as the upstream OIDC provider (Sentry Google SSO).@sentry.ioidentities are admitted; queries run against one shared, server-side Plausible key, so connector users never handle a key (vs./mcpwhere the user's own key is the credential).cf-access.ts).Security posture of the new auth surface
@sentry.iogateuserIdis a hash and no email is stored in metadata; the email lives only in the encryptedprops, used for Sentry attribution/registerkept as fallback;global_fetch_strictly_publicset (CIMD requirement + SSRF guard)sendDefaultPii: false, andAuthorization/ cookie /Cf-Access-Jwt-Assertionscrubbed from spansX-Frame-Options, etc.) on all responsesOther effects on the codebase
worker.ts,env.ts,access-handler.ts,workers-oauth-utils.ts) are now type-checked viapnpm typecheck(tsconfig.worker.json); previously they were transpiled without type-checking.@modelcontextprotocol/sdkis pinned to1.28.0so it resolves to a single copy (matching whatagents@0.8.7ships), required for the worker type-check to pass.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.ioSSO policy), createOAUTH_KV, set the secrets listed in the README, then run the end-to-end Cowork connect test.Verification
Worker type-check +
pnpm buildclean · 68 tests pass · dependency-review + CodeQL + warden green.