implementation of RFC 8707 Resource Indicators (Fixes #592, Fixes #635)

This commit is contained in:
Olivier Chafik 2025-06-16 19:29:04 +01:00
commit 7b5bfcee52
No known key found for this signature in database
21 changed files with 2311 additions and 33 deletions

View file

@ -341,6 +341,31 @@ describe("OAuth Authorization", () => {
expect(codeVerifier).toBe("test_verifier");
});
it("includes resource parameter when provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
resource: "https://api.example.com/mcp-server",
}
);
expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server");
});
it("excludes resource parameter when not provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
}
);
expect(authorizationUrl.searchParams.has("resource")).toBe(false);
});
it("includes scope parameter when provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
@ -489,6 +514,45 @@ describe("OAuth Authorization", () => {
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
});
it("includes resource parameter in token exchange when provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokens,
});
const tokens = await exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
resource: "https://api.example.com/mcp-server",
});
expect(tokens).toEqual(validTokens);
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
});
it("excludes resource parameter from token exchange when not provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokens,
});
await exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
});
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
expect(body.has("resource")).toBe(false);
});
it("validates token response schema", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@ -576,6 +640,41 @@ describe("OAuth Authorization", () => {
expect(body.get("client_secret")).toBe("secret123");
});
it("includes resource parameter in refresh token request when provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokensWithNewRefreshToken,
});
const tokens = await refreshAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
refreshToken: "refresh123",
resource: "https://api.example.com/mcp-server",
});
expect(tokens).toEqual(validTokensWithNewRefreshToken);
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
});
it("excludes resource parameter from refresh token request when not provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokensWithNewRefreshToken,
});
await refreshAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
refreshToken: "refresh123",
});
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
expect(body.has("resource")).toBe(false);
});
it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@ -807,5 +906,418 @@ describe("OAuth Authorization", () => {
"https://resource.example.com/.well-known/oauth-authorization-server"
);
});
it("canonicalizes resource URI by removing fragment", async () => {
// Mock successful metadata discovery
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
// Call the auth function with a resource that has a fragment
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
resource: "https://api.example.com/mcp-server#fragment",
});
expect(result).toBe("REDIRECT");
// Verify redirectToAuthorization was called with the canonicalized resource
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
expect.objectContaining({
searchParams: expect.any(URLSearchParams),
})
);
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server");
});
it("passes resource parameter through authorization flow", async () => {
// Mock successful metadata discovery
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods for authorization flow
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
// Call auth without authorization code (should trigger redirect)
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
resource: "https://api.example.com/mcp-server",
});
expect(result).toBe("REDIRECT");
// Verify the authorization URL includes the resource parameter
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
expect.objectContaining({
searchParams: expect.any(URLSearchParams),
})
);
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server");
});
it("includes resource in token exchange when authorization code is provided", async () => {
// Mock successful metadata discovery and token exchange
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (urlString.includes("/token")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: "access123",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "refresh123",
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods for token exchange
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier");
(mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined);
// Call auth with authorization code
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
authorizationCode: "auth-code-123",
resource: "https://api.example.com/mcp-server",
});
expect(result).toBe("AUTHORIZED");
// Find the token exchange call
const tokenCall = mockFetch.mock.calls.find(call =>
call[0].toString().includes("/token")
);
expect(tokenCall).toBeDefined();
const body = tokenCall![1].body as URLSearchParams;
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
expect(body.get("code")).toBe("auth-code-123");
});
it("includes resource in token refresh", async () => {
// Mock successful metadata discovery and token refresh
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (urlString.includes("/token")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: "new-access123",
token_type: "Bearer",
expires_in: 3600,
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods for token refresh
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue({
access_token: "old-access",
refresh_token: "refresh123",
});
(mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined);
// Call auth with existing tokens (should trigger refresh)
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
resource: "https://api.example.com/mcp-server",
});
expect(result).toBe("AUTHORIZED");
// Find the token refresh call
const tokenCall = mockFetch.mock.calls.find(call =>
call[0].toString().includes("/token")
);
expect(tokenCall).toBeDefined();
const body = tokenCall![1].body as URLSearchParams;
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("refresh123");
});
it("handles empty resource parameter", async () => {
// Mock successful metadata discovery
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
// Call auth with empty resource parameter
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
resource: "",
});
expect(result).toBe("REDIRECT");
// Verify that empty resource is not included in the URL
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.has("resource")).toBe(false);
});
it("handles resource with multiple fragments", async () => {
// Mock successful metadata discovery
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
// Call auth with resource containing multiple # symbols
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
resource: "https://api.example.com/mcp-server#fragment#another",
});
expect(result).toBe("REDIRECT");
// Verify the resource is properly canonicalized (everything after first # removed)
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server");
});
it("verifies resource parameter distinguishes between different paths on same domain", async () => {
// Mock successful metadata discovery
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
// Test with different resource paths on same domain
// This tests the security fix that prevents token confusion between
// multiple MCP servers on the same domain
const result1 = await auth(mockProvider, {
serverUrl: "https://api.example.com",
resource: "https://api.example.com/mcp-server-1/v1",
});
expect(result1).toBe("REDIRECT");
const redirectCall1 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl1: URL = redirectCall1[0];
expect(authUrl1.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-1/v1");
// Clear mock calls
(mockProvider.redirectToAuthorization as jest.Mock).mockClear();
// Test with different path on same domain
const result2 = await auth(mockProvider, {
serverUrl: "https://api.example.com",
resource: "https://api.example.com/mcp-server-2/v1",
});
expect(result2).toBe("REDIRECT");
const redirectCall2 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl2: URL = redirectCall2[0];
expect(authUrl2.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-2/v1");
// Verify that the two resources are different (critical for security)
expect(authUrl1.searchParams.get("resource")).not.toBe(authUrl2.searchParams.get("resource"));
});
it("preserves query parameters in resource URI", async () => {
// Mock successful metadata discovery
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});
// Mock provider methods
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
// Call auth with resource containing query parameters
const result = await auth(mockProvider, {
serverUrl: "https://resource.example.com",
resource: "https://api.example.com/mcp-server?param=value&another=test",
});
expect(result).toBe("REDIRECT");
// Verify query parameters are preserved (only fragment is removed)
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server?param=value&another=test");
});
});
});

View file

