Feat: Separate authorization server and resource server on client auth flow (#416)
* add type and schema for protected resource metadata * add private variable to keep track of authorization server url to avoid fetching Protected Resource Metadata every time * change auth flow to use authroization server and add function to discover protected resource metadata * add/modify tests for new added auth functions * modify test to use the authorization server * remove unused variable * add resource parameter when building auth url * Change resource to use the protected resource metadata * resolve typo. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * Update src/client/sse.ts import. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove log. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * Update section title. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove log. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * remove resource parameter. Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> * make backwards compatibile * s/resourceServerUrl/serverUrl/g for backwards compat * fix test comment --------- Co-authored-by: itsuki <itsuki.enjyoy@gmail.com> Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
ac1b80a331
commit
69dbfac0e4
6 changed files with 674 additions and 171 deletions
|
|
@ -4,6 +4,10 @@ import {
|
|||
exchangeAuthorization,
|
||||
refreshAuthorization,
|
||||
registerClient,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
extractResourceMetadataUrl,
|
||||
auth,
|
||||
type OAuthClientProvider,
|
||||
} from "./auth.js";
|
||||
|
||||
// Mock fetch globally
|
||||
|
|
@ -15,6 +19,165 @@ describe("OAuth Authorization", () => {
|
|||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("extractResourceMetadataUrl", () => {
|
||||
it("returns resource metadata url when present", async () => {
|
||||
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
|
||||
const mockResponse = {
|
||||
headers: {
|
||||
get: jest.fn((name) => name === "WWW-Authenticate" ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null),
|
||||
}
|
||||
} as unknown as Response
|
||||
|
||||
expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl));
|
||||
});
|
||||
|
||||
it("returns undefined if not bearer", async () => {
|
||||
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
|
||||
const mockResponse = {
|
||||
headers: {
|
||||
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
|
||||
}
|
||||
} as unknown as Response
|
||||
|
||||
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined if resource_metadata not present", async () => {
|
||||
const mockResponse = {
|
||||
headers: {
|
||||
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp"` : null),
|
||||
}
|
||||
} as unknown as Response
|
||||
|
||||
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined on invalid url", async () => {
|
||||
const resourceUrl = "invalid-url"
|
||||
const mockResponse = {
|
||||
headers: {
|
||||
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
|
||||
}
|
||||
} as unknown as Response
|
||||
|
||||
expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverOAuthProtectedResourceMetadata", () => {
|
||||
const validMetadata = {
|
||||
resource: "https://resource.example.com",
|
||||
authorization_servers: ["https://auth.example.com"],
|
||||
};
|
||||
|
||||
it("returns metadata when discovery succeeds", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1);
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
});
|
||||
|
||||
it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
|
||||
// Set up a counter to control behavior
|
||||
let callCount = 0;
|
||||
|
||||
// Mock implementation that changes behavior based on call count
|
||||
mockFetch.mockImplementation((_url, _options) => {
|
||||
callCount++;
|
||||
|
||||
if (callCount === 1) {
|
||||
// First call with MCP header - fail with TypeError (simulating CORS error)
|
||||
// We need to use TypeError specifically because that's what the implementation checks for
|
||||
return Promise.reject(new TypeError("Network error"));
|
||||
} else {
|
||||
// Second call without header - succeed
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validMetadata
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Should succeed with the second call
|
||||
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
|
||||
// Verify both calls were made
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify first call had MCP header
|
||||
expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
|
||||
});
|
||||
|
||||
it("throws an error when all fetch attempts fail", async () => {
|
||||
// Set up a counter to control behavior
|
||||
let callCount = 0;
|
||||
|
||||
// Mock implementation that changes behavior based on call count
|
||||
mockFetch.mockImplementation((_url, _options) => {
|
||||
callCount++;
|
||||
|
||||
if (callCount === 1) {
|
||||
// First call - fail with TypeError
|
||||
return Promise.reject(new TypeError("First failure"));
|
||||
} else {
|
||||
// Second call - fail with different error
|
||||
return Promise.reject(new Error("Second failure"));
|
||||
}
|
||||
});
|
||||
|
||||
// Should fail with the second error
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
|
||||
.rejects.toThrow("Second failure");
|
||||
|
||||
// Verify both calls were made
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws on 404 errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
|
||||
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
});
|
||||
|
||||
it("throws on non-404 errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
|
||||
.rejects.toThrow("HTTP 500");
|
||||
});
|
||||
|
||||
it("validates metadata schema", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
// Missing required fields
|
||||
scopes_supported: ["email", "mcp"],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverOAuthMetadata", () => {
|
||||
const validMetadata = {
|
||||
issuer: "https://auth.example.com",
|
||||
|
|
@ -158,6 +321,7 @@ describe("OAuth Authorization", () => {
|
|||
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||
"https://auth.example.com",
|
||||
{
|
||||
metadata: undefined,
|
||||
clientInformation: validClientInfo,
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
}
|
||||
|
|
@ -503,4 +667,101 @@ describe("OAuth Authorization", () => {
|
|||
).rejects.toThrow("Dynamic client registration failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth function", () => {
|
||||
const mockProvider: OAuthClientProvider = {
|
||||
get redirectUrl() { return "http://localhost:3000/callback"; },
|
||||
get clientMetadata() {
|
||||
return {
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
client_name: "Test Client",
|
||||
};
|
||||
},
|
||||
clientInformation: jest.fn(),
|
||||
tokens: jest.fn(),
|
||||
saveTokens: jest.fn(),
|
||||
redirectToAuthorization: jest.fn(),
|
||||
saveCodeVerifier: jest.fn(),
|
||||
codeVerifier: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => {
|
||||
// Setup: First call to protected resource metadata fails (404)
|
||||
// Second call to auth server metadata succeeds
|
||||
let callCount = 0;
|
||||
mockFetch.mockImplementation((url) => {
|
||||
callCount++;
|
||||
|
||||
const urlString = url.toString();
|
||||
|
||||
if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) {
|
||||
// First call - protected resource metadata fails with 404
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
} else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) {
|
||||
// Second call - auth server metadata succeeds
|
||||
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",
|
||||
registration_endpoint: "https://auth.example.com/register",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}),
|
||||
});
|
||||
} else if (callCount === 3 && urlString.includes("/register")) {
|
||||
// Third call - client registration succeeds
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
client_id: "test-client-id",
|
||||
client_secret: "test-client-secret",
|
||||
client_id_issued_at: 1612137600,
|
||||
client_secret_expires_at: 1612224000,
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
client_name: "Test Client",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
|
||||
});
|
||||
|
||||
// Mock provider methods
|
||||
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
|
||||
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
|
||||
mockProvider.saveClientInformation = jest.fn();
|
||||
|
||||
// Call the auth function
|
||||
const result = await auth(mockProvider, {
|
||||
serverUrl: "https://resource.example.com",
|
||||
});
|
||||
|
||||
// Verify the result
|
||||
expect(result).toBe("REDIRECT");
|
||||
|
||||
// Verify the sequence of calls
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
|
||||
// First call should be to protected resource metadata
|
||||
expect(mockFetch.mock.calls[0][0].toString()).toBe(
|
||||
"https://resource.example.com/.well-known/oauth-protected-resource"
|
||||
);
|
||||
|
||||
// Second call should be to oauth metadata
|
||||
expect(mockFetch.mock.calls[1][0].toString()).toBe(
|
||||
"https://resource.example.com/.well-known/oauth-authorization-server"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import pkceChallenge from "pkce-challenge";
|
||||
import { LATEST_PROTOCOL_VERSION } from "../types.js";
|
||||
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js";
|
||||
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
|
||||
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
|
||||
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
|
||||
|
||||
/**
|
||||
* Implements an end-to-end OAuth client to be used with one MCP server.
|
||||
*
|
||||
*
|
||||
* This client relies upon a concept of an authorized "session," the exact
|
||||
* meaning of which is application-defined. Tokens, authorization codes, and
|
||||
* code verifiers should not cross different sessions.
|
||||
|
|
@ -32,7 +32,7 @@ export interface OAuthClientProvider {
|
|||
* If implemented, this permits the OAuth client to dynamically register with
|
||||
* the server. Client information saved this way should later be read via
|
||||
* `clientInformation()`.
|
||||
*
|
||||
*
|
||||
* This method is not required to be implemented if client information is
|
||||
* statically known (e.g., pre-registered).
|
||||
*/
|
||||
|
|
@ -78,7 +78,7 @@ export class UnauthorizedError extends Error {
|
|||
|
||||
/**
|
||||
* Orchestrates the full auth flow with a server.
|
||||
*
|
||||
*
|
||||
* This can be used as a single entry point for all authorization functionality,
|
||||
* instead of linking together the other lower-level functions in this module.
|
||||
*/
|
||||
|
|
@ -87,12 +87,26 @@ export async function auth(
|
|||
{ serverUrl,
|
||||
authorizationCode,
|
||||
scope,
|
||||
resourceMetadataUrl
|
||||
}: {
|
||||
serverUrl: string | URL;
|
||||
authorizationCode?: string;
|
||||
scope?: string;
|
||||
}): Promise<AuthResult> {
|
||||
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||
resourceMetadataUrl?: URL }): Promise<AuthResult> {
|
||||
|
||||
let authorizationServerUrl = serverUrl;
|
||||
try {
|
||||
const resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
resourceMetadataUrl || serverUrl);
|
||||
|
||||
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
|
||||
authorizationServerUrl = resourceMetadata.authorization_servers[0];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error)
|
||||
}
|
||||
|
||||
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
|
||||
|
||||
// Handle client registration if needed
|
||||
let clientInformation = await Promise.resolve(provider.clientInformation());
|
||||
|
|
@ -105,7 +119,7 @@ export async function auth(
|
|||
throw new Error("OAuth client information must be saveable for dynamic registration");
|
||||
}
|
||||
|
||||
const fullInformation = await registerClient(serverUrl, {
|
||||
const fullInformation = await registerClient(authorizationServerUrl, {
|
||||
metadata,
|
||||
clientMetadata: provider.clientMetadata,
|
||||
});
|
||||
|
|
@ -117,7 +131,7 @@ export async function auth(
|
|||
// Exchange authorization code for tokens
|
||||
if (authorizationCode !== undefined) {
|
||||
const codeVerifier = await provider.codeVerifier();
|
||||
const tokens = await exchangeAuthorization(serverUrl, {
|
||||
const tokens = await exchangeAuthorization(authorizationServerUrl, {
|
||||
metadata,
|
||||
clientInformation,
|
||||
authorizationCode,
|
||||
|
|
@ -135,7 +149,7 @@ export async function auth(
|
|||
if (tokens?.refresh_token) {
|
||||
try {
|
||||
// Attempt to refresh the token
|
||||
const newTokens = await refreshAuthorization(serverUrl, {
|
||||
const newTokens = await refreshAuthorization(authorizationServerUrl, {
|
||||
metadata,
|
||||
clientInformation,
|
||||
refreshToken: tokens.refresh_token,
|
||||
|
|
@ -149,7 +163,7 @@ export async function auth(
|
|||
}
|
||||
|
||||
// Start new authorization flow
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, {
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
|
||||
metadata,
|
||||
clientInformation,
|
||||
redirectUrl: provider.redirectUrl,
|
||||
|
|
@ -161,6 +175,82 @@ export async function auth(
|
|||
return "REDIRECT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract resource_metadata from response header.
|
||||
*/
|
||||
export function extractResourceMetadataUrl(res: Response): URL | undefined {
|
||||
|
||||
const authenticateHeader = res.headers.get("WWW-Authenticate");
|
||||
if (!authenticateHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [type, scheme] = authenticateHeader.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer' || !scheme) {
|
||||
console.log("Invalid WWW-Authenticate header format, expected 'Bearer'");
|
||||
return undefined;
|
||||
}
|
||||
const regex = /resource_metadata="([^"]*)"/;
|
||||
const match = regex.exec(authenticateHeader);
|
||||
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(match[1]);
|
||||
} catch {
|
||||
console.log("Invalid resource metadata url: ", match[1]);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata.
|
||||
*
|
||||
* If the server returns a 404 for the well-known endpoint, this function will
|
||||
* return `undefined`. Any other errors will be thrown as exceptions.
|
||||
*/
|
||||
export async function discoverOAuthProtectedResourceMetadata(
|
||||
serverUrl: string | URL,
|
||||
opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL },
|
||||
): Promise<OAuthProtectedResourceMetadata> {
|
||||
|
||||
let url: URL
|
||||
if (opts?.resourceMetadataUrl) {
|
||||
url = new URL(opts?.resourceMetadataUrl);
|
||||
} else {
|
||||
url = new URL("/.well-known/oauth-protected-resource", serverUrl);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// CORS errors come back as TypeError
|
||||
if (error instanceof TypeError) {
|
||||
response = await fetch(url);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`,
|
||||
);
|
||||
}
|
||||
return OAuthProtectedResourceMetadataSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
|
||||
*
|
||||
|
|
@ -168,10 +258,10 @@ export async function auth(
|
|||
* return `undefined`. Any other errors will be thrown as exceptions.
|
||||
*/
|
||||
export async function discoverOAuthMetadata(
|
||||
serverUrl: string | URL,
|
||||
authorizationServerUrl: string | URL,
|
||||
opts?: { protocolVersion?: string },
|
||||
): Promise<OAuthMetadata | undefined> {
|
||||
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
|
||||
const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
|
|
@ -205,7 +295,7 @@ export async function discoverOAuthMetadata(
|
|||
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
|
||||
*/
|
||||
export async function startAuthorization(
|
||||
serverUrl: string | URL,
|
||||
authorizationServerUrl: string | URL,
|
||||
{
|
||||
metadata,
|
||||
clientInformation,
|
||||
|
|
@ -240,7 +330,7 @@ export async function startAuthorization(
|
|||
);
|
||||
}
|
||||
} else {
|
||||
authorizationUrl = new URL("/authorize", serverUrl);
|
||||
authorizationUrl = new URL("/authorize", authorizationServerUrl);
|
||||
}
|
||||
|
||||
// Generate PKCE challenge
|
||||
|
|
@ -256,7 +346,7 @@ export async function startAuthorization(
|
|||
codeChallengeMethod,
|
||||
);
|
||||
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
|
||||
|
||||
|
||||
if (scope) {
|
||||
authorizationUrl.searchParams.set("scope", scope);
|
||||
}
|
||||
|
|
@ -268,7 +358,7 @@ export async function startAuthorization(
|
|||
* Exchanges an authorization code for an access token with the given server.
|
||||
*/
|
||||
export async function exchangeAuthorization(
|
||||
serverUrl: string | URL,
|
||||
authorizationServerUrl: string | URL,
|
||||
{
|
||||
metadata,
|
||||
clientInformation,
|
||||
|
|
@ -298,7 +388,7 @@ export async function exchangeAuthorization(
|
|||
);
|
||||
}
|
||||
} else {
|
||||
tokenUrl = new URL("/token", serverUrl);
|
||||
tokenUrl = new URL("/token", authorizationServerUrl);
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
|
|
@ -333,7 +423,7 @@ export async function exchangeAuthorization(
|
|||
* Exchange a refresh token for an updated access token.
|
||||
*/
|
||||
export async function refreshAuthorization(
|
||||
serverUrl: string | URL,
|
||||
authorizationServerUrl: string | URL,
|
||||
{
|
||||
metadata,
|
||||
clientInformation,
|
||||
|
|
@ -359,7 +449,7 @@ export async function refreshAuthorization(
|
|||
);
|
||||
}
|
||||
} else {
|
||||
tokenUrl = new URL("/token", serverUrl);
|
||||
tokenUrl = new URL("/token", authorizationServerUrl);
|
||||
}
|
||||
|
||||
// Exchange refresh token
|
||||
|
|
@ -380,7 +470,6 @@ export async function refreshAuthorization(
|
|||
},
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
|
@ -392,7 +481,7 @@ export async function refreshAuthorization(
|
|||
* Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
|
||||
*/
|
||||
export async function registerClient(
|
||||
serverUrl: string | URL,
|
||||
authorizationServerUrl: string | URL,
|
||||
{
|
||||
metadata,
|
||||
clientMetadata,
|
||||
|
|
@ -410,7 +499,7 @@ export async function registerClient(
|
|||
|
||||
registrationUrl = new URL(metadata.registration_endpoint);
|
||||
} else {
|
||||
registrationUrl = new URL("/register", serverUrl);
|
||||
registrationUrl = new URL("/register", authorizationServerUrl);
|
||||
}
|
||||
|
||||
const response = await fetch(registrationUrl, {
|
||||
|
|
@ -426,4 +515,4 @@ export async function registerClient(
|
|||
}
|
||||
|
||||
return OAuthClientInformationFullSchema.parse(await response.json());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
|||
import { OAuthTokens } from "../shared/auth.js";
|
||||
|
||||
describe("SSEClientTransport", () => {
|
||||
let server: Server;
|
||||
let resourceServer: Server;
|
||||
let authServer: Server;
|
||||
let transport: SSEClientTransport;
|
||||
let baseUrl: URL;
|
||||
let resourceBaseUrl: URL;
|
||||
let authBaseUrl: URL;
|
||||
let lastServerRequest: IncomingMessage;
|
||||
let sendServerMessage: ((message: string) => void) | null = null;
|
||||
|
||||
|
|
@ -17,8 +19,26 @@ describe("SSEClientTransport", () => {
|
|||
lastServerRequest = null as unknown as IncomingMessage;
|
||||
sendServerMessage = null;
|
||||
|
||||
authServer = createServer((req, res) => {
|
||||
if (req.url === "/.well-known/oauth-authorization-server") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
registration_endpoint: "https://auth.example.com/register",
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(401).end();
|
||||
});
|
||||
|
||||
// Create a test server that will receive the EventSource connection
|
||||
server = createServer((req, res) => {
|
||||
resourceServer = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
// Send SSE headers
|
||||
|
|
@ -30,7 +50,7 @@ describe("SSEClientTransport", () => {
|
|||
|
||||
// Send the endpoint event
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${baseUrl.href}\n\n`);
|
||||
res.write(`data: ${resourceBaseUrl.href}\n\n`);
|
||||
|
||||
// Store reference to send function for tests
|
||||
sendServerMessage = (message: string) => {
|
||||
|
|
@ -51,9 +71,9 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
// Start server on random port
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
done();
|
||||
});
|
||||
|
||||
|
|
@ -62,14 +82,15 @@ describe("SSEClientTransport", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await transport.close();
|
||||
await server.close();
|
||||
await resourceServer.close();
|
||||
await authServer.close();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("connection handling", () => {
|
||||
it("establishes SSE connection and receives endpoint", async () => {
|
||||
transport = new SSEClientTransport(baseUrl);
|
||||
transport = new SSEClientTransport(resourceBaseUrl);
|
||||
await transport.start();
|
||||
|
||||
expect(lastServerRequest.headers.accept).toBe("text/event-stream");
|
||||
|
|
@ -78,27 +99,27 @@ describe("SSEClientTransport", () => {
|
|||
|
||||
it("rejects if server returns non-200 status", async () => {
|
||||
// Create a server that returns 403
|
||||
await server.close();
|
||||
await resourceServer.close();
|
||||
|
||||
server = createServer((req, res) => {
|
||||
resourceServer = createServer((req, res) => {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl);
|
||||
transport = new SSEClientTransport(resourceBaseUrl);
|
||||
await expect(transport.start()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("closes EventSource connection on close()", async () => {
|
||||
transport = new SSEClientTransport(baseUrl);
|
||||
transport = new SSEClientTransport(resourceBaseUrl);
|
||||
await transport.start();
|
||||
|
||||
const closePromise = new Promise((resolve) => {
|
||||
|
|
@ -113,7 +134,7 @@ describe("SSEClientTransport", () => {
|
|||
describe("message handling", () => {
|
||||
it("receives and parses JSON-RPC messages", async () => {
|
||||
const receivedMessages: JSONRPCMessage[] = [];
|
||||
transport = new SSEClientTransport(baseUrl);
|
||||
transport = new SSEClientTransport(resourceBaseUrl);
|
||||
transport.onmessage = (msg) => receivedMessages.push(msg);
|
||||
|
||||
await transport.start();
|
||||
|
|
@ -136,7 +157,7 @@ describe("SSEClientTransport", () => {
|
|||
|
||||
it("handles malformed JSON messages", async () => {
|
||||
const errors: Error[] = [];
|
||||
transport = new SSEClientTransport(baseUrl);
|
||||
transport = new SSEClientTransport(resourceBaseUrl);
|
||||
transport.onerror = (err) => errors.push(err);
|
||||
|
||||
await transport.start();
|
||||
|
|
@ -151,7 +172,7 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
it("handles messages via POST requests", async () => {
|
||||
transport = new SSEClientTransport(baseUrl);
|
||||
transport = new SSEClientTransport(resourceBaseUrl);
|
||||
await transport.start();
|
||||
|
||||
const testMessage: JSONRPCMessage = {
|
||||
|
|
@ -179,9 +200,9 @@ describe("SSEClientTransport", () => {
|
|||
|
||||
it("handles POST request failures", async () => {
|
||||
// Create a server that returns 500 for POST
|
||||
await server.close();
|
||||
await resourceServer.close();
|
||||
|
||||
server = createServer((req, res) => {
|
||||
resourceServer = createServer((req, res) => {
|
||||
if (req.method === "GET") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
|
|
@ -189,7 +210,7 @@ describe("SSEClientTransport", () => {
|
|||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${baseUrl.href}\n\n`);
|
||||
res.write(`data: ${resourceBaseUrl.href}\n\n`);
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end("Internal error");
|
||||
|
|
@ -197,14 +218,14 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl);
|
||||
transport = new SSEClientTransport(resourceBaseUrl);
|
||||
await transport.start();
|
||||
|
||||
const testMessage: JSONRPCMessage = {
|
||||
|
|
@ -229,7 +250,7 @@ describe("SSEClientTransport", () => {
|
|||
return fetch(url.toString(), { ...init, headers });
|
||||
};
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
eventSourceInit: {
|
||||
fetch: fetchWithAuth,
|
||||
},
|
||||
|
|
@ -247,7 +268,7 @@ describe("SSEClientTransport", () => {
|
|||
"X-Custom-Header": "custom-value",
|
||||
};
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
requestInit: {
|
||||
headers: customHeaders,
|
||||
},
|
||||
|
|
@ -319,7 +340,7 @@ describe("SSEClientTransport", () => {
|
|||
token_type: "Bearer"
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
|
|
@ -335,7 +356,7 @@ describe("SSEClientTransport", () => {
|
|||
token_type: "Bearer"
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
|
|
@ -355,27 +376,50 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
it("attempts auth flow on 401 during SSE connection", async () => {
|
||||
// Create server that returns 401s
|
||||
await server.close();
|
||||
|
||||
server = createServer((req, res) => {
|
||||
// Create server that returns 401s
|
||||
resourceServer.close();
|
||||
authServer.close();
|
||||
|
||||
// Start auth server on random port
|
||||
await new Promise<void>(resolve => {
|
||||
authServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = authServer.address() as AddressInfo;
|
||||
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
resourceServer = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
if (req.url === "/.well-known/oauth-protected-resource") {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/") {
|
||||
res.writeHead(404).end();
|
||||
res.writeHead(404).end();
|
||||
} else {
|
||||
res.writeHead(401).end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
|
|
@ -385,25 +429,45 @@ describe("SSEClientTransport", () => {
|
|||
|
||||
it("attempts auth flow on 401 during POST request", async () => {
|
||||
// Create server that accepts SSE but returns 401 on POST
|
||||
await server.close();
|
||||
resourceServer.close();
|
||||
authServer.close();
|
||||
|
||||
server = createServer((req, res) => {
|
||||
await new Promise<void>(resolve => {
|
||||
authServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = authServer.address() as AddressInfo;
|
||||
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
resourceServer = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
switch (req.method) {
|
||||
case "GET":
|
||||
if (req.url === "/.well-known/oauth-protected-resource") {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${baseUrl.href}\n\n`);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${resourceBaseUrl.href}\n\n`);
|
||||
break;
|
||||
|
||||
case "POST":
|
||||
|
|
@ -414,14 +478,14 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
|
|
@ -448,7 +512,7 @@ describe("SSEClientTransport", () => {
|
|||
"X-Custom-Header": "custom-value",
|
||||
};
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
requestInit: {
|
||||
headers: customHeaders,
|
||||
|
|
@ -483,11 +547,14 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
// Create server that returns 401 for expired token, then accepts new token
|
||||
await server.close();
|
||||
resourceServer.close();
|
||||
authServer.close();
|
||||
|
||||
let connectionAttempts = 0;
|
||||
server = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
authServer = createServer((req, res) => {
|
||||
if (req.url === "/.well-known/oauth-authorization-server") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/token" && req.method === "POST") {
|
||||
// Handle token refresh request
|
||||
|
|
@ -512,6 +579,34 @@ describe("SSEClientTransport", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
res.writeHead(401).end();
|
||||
|
||||
});
|
||||
|
||||
// Start auth server on random port
|
||||
await new Promise<void>(resolve => {
|
||||
authServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = authServer.address() as AddressInfo;
|
||||
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
let connectionAttempts = 0;
|
||||
resourceServer = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
if (req.url === "/.well-known/oauth-protected-resource") {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
|
|
@ -523,30 +618,30 @@ describe("SSEClientTransport", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (auth === "Bearer new-token") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${baseUrl.href}\n\n`);
|
||||
connectionAttempts++;
|
||||
return;
|
||||
}
|
||||
if (auth === "Bearer new-token") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${resourceBaseUrl.href}\n\n`);
|
||||
connectionAttempts++;
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(401).end();
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
|
|
@ -573,12 +668,15 @@ describe("SSEClientTransport", () => {
|
|||
currentTokens = tokens;
|
||||
});
|
||||
|
||||
// Create server that accepts SSE but returns 401 on POST with expired token
|
||||
await server.close();
|
||||
// Create server that returns 401 for expired token, then accepts new token
|
||||
resourceServer.close();
|
||||
authServer.close();
|
||||
|
||||
let postAttempts = 0;
|
||||
server = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
authServer = createServer((req, res) => {
|
||||
if (req.url === "/.well-known/oauth-authorization-server") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/token" && req.method === "POST") {
|
||||
// Handle token refresh request
|
||||
|
|
@ -603,6 +701,34 @@ describe("SSEClientTransport", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
res.writeHead(401).end();
|
||||
|
||||
});
|
||||
|
||||
// Start auth server on random port
|
||||
await new Promise<void>(resolve => {
|
||||
authServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = authServer.address() as AddressInfo;
|
||||
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
let postAttempts = 0;
|
||||
resourceServer = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
if (req.url === "/.well-known/oauth-protected-resource") {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (req.method) {
|
||||
case "GET":
|
||||
if (req.url !== "/") {
|
||||
|
|
@ -610,13 +736,13 @@ describe("SSEClientTransport", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${baseUrl.href}\n\n`);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("event: endpoint\n");
|
||||
res.write(`data: ${resourceBaseUrl.href}\n\n`);
|
||||
break;
|
||||
|
||||
case "POST": {
|
||||
|
|
@ -644,14 +770,14 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
|
|
@ -688,10 +814,14 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
// Create server that returns 401 for all tokens
|
||||
await server.close();
|
||||
resourceServer.close();
|
||||
authServer.close();
|
||||
|
||||
server = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
authServer = createServer((req, res) => {
|
||||
if (req.url === "/.well-known/oauth-authorization-server") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/token" && req.method === "POST") {
|
||||
// Handle token refresh request - always fail
|
||||
|
|
@ -699,6 +829,34 @@ describe("SSEClientTransport", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
res.writeHead(401).end();
|
||||
|
||||
});
|
||||
|
||||
|
||||
// Start auth server on random port
|
||||
await new Promise<void>(resolve => {
|
||||
authServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = authServer.address() as AddressInfo;
|
||||
authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
resourceServer = createServer((req, res) => {
|
||||
lastServerRequest = req;
|
||||
|
||||
if (req.url === "/.well-known/oauth-protected-resource") {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
|
|
@ -707,14 +865,14 @@ describe("SSEClientTransport", () => {
|
|||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resourceServer.listen(0, "127.0.0.1", () => {
|
||||
const addr = resourceServer.address() as AddressInfo;
|
||||
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(baseUrl, {
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
|
||||
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
|
||||
export class SseError extends Error {
|
||||
constructor(
|
||||
|
|
@ -19,23 +19,23 @@ export class SseError extends Error {
|
|||
export type SSEClientTransportOptions = {
|
||||
/**
|
||||
* An OAuth client provider to use for authentication.
|
||||
*
|
||||
*
|
||||
* When an `authProvider` is specified and the SSE connection is started:
|
||||
* 1. The connection is attempted with any existing access token from the `authProvider`.
|
||||
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
|
||||
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
|
||||
*
|
||||
*
|
||||
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection.
|
||||
*
|
||||
*
|
||||
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
|
||||
*
|
||||
*
|
||||
* `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected.
|
||||
*/
|
||||
authProvider?: OAuthClientProvider;
|
||||
|
||||
/**
|
||||
* Customizes the initial SSE request to the server (the request that begins the stream).
|
||||
*
|
||||
*
|
||||
* NOTE: Setting this property will prevent an `Authorization` header from
|
||||
* being automatically attached to the SSE request, if an `authProvider` is
|
||||
* also given. This can be worked around by setting the `Authorization` header
|
||||
|
|
@ -58,6 +58,7 @@ export class SSEClientTransport implements Transport {
|
|||
private _endpoint?: URL;
|
||||
private _abortController?: AbortController;
|
||||
private _url: URL;
|
||||
private _resourceMetadataUrl?: URL;
|
||||
private _eventSourceInit?: EventSourceInit;
|
||||
private _requestInit?: RequestInit;
|
||||
private _authProvider?: OAuthClientProvider;
|
||||
|
|
@ -71,6 +72,7 @@ export class SSEClientTransport implements Transport {
|
|||
opts?: SSEClientTransportOptions,
|
||||
) {
|
||||
this._url = url;
|
||||
this._resourceMetadataUrl = undefined;
|
||||
this._eventSourceInit = opts?.eventSourceInit;
|
||||
this._requestInit = opts?.requestInit;
|
||||
this._authProvider = opts?.authProvider;
|
||||
|
|
@ -83,7 +85,7 @@ export class SSEClientTransport implements Transport {
|
|||
|
||||
let result: AuthResult;
|
||||
try {
|
||||
result = await auth(this._authProvider, { serverUrl: this._url });
|
||||
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
|
|
@ -193,7 +195,7 @@ export class SSEClientTransport implements Transport {
|
|||
throw new UnauthorizedError("No auth provider");
|
||||
}
|
||||
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new UnauthorizedError("Failed to authorize");
|
||||
}
|
||||
|
|
@ -225,7 +227,10 @@ export class SSEClientTransport implements Transport {
|
|||
const response = await fetch(this._endpoint, init);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url });
|
||||
|
||||
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
|
||||
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Transport } from "../shared/transport.js";
|
||||
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
|
||||
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
|
||||
import { EventSourceParserStream } from "eventsource-parser/stream";
|
||||
|
||||
// Default reconnection options for StreamableHTTP connections
|
||||
|
|
@ -119,6 +119,7 @@ export type StreamableHTTPClientTransportOptions = {
|
|||
export class StreamableHTTPClientTransport implements Transport {
|
||||
private _abortController?: AbortController;
|
||||
private _url: URL;
|
||||
private _resourceMetadataUrl?: URL;
|
||||
private _requestInit?: RequestInit;
|
||||
private _authProvider?: OAuthClientProvider;
|
||||
private _sessionId?: string;
|
||||
|
|
@ -133,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
opts?: StreamableHTTPClientTransportOptions,
|
||||
) {
|
||||
this._url = url;
|
||||
this._resourceMetadataUrl = undefined;
|
||||
this._requestInit = opts?.requestInit;
|
||||
this._authProvider = opts?.authProvider;
|
||||
this._sessionId = opts?.sessionId;
|
||||
|
|
@ -146,7 +148,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
let result: AuthResult;
|
||||
try {
|
||||
result = await auth(this._authProvider, { serverUrl: this._url });
|
||||
result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
|
|
@ -225,7 +227,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
/**
|
||||
* Calculates the next reconnection delay using backoff algorithm
|
||||
*
|
||||
*
|
||||
* @param attempt Current reconnection attempt count for the specific stream
|
||||
* @returns Time to wait in milliseconds before next reconnection attempt
|
||||
*/
|
||||
|
|
@ -242,7 +244,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
/**
|
||||
* Schedule a reconnection attempt with exponential backoff
|
||||
*
|
||||
*
|
||||
* @param lastEventId The ID of the last received event for resumability
|
||||
* @param attemptCount Current reconnection attempt count for this specific stream
|
||||
*/
|
||||
|
|
@ -356,7 +358,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
throw new UnauthorizedError("No auth provider");
|
||||
}
|
||||
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl });
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new UnauthorizedError("Failed to authorize");
|
||||
}
|
||||
|
|
@ -401,7 +403,10 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url });
|
||||
|
||||
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
|
||||
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl });
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
|
@ -470,12 +475,12 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
/**
|
||||
* Terminates the current session by sending a DELETE request to the server.
|
||||
*
|
||||
*
|
||||
* Clients that no longer need a particular session
|
||||
* (e.g., because the user is leaving the client application) SHOULD send an
|
||||
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
|
||||
* terminate the session.
|
||||
*
|
||||
*
|
||||
* The server MAY respond with HTTP 405 Method Not Allowed, indicating that
|
||||
* the server does not allow clients to terminate sessions.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,27 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* RFC 9728 OAuth Protected Resource Metadata
|
||||
*/
|
||||
export const OAuthProtectedResourceMetadataSchema = z
|
||||
.object({
|
||||
resource: z.string().url(),
|
||||
authorization_servers: z.array(z.string().url()).optional(),
|
||||
jwks_uri: z.string().url().optional(),
|
||||
scopes_supported: z.array(z.string()).optional(),
|
||||
bearer_methods_supported: z.array(z.string()).optional(),
|
||||
resource_signing_alg_values_supported: z.array(z.string()).optional(),
|
||||
resource_name: z.string().optional(),
|
||||
resource_documentation: z.string().optional(),
|
||||
resource_policy_uri: z.string().url().optional(),
|
||||
resource_tos_uri: z.string().url().optional(),
|
||||
tls_client_certificate_bound_access_tokens: z.boolean().optional(),
|
||||
authorization_details_types_supported: z.array(z.string()).optional(),
|
||||
dpop_signing_alg_values_supported: z.array(z.string()).optional(),
|
||||
dpop_bound_access_tokens_required: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* RFC 8414 OAuth 2.0 Authorization Server Metadata
|
||||
*/
|
||||
|
|
@ -109,43 +131,6 @@ export const OAuthTokenRevocationRequestSchema = z.object({
|
|||
token_type_hint: z.string().optional(),
|
||||
}).strip();
|
||||
|
||||
/**
|
||||
* RFC 9728 OAuth Protected Resource Metadata
|
||||
*/
|
||||
export const OAuthProtectedResourceMetadataSchema = z.object({
|
||||
// REQUIRED fields
|
||||
resource: z.string().url(),
|
||||
|
||||
// OPTIONAL fields
|
||||
authorization_servers: z.array(z.string().url()).optional(),
|
||||
|
||||
jwks_uri: z.string().url().optional(),
|
||||
|
||||
scopes_supported: z.array(z.string()).optional(),
|
||||
|
||||
bearer_methods_supported: z.array(z.string()).optional(),
|
||||
|
||||
resource_signing_alg_values_supported: z.array(z.string()).optional(),
|
||||
|
||||
resource_name: z.string().optional(),
|
||||
|
||||
resource_documentation: z.string().url().optional(),
|
||||
|
||||
resource_policy_uri: z.string().url().optional(),
|
||||
|
||||
resource_tos_uri: z.string().url().optional(),
|
||||
|
||||
tls_client_certificate_bound_access_tokens: z.boolean().optional(),
|
||||
|
||||
authorization_details_types_supported: z.array(z.string()).optional(),
|
||||
|
||||
dpop_signing_alg_values_supported: z.array(z.string()).optional(),
|
||||
|
||||
dpop_bound_access_tokens_required: z.boolean().optional(),
|
||||
|
||||
// Signed metadata JWT
|
||||
signed_metadata: z.string().optional()
|
||||
}).strict();
|
||||
|
||||
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
|
||||
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue