This commit is contained in:
parent
f822c1255e
commit
7b5bfcee52
10 changed files with 2311 additions and 33 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
361
src/server/auth/handlers/authorize.config.test.ts
Normal file
361
src/server/auth/handlers/authorize.config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue