Skip to content

Commit 097e8da

Browse files
committed
feature(auth): DelegatedAuthClientProvider
An optional provider that can be passed to the SSE and StreamableHttp client transports in order to completely delegate the authentication to an external system.
1 parent f3584f2 commit 097e8da

File tree

5 files changed

+598
-30
lines changed

5 files changed

+598
-30
lines changed

src/client/auth.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,42 @@ export interface OAuthClientProvider {
126126
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
127127
}
128128

129+
/**
130+
* A provider that delegates authentication to an external system.
131+
*
132+
* This interface allows for custom authentication mechanisms that are
133+
* either already implemented on a specific platform or handled outside the
134+
* standard OAuth flow, such as API keys, custom tokens, or integration with external
135+
* authentication services.
136+
*/
137+
export interface DelegatedAuthClientProvider {
138+
/**
139+
* Returns authentication headers to be included in requests.
140+
*
141+
* These headers will be added to all HTTP requests made by the transport.
142+
* Common examples include Authorization headers, API keys, or custom
143+
* authentication tokens.
144+
*
145+
* @returns Headers to include in requests, or undefined if no authentication is available
146+
*/
147+
headers(): HeadersInit | undefined | Promise<HeadersInit | undefined>;
148+
149+
/**
150+
* Performs authentication when a 401 Unauthorized response is received.
151+
*
152+
* This method is called when the server responds with a 401 status code,
153+
* indicating that the current authentication is invalid or expired.
154+
* The implementation should attempt to refresh or re-establish authentication.
155+
*
156+
* @param context Authentication context providing server and resource information
157+
* @param context.serverUrl The URL of the MCP server being authenticated against
158+
* @param context.resourceMetadataUrl Optional URL for resource metadata, if available
159+
* @returns Promise that resolves to true if authentication was successful,
160+
* false if authentication failed
161+
*/
162+
authorize(context: { serverUrl: string | URL; resourceMetadataUrl?: URL }): boolean | Promise<boolean>;
163+
}
164+
129165
export type AuthResult = "AUTHORIZED" | "REDIRECT";
130166

131167
export class UnauthorizedError extends Error {

src/client/sse.test.ts

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createServer, type IncomingMessage, type Server } from "http";
22
import { AddressInfo } from "net";
33
import { JSONRPCMessage } from "../types.js";
44
import { SSEClientTransport } from "./sse.js";
5-
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
5+
import { DelegatedAuthClientProvider, OAuthClientProvider, UnauthorizedError } from "./auth.js";
66
import { OAuthTokens } from "../shared/auth.js";
77
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
88

