Skip to content

Commit f7c11c8

Browse files
authored
Merge pull request #299 from cloudflare/fix-state-management
fix: state management using a session binding
2 parents 8bb2a0c + a1792da commit f7c11c8

File tree

23 files changed

+871
-90
lines changed

23 files changed

+871
-90
lines changed

demos/mcp-slack-oauth/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Remote Slack MCP Server with OAuth
22

3-
This is an implementation of a remote Slack [MCP server](https://modelcontextprotocol.io/introduction) that requires users to login to their Slack account in order to use the tools to read and post messages from their channels.
3+
This is an implementation of a remote Slack [MCP server](https://modelcontextprotocol.io/introduction) that requires users to login to their Slack account in order to use the tools to read and post messages from their channels.
44

55
You can deploy it to your own Cloudflare account, once you create a [Slack OAuth](https://api.slack.com/authentication/oauth-v2) app.
66

@@ -11,6 +11,9 @@ The MCP server (powered by Cloudflare Workers):
1111
Acts as OAuth Server to your MCP clients
1212
Acts as OAuth Client to your real OAuth server (in this case, Slack)
1313

14+
> [!WARNING]
15+
> This is a demo template designed to help you get started quickly. While we have implemented several security controls, **you must implement all preventive and defense-in-depth security measures before deploying to production**. Please review our comprehensive security guide: [Securing MCP Servers](https://github.com/cloudflare/agents/blob/main/docs/securing-mcp-servers.md)
16+
1417
## Available Tools
1518

1619
- `whoami`: Get information about your Slack user

demos/mcp-slack-oauth/src/slack-handler.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Hono } from "hono";
44
import { getUpstreamAuthorizeUrl } from "./utils";
55
import {
66
addApprovedClient,
7+
bindStateToSession,
78
createOAuthState,
89
generateCSRFProtection,
910
isClientApproved,
@@ -45,9 +46,10 @@ app.get("/authorize", async (c) => {
4546

4647
// Check if client is already approved
4748
if (await isClientApproved(c.req.raw, clientId, c.env.COOKIE_ENCRYPTION_KEY)) {
48-
// Skip approval dialog but still create secure state
49+
// Skip approval dialog but still create secure state and bind to session
4950
const { stateToken } = await createOAuthState(oauthReqInfo, c.env.OAUTH_KV);
50-
return redirectToSlack(c.req.raw, stateToken);
51+
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);
52+
return redirectToSlack(c.req.raw, stateToken, { "Set-Cookie": sessionBindingCookie });
5153
}
5254

5355
// Generate CSRF protection for the approval form
@@ -97,10 +99,16 @@ app.post("/authorize", async (c) => {
9799
c.env.COOKIE_ENCRYPTION_KEY,
98100
);
99101

100-
// Create OAuth state with CSRF protection
102+
// Create OAuth state and bind it to this user's session
101103
const { stateToken } = await createOAuthState(state.oauthReqInfo, c.env.OAUTH_KV);
104+
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);
102105

103-
return redirectToSlack(c.req.raw, stateToken, { "Set-Cookie": approvedClientCookie });
106+
// Set both cookies: approved client list + session binding
107+
const headers = new Headers();
108+
headers.append("Set-Cookie", approvedClientCookie);
109+
headers.append("Set-Cookie", sessionBindingCookie);
110+
111+
return redirectToSlack(c.req.raw, stateToken, Object.fromEntries(headers));
104112
} catch (error: any) {
105113
console.error("POST /authorize error:", error);
106114
if (error instanceof OAuthError) {
@@ -156,14 +164,25 @@ type SlackOauthTokenResponse =
156164
* It validates the state parameter, exchanges the temporary code for an access token,
157165
* then stores user metadata & the auth token as part of the 'props' on the token passed
158166
* down to the client. It ends by redirecting the client back to _its_ callback URL
167+
*
168+
* SECURITY: This endpoint validates that the state parameter from Slack
169+
* matches both:
170+
* 1. A valid state token in KV (proves it was created by our server)
171+
* 2. The __Host-CONSENTED_STATE cookie (proves THIS browser consented to it)
172+
*
173+
* This prevents CSRF attacks where an attacker's state token is injected
174+
* into a victim's OAuth flow.
159175
*/
160176
app.get("/callback", async (c) => {
161-
// Validate OAuth state (retrieves stored data from KV)
177+
// Validate OAuth state with session binding
178+
// This checks both KV storage AND the session cookie
162179
let oauthReqInfo: AuthRequest;
180+
let clearSessionCookie: string;
163181

164182
try {
165183
const result = await validateOAuthState(c.req.raw, c.env.OAUTH_KV);
166184
oauthReqInfo = result.oauthReqInfo;
185+
clearSessionCookie = result.clearCookie;
167186
} catch (error: any) {
168187
if (error instanceof OAuthError) {
169188
return error.toResponse();
@@ -247,7 +266,16 @@ app.get("/callback", async (c) => {
247266
userId: userId,
248267
});
249268

250-
return Response.redirect(redirectTo, 302);
269+
// Clear the session binding cookie (one-time use) by creating response with headers
270+
const headers = new Headers({ Location: redirectTo });
271+
if (clearSessionCookie) {
272+
headers.set("Set-Cookie", clearSessionCookie);
273+
}
274+
275+
return new Response(null, {
276+
status: 302,
277+
headers,
278+
});
251279
});
252280

253281
export const SlackHandler = app;

demos/mcp-slack-oauth/src/workers-oauth-utils.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ export interface ValidateStateResult {
6666
clearCookie: string;
6767
}
6868

69+
/**
70+
* Result from bindStateToSession containing the cookie to set
71+
*/
72+
export interface BindStateResult {
73+
/**
74+
* Set-Cookie header value to bind the state to the user's session
75+
*/
76+
setCookie: string;
77+
}
78+
6979
/**
7080
* Result from generateCSRFProtection containing the CSRF token and cookie header
7181
*/
@@ -256,8 +266,43 @@ export async function createOAuthState(
256266
}
257267

258268
/**
259-
* Validates OAuth state from the request, ensuring the state parameter matches the cookie
260-
* and retrieving the stored OAuth request information
269+
* Binds an OAuth state token to the user's browser session using a secure cookie.
270+
* This prevents CSRF attacks where an attacker's state token is used by a victim.
271+
*
272+
* SECURITY: This cookie proves that the browser completing the OAuth callback
273+
* is the same browser that consented to the authorization request.
274+
*
275+
* We hash the state token rather than storing it directly for defense-in-depth:
276+
* - Even if the state parameter leaks (URL logs, referrer headers), the cookie value cannot be derived
277+
* - The cookie serves as cryptographic proof of consent, not just a copy of the state
278+
* - Provides an additional layer of security beyond HttpOnly/Secure flags
279+
*
280+
* @param stateToken - The state token to bind to the session
281+
* @returns Object containing the Set-Cookie header to send to the client
282+
*/
283+
export async function bindStateToSession(stateToken: string): Promise<BindStateResult> {
284+
const consentedStateCookieName = "__Host-CONSENTED_STATE";
285+
286+
// Hash the state token to provide defense-in-depth
287+
const encoder = new TextEncoder();
288+
const data = encoder.encode(stateToken);
289+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
290+
const hashArray = Array.from(new Uint8Array(hashBuffer));
291+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
292+
293+
const setCookie = `${consentedStateCookieName}=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
294+
295+
return { setCookie };
296+
}
297+
298+
/**
299+
* Validates OAuth state from the request, ensuring:
300+
* 1. The state parameter exists in KV (proves it was created by our server)
301+
* 2. The state hash matches the session cookie (proves this browser consented to it)
302+
*
303+
* This prevents attacks where an attacker's valid state token is injected into
304+
* a victim's OAuth flow.
305+
*
261306
* @param request - The HTTP request containing state parameter and cookies
262307
* @param kv - Cloudflare KV namespace for storing OAuth state data
263308
* @returns Object containing the original OAuth request info and cookie to clear
@@ -267,6 +312,7 @@ export async function validateOAuthState(
267312
request: Request,
268313
kv: KVNamespace,
269314
): Promise<ValidateStateResult> {
315+
const consentedStateCookieName = "__Host-CONSENTED_STATE";
270316
const url = new URL(request.url);
271317
const stateFromQuery = url.searchParams.get("state");
272318

@@ -280,6 +326,38 @@ export async function validateOAuthState(
280326
throw new OAuthError("invalid_request", "Invalid or expired state", 400);
281327
}
282328

329+
// SECURITY FIX: Validate that this state token belongs to this browser session
330+
// by checking that the state hash matches the session cookie
331+
const cookieHeader = request.headers.get("Cookie") || "";
332+
const cookies = cookieHeader.split(";").map((c) => c.trim());
333+
const consentedStateCookie = cookies.find((c) => c.startsWith(`${consentedStateCookieName}=`));
334+
const consentedStateHash = consentedStateCookie
335+
? consentedStateCookie.substring(consentedStateCookieName.length + 1)
336+
: null;
337+
338+
if (!consentedStateHash) {
339+
throw new OAuthError(
340+
"invalid_request",
341+
"Missing session binding cookie - authorization flow must be restarted",
342+
400,
343+
);
344+
}
345+
346+
// Hash the state from query and compare with cookie
347+
const encoder = new TextEncoder();
348+
const data = encoder.encode(stateFromQuery);
349+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
350+
const hashArray = Array.from(new Uint8Array(hashBuffer));
351+
const stateHash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
352+
353+
if (stateHash !== consentedStateHash) {
354+
throw new OAuthError(
355+
"invalid_request",
356+
"State token does not match session - possible CSRF attack detected",
357+
400,
358+
);
359+
}
360+
283361
let oauthReqInfo: AuthRequest;
284362
try {
285363
oauthReqInfo = JSON.parse(storedDataJson) as AuthRequest;
@@ -290,8 +368,8 @@ export async function validateOAuthState(
290368
// Delete state from KV (one-time use)
291369
await kv.delete(`oauth:state:${stateFromQuery}`);
292370

293-
// No cookie to clear since we're not using state cookies anymore
294-
const clearCookie = "";
371+
// Clear the session binding cookie (one-time use per OAuth flow)
372+
const clearCookie = `${consentedStateCookieName}=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`;
295373

296374
return { oauthReqInfo, clearCookie };
297375
}

demos/mcp-stytch-b2b-okr-manager/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
This is a Workers server that composes three functions:
44
* A static website built using React and Vite on top of [Worker Assets](https://developers.cloudflare.com/workers/static-assets/)
5-
* A REST API built using Hono on top of [Workers KV](https://developers.cloudflare.com/kv/)
5+
* A REST API built using Hono on top of [Workers KV](https://developers.cloudflare.com/kv/)
66
* A [Model Context Protocol](https://modelcontextprotocol.io/introduction) Server built using on top of [Workers Durable Objects](https://developers.cloudflare.com/durable-objects/)
77

88
Member, Tenant, and client identity is managed using [Stytch](https://stytch.com/). Put together, these three features show how to extend a traditional full-stack CRUD application for use by an AI agent.
99

10-
This demo uses the [Stytch B2B](https://stytch.com/b2b) product, which is purpose-built for B2B SaaS authentication requirements like multi-tenancy, MFA, and RBAC.
10+
This demo uses the [Stytch B2B](https://stytch.com/b2b) product, which is purpose-built for B2B SaaS authentication requirements like multi-tenancy, MFA, and RBAC.
1111
If you are more interested in Stytch's [Consumer](https://stytch.com/b2c) product, see [this demo](https://github.com/stytchauth/mcp-stytch-consumer-todo-list/) instead.
1212

13+
> [!WARNING]
14+
> This is a demo template designed to help you get started quickly. While we have implemented several security controls, **you must implement all preventive and defense-in-depth security measures before deploying to production**. Please review our comprehensive security guide: [Securing MCP Servers](https://github.com/cloudflare/agents/blob/main/docs/securing-mcp-servers.md)
15+
1316
![](./.github/hero.png)
1417

1518
## Set up

demos/mcp-stytch-consumer-todo-list/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
This is a Workers server that composes three functions:
44

55
* A static website built using React and Vite on top of [Worker Assets](https://developers.cloudflare.com/workers/static-assets/)
6-
* A REST API built using Hono on top of [Workers KV](https://developers.cloudflare.com/kv/)
6+
* A REST API built using Hono on top of [Workers KV](https://developers.cloudflare.com/kv/)
77
* A [Model Context Protocol](https://modelcontextprotocol.io/introduction) Server built using on top of [Workers Durable Objects](https://developers.cloudflare.com/durable-objects/)
88

99
User and client identity is managed using [Stytch](https://stytch.com/). Put together, these three features show how to extend a traditional full-stack application for use by an AI agent.
1010

1111
This demo uses the [Stytch Consumer](https://stytch.com/b2c) product, which is purpose-built for Consumer SaaS authentication requirements.
1212
If you are more interested in Stytch's [B2B](https://stytch.com/b2b) product, see [this demo](https://github.com/stytchauth/mcp-stytch-b2b-okr-manager/) instead.
1313

14+
> [!WARNING]
15+
> This is a demo template designed to help you get started quickly. While we have implemented several security controls, **you must implement all preventive and defense-in-depth security measures before deploying to production**. Please review our comprehensive security guide: [Securing MCP Servers](https://github.com/cloudflare/agents/blob/main/docs/securing-mcp-servers.md)
16+
1417
## Set up
1518

1619
Follow the steps below to get this application fully functional and running using your own Stytch credentials.

demos/remote-mcp-auth0/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ The MCP server (powered by [Cloudflare Workers](https://developers.cloudflare.co
77
- Acts as OAuth _Server_ to your MCP clients
88
- Acts as OIDC _Client_ to your Auth0 Tenant
99

10+
> [!WARNING]
11+
> This is a demo template designed to help you get started quickly. While we have implemented several security controls, **you must implement all preventive and defense-in-depth security measures before deploying to production**. Please review our comprehensive security guide: [Securing MCP Servers](https://github.com/cloudflare/agents/blob/main/docs/securing-mcp-servers.md)
12+
1013
## Getting Started
1114

1215
This demo allows an MCP Server to call a protected API on behalf of the authenticated user. To get started you will need the following:

demos/remote-mcp-auth0/mcp-auth0-oidc/src/auth.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import * as oauth from "oauth4webapi";
1212

1313
import type { UserProps } from "./types";
1414
import {
15+
addApprovedClient,
16+
bindStateToSession,
1517
createOAuthState,
1618
generateCSRFProtection,
1719
isClientApproved,
18-
addApprovedClient,
1920
OAuthError,
2021
renderApprovalDialog,
2122
validateCSRFToken,
@@ -79,7 +80,7 @@ export async function authorize(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: O
7980
c.env.COOKIE_ENCRYPTION_KEY,
8081
)
8182
) {
82-
// Skip approval dialog but still use secure state management
83+
// Skip approval dialog but still create secure state and bind to session
8384
// Generate all that is needed for the Auth0 auth request
8485
const codeVerifier = oauth.generateRandomCodeVerifier();
8586
const nonce = oauth.generateRandomNonce();
@@ -99,6 +100,7 @@ export async function authorize(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: O
99100
auth0Data,
100101
};
101102
const { stateToken } = await createOAuthState(extendedRequest, c.env.OAUTH_KV);
103+
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);
102104

103105
// Redirect directly to Auth0
104106
const { as } = await getOidcConfig({
@@ -118,7 +120,13 @@ export async function authorize(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: O
118120
authorizationUrl.searchParams.set("nonce", nonce);
119121
authorizationUrl.searchParams.set("state", stateToken);
120122

121-
return c.redirect(authorizationUrl.href);
123+
return new Response(null, {
124+
status: 302,
125+
headers: {
126+
"Location": authorizationUrl.href,
127+
"Set-Cookie": sessionBindingCookie,
128+
},
129+
});
122130
}
123131

124132
// Generate CSRF protection for the approval form
@@ -191,12 +199,13 @@ export async function confirmConsent(
191199
c.env.COOKIE_ENCRYPTION_KEY,
192200
);
193201

194-
// Create OAuth state in KV (secure, one-time use)
202+
// Create OAuth state and bind it to this user's session
195203
const extendedRequest: ExtendedAuthRequest = {
196204
...state.oauthReqInfo,
197205
auth0Data: state.auth0Data,
198206
};
199207
const { stateToken } = await createOAuthState(extendedRequest, c.env.OAUTH_KV);
208+
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);
200209

201210
// Get Auth0 configuration
202211
const { as } = await getOidcConfig({
@@ -217,13 +226,15 @@ export async function confirmConsent(
217226
authorizationUrl.searchParams.set("nonce", state.auth0Data.nonce);
218227
authorizationUrl.searchParams.set("state", stateToken);
219228

220-
// Return redirect with cleared CSRF cookie and new approved client cookie
229+
// Set both cookies: approved client list + session binding
230+
const headers = new Headers();
231+
headers.append("Set-Cookie", approvedClientCookie);
232+
headers.append("Set-Cookie", sessionBindingCookie);
233+
headers.set("Location", authorizationUrl.href);
234+
221235
return new Response(null, {
222236
status: 302,
223-
headers: {
224-
Location: authorizationUrl.href,
225-
"Set-Cookie": approvedClientCookie,
226-
},
237+
headers,
227238
});
228239
} catch (error: any) {
229240
console.error("POST /authorize error:", error);
@@ -241,14 +252,25 @@ export async function confirmConsent(
241252
* This route handles the callback from Auth0 after user authentication.
242253
* It validates the OAuth state, exchanges the authorization code for tokens,
243254
* and completes the authorization process.
255+
*
256+
* SECURITY: This endpoint validates that the state parameter from Auth0
257+
* matches both:
258+
* 1. A valid state token in KV (proves it was created by our server)
259+
* 2. The __Host-CONSENTED_STATE cookie (proves THIS browser consented to it)
260+
*
261+
* This prevents CSRF attacks where an attacker's state token is injected
262+
* into a victim's OAuth flow.
244263
*/
245264
export async function callback(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>) {
246-
// Validate OAuth state (retrieves stored data from KV)
265+
// Validate OAuth state with session binding
266+
// This checks both KV storage AND the session cookie
247267
let storedData: ExtendedAuthRequest;
268+
let clearSessionCookie: string;
248269

249270
try {
250271
const result = await validateOAuthState(c.req.raw, c.env.OAUTH_KV);
251272
storedData = result.oauthReqInfo as ExtendedAuthRequest;
273+
clearSessionCookie = result.clearCookie;
252274
} catch (error: any) {
253275
if (error instanceof OAuthError) {
254276
return error.toResponse();
@@ -312,7 +334,16 @@ export async function callback(c: Context<{ Bindings: Env & { OAUTH_PROVIDER: OA
312334
userId: claims.sub!,
313335
});
314336

315-
return Response.redirect(redirectTo);
337+
// Clear the session binding cookie (one-time use) by creating response with headers
338+
const headers = new Headers({ Location: redirectTo });
339+
if (clearSessionCookie) {
340+
headers.set("Set-Cookie", clearSessionCookie);
341+
}
342+
343+
return new Response(null, {
344+
status: 302,
345+
headers,
346+
});
316347
}
317348

318349
/**

0 commit comments

Comments
 (0)