Skip to content
Closed
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: 1 addition & 1 deletion packages/mcp-cloudflare/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const addCorsHeaders = (response: Response): Response => {
};

// Wrap OAuth Provider to restrict CORS headers on public metadata endpoints
// OAuth Provider v0.0.12 adds overly permissive CORS (allows all methods/headers).
// OAuth Provider v0.1.0 adds overly permissive CORS (allows all methods/headers).
// We override with secure headers for .well-known endpoints and add CORS to robots.txt/llms.txt.
const wrappedOAuthProvider = {
fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {
Expand Down
64 changes: 64 additions & 0 deletions packages/mcp-cloudflare/src/server/oauth/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,70 @@ describe("tokenExchangeCallback", () => {
"Failed to refresh upstream token in OAuth provider",
);
});

it("should fall back to cached token when refresh fails and TTL > 1 min", async () => {
// Set expiry to 90 seconds (less than 2-minute safety window, so refresh is attempted)
// but more than 1 minute (so fallback succeeds instead of throwing)
const nearExpiry = Date.now() + 90 * 1000; // 90 seconds left
const options: TokenExchangeCallbackOptions = {
grantType: "refresh_token",
clientId: "test-client-id",
userId: "test-user-id",
scope: ["org:read", "project:read"],
props: {
id: "user-id",
clientId: "test-client-id",
scope: "org:read project:read",
accessToken: "cached-token",
refreshToken: "refresh-token",
accessTokenExpiresAt: nearExpiry,
} as WorkerProps,
};

// Mock failed refresh response (network error or service unavailable)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => "Service unavailable",
});

const result = await tokenExchangeCallback(options, mockEnv);

// Should return TTL without newProps to prevent overwriting tokens in race conditions
expect(result).toBeDefined();
expect(result?.newProps).toBeUndefined(); // No newProps to prevent stale prop overwrites
expect(result?.accessTokenTTL).toBeLessThanOrEqual(90); // ~90 seconds
expect(result?.accessTokenTTL).toBeGreaterThan(0);
});

it("should throw error when refresh fails and TTL < 1 min", async () => {
const almostExpired = Date.now() + 30 * 1000; // 30 seconds left
const options: TokenExchangeCallbackOptions = {
grantType: "refresh_token",
clientId: "test-client-id",
userId: "test-user-id",
scope: ["org:read", "project:read"],
props: {
id: "user-id",
clientId: "test-client-id",
scope: "org:read project:read",
accessToken: "cached-token",
refreshToken: "refresh-token",
accessTokenExpiresAt: almostExpired,
} as WorkerProps,
};

// Mock failed refresh response
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => "Service unavailable",
});

await expect(tokenExchangeCallback(options, mockEnv)).rejects.toThrow(
"Failed to refresh upstream token",
);
});
});

