Skip to content

Commit 2be7d47

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 66b7cd2 commit 2be7d47

File tree

2 files changed

+241
-0
lines changed

2 files changed

+241
-0
lines changed

src/client/auth.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,5 +1106,211 @@ describe("OAuth Authorization", () => {
11061106
// Should use the PRM's resource value, not the full requested URL
11071107
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/");
11081108
});
1109+
1110+
describe("delegateAuthorization", () => {
1111+
const validMetadata = {
1112+
issuer: "https://auth.example.com",
1113+
authorization_endpoint: "https://auth.example.com/authorize",
1114+
token_endpoint: "https://auth.example.com/token",
1115+
registration_endpoint: "https://auth.example.com/register",
1116+
response_types_supported: ["code"],
1117+
code_challenge_methods_supported: ["S256"],
1118+
};
1119+
1120+
const validClientInfo = {
1121+
client_id: "client123",
1122+
client_secret: "secret123",
1123+
redirect_uris: ["http://localhost:3000/callback"],
1124+
client_name: "Test Client",
1125+
};
1126+
1127+
const validTokens = {
1128+
access_token: "access123",
1129+
token_type: "Bearer",
1130+
expires_in: 3600,
1131+
refresh_token: "refresh123",
1132+
};
1133+
1134+
// Setup shared mock function for all tests
1135+
beforeEach(() => {
1136+
// Reset mockFetch implementation
1137+
mockFetch.mockReset();
1138+
1139+
// Set up the mockFetch to respond to all necessary API calls
1140+
mockFetch.mockImplementation((url) => {
1141+
const urlString = url.toString();
1142+
1143+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1144+
return Promise.resolve({
1145+
ok: false,
1146+
status: 404
1147+
});
1148+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1149+
return Promise.resolve({
1150+
ok: true,
1151+
status: 200,
1152+
json: async () => validMetadata
1153+
});
1154+
} else if (urlString.includes("/token")) {
1155+
return Promise.resolve({
1156+
ok: true,
1157+
status: 200,
1158+
json: async () => validTokens
1159+
});
1160+
}
1161+
1162+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
1163+
});
1164+
});
1165+
1166+
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
1167+
const mockProvider: OAuthClientProvider = {
1168+
redirectUrl: "http://localhost:3000/callback",
1169+
clientMetadata: {
1170+
redirect_uris: ["http://localhost:3000/callback"],
1171+
client_name: "Test Client"
1172+
},
1173+
clientInformation: () => validClientInfo,
1174+
tokens: () => validTokens,
1175+
saveTokens: jest.fn(),
1176+
redirectToAuthorization: jest.fn(),
1177+
saveCodeVerifier: jest.fn(),
1178+
codeVerifier: () => "test_verifier",
1179+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
1180+
};
1181+
1182+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1183+
1184+
expect(result).toBe("AUTHORIZED");
1185+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
1186+
"https://auth.example.com",
1187+
{
1188+
metadata: expect.objectContaining(validMetadata),
1189+
resource: expect.any(URL)
1190+
}
1191+
);
1192+
expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
1193+
});
1194+
1195+
it("should fall back to standard flow when delegateAuthorization returns undefined", async () => {
1196+
const mockProvider: OAuthClientProvider = {
1197+
redirectUrl: "http://localhost:3000/callback",
1198+
clientMetadata: {
1199+
redirect_uris: ["http://localhost:3000/callback"],
1200+
client_name: "Test Client"
1201+
},
1202+
clientInformation: () => validClientInfo,
1203+
tokens: () => validTokens,
1204+
saveTokens: jest.fn(),
1205+
redirectToAuthorization: jest.fn(),
1206+
saveCodeVerifier: jest.fn(),
1207+
codeVerifier: () => "test_verifier",
1208+
delegateAuthorization: jest.fn().mockResolvedValue(undefined)
1209+
};
1210+
1211+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1212+
1213+
expect(result).toBe("AUTHORIZED");
1214+
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
1215+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1216+
});
1217+
1218+
it("should not call delegateAuthorization when processing authorizationCode", async () => {
1219+
const mockProvider: OAuthClientProvider = {
1220+
redirectUrl: "http://localhost:3000/callback",
1221+
clientMetadata: {
1222+
redirect_uris: ["http://localhost:3000/callback"],
1223+
client_name: "Test Client"
1224+
},
1225+
clientInformation: () => validClientInfo,
1226+
tokens: jest.fn(),
1227+
saveTokens: jest.fn(),
1228+
redirectToAuthorization: jest.fn(),
1229+
saveCodeVerifier: jest.fn(),
1230+
codeVerifier: () => "test_verifier",
1231+
delegateAuthorization: jest.fn()
1232+
};
1233+
1234+
await auth(mockProvider, {
1235+
serverUrl: "https://auth.example.com",
1236+
authorizationCode: "code123"
1237+
});
1238+
1239+
expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
1240+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1241+
});
1242+
1243+
it("should propagate errors from delegateAuthorization", async () => {
1244+
const mockProvider: OAuthClientProvider = {
1245+
redirectUrl: "http://localhost:3000/callback",
1246+
clientMetadata: {
1247+
redirect_uris: ["http://localhost:3000/callback"],
1248+
client_name: "Test Client"
1249+
},
1250+
clientInformation: () => validClientInfo,
1251+
tokens: jest.fn(),
1252+
saveTokens: jest.fn(),
1253+
redirectToAuthorization: jest.fn(),
1254+
saveCodeVerifier: jest.fn(),
1255+
codeVerifier: () => "test_verifier",
1256+
delegateAuthorization: jest.fn().mockRejectedValue(new Error("Delegation failed"))
1257+
};
1258+
1259+
await expect(auth(mockProvider, { serverUrl: "https://auth.example.com" }))
1260+
.rejects.toThrow("Delegation failed");
1261+
});
1262+
1263+
it("should pass both resource and metadata to delegateAuthorization when available", async () => {
1264+
// Mock resource metadata to be returned by the fetch
1265+
mockFetch.mockImplementation((url) => {
1266+
const urlString = url.toString();
1267+
1268+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1269+
return Promise.resolve({
1270+
ok: true,
1271+
status: 200,
1272+
json: async () => ({
1273+
resource: "https://api.example.com/",
1274+
authorization_servers: ["https://auth.example.com"]
1275+
})
1276+
});
1277+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1278+
return Promise.resolve({
1279+
ok: true,
1280+
status: 200,
1281+
json: async () => validMetadata
1282+
});
1283+
}
1284+
1285+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
1286+
});
1287+
1288+
const mockProvider: OAuthClientProvider = {
1289+
redirectUrl: "http://localhost:3000/callback",
1290+
clientMetadata: {
1291+
redirect_uris: ["http://localhost:3000/callback"],
1292+
client_name: "Test Client"
1293+
},
1294+
clientInformation: () => validClientInfo,
1295+
tokens: jest.fn(),
1296+
saveTokens: jest.fn(),
1297+
redirectToAuthorization: jest.fn(),
1298+
saveCodeVerifier: jest.fn(),
1299+
codeVerifier: () => "test_verifier",
1300+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
1301+
};
1302+
1303+
const result = await auth(mockProvider, { serverUrl: "https://api.example.com" });
1304+
1305+
expect(result).toBe("AUTHORIZED");
1306+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
1307+
"https://auth.example.com",
1308+
{
1309+
resource: new URL("https://api.example.com/"),
1310+
metadata: expect.objectContaining(validMetadata)
1311+
}
1312+
);
1313+
});
1314+
});
11091315
});
11101316
});

