Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ node_modules/
dist/
.env
.env.local
.dev.vars
*.tsbuildinfo
.sentry-build/

# Sentry Config File
.sentryclirc
.wrangler/
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
],
"license": "MIT",
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.4.0",
"@cloudflare/workers-types": "^4.20260317.1",
"@modelcontextprotocol/sdk": "^1.7.0",
"@sentry/cloudflare": "^10.45.0",
Expand Down
125 changes: 125 additions & 0 deletions src/auth-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { OAuthHelpers, AuthRequest } from "@cloudflare/workers-oauth-provider";

export interface AuthEnv {
OAUTH_PROVIDER: OAuthHelpers;
OAUTH_KV: KVNamespace;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
}

/**
* Handles the OAuth authorization flow using Google SSO.
* Only @sentry.io emails are allowed.
*/
export const authHandler: ExportedHandler<AuthEnv> = {
async fetch(request: Request, env: AuthEnv): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === "/authorize") {
return handleAuthorize(request, env);
}
if (url.pathname === "/callback") {
return handleCallback(request, env);
}

return new Response("Not Found", { status: 404 });
},
};

async function handleAuthorize(request: Request, env: AuthEnv): Promise<Response> {
// Parse the OAuth authorization request from the MCP client
const oauthReq = await env.OAUTH_PROVIDER.parseAuthRequest(request);
if (!oauthReq.clientId) {
return new Response("Invalid OAuth request", { status: 400 });
}

// Store the OAuth request in KV so we can retrieve it after Google callback
const stateKey = crypto.randomUUID();
await env.OAUTH_KV.put(`auth:${stateKey}`, JSON.stringify(oauthReq), {
expirationTtl: 600, // 10 minutes
});

// Redirect to Google OAuth
const googleAuthUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
googleAuthUrl.searchParams.set("client_id", env.GOOGLE_CLIENT_ID);
googleAuthUrl.searchParams.set("redirect_uri", `${new URL(request.url).origin}/callback`);
googleAuthUrl.searchParams.set("response_type", "code");
googleAuthUrl.searchParams.set("scope", "openid email profile");
googleAuthUrl.searchParams.set("state", stateKey);
googleAuthUrl.searchParams.set("hd", "sentry.io"); // Restrict to Sentry domain

return Response.redirect(googleAuthUrl.toString(), 302);
}

async function handleCallback(request: Request, env: AuthEnv): Promise<Response> {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const stateKey = url.searchParams.get("state");

if (!code || !stateKey) {
return new Response("Missing code or state", { status: 400 });
}

// Retrieve the original OAuth request
const stored = await env.OAUTH_KV.get(`auth:${stateKey}`);
if (!stored) {
return new Response("Authorization request expired", { status: 400 });
}
await env.OAUTH_KV.delete(`auth:${stateKey}`);
const oauthReq: AuthRequest = JSON.parse(stored);

// Exchange Google auth code for tokens
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${url.origin}/callback`,
grant_type: "authorization_code",
}),
});

if (!tokenRes.ok) {
return new Response("Failed to exchange Google auth code", { status: 502 });
}

const tokens = (await tokenRes.json()) as { access_token: string };

// Get user info from Google
const userRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});

if (!userRes.ok) {
return new Response("Failed to get user info", { status: 502 });
}

const user = (await userRes.json()) as {
email: string;
name: string;
hd?: string;
};

// Verify @sentry.io email
if (user.hd !== "sentry.io" || !user.email.endsWith("@sentry.io")) {
return new Response("Access restricted to @sentry.io accounts", {
status: 403,
Comment on lines +106 to +108
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The authentication logic incorrectly uses an || operator, which will deny access to legitimate users with @sentry.io emails if they use personal Google accounts.
Severity: MEDIUM

Suggested Fix

Change the logical operator from || (OR) to && (AND) in the if condition. A simpler and more robust fix would be to remove the check for user.hd and only validate that user.email.endsWith("@sentry.io") is true.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/auth-handler.ts#L106-L108

Potential issue: The authentication logic at `src/auth-handler.ts:106` incorrectly uses
an `||` (OR) operator to validate a user's domain. The condition `user.hd !==
"sentry.io" || !user.email.endsWith("@sentry.io")` will incorrectly deny access to users
with a valid `@sentry.io` email address if they authenticate using a personal Google
account, because the `user.hd` field will be `undefined`. An `undefined` `hd` field
causes the first part of the condition to evaluate to `true`, leading to an access
denied error for a legitimate user.

Did we get this right? 👍 / 👎 to inform future reviews.

});
}

// Complete the OAuth authorization — issue our own token
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReq,
userId: user.email,
metadata: { label: `${user.name} (${user.email})` },
scope: oauthReq.scope,
props: {
email: user.email,
name: user.name,
},
});

return Response.redirect(redirectTo, 302);
}
Loading
Loading