describe("refreshAccessToken", () => {
Expand Down
120 changes: 82 additions & 38 deletions packages/mcp-cloudflare/src/server/oauth/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
TokenExchangeCallbackResult,
} from "@cloudflare/workers-oauth-provider";
import type { z } from "zod";
import { logIssue } from "@sentry/mcp-core/telem/logging";
import { logIssue, logInfo, logWarn } from "@sentry/mcp-core/telem/logging";
import { TokenResponseSchema, SENTRY_TOKEN_URL } from "./constants";
import type { WorkerProps } from "../types";
import * as Sentry from "@sentry/cloudflare";
Expand Down Expand Up @@ -213,6 +213,47 @@ export async function refreshAccessToken({
}
}

/**
* Handles errors during token refresh with graceful fallback.
* If the token has enough TTL remaining (>1 minute), reuses it instead of failing.
*
* IMPORTANT: Does NOT return newProps to prevent race condition corruption.
* When multiple requests race to refresh, returning stale props can overwrite
* newer tokens that were just saved by a concurrent request.
*/
function handleRefreshError(
error: any,
props: WorkerProps,
): TokenExchangeCallbackResult {
const maybeExpiresAt = props.accessTokenExpiresAt;
if (maybeExpiresAt) {
const remainingMs = maybeExpiresAt - Date.now();
if (remainingMs > 60 * 1000) {
// At least 1 minute left
logWarn("Upstream refresh failed, falling back to cached token", {
loggerScope: ["cloudflare", "oauth", "refresh"],
extra: {
error: String(error),
remainingSec: Math.floor(remainingMs / 1000),
},
});

// Do NOT return newProps here - only return TTL
// This tells the OAuth provider to keep using existing props in KV
// without overwriting them with potentially stale data from this request.
// In race conditions, another request may have already updated the tokens.
return {
accessTokenTTL: Math.floor(remainingMs / 1000),
};
}
}

logIssue(error);
throw new Error("Failed to refresh upstream token in OAuth provider", {
cause: error,
});
}

/**
* Token exchange callback for handling Sentry OAuth token refreshes.
*/
Expand Down Expand Up @@ -241,31 +282,43 @@ export async function tokenExchangeCallback(
return undefined;
}

try {
// If we have a cached upstream expiry, and there's ample time left,
// avoid calling upstream to reduce unnecessary refreshes.
// Mint a new provider token with the remaining TTL.
const props = options.props as WorkerProps;
const maybeExpiresAt = props.accessTokenExpiresAt;
if (maybeExpiresAt && Number.isFinite(maybeExpiresAt)) {
const remainingMs = maybeExpiresAt - Date.now();
const SAFE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes safety window
if (remainingMs > SAFE_WINDOW_MS) {
const remainingSec = Math.floor(remainingMs / 1000);
return {
newProps: { ...options.props },
accessTokenTTL: remainingSec,
};
}
// If we have a cached upstream expiry, and there's ample time left,
// avoid calling upstream to reduce unnecessary refreshes.
// Mint a new provider token with the remaining TTL.
const props = options.props as WorkerProps;
const maybeExpiresAt = props.accessTokenExpiresAt;
if (maybeExpiresAt && Number.isFinite(maybeExpiresAt)) {
const remainingMs = maybeExpiresAt - Date.now();
const SAFE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes safety window
if (remainingMs > SAFE_WINDOW_MS) {
const remainingSec = Math.floor(remainingMs / 1000);
logInfo("Token still valid, reusing cached token", {
loggerScope: ["cloudflare", "oauth", "refresh"],
extra: { remainingSec },
});
return {
newProps: { ...options.props },
accessTokenTTL: remainingSec,
};
}
}

// Construct the upstream token URL for Sentry
const upstreamTokenUrl = new URL(
SENTRY_TOKEN_URL,
`https://${env.SENTRY_HOST || "sentry.io"}`,
).href;
// Construct the upstream token URL for Sentry
const upstreamTokenUrl = new URL(
SENTRY_TOKEN_URL,
`https://${env.SENTRY_HOST || "sentry.io"}`,
).href;

// Use our refresh token function to get new tokens from Sentry
logInfo("Token expired or expiring soon, refreshing from Sentry", {
loggerScope: ["cloudflare", "oauth", "refresh"],
extra: {
expiresAt: maybeExpiresAt,
hasExpiry: !!maybeExpiresAt,
},
});

// Use our refresh token function to get new tokens from Sentry
try {
const [tokenResponse, errorResponse] = await refreshAccessToken({
client_id: env.SENTRY_CLIENT_ID,
client_secret: env.SENTRY_CLIENT_SECRET,
Expand All @@ -274,19 +327,13 @@ export async function tokenExchangeCallback(
});

if (errorResponse) {
// Convert the Response to an Error for the OAuth provider
const errorText = await errorResponse.text();
throw new Error(
`Failed to refresh upstream token in OAuth provider: ${errorText}`,
);
return handleRefreshError(errorResponse, props);
}

if (!tokenResponse.refresh_token) {
logIssue("[oauth] Upstream refresh response missing refresh_token", {
loggerScope: ["cloudflare", "oauth", "refresh"],
});
return undefined;
}
logInfo("Successfully refreshed upstream token", {
loggerScope: ["cloudflare", "oauth", "refresh"],
extra: { expires_in: tokenResponse.expires_in },
});

// Return the updated props with new tokens and TTL
return {
Expand All @@ -300,9 +347,6 @@ export async function tokenExchangeCallback(
accessTokenTTL: tokenResponse.expires_in,
};
} catch (error) {
logIssue(error);
throw new Error("Failed to refresh upstream token in OAuth provider", {
cause: error,
});
return handleRefreshError(error, props);
}
}
21 changes: 20 additions & 1 deletion packages/mcp-cloudflare/src/server/oauth/routes/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Env, WorkerProps } from "../../types";
import { SENTRY_TOKEN_URL } from "../constants";
import { exchangeCodeForAccessToken } from "../helpers";
import { verifyAndParseState, type OAuthState } from "../state";
import { logWarn } from "@sentry/mcp-core/telem/logging";
import { logWarn, logIssue } from "@sentry/mcp-core/telem/logging";
import { parseSkills } from "@sentry/mcp-core/skills";

/**
Expand Down Expand Up @@ -120,6 +120,25 @@ export default new Hono<{ Bindings: Env }>().get("/", async (c) => {
});
if (errResponse) return errResponse;

// Validate that Sentry returned a refresh token
// Without it, future token refreshes will fail
if (!payload.refresh_token) {
const eventId = logIssue(
"Sentry did not return refresh_token during authorization",
{
loggerScope: ["cloudflare", "oauth", "callback"],
oauth: {
client_id: c.env.SENTRY_CLIENT_ID,
user_id: payload.user.id,
},
},
);
return c.text(
"Authentication failed: No refresh token provided. Please try again.",
{ status: 500, headers: { "X-Event-ID": eventId ?? "" } },
);
}

// Parse and validate granted skills first
const { valid: validSkills, invalid: invalidSkills } = parseSkills(
oauthReqInfo.skills,
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

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

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ catalog:
"@biomejs/biome": ^1.9.4
"@cloudflare/vite-plugin": ^1.13.15
"@cloudflare/vitest-pool-workers": ^0.8.47
"@cloudflare/workers-oauth-provider": ^0.0.12
"@cloudflare/workers-oauth-provider": ^0.1.0
"@cloudflare/workers-types": ^4.20251014.0
"@modelcontextprotocol/sdk": ^1.21.0
"@radix-ui/react-accordion": ^1.2.11
Expand Down
Loading