src/client/auth.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,32 @@ 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 options The options for the method
105+
* @param options.resource The protected resource (RFC 8707) to authorize (may be undefined if not available)
106+
* @param options.metadata The OAuth metadata if available (may be undefined if discovery fails)
107+
* @returns "AUTHORIZED" if delegation succeeded and tokens are already available; otherwise `undefined`.
108+
*/
109+
delegateAuthorization?(serverUrl: string | URL, options?: { resource?: URL, metadata?: OAuthMetadata}): "AUTHORIZED" | undefined | Promise<"AUTHORIZED" | undefined>;
84110
}
85111

86112
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@@ -124,6 +150,15 @@ export async function auth(
124150

125151
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
126152

153+
// Delegate the authorization if supported and if not already in the middle of the standard flow
154+
if (provider.delegateAuthorization && authorizationCode === undefined) {
155+
const options = resource || metadata ? { resource, metadata } : undefined;
156+
const result = await provider.delegateAuthorization(authorizationServerUrl, options);
157+
if (result === "AUTHORIZED") {
158+
return "AUTHORIZED";
159+
}
160+
}
161+
127162
// Handle client registration if needed
128163
let clientInformation = await Promise.resolve(provider.clientInformation());
129164
if (!clientInformation) {

0 commit comments

Comments
 (0)