From 6cae17585275fff00f5ee406ea2277e90a890c44 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:53:06 +0000 Subject: [PATCH] fix(mcp): return 503 on auth-backend outages instead of invalid_token (#1591) Cherry-picks #1587 from @Sravanjangam, plus the OAuth half on top. When the auth backend is slow or returns a 5xx, the MCP server currently answers `invalid_token`. That is the protocol's signal to discard the credential and re-authenticate, so a brief upstream blip logs every connected client out, and `sm_` API key users have no automatic way back. These requests now return 503 with `Retry-After: 5` so clients retry instead. His change covered the API key path only. This shares one `transientAuthErrorFor` helper between `validateApiKey` and `validateOAuthToken`, so a JWKS timeout or a 5xx also returns 503 on the OAuth path that Claude, Cursor and browser clients use. Genuinely bad tokens are unaffected: bad signature, expired, and no-matching-key still resolve to 401. Verified across all seven cases. Co-Authored-By: Sravanjangam <163002695+Sravanjangam@users.noreply.github.com> --- apps/mcp/src/server/auth/index.test.ts | 35 ++++++++++++++++++- apps/mcp/src/server/auth/index.ts | 44 +++++++++++++++++++++++ apps/mcp/src/server/index.ts | 48 +++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/apps/mcp/src/server/auth/index.test.ts b/apps/mcp/src/server/auth/index.test.ts index 8e8d043ee..d551a7e27 100644 --- a/apps/mcp/src/server/auth/index.test.ts +++ b/apps/mcp/src/server/auth/index.test.ts @@ -1,6 +1,11 @@ import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose" import { afterEach, beforeAll, describe, expect, it, vi } from "vitest" -import { fetchSession, validateApiKey, validateOAuthToken } from "./index" +import { + fetchSession, + TransientAuthError, + validateApiKey, + validateOAuthToken, +} from "./index" const API_URL = "https://api.example.com" const ISSUER = `${API_URL}/api/auth` @@ -180,4 +185,32 @@ describe("MCP authentication", () => { await expect(validateApiKey("not_a_key", API_URL)).resolves.toBeNull() expect(fetchSpy).not.toHaveBeenCalled() }) + + it("surfaces a 500 from the session endpoint as TransientAuthError, not invalid token", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 500 })), + ) + + await expect( + validateApiKey("sm_outage_key_0123456789abcdef", API_URL), + ).rejects.toThrow(TransientAuthError) + }) + + it("surfaces a session-endpoint timeout as TransientAuthError", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue( + Object.assign(new Error("The operation was aborted"), { + name: "TimeoutError", + }), + ), + ) + + await expect( + validateApiKey("sm_timeout_key_0123456789abcd", API_URL), + ).rejects.toThrow(TransientAuthError) + }) }) diff --git a/apps/mcp/src/server/auth/index.ts b/apps/mcp/src/server/auth/index.ts index 425f05970..3e2374a90 100644 --- a/apps/mcp/src/server/auth/index.ts +++ b/apps/mcp/src/server/auth/index.ts @@ -65,6 +65,46 @@ export function isApiKey(token: string): boolean { return API_KEY_PATTERN.test(token) } +// Upstream was unreachable, not the token being bad: reporting these as invalid_token makes clients discard working credentials. +export class TransientAuthError extends Error { + readonly status?: number + + constructor(message: string, status?: number) { + super(message) + this.name = "TransientAuthError" + this.status = status + } +} + +const TRANSIENT_ERROR_NAMES = new Set([ + "AbortError", + "TimeoutError", + "JWKSTimeout", +]) + +// ERR_JOSE_GENERIC is what jose throws when the JWKS endpoint answers non-200 or unparseable JSON. +const TRANSIENT_JOSE_CODES = new Set(["ERR_JWKS_TIMEOUT", "ERR_JOSE_GENERIC"]) + +function transientAuthErrorFor(error: unknown): TransientAuthError | null { + const status = (error as { status?: unknown } | null)?.status + if (typeof status === "number" && status !== 401 && status !== 403) { + return new TransientAuthError(`Session endpoint returned ${status}`, status) + } + if (error instanceof TypeError) { + return new TransientAuthError(`Auth backend unreachable: ${error.message}`) + } + if (error instanceof Error && TRANSIENT_ERROR_NAMES.has(error.name)) { + return new TransientAuthError(error.message) + } + const code = (error as { code?: unknown } | null)?.code + if (typeof code === "string" && TRANSIENT_JOSE_CODES.has(code)) { + return new TransientAuthError( + `JWKS fetch failed: ${(error as Error).message}`, + ) + } + return null +} + export async function validateApiKey( token: string, apiUrl: string, @@ -93,6 +133,8 @@ export async function validateApiKey( return user } catch (error) { console.error("API key validation error:", error) + const transient = transientAuthErrorFor(error) + if (transient) throw transient return null } } @@ -142,6 +184,8 @@ export async function validateOAuthToken( } } catch (error) { console.error("OAuth token validation error:", error) + const transient = transientAuthErrorFor(error) + if (transient) throw transient return null } } diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index 0fac82c71..70505a21f 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -4,6 +4,7 @@ import { Hono, type Context } from "hono" import { cors } from "hono/cors" import { isApiKey, + TransientAuthError, validateApiKey, validateOAuthToken, type AuthUser, @@ -47,7 +48,7 @@ app.use( allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], // When omitted, Hono echoes Access-Control-Request-Headers. This keeps // modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible. - exposeHeaders: ["WWW-Authenticate"], + exposeHeaders: ["WWW-Authenticate", "Retry-After"], }), ) @@ -128,6 +129,30 @@ function authInfoFor( } } +type AuthResolution = + | { ok: true; user: AuthUser } + | { ok: false; reason: "invalid" } + | { ok: false; reason: "transient" } + +// Keeps a transient upstream failure distinct from an invalid token. +async function resolveAuthUser( + token: string, + apiUrl: string, + mcpResource: string, +): Promise { + try { + const user = isApiKey(token) + ? await validateApiKey(token, apiUrl) + : await validateOAuthToken(token, apiUrl, mcpResource) + return user ? { ok: true, user } : { ok: false, reason: "invalid" } + } catch (error) { + if (error instanceof TransientAuthError) { + return { ok: false, reason: "transient" } + } + throw error + } +} + function unauthorizedResponse( resourceMetadataUrl: string, invalidToken = false, @@ -181,10 +206,23 @@ async function handleMcpRequest( if (!token) return unauthorizedResponse(resourceMetadataUrl) - const authUser = isApiKey(token) - ? await validateApiKey(token, apiUrl) - : await validateOAuthToken(token, apiUrl, mcpResource) - if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true) + const resolved = await resolveAuthUser(token, apiUrl, mcpResource) + if (!resolved.ok && resolved.reason === "transient") { + return Response.json( + { + jsonrpc: "2.0", + error: { + code: -32001, + message: + "Authentication backend temporarily unavailable, please retry", + }, + id: null, + }, + { status: 503, headers: { "Retry-After": "5" } }, + ) + } + if (!resolved.ok) return unauthorizedResponse(resourceMetadataUrl, true) + const authUser = resolved.user const actor: ActorContext = { userId: authUser.userId,