@ -2,6 +2,7 @@ import pkceChallenge from "pkce-challenge";
import { LATEST_PROTOCOL_VERSION } from "../types.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
import { canonicalizeResourceUri } from "../shared/auth-utils.js";
/**
* Implements an end-to-end OAuth client to be used with one MCP server.
@ -92,12 +93,20 @@ export async function auth(
{ serverUrl,
authorizationCode,
scope,
resourceMetadataUrl
resourceMetadataUrl,
resource
}: {
serverUrl: string | URL;
authorizationCode?: string;
scope?: string;
resourceMetadataUrl?: URL }): Promise<AuthResult> {
resourceMetadataUrl?: URL;
resource?: string }): Promise<AuthResult> {
// Remove fragment from resource parameter if provided
let canonicalResource: string | undefined;
if (resource) {
canonicalResource = canonicalizeResourceUri(resource);
}
let authorizationServerUrl = serverUrl;
try {
@ -142,6 +151,7 @@ export async function auth(
authorizationCode,
codeVerifier,
redirectUri: provider.redirectUrl,
resource: canonicalResource,
});
await provider.saveTokens(tokens);
@ -158,6 +168,7 @@ export async function auth(
metadata,
clientInformation,
refreshToken: tokens.refresh_token,
resource: canonicalResource,
});
await provider.saveTokens(newTokens);
@ -176,6 +187,7 @@ export async function auth(
state,
redirectUrl: provider.redirectUrl,
scope: scope || provider.clientMetadata.scope,
resource: canonicalResource,
});
await provider.saveCodeVerifier(codeVerifier);
@ -310,12 +322,14 @@ export async function startAuthorization(
redirectUrl,
scope,
state,
resource,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
redirectUrl: string | URL;
scope?: string;
state?: string;
resource?: string;
},
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
const responseType = "code";
@ -365,6 +379,10 @@ export async function startAuthorization(
authorizationUrl.searchParams.set("scope", scope);
}
if (resource) {
authorizationUrl.searchParams.set("resource", resource);
}
return { authorizationUrl, codeVerifier };
}
@ -379,12 +397,14 @@ export async function exchangeAuthorization(
authorizationCode,
codeVerifier,
redirectUri,
resource,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
authorizationCode: string;
codeVerifier: string;
redirectUri: string | URL;
resource?: string;
},
): Promise<OAuthTokens> {
const grantType = "authorization_code";
@ -418,6 +438,10 @@ export async function exchangeAuthorization(
params.set("client_secret", clientInformation.client_secret);
}
if (resource) {
params.set("resource", resource);
}
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
@ -442,10 +466,12 @@ export async function refreshAuthorization(
metadata,
clientInformation,
refreshToken,
resource,
}: {
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
refreshToken: string;
resource?: string;
},
): Promise<OAuthTokens> {
const grantType = "refresh_token";
@ -477,6 +503,10 @@ export async function refreshAuthorization(
params.set("client_secret", clientInformation.client_secret);
}
if (resource) {
params.set("resource", resource);
}
const response = await fetch(tokenUrl, {
method: "POST",
headers: {

View file

@ -2,6 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { extractCanonicalResourceUri } from "../shared/auth-utils.js";
export class SseError extends Error {
constructor(
@ -86,7 +87,11 @@ export class SSEClientTransport implements Transport {
let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
});
} catch (error) {
this.onerror?.(error as Error);
throw error;
@ -201,7 +206,12 @@ export class SSEClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
const result = await auth(this._authProvider, {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@ -236,7 +246,11 @@ export class SSEClientTransport implements Transport {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}

View file

@ -2,6 +2,7 @@ import { Transport } from "../shared/transport.js";
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSourceParserStream } from "eventsource-parser/stream";
import { extractCanonicalResourceUri } from "../shared/auth-utils.js";
// Default reconnection options for StreamableHTTP connections
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
@ -149,7 +150,11 @@ export class StreamableHTTPClientTransport implements Transport {
let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
});
} catch (error) {
this.onerror?.(error as Error);
throw error;
@ -362,7 +367,12 @@ export class StreamableHTTPClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
const result = await auth(this._authProvider, {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@ -410,7 +420,11 @@ export class StreamableHTTPClientTransport implements Transport {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}

View file

@ -0,0 +1,218 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js';
import { InvalidTargetError } from '../../server/auth/errors.js';
import { OAuthClientInformationFull } from '../../shared/auth.js';
import { Response } from 'express';
describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => {
let provider: DemoInMemoryAuthProvider;
let clientsStore: DemoInMemoryClientsStore;
let mockClient: OAuthClientInformationFull & { allowed_resources?: string[] };
let mockResponse: Partial<Response>;
beforeEach(() => {
provider = new DemoInMemoryAuthProvider();
clientsStore = provider.clientsStore as DemoInMemoryClientsStore;
mockClient = {
client_id: 'test-client',
client_name: 'Test Client',
client_uri: 'https://example.com',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'mcp:tools',
token_endpoint_auth_method: 'none',
};
mockResponse = {
redirect: jest.fn(),
};
});
describe('Authorization with resource parameter', () => {
it('should allow authorization when no resources are configured', async () => {
await clientsStore.registerClient(mockClient);
await expect(provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://api.example.com/v1',
scopes: ['mcp:tools']
}, mockResponse as Response)).resolves.not.toThrow();
expect(mockResponse.redirect).toHaveBeenCalled();
});
it('should allow authorization when resource is in allowed list', async () => {
mockClient.allowed_resources = ['https://api.example.com/v1', 'https://api.example.com/v2'];
await clientsStore.registerClient(mockClient);
await expect(provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://api.example.com/v1',
scopes: ['mcp:tools']
}, mockResponse as Response)).resolves.not.toThrow();
expect(mockResponse.redirect).toHaveBeenCalled();
});
it('should reject authorization when resource is not in allowed list', async () => {
mockClient.allowed_resources = ['https://api.example.com/v1'];
await clientsStore.registerClient(mockClient);
await expect(provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://api.forbidden.com',
scopes: ['mcp:tools']
}, mockResponse as Response)).rejects.toThrow(InvalidTargetError);
});
});
describe('Token exchange with resource validation', () => {
let authorizationCode: string;
beforeEach(async () => {
await clientsStore.registerClient(mockClient);
// Authorize without resource first
await provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
scopes: ['mcp:tools']
}, mockResponse as Response);
// Extract authorization code from redirect call
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
const url = new URL(redirectCall);
authorizationCode = url.searchParams.get('code')!;
});
it('should exchange code successfully when resource matches', async () => {
// First authorize with a specific resource
mockResponse.redirect = jest.fn();
await provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://api.example.com/v1',
scopes: ['mcp:tools']
}, mockResponse as Response);
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
const url = new URL(redirectCall);
const codeWithResource = url.searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(
mockClient,
codeWithResource,
undefined,
undefined,
'https://api.example.com/v1'
);
expect(tokens).toHaveProperty('access_token');
expect(tokens.token_type).toBe('bearer');
});
it('should reject token exchange when resource does not match', async () => {
// First authorize with a specific resource
mockResponse.redirect = jest.fn();
await provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://api.example.com/v1',
scopes: ['mcp:tools']
}, mockResponse as Response);
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
const url = new URL(redirectCall);
const codeWithResource = url.searchParams.get('code')!;
await expect(provider.exchangeAuthorizationCode(
mockClient,
codeWithResource,
undefined,
undefined,
'https://api.different.com'
)).rejects.toThrow(InvalidTargetError);
});
it('should reject token exchange when resource was not authorized but is requested', async () => {
await expect(provider.exchangeAuthorizationCode(
mockClient,
authorizationCode,
undefined,
undefined,
'https://api.example.com/v1'
)).rejects.toThrow(InvalidTargetError);
});
it('should store resource in token data', async () => {
// Authorize with resource
mockResponse.redirect = jest.fn();
await provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://api.example.com/v1',
scopes: ['mcp:tools']
}, mockResponse as Response);
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
const url = new URL(redirectCall);
const codeWithResource = url.searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(
mockClient,
codeWithResource,
undefined,
undefined,
'https://api.example.com/v1'
);
// Verify token has resource information
const tokenDetails = provider.getTokenDetails(tokens.access_token);
expect(tokenDetails?.resource).toBe('https://api.example.com/v1');
});
});
describe('Refresh token with resource validation', () => {
it('should validate resource when exchanging refresh token', async () => {
mockClient.allowed_resources = ['https://api.example.com/v1'];
await clientsStore.registerClient(mockClient);
await expect(provider.exchangeRefreshToken(
mockClient,
'refresh-token',
undefined,
'https://api.forbidden.com'
)).rejects.toThrow(InvalidTargetError);
});
});
describe('Allowed resources management', () => {
it('should update allowed resources for a client', async () => {
await clientsStore.registerClient(mockClient);
// Initially no resources configured
await expect(provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://any.api.com',
scopes: ['mcp:tools']
}, mockResponse as Response)).resolves.not.toThrow();
// Set allowed resources
clientsStore.setAllowedResources(mockClient.client_id, ['https://api.example.com/v1']);
// Now should reject unauthorized resources
await expect(provider.authorize(mockClient, {
codeChallenge: 'test-challenge',
redirectUri: mockClient.redirect_uris[0],
resource: 'https://any.api.com',
scopes: ['mcp:tools']
}, mockResponse as Response)).rejects.toThrow(InvalidTargetError);
});
});
});

View file

@ -1,23 +1,38 @@
import { randomUUID } from 'node:crypto';
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js';
import express, { Request, Response } from "express";
import { AuthInfo } from 'src/server/auth/types.js';
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';
import { AuthInfo } from '../../server/auth/types.js';
import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js';
import { InvalidTargetError } from '../../server/auth/errors.js';
interface ExtendedClientInformation extends OAuthClientInformationFull {
allowed_resources?: string[];
}
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
private clients = new Map<string, OAuthClientInformationFull>();
private clients = new Map<string, ExtendedClientInformation>();
async getClient(clientId: string) {
return this.clients.get(clientId);
}
async registerClient(clientMetadata: OAuthClientInformationFull) {
async registerClient(clientMetadata: OAuthClientInformationFull & { allowed_resources?: string[] }) {
this.clients.set(clientMetadata.client_id, clientMetadata);
return clientMetadata;
}
/**
* Demo method to set allowed resources for a client
*/
setAllowedResources(clientId: string, resources: string[]) {
const client = this.clients.get(clientId);
if (client) {
client.allowed_resources = resources;
}
}
}
/**
@ -28,18 +43,28 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
* - Persistent token storage
* - Rate limiting
*/
interface ExtendedAuthInfo extends AuthInfo {
resource?: string;
type?: string;
}
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
clientsStore = new DemoInMemoryClientsStore();
private codes = new Map<string, {
params: AuthorizationParams,
client: OAuthClientInformationFull}>();
private tokens = new Map<string, AuthInfo>();
private tokens = new Map<string, ExtendedAuthInfo>();
async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response
): Promise<void> {
// Validate resource parameter if provided
if (params.resource) {
await this.validateResource(client, params.resource);
}
const code = randomUUID();
const searchParams = new URLSearchParams({
@ -78,7 +103,9 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
authorizationCode: string,
// Note: code verifier is checked in token.ts by default
// it's unused here for that reason.
_codeVerifier?: string
_codeVerifier?: string,
_redirectUri?: string,
resource?: string
): Promise<OAuthTokens> {
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
@ -89,15 +116,26 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
}
// Validate that the resource matches what was authorized
if (resource !== codeData.params.resource) {
throw new InvalidTargetError('Resource parameter does not match the authorized resource');
}
// If resource was specified during authorization, validate it's still allowed
if (codeData.params.resource) {
await this.validateResource(client, codeData.params.resource);
}
this.codes.delete(authorizationCode);
const token = randomUUID();
const tokenData = {
const tokenData: ExtendedAuthInfo = {
token,
clientId: client.client_id,
scopes: codeData.params.scopes || [],
expiresAt: Date.now() + 3600000, // 1 hour
type: 'access'
type: 'access',
resource: codeData.params.resource
};
this.tokens.set(token, tokenData);
@ -111,11 +149,16 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
}
async exchangeRefreshToken(
_client: OAuthClientInformationFull,
client: OAuthClientInformationFull,
_refreshToken: string,
_scopes?: string[]
_scopes?: string[],
resource?: string
): Promise<OAuthTokens> {
throw new Error('Not implemented for example demo');
// Validate resource parameter if provided
if (resource) {
await this.validateResource(client, resource);
}
throw new Error('Refresh tokens not implemented for example demo');
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
@ -131,6 +174,33 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
expiresAt: Math.floor(tokenData.expiresAt / 1000),
};
}
/**
* Validates that the client is allowed to access the requested resource.
* In a real implementation, this would check against a database or configuration.
*/
private async validateResource(client: OAuthClientInformationFull, resource: string): Promise<void> {
const extendedClient = client as ExtendedClientInformation;
// If no resources are configured, allow any resource (for demo purposes)
if (!extendedClient.allowed_resources) {
return;
}
// Check if the requested resource is in the allowed list
if (!extendedClient.allowed_resources.includes(resource)) {
throw new InvalidTargetError(
`Client is not authorized to access resource: ${resource}`
);
}
}
/**
* Get token details including resource information (for demo introspection endpoint)
*/
getTokenDetails(token: string): ExtendedAuthInfo | undefined {
return this.tokens.get(token);
}
}
@ -164,11 +234,14 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
}
const tokenInfo = await provider.verifyAccessToken(token);
// For demo purposes, we'll add a method to get token details
const tokenDetails = provider.getTokenDetails(token);
res.json({
active: true,
client_id: tokenInfo.clientId,
scope: tokenInfo.scopes.join(' '),
exp: tokenInfo.expiresAt
exp: tokenInfo.expiresAt,
...(tokenDetails?.resource && { aud: tokenDetails.resource })
});
return
} catch (error) {

View file

@ -0,0 +1,152 @@
/**
* Example demonstrating RFC 8707 Resource Indicators for OAuth 2.0
*
* This example shows how to configure and use resource validation in the MCP OAuth flow.
* RFC 8707 allows OAuth clients to specify which protected resource they intend to access,
* and enables authorization servers to restrict tokens to specific resources.
*/
import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js';
import { OAuthClientInformationFull } from '../../shared/auth.js';
async function demonstrateResourceValidation() {
// Create the OAuth provider
const provider = new DemoInMemoryAuthProvider();
const clientsStore = provider.clientsStore;
// Register a client with specific allowed resources
const clientWithResources: OAuthClientInformationFull & { allowed_resources?: string[] } = {
client_id: 'resource-aware-client',
client_name: 'Resource-Aware MCP Client',
client_uri: 'https://example.com',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'mcp:tools mcp:resources',
token_endpoint_auth_method: 'none',
// RFC 8707: Specify which resources this client can access
allowed_resources: [
'https://api.example.com/mcp/v1',
'https://api.example.com/mcp/v2',
'https://tools.example.com/mcp'
]
};
await clientsStore.registerClient(clientWithResources);
console.log('Registered client with allowed resources:', clientWithResources.allowed_resources);
// Example 1: Authorization request with valid resource
try {
const mockResponse = {
redirect: (url: string) => {
console.log('✅ Authorization successful, redirecting to:', url);
}
};
await provider.authorize(clientWithResources, {
codeChallenge: 'S256-challenge-here',
redirectUri: clientWithResources.redirect_uris[0],
resource: 'https://api.example.com/mcp/v1', // Valid resource
scopes: ['mcp:tools']
}, mockResponse as any);
} catch (error) {
console.error('Authorization failed:', error);
}
// Example 2: Authorization request with invalid resource
try {
const mockResponse = {
redirect: (url: string) => {
console.log('Redirecting to:', url);
}
};
await provider.authorize(clientWithResources, {
codeChallenge: 'S256-challenge-here',
redirectUri: clientWithResources.redirect_uris[0],
resource: 'https://unauthorized.api.com/mcp', // Invalid resource
scopes: ['mcp:tools']
}, mockResponse as any);
} catch (error) {
console.error('❌ Authorization failed as expected:', error instanceof Error ? error.message : String(error));
}
// Example 3: Client without resource restrictions
const openClient: OAuthClientInformationFull = {
client_id: 'open-client',
client_name: 'Open MCP Client',
client_uri: 'https://open.example.com',
redirect_uris: ['https://open.example.com/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'mcp:tools',
token_endpoint_auth_method: 'none',
// No allowed_resources specified - can access any resource
};
await clientsStore.registerClient(openClient);
try {
const mockResponse = {
redirect: (url: string) => {
console.log('✅ Open client can access any resource, redirecting to:', url);
}
};
await provider.authorize(openClient, {
codeChallenge: 'S256-challenge-here',
redirectUri: openClient.redirect_uris[0],
resource: 'https://any.api.com/mcp', // Any resource is allowed
scopes: ['mcp:tools']
}, mockResponse as any);
} catch (error) {
console.error('Authorization failed:', error);
}
// Example 4: Token introspection with resource information
// First, simulate getting a token with resource restriction
const mockAuthCode = 'demo-auth-code';
const mockTokenResponse = await simulateTokenExchange(provider, clientWithResources, mockAuthCode);
if (mockTokenResponse) {
const tokenDetails = provider.getTokenDetails(mockTokenResponse.access_token);
console.log('\n📋 Token introspection result:');
console.log('- Client ID:', tokenDetails?.clientId);
console.log('- Scopes:', tokenDetails?.scopes);
console.log('- Resource (aud):', tokenDetails?.resource);
console.log('- Token is restricted to:', tokenDetails?.resource || 'No resource restriction');
}
}
async function simulateTokenExchange(
provider: DemoInMemoryAuthProvider,
client: OAuthClientInformationFull,
authCode: string
) {
// This is a simplified simulation - in real usage, the auth code would come from the authorization flow
console.log('\n🔄 Simulating token exchange with resource validation...');
// Note: In a real implementation, you would:
// 1. Get the authorization code from the redirect after authorize()
// 2. Exchange it for tokens using the token endpoint
// 3. The resource parameter in the token request must match the one from authorization
return {
access_token: 'demo-token-with-resource',
token_type: 'bearer',
expires_in: 3600,
scope: 'mcp:tools'
};
}
// Usage instructions
console.log('🚀 RFC 8707 Resource Indicators Demo\n');
console.log('This example demonstrates how to:');
console.log('1. Register clients with allowed resources');
console.log('2. Validate resource parameters during authorization');
console.log('3. Include resource information in tokens');
console.log('4. Handle invalid_target errors\n');
// Run the demonstration
demonstrateResourceValidation().catch(console.error);

View file

@ -0,0 +1,103 @@
/**
* Example demonstrating server URL validation for RFC 8707 compliance
*
* This example shows how to configure an OAuth server to validate that
* the resource parameter in requests matches the server's own URL,
* ensuring tokens are only issued for this specific server.
*/
import express from 'express';
import { authorizationHandler } from '../../server/auth/handlers/authorize.js';
import { tokenHandler } from '../../server/auth/handlers/token.js';
import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js';
import { OAuthServerConfig } from '../../server/auth/types.js';
// The canonical URL where this MCP server is accessible
const SERVER_URL = 'https://api.example.com/mcp';
// Configuration that validates resource matches this server
const serverValidationConfig: OAuthServerConfig = {
// The server's canonical URL (without fragment)
serverUrl: SERVER_URL,
// Enable validation that resource parameter matches serverUrl
// This also makes the resource parameter required
validateResourceMatchesServer: true
};
// Create the OAuth provider
const provider = new DemoInMemoryAuthProvider();
// Create Express app
const app = express();
// Configure authorization endpoint with server URL validation
app.use('/oauth/authorize', authorizationHandler({
provider,
config: serverValidationConfig
}));
// Configure token endpoint with server URL validation
app.use('/oauth/token', tokenHandler({
provider,
config: serverValidationConfig
}));
// Example scenarios
console.log('🔐 Server URL Validation Example\n');
console.log(`This server only accepts resource parameters matching: ${SERVER_URL}\n`);
console.log('✅ Valid request examples:');
console.log(`1. Resource matches server URL:
GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}&...
Result: Authorization proceeds normally\n`);
console.log(`2. Resource with query parameters (exact match required):
GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}?version=2&...
Result: Rejected - resource must match exactly\n`);
console.log('❌ Invalid request examples:');
console.log(`1. Different domain:
GET /oauth/authorize?client_id=my-client&resource=https://evil.com/mcp&...
Response: 400 invalid_target - "Resource parameter 'https://evil.com/mcp' does not match this server's URL"\n`);
console.log(`2. Different path:
GET /oauth/authorize?client_id=my-client&resource=https://api.example.com/different&...
Response: 400 invalid_target - "Resource parameter does not match this server's URL"\n`);
console.log(`3. Missing resource (with validateResourceMatchesServer: true):
GET /oauth/authorize?client_id=my-client&...
Response: 400 invalid_request - "Resource parameter is required when server URL validation is enabled"\n`);
console.log('🛡️ Security Benefits:');
console.log('1. Prevents token confusion attacks - tokens cannot be obtained for other servers');
console.log('2. Ensures all tokens are scoped to this specific MCP server');
console.log('3. Provides clear audit trail of resource access attempts');
console.log('4. Protects against malicious clients trying to obtain tokens for other services\n');
console.log('📝 Configuration Notes:');
console.log('- serverUrl should be the exact URL clients use to connect');
console.log('- Fragments are automatically removed from both serverUrl and resource');
console.log('- When validateResourceMatchesServer is true, resource parameter is required');
console.log('- Validation ensures exact match between resource and serverUrl\n');
console.log('🔧 Implementation Tips:');
console.log('1. Set serverUrl from environment variable for different deployments:');
console.log(' serverUrl: process.env.MCP_SERVER_URL || "https://api.example.com/mcp"\n');
console.log('2. For development environments, you might disable validation:');
console.log(' validateResourceMatchesServer: process.env.NODE_ENV === "production"\n');
console.log('3. Consider logging failed validation attempts for security monitoring:');
console.log(' Monitor logs for patterns of invalid_target errors\n');
// Example of dynamic configuration based on environment
const productionConfig: OAuthServerConfig = {
serverUrl: process.env.MCP_SERVER_URL || SERVER_URL,
validateResourceMatchesServer: process.env.NODE_ENV === 'production'
};
console.log('🚀 Production configuration example:');
console.log(JSON.stringify(productionConfig, null, 2));
export { app, provider, serverValidationConfig };

View file

@ -0,0 +1,85 @@
/**
* Example demonstrating strict RFC 8707 enforcement mode
*
* This example shows how to configure an OAuth server that requires
* all requests to include a resource parameter, ensuring maximum
* security against token confusion attacks.
*/
import express from 'express';
import { authorizationHandler } from '../../server/auth/handlers/authorize.js';
import { tokenHandler } from '../../server/auth/handlers/token.js';
import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js';
import { OAuthServerConfig } from '../../server/auth/types.js';
// Strict mode configuration - validates resource matches server URL
const SERVER_URL = 'https://api.example.com/mcp';
const strictConfig: OAuthServerConfig = {
serverUrl: SERVER_URL,
validateResourceMatchesServer: true
};
// Create the OAuth provider
const provider = new DemoInMemoryAuthProvider();
// Create Express app
const app = express();
// Configure authorization endpoint with strict mode
app.use('/oauth/authorize', authorizationHandler({
provider,
config: strictConfig,
rateLimit: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10 // limit each IP to 10 requests per window
}
}));
// Configure token endpoint with strict mode
app.use('/oauth/token', tokenHandler({
provider,
config: strictConfig,
rateLimit: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20 // limit each IP to 20 requests per window
}
}));
// Example of what happens with different requests:
console.log('🔒 Strict RFC 8707 Mode Example\n');
console.log(`This server validates that resource parameter matches: ${SERVER_URL}\n`);
console.log('✅ Valid request example:');
console.log(`GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=${SERVER_URL}\n`);
console.log('❌ Invalid request examples:');
console.log('1. Missing resource:');
console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256');
console.log('Response: 400 Bad Request - "Resource parameter is required when server URL validation is enabled"\n');
console.log('2. Wrong resource:');
console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=https://evil.com/mcp');
console.log(`Response: 400 Bad Request - "Resource parameter 'https://evil.com/mcp' does not match this server's URL '${SERVER_URL}'"\n`);
console.log('📋 Benefits of server URL validation:');
console.log('1. Prevents token confusion attacks - tokens can only be issued for this server');
console.log('2. Ensures all tokens are properly scoped to this specific MCP server');
console.log('3. No accidental token leakage to other services');
console.log('4. Clear security boundary enforcement\n');
console.log('⚠️ Migration considerations:');
console.log('1. Server must know its canonical URL (configure via environment variable)');
console.log('2. All clients must send the exact matching resource parameter');
console.log('3. Consider using warnings-only mode first (validateResourceMatchesServer: false)');
console.log('4. Monitor logs to track adoption before enabling validation\n');
// Example middleware to track resource parameter usage
app.use((req, res, next) => {
if (req.path.includes('/oauth/')) {
const hasResource = req.query.resource || req.body?.resource;
console.log(`[${new Date().toISOString()}] OAuth request to ${req.path} - Resource parameter: ${hasResource ? 'present' : 'MISSING'}`);
}
next();
});
export { app, provider, strictConfig };

