Skip to content

Commit 861ea8c

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 ebf8e2d commit 861ea8c

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
@@ -1244,5 +1244,211 @@ describe("OAuth Authorization", () => {
12441244
// Should use the PRM's resource value, not the full requested URL
12451245
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/");
12461246
});
1247+
1248+
describe("delegateAuthorization", () => {
1249+
const validMetadata = {
1250+
issuer: "https://auth.example.com",
1251+
authorization_endpoint: "https://auth.example.com/authorize",
1252+
token_endpoint: "https://auth.example.com/token",
1253+
registration_endpoint: "https://auth.example.com/register",
1254+
response_types_supported: ["code"],
1255+
code_challenge_methods_supported: ["S256"],
1256+
};
1257+
1258+
const validClientInfo = {
1259+
client_id: "client123",
1260+
client_secret: "secret123",
1261+
redirect_uris: ["http://localhost:3000/callback"],
1262+
client_name: "Test Client",
1263+
};
1264+
1265+
const validTokens = {
1266+
access_token: "access123",
1267+
token_type: "Bearer",
1268+
expires_in: 3600,
1269+
refresh_token: "refresh123",
1270+
};
1271+
1272+
// Setup shared mock function for all tests
1273+
beforeEach(() => {
1274+
// Reset mockFetch implementation
1275+
mockFetch.mockReset();
1276+
1277+
// Set up the mockFetch to respond to all necessary API calls
1278+
mockFetch.mockImplementation((url) => {
1279+
const urlString = url.toString();
1280+
1281+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1282+
return Promise.resolve({
1283+
ok: false,
1284+
status: 404
1285+
});
1286+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1287+
return Promise.resolve({
1288+
ok: true,
1289+
status: 200,
1290+
json: async () => validMetadata
1291+
});
1292+
} else if (urlString.includes("/token")) {
1293+
return Promise.resolve({
1294+
ok: true,
1295+
status: 200,
1296+
json: async () => validTokens
1297+
});
1298+
}
1299+
1300+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
1301+
});
1302+
});
1303+
1304+
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
1305+
const mockProvider: OAuthClientProvider = {
1306+
redirectUrl: "http://localhost:3000/callback",
1307+
clientMetadata: {
1308+
redirect_uris: ["http://localhost:3000/callback"],
1309+
client_name: "Test Client"
1310+
},
1311+
clientInformation: () => validClientInfo,
1312+
tokens: () => validTokens,
1313+
saveTokens: jest.fn(),
1314+
redirectToAuthorization: jest.fn(),
1315+
saveCodeVerifier: jest.fn(),
1316+
codeVerifier: () => "test_verifier",
1317+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
1318+
};
1319+
1320+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1321+
1322+
expect(result).toBe("AUTHORIZED");
1323+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
1324+
"https://auth.example.com",
1325+
{
1326+
metadata: expect.objectContaining(validMetadata),
1327+
resource: expect.any(URL)
1328+
}
1329+
);
1330+
expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
1331+
});
1332+
1333+
it("should fall back to standard flow when delegateAuthorization returns undefined", async () => {
1334+
const mockProvider: OAuthClientProvider = {
1335+
redirectUrl: "http://localhost:3000/callback",
1336+
clientMetadata: {
1337+
redirect_uris: ["http://localhost:3000/callback"],
1338+
client_name: "Test Client"
1339+
},
1340+
clientInformation: () => validClientInfo,
1341+
tokens: () => validTokens,
1342+
saveTokens: jest.fn(),
1343+
redirectToAuthorization: jest.fn(),
1344+
saveCodeVerifier: jest.fn(),
1345+
codeVerifier: () => "test_verifier",
1346+
delegateAuthorization: jest.fn().mockResolvedValue(undefined)
1347+
};
1348+
1349+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1350+
1351+
expect(result).toBe("AUTHORIZED");
1352+
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
1353+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1354+
});
1355+
1356+
it("should not call delegateAuthorization when processing authorizationCode", async () => {
1357+
const mockProvider: OAuthClientProvider = {
1358+
redirectUrl: "http://localhost:3000/callback",
1359+
clientMetadata: {
1360+
redirect_uris: ["http://localhost:3000/callback"],
1361+
client_name: "Test Client"
1362+
},
1363+
clientInformation: () => validClientInfo,
1364+
tokens: jest.fn(),
1365+
saveTokens: jest.fn(),
1366+
redirectToAuthorization: jest.fn(),
1367+
saveCodeVerifier: jest.fn(),
1368+
codeVerifier: () => "test_verifier",
1369+
delegateAuthorization: jest.fn()
1370+
};
1371+
1372+
await auth(mockProvider, {
1373+
serverUrl: "https://auth.example.com",
1374+
authorizationCode: "code123"
1375+
});
1376+
1377+
expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
1378+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1379+
});
1380+
1381+
it("should propagate errors from delegateAuthorization", async () => {
1382+
const mockProvider: OAuthClientProvider = {
1383+
redirectUrl: "http://localhost:3000/callback",
1384+
clientMetadata: {
1385+
redirect_uris: ["http://localhost:3000/callback"],
1386+
client_name: "Test Client"
1387+
},
1388+
clientInformation: () => validClientInfo,
1389+
tokens: jest.fn(),
1390+
saveTokens: jest.fn(),
1391+
redirectToAuthorization: jest.fn(),
1392+
saveCodeVerifier: jest.fn(),
1393+
codeVerifier: () => "test_verifier",
1394+
delegateAuthorization: jest.fn().mockRejectedValue(new Error("Delegation failed"))
1395+
};
1396+
1397+
await expect(auth(mockProvider, { serverUrl: "https://auth.example.com" }))
1398+
.rejects.toThrow("Delegation failed");
1399+
});
1400+
1401+
it("should pass both resource and metadata to delegateAuthorization when available", async () => {
1402+
// Mock resource metadata to be returned by the fetch
1403+
mockFetch.mockImplementation((url) => {
1404+
const urlString = url.toString();
1405+
1406+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1407+
return Promise.resolve({
1408+
ok: true,
1409+
status: 200,
1410+
json: async () => ({
1411+
resource: "https://api.example.com/",
1412+
authorization_servers: ["https://auth.example.com"]
1413+
})
1414+
});
1415+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1416+
return Promise.resolve({
1417+
ok: true,
1418+
status: 200,
1419+
json: async () => validMetadata
1420+
});
1421+
}
1422+
1423+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
1424+
});
1425+
1426+
const mockProvider: OAuthClientProvider = {
1427+
redirectUrl: "http://localhost:3000/callback",
1428+
clientMetadata: {
1429+
redirect_uris: ["http://localhost:3000/callback"],
1430+
client_name: "Test Client"
1431+
},
1432+
clientInformation: () => validClientInfo,
1433+
tokens: jest.fn(),
1434+
saveTokens: jest.fn(),
1435+
redirectToAuthorization: jest.fn(),
1436+
saveCodeVerifier: jest.fn(),
1437+
codeVerifier: () => "test_verifier",
1438+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
1439+
};
1440+
1441+
const result = await auth(mockProvider, { serverUrl: "https://api.example.com" });
1442+
1443+
expect(result).toBe("AUTHORIZED");
1444+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
1445+
"https://auth.example.com",
1446+
{
1447+
resource: new URL("https://api.example.com/"),
1448+
metadata: expect.objectContaining(validMetadata)
1449+
}
1450+
);
1451+
});
1452+
});
12471453
});
12481454
});

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)