Skip to content

Commit 8814ed5

Browse files
committed
feature(auth): OAuthClientProvider.delegateAuthorization
An optional method that clients can use whenever the authorization should be delegated to an existing implementation.
1 parent a68dcdf commit 8814ed5

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

src/client/auth.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,5 +1045,156 @@ describe("OAuth Authorization", () => {
10451045
"https://different-resource.example.com/mcp-server"
10461046
);
10471047
});
1048+
1049+
describe("delegateAuthorization", () => {
1050+
const validMetadata = {
1051+
issuer: "https://auth.example.com",
1052+
authorization_endpoint: "https://auth.example.com/authorize",
1053+
token_endpoint: "https://auth.example.com/token",
1054+
registration_endpoint: "https://auth.example.com/register",
1055+
response_types_supported: ["code"],
1056+
code_challenge_methods_supported: ["S256"],
1057+
};
1058+
1059+
const validClientInfo = {
1060+
client_id: "client123",
1061+
client_secret: "secret123",
1062+
redirect_uris: ["http://localhost:3000/callback"],
1063+
client_name: "Test Client",
1064+
};
1065+
1066+
const validTokens = {
1067+
access_token: "access123",
1068+
token_type: "Bearer",
1069+
expires_in: 3600,
1070+
refresh_token: "refresh123",
1071+
};
1072+
1073+
// Setup shared mock function for all tests
1074+
beforeEach(() => {
1075+
// Reset mockFetch implementation
1076+
mockFetch.mockReset();
1077+
1078+
// Set up the mockFetch to respond to all necessary API calls
1079+
mockFetch.mockImplementation((url) => {
1080+
const urlString = url.toString();
1081+
1082+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1083+
return Promise.resolve({
1084+
ok: false,
1085+
status: 404
1086+
});
1087+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1088+
return Promise.resolve({
1089+
ok: true,
1090+
status: 200,
1091+
json: async () => validMetadata
1092+
});
1093+
} else if (urlString.includes("/token")) {
1094+
return Promise.resolve({
1095+
ok: true,
1096+
status: 200,
1097+
json: async () => validTokens
1098+
});
1099+
}
1100+
1101+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
1102+
});
1103+
});
1104+
1105+
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
1106+
const mockProvider: OAuthClientProvider = {
1107+
redirectUrl: "http://localhost:3000/callback",
1108+
clientMetadata: {
1109+
redirect_uris: ["http://localhost:3000/callback"],
1110+
client_name: "Test Client"
1111+
},
1112+
clientInformation: () => validClientInfo,
1113+
tokens: () => validTokens,
1114+
saveTokens: jest.fn(),
1115+
redirectToAuthorization: jest.fn(),
1116+
saveCodeVerifier: jest.fn(),
1117+
codeVerifier: () => "test_verifier",
1118+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
1119+
};
1120+
1121+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1122+
1123+
expect(result).toBe("AUTHORIZED");
1124+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
1125+
"https://auth.example.com",
1126+
expect.objectContaining(validMetadata)
1127+
);
1128+
expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
1129+
});
1130+
1131+
it("should fall back to standard flow when delegateAuthorization returns undefined", async () => {
1132+
const mockProvider: OAuthClientProvider = {
1133+
redirectUrl: "http://localhost:3000/callback",
1134+
clientMetadata: {
1135+
redirect_uris: ["http://localhost:3000/callback"],
1136+
client_name: "Test Client"
1137+
},
1138+
clientInformation: () => validClientInfo,
1139+
tokens: () => validTokens,
1140+
saveTokens: jest.fn(),
1141+
redirectToAuthorization: jest.fn(),
1142+
saveCodeVerifier: jest.fn(),
1143+
codeVerifier: () => "test_verifier",
1144+
delegateAuthorization: jest.fn().mockResolvedValue(undefined)
1145+
};
1146+
1147+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1148+
1149+
expect(result).toBe("AUTHORIZED");
1150+
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
1151+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1152+
});
1153+
1154+
it("should not call delegateAuthorization when processing authorizationCode", async () => {
1155+
const mockProvider: OAuthClientProvider = {
1156+
redirectUrl: "http://localhost:3000/callback",
1157+
clientMetadata: {
1158+
redirect_uris: ["http://localhost:3000/callback"],
1159+
client_name: "Test Client"
1160+
},
1161+
clientInformation: () => validClientInfo,
1162+
tokens: jest.fn(),
1163+
saveTokens: jest.fn(),
1164+
redirectToAuthorization: jest.fn(),
1165+
saveCodeVerifier: jest.fn(),
1166+
codeVerifier: () => "test_verifier",
1167+
delegateAuthorization: jest.fn()
1168+
};
1169+
1170+
await auth(mockProvider, {
1171+
serverUrl: "https://auth.example.com",
1172+
authorizationCode: "code123"
1173+
});
1174+
1175+
expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
1176+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1177+
});
1178+
1179+
it("should propagate errors from delegateAuthorization", async () => {
1180+
const mockProvider: OAuthClientProvider = {
1181+
redirectUrl: "http://localhost:3000/callback",
1182+
clientMetadata: {
1183+
redirect_uris: ["http://localhost:3000/callback"],
1184+
client_name: "Test Client"
1185+
},
1186+
clientInformation: () => validClientInfo,
1187+
tokens: jest.fn(),
1188+
saveTokens: jest.fn(),
1189+
redirectToAuthorization: jest.fn(),
1190+
saveCodeVerifier: jest.fn(),
1191+
codeVerifier: () => "test_verifier",
1192+
delegateAuthorization: jest.fn().mockRejectedValue(new Error("Delegation failed"))
1193+
};
1194+
1195+
await expect(auth(mockProvider, { serverUrl: "https://auth.example.com" }))
1196+
.rejects.toThrow("Delegation failed");
1197+
});
1198+
});
10481199
});
10491200
});

