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
60 changes: 50 additions & 10 deletions demos/remote-mcp-github-oauth/src/github-handler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { env } from "cloudflare:workers";
import type { AuthRequest, OAuthHelpers } from "@cloudflare/workers-oauth-provider";
import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider";
import { Hono } from "hono";
import { Octokit } from "octokit";
import { fetchUpstreamAuthToken, getUpstreamAuthorizeUrl, type Props } from "./utils";
import {
bindStateToSession,
clientIdAlreadyApproved,
createOAuthState,
generateCSRFProtection,
parseRedirectApproval,
renderApprovalDialog,
validateOAuthState,
} from "./workers-oauth-utils";

const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>();
Expand All @@ -21,16 +25,23 @@ app.get("/authorize", async (c) => {
if (
await clientIdAlreadyApproved(c.req.raw, oauthReqInfo.clientId, env.COOKIE_ENCRYPTION_KEY)
) {
return redirectToGithub(c.req.raw, oauthReqInfo);
// Skip approval dialog but still create secure state and bind to session
const stateToken = await createOAuthState(oauthReqInfo, c.env.OAUTH_KV);
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);
return redirectToGithub(c.req.raw, stateToken, { "Set-Cookie": sessionBindingCookie });
}

const { token: csrfToken, setCookie } = generateCSRFProtection();

return renderApprovalDialog(c.req.raw, {
client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
csrfToken,
server: {
description: "This is a demo MCP Remote Server using GitHub for authentication.",
logo: "https://avatars.githubusercontent.com/u/314135?s=200&v=4",
name: "Cloudflare GitHub MCP Server", // optional
},
setCookie,
state: { oauthReqInfo }, // arbitrary data that flows through the form submission below
});
});
Expand All @@ -42,12 +53,23 @@ app.post("/authorize", async (c) => {
return c.text("Invalid request", 400);
}

return redirectToGithub(c.req.raw, state.oauthReqInfo, headers);
// Create OAuth state and bind it to this user's session
const stateToken = await createOAuthState(state.oauthReqInfo, c.env.OAUTH_KV);
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);

// Set both cookies: approved client list + session binding
const allHeaders = new Headers();
for (const [key, value] of Object.entries(headers)) {
allHeaders.append(key, value);
}
allHeaders.append("Set-Cookie", sessionBindingCookie);

return redirectToGithub(c.req.raw, stateToken, Object.fromEntries(allHeaders));
});

async function redirectToGithub(
request: Request,
oauthReqInfo: AuthRequest,
stateToken: string,
headers: Record<string, string> = {},
) {
return new Response(null, {
Expand All @@ -57,7 +79,7 @@ async function redirectToGithub(
client_id: env.GITHUB_CLIENT_ID,
redirect_uri: new URL("/callback", request.url).href,
scope: "read:user",
state: btoa(JSON.stringify(oauthReqInfo)),
state: stateToken,
upstream_url: "https://github.com/login/oauth/authorize",
}),
},
Expand All @@ -69,13 +91,22 @@ async function redirectToGithub(
* OAuth Callback Endpoint
*
* This route handles the callback from GitHub after user authentication.
* It exchanges the temporary code for an access token, then stores some
* user metadata & the auth token as part of the 'props' on the token passed
* It validates the state parameter, exchanges the temporary code for an access token,
* then stores user metadata & the auth token as part of the 'props' on the token passed
* down to the client. It ends by redirecting the client back to _its_ callback URL
*
* SECURITY: This endpoint validates that the state parameter from GitHub
* matches both:
* 1. A valid state token in KV (proves it was created by our server)
* 2. The __Host-CONSENTED_STATE cookie (proves THIS browser consented to it)
*
* This prevents CSRF attacks where an attacker's state token is injected
* into a victim's OAuth flow.
*/
app.get("/callback", async (c) => {
// Get the oathReqInfo out of KV
const oauthReqInfo = JSON.parse(atob(c.req.query("state") as string)) as AuthRequest;
// Validate OAuth state with session binding
// This checks both KV storage AND the session cookie
const { oauthReqInfo, clearCookie } = await validateOAuthState(c.req.raw, c.env.OAUTH_KV);
if (!oauthReqInfo.clientId) {
return c.text("Invalid state", 400);
}
Expand Down Expand Up @@ -111,7 +142,16 @@ app.get("/callback", async (c) => {
userId: login,
});

return Response.redirect(redirectTo);
// Clear the session binding cookie (one-time use) by creating response with headers
const headers = new Headers({ Location: redirectTo });
if (clearCookie) {
headers.set("Set-Cookie", clearCookie);
}

return new Response(null, {
status: 302,
headers,
});
});

export { app as GitHubHandler };
Loading