View file

@ -189,3 +189,13 @@ export class InsufficientScopeError extends OAuthError {
super("insufficient_scope", message, errorUri);
}
}
/**
* Invalid target error - The requested resource is invalid, unknown, or malformed.
* (RFC 8707 - Resource Indicators for OAuth 2.0)
*/
export class InvalidTargetError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_target", message, errorUri);
}
}

View file

@ -0,0 +1,361 @@
import express from "express";
import request from "supertest";
import { authorizationHandler } from "./authorize.js";
import { OAuthServerProvider } from "../provider.js";
import { OAuthServerConfig } from "../types.js";
import { InvalidRequestError, InvalidTargetError } from "../errors.js";
describe("Authorization handler with config", () => {
let app: express.Application;
let mockProvider: jest.Mocked<OAuthServerProvider>;
beforeEach(() => {
app = express();
const mockClientsStore = {
getClient: jest.fn(),
registerClient: jest.fn(),
};
mockProvider = {
clientsStore: mockClientsStore,
authorize: jest.fn(),
exchangeAuthorizationCode: jest.fn(),
exchangeRefreshToken: jest.fn(),
challengeForAuthorizationCode: jest.fn(),
verifyAccessToken: jest.fn(),
} as jest.Mocked<OAuthServerProvider>;
});
describe("validateResourceMatchesServer configuration", () => {
it("should throw error when validateResourceMatchesServer is true but serverUrl is not set", () => {
const invalidConfig: OAuthServerConfig = {
validateResourceMatchesServer: true
// serverUrl is missing
};
expect(() => {
authorizationHandler({
provider: mockProvider,
config: invalidConfig
});
}).toThrow("serverUrl must be configured when validateResourceMatchesServer is true");
});
});
describe("server URL validation (validateResourceMatchesServer: true)", () => {
const serverValidationConfig: OAuthServerConfig = {
serverUrl: "https://api.example.com/mcp",
validateResourceMatchesServer: true
};
beforeEach(() => {
app.use("/oauth/authorize", authorizationHandler({
provider: mockProvider,
config: serverValidationConfig
}));
});
it("should reject requests without resource parameter", async () => {
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
const response = await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state"
});
expect(response.status).toBe(302);
expect(response.headers.location).toContain("error=invalid_request");
expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled");
});
it("should accept requests with resource parameter", async () => {
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
mockProvider.authorize.mockImplementation(async (client, params, res) => {
res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`);
});
const response = await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state",
resource: "https://api.example.com/mcp"
});
expect(response.status).toBe(302);
expect(response.headers.location).toContain("code=auth-code");
expect(mockProvider.authorize).toHaveBeenCalledWith(
mockClient,
expect.objectContaining({
resource: "https://api.example.com/mcp"
}),
expect.any(Object)
);
});
});
describe("warning mode (default behavior)", () => {
const warnConfig: OAuthServerConfig = {
// No configuration needed - warnings are always enabled by default
};
let consoleWarnSpy: jest.SpyInstance;
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
app.use("/oauth/authorize", authorizationHandler({
provider: mockProvider,
config: warnConfig
}));
});
afterEach(() => {
consoleWarnSpy.mockRestore();
});
it("should log warning when resource is missing", async () => {
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
mockProvider.authorize.mockImplementation(async (client, params, res) => {
res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`);
});
await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state"
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("test-client is missing the resource parameter")
);
});
it("should not log warning when resource is present", async () => {
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
mockProvider.authorize.mockImplementation(async (client, params, res) => {
res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`);
});
await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state",
resource: "https://api.example.com/mcp"
});
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
});
// Note: No silent mode test anymore - warnings are always enabled
describe("server URL validation (validateResourceMatchesServer: true)", () => {
const serverValidationConfig: OAuthServerConfig = {
serverUrl: "https://api.example.com/mcp",
validateResourceMatchesServer: true
};
beforeEach(() => {
app.use("/oauth/authorize", authorizationHandler({
provider: mockProvider,
config: serverValidationConfig
}));
});
it("should accept requests when resource matches server URL", async () => {
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
mockProvider.authorize.mockImplementation(async (client, params, res) => {
res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`);
});
const response = await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state",
resource: "https://api.example.com/mcp"
});
expect(response.status).toBe(302);
expect(response.headers.location).toContain("code=auth-code");
});
it("should reject requests when resource does not match server URL", async () => {
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
const response = await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state",
resource: "https://different.api.com/mcp"
});
expect(response.status).toBe(302);
expect(response.headers.location).toContain("error=invalid_target");
expect(response.headers.location).toContain("does+not+match+this+server");
});
it("should reject requests without resource parameter when validation is enabled", async () => {
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
const response = await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state"
});
expect(response.status).toBe(302);
expect(response.headers.location).toContain("error=invalid_request");
expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled");
});
it("should handle server URL with fragment correctly", async () => {
// Reconfigure with a server URL that has a fragment (though it shouldn't)
const configWithFragment: OAuthServerConfig = {
serverUrl: "https://api.example.com/mcp#fragment",
validateResourceMatchesServer: true
};
app = express();
app.use("/oauth/authorize", authorizationHandler({
provider: mockProvider,
config: configWithFragment
}));
const mockClient = {
client_id: "test-client",
client_name: "Test Client",
redirect_uris: ["https://example.com/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
scope: "read write",
token_endpoint_auth_method: "none",
};
(mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient);
mockProvider.authorize.mockImplementation(async (client, params, res) => {
res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`);
});
const response = await request(app)
.get("/oauth/authorize")
.query({
client_id: "test-client",
redirect_uri: "https://example.com/callback",
response_type: "code",
code_challenge: "test-challenge",
code_challenge_method: "S256",
state: "test-state",
resource: "https://api.example.com/mcp" // No fragment
});
expect(response.status).toBe(302);
expect(response.headers.location).toContain("code=auth-code");
});
});
});

View file

@ -276,6 +276,128 @@ describe('Authorization Handler', () => {
});
});
describe('Resource parameter validation', () => {
it('accepts valid resource parameter', async () => {
const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize');
const response = await supertest(app)
.get('/authorize')
.query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(302);
expect(mockProviderWithResource).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: 'https://api.example.com/resource',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge123'
}),
expect.any(Object)
);
});
it('rejects invalid resource parameter (non-URL)', async () => {
const response = await supertest(app)
.get('/authorize')
.query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'not-a-url'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.get('error')).toBe('invalid_request');
expect(location.searchParams.get('error_description')).toContain('resource');
});
it('handles authorization without resource parameter', async () => {
const mockProviderWithoutResource = jest.spyOn(mockProvider, 'authorize');
const response = await supertest(app)
.get('/authorize')
.query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
expect(mockProviderWithoutResource).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: undefined,
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge123'
}),
expect.any(Object)
);
});
it('passes multiple resources if provided', async () => {
const mockProviderWithResources = jest.spyOn(mockProvider, 'authorize');
const response = await supertest(app)
.get('/authorize')
.query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'https://api1.example.com/resource',
state: 'test-state'
});
expect(response.status).toBe(302);
expect(mockProviderWithResources).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: 'https://api1.example.com/resource',
state: 'test-state'
}),
expect.any(Object)
);
});
it('validates resource parameter in POST requests', async () => {
const mockProviderPost = jest.spyOn(mockProvider, 'authorize');
const response = await supertest(app)
.post('/authorize')
.type('form')
.send({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(302);
expect(mockProviderPost).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: 'https://api.example.com/resource'
}),
expect.any(Object)
);
});
});
describe('Successful authorization', () => {
it('handles successful authorization with all parameters', async () => {
const response = await supertest(app)

View file

@ -8,10 +8,12 @@ import {
InvalidRequestError,
InvalidClientError,
InvalidScopeError,
InvalidTargetError,
ServerError,
TooManyRequestsError,
OAuthError
} from "../errors.js";
import { OAuthServerConfig } from "../types.js";
export type AuthorizationHandlerOptions = {
provider: OAuthServerProvider;
@ -20,6 +22,10 @@ export type AuthorizationHandlerOptions = {
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
/**
* OAuth server configuration options
*/
config?: OAuthServerConfig;
};
// Parameters that must be validated in order to issue redirects.
@ -35,9 +41,15 @@ const RequestAuthorizationParamsSchema = z.object({
code_challenge_method: z.literal("S256"),
scope: z.string().optional(),
state: z.string().optional(),
resource: z.string().url().optional(),
});
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
export function authorizationHandler({ provider, rateLimit: rateLimitConfig, config }: AuthorizationHandlerOptions): RequestHandler {
// Validate configuration
if (config?.validateResourceMatchesServer && !config?.serverUrl) {
throw new Error("serverUrl must be configured when validateResourceMatchesServer is true");
}
// Create a router to apply middleware
const router = express.Router();
router.use(allowedMethods(["GET", "POST"]));
@ -115,9 +127,30 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
throw new InvalidRequestError(parseResult.error.message);
}
const { scope, code_challenge } = parseResult.data;
const { scope, code_challenge, resource } = parseResult.data;
state = parseResult.data.state;
// If validateResourceMatchesServer is enabled, resource is required and must match
if (config?.validateResourceMatchesServer) {
if (!resource) {
throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled");
}
// Remove fragment from server URL if present (though it shouldn't have one)
const canonicalServerUrl = config.serverUrl!.split('#')[0];
if (resource !== canonicalServerUrl) {
throw new InvalidTargetError(
`Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'`
);
}
} else {
// Always log warning if resource is missing (unless validation is enabled)
if (!resource) {
console.warn(`Authorization request from client ${client_id} is missing the resource parameter. Consider migrating to RFC 8707.`);
}
}
// Validate scopes
let requestedScopes: string[] = [];
if (scope !== undefined) {
@ -138,6 +171,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
scopes: requestedScopes,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
resource,
}, res);
} catch (error) {
// Post-redirect errors - redirect with error parameters

View file

@ -282,6 +282,99 @@ describe('Token Handler', () => {
expect(response.body.refresh_token).toBe('mock_refresh_token');
});
it('accepts and passes resource parameter to provider', async () => {
const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'valid_verifier',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(200);
expect(mockExchangeCode).toHaveBeenCalledWith(
validClient,
'valid_code',
undefined, // code_verifier is undefined after PKCE validation
undefined, // redirect_uri
'https://api.example.com/resource' // resource parameter
);
});
it('rejects invalid resource parameter (non-URL)', async () => {
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'valid_verifier',
resource: 'not-a-url'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_request');
expect(response.body.error_description).toContain('resource');
});
it('handles authorization code exchange without resource parameter', async () => {
const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'valid_verifier'
});
expect(response.status).toBe(200);
expect(mockExchangeCode).toHaveBeenCalledWith(
validClient,
'valid_code',
undefined, // code_verifier is undefined after PKCE validation
undefined, // redirect_uri
undefined // resource parameter
);
});
it('passes resource with redirect_uri', async () => {
const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'valid_verifier',
redirect_uri: 'https://example.com/callback',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(200);
expect(mockExchangeCode).toHaveBeenCalledWith(
validClient,
'valid_code',
undefined, // code_verifier is undefined after PKCE validation
'https://example.com/callback', // redirect_uri
'https://api.example.com/resource' // resource parameter
);
});
it('passes through code verifier when using proxy provider', async () => {
const originalFetch = global.fetch;
@ -472,6 +565,92 @@ describe('Token Handler', () => {
expect(response.status).toBe(200);
expect(response.body.scope).toBe('profile email');
});
it('accepts and passes resource parameter to provider on refresh', async () => {
const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(200);
expect(mockExchangeRefresh).toHaveBeenCalledWith(
validClient,
'valid_refresh_token',
undefined, // scopes
'https://api.example.com/resource' // resource parameter
);
});
it('rejects invalid resource parameter (non-URL) on refresh', async () => {
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token',
resource: 'not-a-url'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_request');
expect(response.body.error_description).toContain('resource');
});
it('handles refresh token exchange without resource parameter', async () => {
const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token'
});
expect(response.status).toBe(200);
expect(mockExchangeRefresh).toHaveBeenCalledWith(
validClient,
'valid_refresh_token',
undefined, // scopes
undefined // resource parameter
);
});
it('passes resource with scopes on refresh', async () => {
const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token',
scope: 'profile email',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(200);
expect(mockExchangeRefresh).toHaveBeenCalledWith(
validClient,
'valid_refresh_token',
['profile', 'email'], // scopes
'https://api.example.com/resource' // resource parameter
);
});
});
describe('CORS support', () => {

View file

@ -12,8 +12,10 @@ import {
UnsupportedGrantTypeError,
ServerError,
TooManyRequestsError,
OAuthError
OAuthError,
InvalidTargetError
} from "../errors.js";
import { OAuthServerConfig } from "../types.js";
export type TokenHandlerOptions = {
provider: OAuthServerProvider;
@ -22,6 +24,10 @@ export type TokenHandlerOptions = {
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
/**
* OAuth server configuration options
*/
config?: OAuthServerConfig;
};
const TokenRequestSchema = z.object({
@ -32,14 +38,21 @@ const AuthorizationCodeGrantSchema = z.object({
code: z.string(),
code_verifier: z.string(),
redirect_uri: z.string().optional(),
resource: z.string().url().optional(),
});
const RefreshTokenGrantSchema = z.object({
refresh_token: z.string(),
scope: z.string().optional(),
resource: z.string().url().optional(),
});
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: TokenHandlerOptions): RequestHandler {
// Validate configuration
if (config?.validateResourceMatchesServer && !config?.serverUrl) {
throw new Error("serverUrl must be configured when validateResourceMatchesServer is true");
}
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
@ -89,7 +102,27 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
throw new InvalidRequestError(parseResult.error.message);
}
const { code, code_verifier, redirect_uri } = parseResult.data;
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
// If validateResourceMatchesServer is enabled, resource is required and must match
if (config?.validateResourceMatchesServer) {
if (!resource) {
throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled");
}
const canonicalServerUrl = config.serverUrl!.split('#')[0];
if (resource !== canonicalServerUrl) {
throw new InvalidTargetError(
`Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'`
);
}
} else {
// Always log warning if resource is missing (unless validation is enabled)
if (!resource) {
console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`);
}
}
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
@ -107,7 +140,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
client,
code,
skipLocalPkceValidation ? code_verifier : undefined,
redirect_uri
redirect_uri,
resource
);
res.status(200).json(tokens);
break;
@ -119,10 +153,30 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
throw new InvalidRequestError(parseResult.error.message);
}
const { refresh_token, scope } = parseResult.data;
const { refresh_token, scope, resource } = parseResult.data;
// If validateResourceMatchesServer is enabled, resource is required and must match
if (config?.validateResourceMatchesServer) {
if (!resource) {
throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled");
}
const canonicalServerUrl = config.serverUrl!.split('#')[0];
if (resource !== canonicalServerUrl) {
throw new InvalidTargetError(
`Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'`
);
}
} else {
// Always log warning if resource is missing (unless validation is enabled)
if (!resource) {
console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`);
}
}
const scopes = scope?.split(" ");
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes);
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource);
res.status(200).json(tokens);
break;
}

View file

@ -8,6 +8,7 @@ export type AuthorizationParams = {
scopes?: string[];
codeChallenge: string;
redirectUri: string;
resource?: string;
};
/**
@ -40,13 +41,14 @@ export interface OAuthServerProvider {
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string
redirectUri?: string,
resource?: string
): Promise<OAuthTokens>;
/**
* Exchanges a refresh token for an access token.
*/
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens>;
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: string): Promise<OAuthTokens>;
/**
* Verifies an access token and returns information about it.

View file

@ -103,6 +103,49 @@ describe("Proxy OAuth Server Provider", () => {
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
});
it('includes resource parameter in authorization redirect', async () => {
await provider.authorize(
validClient,
{
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
scopes: ['read', 'write'],
resource: 'https://api.example.com/resource'
},
mockResponse
);
const expectedUrl = new URL('https://auth.example.com/authorize');
expectedUrl.searchParams.set('client_id', 'test-client');
expectedUrl.searchParams.set('response_type', 'code');
expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback');
expectedUrl.searchParams.set('code_challenge', 'test-challenge');
expectedUrl.searchParams.set('code_challenge_method', 'S256');
expectedUrl.searchParams.set('state', 'test-state');
expectedUrl.searchParams.set('scope', 'read write');
expectedUrl.searchParams.set('resource', 'https://api.example.com/resource');
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
});
it('handles authorization without resource parameter', async () => {
await provider.authorize(
validClient,
{
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
scopes: ['read']
},
mockResponse
);
const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
const url = new URL(redirectUrl);
expect(url.searchParams.has('resource')).toBe(false);
});
});
describe("token exchange", () => {
@ -164,6 +207,41 @@ describe("Proxy OAuth Server Provider", () => {
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in authorization code exchange', async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
'test-code',
'test-verifier',
'https://example.com/callback',
'https://api.example.com/resource'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('handles authorization code exchange without resource parameter', async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
'test-code',
'test-verifier'
);
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const body = fetchCall[1].body as string;
expect(body).not.toContain('resource=');
expect(tokens).toEqual(mockTokenResponse);
});
it("exchanges refresh token for new tokens", async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
@ -184,6 +262,55 @@ describe("Proxy OAuth Server Provider", () => {
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in refresh token exchange', async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
'test-refresh-token',
['read', 'write'],
'https://api.example.com/resource'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('handles refresh token exchange without resource parameter', async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
'test-refresh-token',
['read']
);
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const body = fetchCall[1].body as string;
expect(body).not.toContain('resource=');
expect(tokens).toEqual(mockTokenResponse);
});
it('includes both scope and resource parameters in refresh', async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
'test-refresh-token',
['profile', 'email'],
'https://api.example.com/resource'
);
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const body = fetchCall[1].body as string;
expect(body).toContain('scope=profile+email');
expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/resource'));
expect(tokens).toEqual(mockTokenResponse);
});
});
describe("client registration", () => {

View file

@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
// Add optional standard OAuth parameters
if (params.state) searchParams.set("state", params.state);
if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" "));
if (params.resource) searchParams.set("resource", params.resource);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
@ -152,7 +153,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string
redirectUri?: string,
resource?: string
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: "authorization_code",
@ -172,6 +174,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
params.append("redirect_uri", redirectUri);
}
if (resource) {
params.append("resource", resource);
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {
@ -192,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
scopes?: string[]
scopes?: string[],
resource?: string
): Promise<OAuthTokens> {
const params = new URLSearchParams({
@ -209,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
params.set("scope", scopes.join(" "));
}
if (resource) {
params.set("resource", resource);
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {

View file

@ -27,4 +27,37 @@ export interface AuthInfo {
* This field should be used for any additional data that needs to be attached to the auth info.
*/
extra?: Record<string, unknown>;
}
/**
* Configuration options for OAuth server behavior
*/
export interface OAuthServerConfig {
/**
* The canonical URL of this MCP server. When provided, the server will validate
* that the resource parameter in OAuth requests matches this URL.
*
* This should be the full URL that clients use to connect to this server,
* without any fragment component (e.g., "https://api.example.com/mcp").
*
* Required when validateResourceMatchesServer is true.
*/
serverUrl?: string;
/**
* If true, validates that the resource parameter matches the configured serverUrl.
*
* When enabled:
* - serverUrl must be configured (throws error if not)
* - resource parameter is required on all requests
* - resource must exactly match serverUrl (after fragment removal)
* - requests without resource parameter will be rejected with invalid_request error
* - requests with non-matching resource will be rejected with invalid_target error
*
* When disabled:
* - warnings are logged when resource parameter is missing (for migration tracking)
*
* @default false
*/
validateResourceMatchesServer?: boolean;
}

View file

@ -0,0 +1,100 @@
import { canonicalizeResourceUri, validateResourceUri, extractCanonicalResourceUri, resourceUrlFromServerUrl } from './auth-utils.js';
describe('auth-utils', () => {
describe('resourceUrlFromServerUrl', () => {
it('should remove fragments', () => {
expect(resourceUrlFromServerUrl('https://example.com/path#fragment')).toBe('https://example.com/path');
expect(resourceUrlFromServerUrl('https://example.com#fragment')).toBe('https://example.com');
expect(resourceUrlFromServerUrl('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1');
});
it('should return URL unchanged if no fragment', () => {
expect(resourceUrlFromServerUrl('https://example.com')).toBe('https://example.com');
expect(resourceUrlFromServerUrl('https://example.com/path')).toBe('https://example.com/path');
expect(resourceUrlFromServerUrl('https://example.com/path?query=1')).toBe('https://example.com/path?query=1');
});
it('should keep everything else unchanged', () => {
// Case sensitivity preserved
expect(resourceUrlFromServerUrl('HTTPS://EXAMPLE.COM/PATH')).toBe('HTTPS://EXAMPLE.COM/PATH');
// Ports preserved
expect(resourceUrlFromServerUrl('https://example.com:443/path')).toBe('https://example.com:443/path');
expect(resourceUrlFromServerUrl('https://example.com:8080/path')).toBe('https://example.com:8080/path');
// Query parameters preserved
expect(resourceUrlFromServerUrl('https://example.com?foo=bar&baz=qux')).toBe('https://example.com?foo=bar&baz=qux');
// Trailing slashes preserved
expect(resourceUrlFromServerUrl('https://example.com/')).toBe('https://example.com/');
expect(resourceUrlFromServerUrl('https://example.com/path/')).toBe('https://example.com/path/');
});
});
describe('canonicalizeResourceUri', () => {
it('should remove fragments', () => {
expect(canonicalizeResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path');
});
it('should keep everything else unchanged', () => {
expect(canonicalizeResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path');
expect(canonicalizeResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path');
expect(canonicalizeResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1');
});
});
describe('validateResourceUri', () => {
it('should accept valid resource URIs without fragments', () => {
expect(() => validateResourceUri('https://example.com')).not.toThrow();
expect(() => validateResourceUri('https://example.com/path')).not.toThrow();
expect(() => validateResourceUri('http://example.com:8080')).not.toThrow();
expect(() => validateResourceUri('https://example.com?query=1')).not.toThrow();
expect(() => validateResourceUri('ftp://example.com')).not.toThrow(); // Only fragment check now
});
it('should reject URIs with fragments', () => {
expect(() => validateResourceUri('https://example.com#fragment')).toThrow('must not contain a fragment');
expect(() => validateResourceUri('https://example.com/path#section')).toThrow('must not contain a fragment');
expect(() => validateResourceUri('https://example.com?query=1#anchor')).toThrow('must not contain a fragment');
});
it('should accept any URI without fragment', () => {
// These are all valid now since we only check for fragments
expect(() => validateResourceUri('//example.com')).not.toThrow();
expect(() => validateResourceUri('https://user:pass@example.com')).not.toThrow();
expect(() => validateResourceUri('/path')).not.toThrow();
expect(() => validateResourceUri('path')).not.toThrow();
});
});
describe('extractCanonicalResourceUri', () => {
it('should remove fragments from URLs', () => {
expect(extractCanonicalResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path');
expect(extractCanonicalResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1');
});
it('should handle URL object', () => {
const url = new URL('https://example.com:8443/path?query=1#fragment');
expect(extractCanonicalResourceUri(url)).toBe('https://example.com:8443/path?query=1');
});
it('should keep everything else unchanged', () => {
// Preserves case
expect(extractCanonicalResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path');
// Preserves all ports
expect(extractCanonicalResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path');
expect(extractCanonicalResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path');
// Preserves query parameters
expect(extractCanonicalResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1');
// Preserves trailing slashes
expect(extractCanonicalResourceUri('https://example.com/')).toBe('https://example.com/');
expect(extractCanonicalResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/');
});
it('should distinguish between different paths on same domain', () => {
// This is the key test for the security concern mentioned
const app1 = extractCanonicalResourceUri('https://api.example.com/mcp-server-1');
const app2 = extractCanonicalResourceUri('https://api.example.com/mcp-server-2');
expect(app1).not.toBe(app2);
expect(app1).toBe('https://api.example.com/mcp-server-1');
expect(app2).toBe('https://api.example.com/mcp-server-2');
});
});
});

Some files were not shown because too many files have changed in this diff Show more