src/client/auth.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,30 @@ export interface OAuthClientProvider {
8181
* Implementations must verify the returned resource matches the MCP server.
8282
*/
8383
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
84+
85+
/**
86+
* Optional method that allows the OAuth client to delegate authorization
87+
* to an existing implementation, such as a platform or app-level identity provider.
88+
*
89+
* If this method returns "AUTHORIZED", the standard authorization flow will be bypassed.
90+
* If it returns `undefined`, the SDK will proceed with its default OAuth implementation.
91+
*
92+
* When returning "AUTHORIZED", the implementation must ensure tokens have been saved
93+
* through the provider's saveTokens method, or are accessible via the tokens() method.
94+
*
95+
* This method is useful when the host application already manages OAuth tokens or user sessions
96+
* and does not need the SDK to handle the entire authorization flow directly.
97+
*
98+
* For example, in a mobile app, this could delegate to the native platform authentication,
99+
* or in a browser application, it could use existing tokens from localStorage.
100+
*
101+
* Note: This method will NOT be called when processing an authorization code callback.
102+
*
103+
* @param serverUrl The URL of the authorization server.
104+
* @param metadata The OAuth metadata if available.
105+
* @returns "AUTHORIZED" if delegation succeeded and tokens are already available; otherwise `undefined`.
106+
*/
107+
delegateAuthorization?(serverUrl: string | URL, metadata: OAuthMetadata | undefined): "AUTHORIZED" | undefined | Promise<"AUTHORIZED" | undefined>;
84108
}
85109

86110
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@@ -124,6 +148,14 @@ export async function auth(
124148

125149
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
126150

151+
// Delegate the authorization if supported and if not already in the middle of the standard flow
152+
if (provider.delegateAuthorization && authorizationCode === undefined) {
153+
const result = await provider.delegateAuthorization(authorizationServerUrl, metadata);
154+
if (result === "AUTHORIZED") {
155+
return "AUTHORIZED";
156+
}
157+
}
158+
127159
// Handle client registration if needed
128160
let clientInformation = await Promise.resolve(provider.clientInformation());
129161
if (!clientInformation) {

0 commit comments

Comments
 (0)