From 3d78896895cf1b59df4b361087b1026ec143b49e Mon Sep 17 00:00:00 2001 From: Anshu Saurabh <677936+anshusaurav@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:13:18 +0530 Subject: [PATCH] fix(auth): check token expiry before scopes in withMcpAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expired token that also lacked a required scope was reported as 403 insufficient_scope instead of 401 invalid_token. Per RFC 6750 §3.1 an expired token is always invalid_token — this matters because clients use the error code to decide whether to refresh the token or surface a permissions error. Move the expiresAt check above the requiredScopes check so expiry is evaluated first. Add regression tests covering both orderings. Fixes #181 --- src/auth/auth-wrapper.ts | 13 +++++--- tests/auth.test.ts | 68 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/auth/auth-wrapper.ts b/src/auth/auth-wrapper.ts index 9d6c6ae..2b138a7 100644 --- a/src/auth/auth-wrapper.ts +++ b/src/auth/auth-wrapper.ts @@ -74,6 +74,14 @@ export function withMcpAuth( return handler(req); } + // Check if the token is expired before authorizing it. An expired token + // is invalid_token (401) per RFC 6750 §3.1, so it has to be reported that + // way even when scopes are also missing: insufficient_scope (403) tells + // the client its permissions are wrong and it will not refresh. + if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { + throw new OAuthError(OAuthErrorCode.InvalidToken, "Token has expired"); + } + // Check if token has the required scopes (if any) if (requiredScopes?.length) { const hasAllScopes = requiredScopes.every((scope) => @@ -88,11 +96,6 @@ export function withMcpAuth( } } - // Check if the token is expired - if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { - throw new OAuthError(OAuthErrorCode.InvalidToken, "Token has expired"); - } - // Set auth info on the request object after successful verification req.auth = authInfo; diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 86bd43e..179576e 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { protectedResourceHandler } from "../src/index"; +import { describe, it, expect, vi } from "vitest"; +import { protectedResourceHandler, withMcpAuth } from "../src/index"; describe("auth", () => { describe("resource metadata URL to resource identifier mapping", () => { @@ -137,5 +137,69 @@ describe("auth", () => { expect(json.resource).toBe("https://my-public-domain.com"); }); }); + + describe("withMcpAuth token expiry and scope checks", () => { + const ok = () => new Response("ok"); + + function buildHandler(requiredScopes?: string[]) { + return withMcpAuth(ok, (_req, bearer) => { + if (!bearer) return undefined; + const [scopes, expiresAt] = bearer.split("|"); + return { + token: bearer, + clientId: "c1", + scopes: scopes.split(","), + expiresAt: expiresAt ? Number(expiresAt) : undefined, + }; + }, { required: true, requiredScopes }); + } + + function request(token: string) { + return new Request("https://example.com/mcp", { + headers: { Authorization: `Bearer ${token}` }, + }); + } + + it("returns 401 for an expired token even when scopes are missing", async () => { + const handler = buildHandler(["admin"]); + const expired = Math.floor(Date.now() / 1000) - 60; + const res = await handler(request(`read|${expired}`)); + expect(res.status).toBe(401); + const challenge = res.headers.get("WWW-Authenticate") ?? ""; + expect(challenge).toContain("invalid_token"); + expect(challenge).not.toContain("insufficient_scope"); + }); + + it("returns 403 for a live token that lacks required scopes", async () => { + const handler = buildHandler(["admin"]); + const future = Math.floor(Date.now() / 1000) + 3600; + const res = await handler(request(`read|${future}`)); + expect(res.status).toBe(403); + const challenge = res.headers.get("WWW-Authenticate") ?? ""; + expect(challenge).toContain("insufficient_scope"); + }); + + it("passes through when token is live and scopes match", async () => { + const handler = buildHandler(["read"]); + const future = Math.floor(Date.now() / 1000) + 3600; + const res = await handler(request(`read|${future}`)); + expect(res.status).toBe(200); + }); + + it("returns 401 for an expired token with no required scopes", async () => { + const handler = buildHandler(); + const expired = Math.floor(Date.now() / 1000) - 60; + const res = await handler(request(`read|${expired}`)); + expect(res.status).toBe(401); + }); + + it("returns 401 when no authorization is provided and auth is required", async () => { + const handler = buildHandler(); + const res = await handler( + new Request("https://example.com/mcp"), + ); + expect(res.status).toBe(401); + }); + }); });