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
13 changes: 8 additions & 5 deletions src/auth/auth-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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;

Expand Down
68 changes: 66 additions & 2 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});