@@ -1108,4 +1108,206 @@ describe("SSEClientTransport", () => {
11081108
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
11091109
});
11101110
});
1111+
1112+
describe("delegated authentication", () => {
1113+
let mockDelegatedAuthProvider: jest.Mocked<DelegatedAuthClientProvider>;
1114+
1115+
beforeEach(() => {
1116+
mockDelegatedAuthProvider = {
1117+
headers: jest.fn(),
1118+
authorize: jest.fn(),
1119+
};
1120+
});
1121+
1122+
it("includes delegated auth headers in requests", async () => {
1123+
mockDelegatedAuthProvider.headers.mockResolvedValue({
1124+
"Authorization": "Bearer delegated-token",
1125+
"X-API-Key": "api-key-123"
1126+
});
1127+
1128+
transport = new SSEClientTransport(resourceBaseUrl, {
1129+
delegatedAuthProvider: mockDelegatedAuthProvider,
1130+
});
1131+
1132+
await transport.start();
1133+
1134+
expect(lastServerRequest.headers.authorization).toBe("Bearer delegated-token");
1135+
expect(lastServerRequest.headers["x-api-key"]).toBe("api-key-123");
1136+
});
1137+
1138+
it("takes precedence over OAuth provider", async () => {
1139+
const mockOAuthProvider = {
1140+
get redirectUrl() { return "http://localhost/callback"; },
1141+
get clientMetadata() { return { redirect_uris: ["http://localhost/callback"] }; },
1142+
clientInformation: jest.fn(() => ({ client_id: "oauth-client", client_secret: "oauth-secret" })),
1143+
tokens: jest.fn(() => Promise.resolve({ access_token: "oauth-token", token_type: "Bearer" })),
1144+
saveTokens: jest.fn(),
1145+
redirectToAuthorization: jest.fn(),
1146+
saveCodeVerifier: jest.fn(),
1147+
codeVerifier: jest.fn(),
1148+
};
1149+
1150+
mockDelegatedAuthProvider.headers.mockResolvedValue({
1151+
"Authorization": "Bearer delegated-token"
1152+
});
1153+
1154+
transport = new SSEClientTransport(resourceBaseUrl, {
1155+
authProvider: mockOAuthProvider,
1156+
delegatedAuthProvider: mockDelegatedAuthProvider,
1157+
});
1158+
1159+
await transport.start();
1160+
1161+
expect(lastServerRequest.headers.authorization).toBe("Bearer delegated-token");
1162+
expect(mockOAuthProvider.tokens).not.toHaveBeenCalled();
1163+
});
1164+
1165+
it("handles 401 during SSE connection with successful reauth", async () => {
1166+
mockDelegatedAuthProvider.headers.mockResolvedValueOnce(undefined);
1167+
mockDelegatedAuthProvider.authorize.mockResolvedValue(true);
1168+
mockDelegatedAuthProvider.headers.mockResolvedValueOnce({
1169+
"Authorization": "Bearer new-delegated-token"
1170+
});
1171+
1172+
// Create server that returns 401 on first attempt, 200 on second
1173+
resourceServer.close();
1174+
1175+
let attemptCount = 0;
1176+
resourceServer = createServer((req, res) => {
1177+
lastServerRequest = req;
1178+
attemptCount++;
1179+
1180+
if (attemptCount === 1) {
1181+
res.writeHead(401).end();
1182+
return;
1183+
}
1184+
1185+
res.writeHead(200, {
1186+
"Content-Type": "text/event-stream",
1187+
"Cache-Control": "no-cache, no-transform",
1188+
Connection: "keep-alive",
1189+
});
1190+
res.write("event: endpoint\n");
1191+
res.write(`data: ${resourceBaseUrl.href}\n\n`);
1192+
});
1193+
1194+
await new Promise<void>((resolve) => {
1195+
resourceServer.listen(0, "127.0.0.1", () => {
1196+
const addr = resourceServer.address() as AddressInfo;
1197+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
1198+
resolve();
1199+
});
1200+
});
1201+
1202+
transport = new SSEClientTransport(resourceBaseUrl, {
1203+
delegatedAuthProvider: mockDelegatedAuthProvider,
1204+
});
1205+
1206+
await transport.start();
1207+
1208+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1209+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1210+
serverUrl: resourceBaseUrl,
1211+
resourceMetadataUrl: undefined
1212+
});
1213+
expect(attemptCount).toBe(2);
1214+
});
1215+
1216+
it("throws UnauthorizedError when reauth fails", async () => {
1217+
mockDelegatedAuthProvider.headers.mockResolvedValue(undefined);
1218+
mockDelegatedAuthProvider.authorize.mockResolvedValue(false);
1219+
1220+
// Create server that always returns 401
1221+
resourceServer.close();
1222+
1223+
resourceServer = createServer((req, res) => {
1224+
res.writeHead(401).end();
1225+
});
1226+
1227+
await new Promise<void>((resolve) => {
1228+
resourceServer.listen(0, "127.0.0.1", () => {
1229+
const addr = resourceServer.address() as AddressInfo;
1230+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
1231+
resolve();
1232+
});
1233+
});
1234+
1235+
transport = new SSEClientTransport(resourceBaseUrl, {
1236+
delegatedAuthProvider: mockDelegatedAuthProvider,
1237+
});
1238+
1239+
await expect(transport.start()).rejects.toThrow(UnauthorizedError);
1240+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1241+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1242+
serverUrl: resourceBaseUrl,
1243+
resourceMetadataUrl: undefined
1244+
});
1245+
});
1246+
1247+
it("handles 401 during POST request with successful reauth", async () => {
1248+
mockDelegatedAuthProvider.headers.mockResolvedValue({
1249+
"Authorization": "Bearer delegated-token"
1250+
});
1251+
mockDelegatedAuthProvider.authorize.mockResolvedValue(true);
1252+
1253+
// Create server that accepts SSE but returns 401 on first POST, 200 on second
1254+
resourceServer.close();
1255+
1256+
let postAttempts = 0;
1257+
resourceServer = createServer((req, res) => {
1258+
lastServerRequest = req;
1259+
1260+
switch (req.method) {
1261+
case "GET":
1262+
res.writeHead(200, {
1263+
"Content-Type": "text/event-stream",
1264+
"Cache-Control": "no-cache, no-transform",
1265+
Connection: "keep-alive",
1266+
});
1267+
res.write("event: endpoint\n");
1268+
res.write(`data: ${resourceBaseUrl.href}\n\n`);
1269+
break;
1270+
1271+
case "POST":
1272+
postAttempts++;
1273+
if (postAttempts === 1) {
1274+
res.writeHead(401).end();
1275+
} else {
1276+
res.writeHead(200).end();
1277+
}
1278+
break;
1279+
}
1280+
});
1281+
1282+
await new Promise<void>((resolve) => {
1283+
resourceServer.listen(0, "127.0.0.1", () => {
1284+
const addr = resourceServer.address() as AddressInfo;
1285+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
1286+
resolve();
1287+
});
1288+
});
1289+
1290+
transport = new SSEClientTransport(resourceBaseUrl, {
1291+
delegatedAuthProvider: mockDelegatedAuthProvider,
1292+
});
1293+
1294+
await transport.start();
1295+
1296+
const message: JSONRPCMessage = {
1297+
jsonrpc: "2.0",
1298+
id: "1",
1299+
method: "test",
1300+
params: {},
1301+
};
1302+
1303+
await transport.send(message);
1304+
1305+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1306+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1307+
serverUrl: resourceBaseUrl,
1308+
resourceMetadataUrl: undefined
1309+
});
1310+
expect(postAttempts).toBe(2);
1311+
});
1312+
});
11111313
});

0 commit comments

Comments
 (0)