Skip to content

Commit 0c2f2f3

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 11e84f0 commit 0c2f2f3

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
@@ -103,6 +103,42 @@ export interface OAuthClientProvider {
103103
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
104104
}
105105

106+
/**
107+
* A provider that delegates authentication to an external system.
108+
*
109+
* This interface allows for custom authentication mechanisms that are
110+
* either already implemented on a specific platform or handled outside the
111+
* standard OAuth flow, such as API keys, custom tokens, or integration with external
112+
* authentication services.
113+
*/
114+
export interface DelegatedAuthClientProvider {
115+
/**
116+
* Returns authentication headers to be included in requests.
117+
*
118+
* These headers will be added to all HTTP requests made by the transport.
119+
* Common examples include Authorization headers, API keys, or custom
120+
* authentication tokens.
121+
*
122+
* @returns Headers to include in requests, or undefined if no authentication is available
123+
*/
124+
headers(): HeadersInit | undefined | Promise<HeadersInit | undefined>;
125+
126+
/**
127+
* Performs authentication when a 401 Unauthorized response is received.
128+
*
129+
* This method is called when the server responds with a 401 status code,
130+
* indicating that the current authentication is invalid or expired.
131+
* The implementation should attempt to refresh or re-establish authentication.
132+
*
133+
* @param context Authentication context providing server and resource information
134+
* @param context.serverUrl The URL of the MCP server being authenticated against
135+
* @param context.resourceMetadataUrl Optional URL for resource metadata, if available
136+
* @returns Promise that resolves to true if authentication was successful,
137+
* false if authentication failed
138+
*/
139+
authorize(context: { serverUrl: string | URL; resourceMetadataUrl?: URL }): boolean | Promise<boolean>;
140+
}
141+
106142
export type AuthResult = "AUTHORIZED" | "REDIRECT";
107143

108144
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

