Skip to content

Commit eb43d42

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 0b0ea5b commit eb43d42

File tree

5 files changed

+595
-29
lines changed

5 files changed

+595
-29
lines changed

src/client/auth.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,42 @@ export interface OAuthClientProvider {
8383
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
8484
}
8585

86+
/**
87+
* A provider that delegates authentication to an external system.
88+
*
89+
* This interface allows for custom authentication mechanisms that are
90+
* either already implemented on a specific platform or handled outside the
91+
* standard OAuth flow, such as API keys, custom tokens, or integration with external
92+
* authentication services.
93+
*/
94+
export interface DelegatedAuthClientProvider {
95+
/**
96+
* Returns authentication headers to be included in requests.
97+
*
98+
* These headers will be added to all HTTP requests made by the transport.
99+
* Common examples include Authorization headers, API keys, or custom
100+
* authentication tokens.
101+
*
102+
* @returns Headers to include in requests, or undefined if no authentication is available
103+
*/
104+
headers(): HeadersInit | undefined | Promise<HeadersInit | undefined>;
105+
106+
/**
107+
* Performs authentication when a 401 Unauthorized response is received.
108+
*
109+
* This method is called when the server responds with a 401 status code,
110+
* indicating that the current authentication is invalid or expired.
111+
* The implementation should attempt to refresh or re-establish authentication.
112+
*
113+
* @param context Authentication context providing server and resource information
114+
* @param context.serverUrl The URL of the MCP server being authenticated against
115+
* @param context.resourceMetadataUrl Optional URL for resource metadata, if available
116+
* @returns Promise that resolves to true if authentication was successful,
117+
* false if authentication failed
118+
*/
119+
authorize(context: { serverUrl: string | URL; resourceMetadataUrl?: URL }): boolean | Promise<boolean>;
120+
}
121+
86122
export type AuthResult = "AUTHORIZED" | "REDIRECT";
87123

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

0 commit comments

Comments
 (0)