Skip to content

Commit 0ac8051

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 1b14bd7 commit 0ac8051

File tree

5 files changed

+598
-32
lines changed

5 files changed

+598
-32
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", () => {
@@ -880,4 +880,206 @@ describe("SSEClientTransport", () => {
880880
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
881881
});
882882
});
883+
884+
describe("delegated authentication", () => {
885+
let mockDelegatedAuthProvider: jest.Mocked<DelegatedAuthClientProvider>;
886+
887+
beforeEach(() => {
888+
mockDelegatedAuthProvider = {
889+
headers: jest.fn(),
890+
authorize: jest.fn(),
891+
};
892+
});
893+
894+
it("includes delegated auth headers in requests", async () => {
895+
mockDelegatedAuthProvider.headers.mockResolvedValue({
896+
"Authorization": "Bearer delegated-token",
897+
"X-API-Key": "api-key-123"
898+
});
899+
900+
transport = new SSEClientTransport(resourceBaseUrl, {
901+
delegatedAuthProvider: mockDelegatedAuthProvider,
902+
});
903+
904+
await transport.start();
905+
906+
expect(lastServerRequest.headers.authorization).toBe("Bearer delegated-token");
907+
expect(lastServerRequest.headers["x-api-key"]).toBe("api-key-123");
908+
});
909+
910+
it("takes precedence over OAuth provider", async () => {
911+
const mockOAuthProvider = {
912+
get redirectUrl() { return "http://localhost/callback"; },
913+
get clientMetadata() { return { redirect_uris: ["http://localhost/callback"] }; },
914+
clientInformation: jest.fn(() => ({ client_id: "oauth-client", client_secret: "oauth-secret" })),
915+
tokens: jest.fn(() => Promise.resolve({ access_token: "oauth-token", token_type: "Bearer" })),
916+
saveTokens: jest.fn(),
917+
redirectToAuthorization: jest.fn(),
918+
saveCodeVerifier: jest.fn(),
919+
codeVerifier: jest.fn(),
920+
};
921+
922+
mockDelegatedAuthProvider.headers.mockResolvedValue({
923+
"Authorization": "Bearer delegated-token"
924+
});
925+
926+
transport = new SSEClientTransport(resourceBaseUrl, {
927+
authProvider: mockOAuthProvider,
928+
delegatedAuthProvider: mockDelegatedAuthProvider,
929+
});
930+
931+
await transport.start();
932+
933+
expect(lastServerRequest.headers.authorization).toBe("Bearer delegated-token");
934+
expect(mockOAuthProvider.tokens).not.toHaveBeenCalled();
935+
});
936+
937+
it("handles 401 during SSE connection with successful reauth", async () => {
938+
mockDelegatedAuthProvider.headers.mockResolvedValueOnce(undefined);
939+
mockDelegatedAuthProvider.authorize.mockResolvedValue(true);
940+
mockDelegatedAuthProvider.headers.mockResolvedValueOnce({
941+
"Authorization": "Bearer new-delegated-token"
942+
});
943+
944+
// Create server that returns 401 on first attempt, 200 on second
945+
resourceServer.close();
946+
947+
let attemptCount = 0;
948+
resourceServer = createServer((req, res) => {
949+
lastServerRequest = req;
950+
attemptCount++;
951+
952+
if (attemptCount === 1) {
953+
res.writeHead(401).end();
954+
return;
955+
}
956+
957+
res.writeHead(200, {
958+
"Content-Type": "text/event-stream",
959+
"Cache-Control": "no-cache, no-transform",
960+
Connection: "keep-alive",
961+
});
962+
res.write("event: endpoint\n");
963+
res.write(`data: ${resourceBaseUrl.href}\n\n`);
964+
});
965+
966+
await new Promise<void>((resolve) => {
967+
resourceServer.listen(0, "127.0.0.1", () => {
968+
const addr = resourceServer.address() as AddressInfo;
969+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
970+
resolve();
971+
});
972+
});
973+
974+
transport = new SSEClientTransport(resourceBaseUrl, {
975+
delegatedAuthProvider: mockDelegatedAuthProvider,
976+
});
977+
978+
await transport.start();
979+
980+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
981+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
982+
serverUrl: resourceBaseUrl,
983+
resourceMetadataUrl: undefined
984+
});
985+
expect(attemptCount).toBe(2);
986+
});
987+
988+
it("throws UnauthorizedError when reauth fails", async () => {
989+
mockDelegatedAuthProvider.headers.mockResolvedValue(undefined);
990+
mockDelegatedAuthProvider.authorize.mockResolvedValue(false);
991+
992+
// Create server that always returns 401
993+
resourceServer.close();
994+
995+
resourceServer = createServer((req, res) => {
996+
res.writeHead(401).end();
997+
});
998+
999+
await new Promise<void>((resolve) => {
1000+
resourceServer.listen(0, "127.0.0.1", () => {
1001+
const addr = resourceServer.address() as AddressInfo;
1002+
resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`);
1003+
resolve();
1004+
});
1005+
});
1006+
1007+
transport = new SSEClientTransport(resourceBaseUrl, {
1008+
delegatedAuthProvider: mockDelegatedAuthProvider,
1009+
});
1010+
1011+
await expect(transport.start()).rejects.toThrow(UnauthorizedError);
1012+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1013+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1014+
serverUrl: resourceBaseUrl,
1015+
resourceMetadataUrl: undefined
1016+
});
1017+
});
1018+
1019+
it("handles 401 during POST request with successful reauth", async () => {
1020+
mockDelegatedAuthProvider.headers.mockResolvedValue({
1021+
"Authorization": "Bearer delegated-token"
1022+
});
1023+
mockDelegatedAuthProvider.authorize.mockResolvedValue(true);
1024+
1025+
// Create server that accepts SSE but returns 401 on first POST, 200 on second
1026+
resourceServer.close();
1027+
1028+
let postAttempts = 0;
1029+
resourceServer = createServer((req, res) => {
1030+
lastServerRequest = req;
1031+
1032+
switch (req.method) {
1033+
case "GET":
1034+
res.writeHead(200, {
1035+
"Content-Type": "text/event-stream",
1036+
"Cache-Control": "no-cache, no-transform",
1037+
Connection: "keep-alive",
1038+
});
1039+
res.write("event: endpoint\n");
1040+
res.write(`data: ${resourceBaseUrl.href}\n\n`);
1041+
break;
1042+
1043+
case "POST":
1044+
postAttempts++;
1045+
if (postAttempts === 1) {
1046+
res.writeHead(401).end();
1047+
} else {
1048+
res.writeHead(200).end();
1049+
}
1050+
break;
1051+
}
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 transport.start();
1067+
1068+
const message: JSONRPCMessage = {
1069+
jsonrpc: "2.0",
1070+
id: "1",
1071+
method: "test",
1072+
params: {},
1073+
};
1074+
1075+
await transport.send(message);
1076+
1077+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledTimes(1);
1078+
expect(mockDelegatedAuthProvider.authorize).toHaveBeenCalledWith({
1079+
serverUrl: resourceBaseUrl,
1080+
resourceMetadataUrl: undefined
1081+
});
1082+
expect(postAttempts).toBe(2);
1083+
});
1084+
});
8831085
});

0 commit comments

Comments
 (0)