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
10 changed files with 2311 additions and 33 deletions

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;
}