88
describe("SSEClientTransport", () => {
@@ -935,4 +935,206 @@ describe("SSEClientTransport", () => {
935935
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
936936
});
937937
});
938+
939+
describe("delegated authentication", () => {
940+
let mockDelegatedAuthProvider: jest.Mocked<DelegatedAuthClientProvider>;
941+
942+
beforeEach(() => {
943+
mockDelegatedAuthProvider = {
944+
headers: jest.fn(),
945+
authorize: jest.fn(),
946+
};
947+
});
948+
949+
it("includes delegated auth headers in requests", async () => {
950+
mockDelegatedAuthProvider.headers.mockResolvedValue({
951+
"Authorization": "Bearer delegated-token",
952+
"X-API-Key": "api-key-123"
953+
});
954+
955+
transport = new SSEClientTransport(resourceBaseUrl, {
956+
delegatedAuthProvider: mockDelegatedAuthProvider,
957+
});
958+
959+
await transport.start();
960+
961+
expect(lastServerRequest.headers.authorization).toBe("Bearer delegated-token");
962+
expect(lastServerRequest.headers["x-api-key"]).toBe("api-key-123");
963+
});
964+
965+
it("takes precedence over OAuth provider", async () => {
966+
const mockOAuthProvider = {
967+
get redirectUrl() { return "http://localhost/callback"; },
968+
get clientMetadata() { return { redirect_uris: ["http://localhost/callback"] }; },
969+
clientInformation: jest.fn(() => ({ client_id: "oauth-client", client_secret: "oauth-secret" })),
970+
tokens: jest.fn(() => Promise.resolve({ access_token: "oauth-token", token_type: "Bearer" })),
971+
saveTokens: jest.fn(),
972+
redirectToAuthorization: jest.fn(),
973+
saveCodeVerifier: jest.fn(),
974+
codeVerifier: jest.fn(),
975+
};
976+
977+
mockDelegatedAuthProvider.headers.mockResolvedValue({
978+
"Authorization": "Bearer delegated-token"
979+
});
980+
981+
transport = new SSEClientTransport(resourceBaseUrl, {
982+
authProvider: mockOAuthProvider,
983+
delegatedAuthProvider: mockDelegatedAuthProvider,
984+
});
985+
986+
await transport.start();
987+
988+
expect(lastServerRequest.headers.authorization).toBe("Bearer delegated-token");
989+
expect(mockOAuthProvider.tokens).not.toHaveBeenCalled();
990+
});
991+
992+
it("handles 401 during SSE connection with successful reauth", async () => {
993+
mockDelegatedAuthProvider.headers.mockResolvedValueOnce(undefined);
994+
mockDelegatedAuthProvider.authorize.mockResolvedValue(true);
995+
mockDelegatedAuthProvider.headers.mockResolvedValueOnce({
996+
"Authorization": "Bearer new-delegated-token"
997+
});
998+
999+
// Create server that returns 401 on first attempt, 200 on second
1000+
resourceServer.close();
1001+
1002+
let attemptCount = 0;
1003+
resourceServer = createServer((req, res) => {
1004+
lastServerRequest = req;
1005+
attemptCount++;
1006+
1007+
if (attemptCount === 1) {
1008+
res.writeHead(401).end();
1009+
return;
1010+
}
1011+
1012+
res.writeHead(200, {
1013+
"Content-Type": "text/event-stream",
1014+
"Cache-Control": "no-cache, no-transform",
1015+
Connection: "keep-alive",
1016+
});
1017+
res.write("event: endpoint\n");
1018+
res.write(`data: ${resourceBaseUrl.href}\n\n`);
1019+
});
1020+
1021+
await new Promise<void>((resolve) => {
1022+
resourceServer.listen(0, "127.0.0.1", () => {
1023+
const addr = resourceServer.address() as AddressInfo;
1024+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
1025+
resolve();
1026+
});
1027+
});
1028+
1029+
transport = new SSEClientTransport(resourceBaseUrl, {
1030+
delegatedAuthProvider: mockDelegatedAuthProvider,
1031+
});
1032+
1033+
await transport.start();
1034+
1035+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1036+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1037+
serverUrl: resourceBaseUrl,
1038+
resourceMetadataUrl: undefined
1039+
});
1040+
expect(attemptCount).toBe(2);
1041+
});
1042+
1043+
it("throws UnauthorizedError when reauth fails", async () => {
1044+
mockDelegatedAuthProvider.headers.mockResolvedValue(undefined);
1045+
mockDelegatedAuthProvider.authorize.mockResolvedValue(false);
1046+
1047+
// Create server that always returns 401
1048+
resourceServer.close();
1049+
1050+
resourceServer = createServer((req, res) => {
1051+
res.writeHead(401).end();
1052+
});
1053+
1054+
await new Promise<void>((resolve) => {
1055+
resourceServer.listen(0, "127.0.0.1", () => {
1056+
const addr = resourceServer.address() as AddressInfo;
1057+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
1058+
resolve();
1059+
});
1060+
});
1061+
1062+
transport = new SSEClientTransport(resourceBaseUrl, {
1063+
delegatedAuthProvider: mockDelegatedAuthProvider,
1064+
});
1065+
1066+
await expect(transport.start()).rejects.toThrow(UnauthorizedError);
1067+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1068+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1069+
serverUrl: resourceBaseUrl,
1070+
resourceMetadataUrl: undefined
1071+
});
1072+
});
1073+
1074+
it("handles 401 during POST request with successful reauth", async () => {
1075+
mockDelegatedAuthProvider.headers.mockResolvedValue({
1076+
"Authorization": "Bearer delegated-token"
1077+
});
1078+
mockDelegatedAuthProvider.authorize.mockResolvedValue(true);
1079+
1080+
// Create server that accepts SSE but returns 401 on first POST, 200 on second
1081+
resourceServer.close();
1082+
1083+
let postAttempts = 0;
1084+
resourceServer = createServer((req, res) => {
1085+
lastServerRequest = req;
1086+
1087+
switch (req.method) {
1088+
case "GET":
1089+
res.writeHead(200, {
1090+
"Content-Type": "text/event-stream",
1091+
"Cache-Control": "no-cache, no-transform",
1092+
Connection: "keep-alive",
1093+
});
1094+
res.write("event: endpoint\n");
1095+
res.write(`data: ${resourceBaseUrl.href}\n\n`);
1096+
break;
1097+
1098+
case "POST":
1099+
postAttempts++;
1100+
if (postAttempts === 1) {
1101+
res.writeHead(401).end();
1102+
} else {
1103+
res.writeHead(200).end();
1104+
}
1105+
break;
1106+
}
1107+
});
1108+
1109+
await new Promise<void>((resolve) => {
1110+
resourceServer.listen(0, "127.0.0.1", () => {
1111+
const addr = resourceServer.address() as AddressInfo;
1112+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
1113+
resolve();
1114+
});
1115+
});
1116+
1117+
transport = new SSEClientTransport(resourceBaseUrl, {
1118+
delegatedAuthProvider: mockDelegatedAuthProvider,
1119+
});
1120+
1121+
await transport.start();
1122+
1123+
const message: JSONRPCMessage = {
1124+
jsonrpc: "2.0",
1125+
id: "1",
1126+
method: "test",
1127+
params: {},
1128+
};
1129+
1130+
await transport.send(message);
1131+
1132+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1133+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1134+
serverUrl: resourceBaseUrl,
1135+
resourceMetadataUrl: undefined
1136+
});
1137+
expect(postAttempts).toBe(2);
1138+
});
1139+
});
9381140
});

0 commit comments

Comments
 (0)