Skip to content

Commit dd35f24

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 4621105 commit dd35f24

File tree

2 files changed

+255
-14
lines changed

2 files changed

+255
-14
lines changed

src/client/auth.test.ts

Lines changed: 220 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ describe("OAuth Authorization", () => {
231231
ok: false,
232232
status: 404,
233233
});
234-
234+
235235
// Second call (root fallback) succeeds
236236
mockFetch.mockResolvedValueOnce({
237237
ok: true,
@@ -241,17 +241,17 @@ describe("OAuth Authorization", () => {
241241

242242
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
243243
expect(metadata).toEqual(validMetadata);
244-
244+
245245
const calls = mockFetch.mock.calls;
246246
expect(calls.length).toBe(2);
247-
247+
248248
// First call should be path-aware
249249
const [firstUrl, firstOptions] = calls[0];
250250
expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
251251
expect(firstOptions.headers).toEqual({
252252
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
253253
});
254-
254+
255255
// Second call should be root fallback
256256
const [secondUrl, secondOptions] = calls[1];
257257
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
@@ -266,7 +266,7 @@ describe("OAuth Authorization", () => {
266266
ok: false,
267267
status: 404,
268268
});
269-
269+
270270
// Second call (root fallback) also returns 404
271271
mockFetch.mockResolvedValueOnce({
272272
ok: false,
@@ -275,7 +275,7 @@ describe("OAuth Authorization", () => {
275275

276276
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
277277
expect(metadata).toBeUndefined();
278-
278+
279279
const calls = mockFetch.mock.calls;
280280
expect(calls.length).toBe(2);
281281
});
@@ -289,10 +289,10 @@ describe("OAuth Authorization", () => {
289289

290290
const metadata = await discoverOAuthMetadata("https://auth.example.com/");
291291
expect(metadata).toBeUndefined();
292-
292+
293293
const calls = mockFetch.mock.calls;
294294
expect(calls.length).toBe(1); // Should not attempt fallback
295-
295+
296296
const [url] = calls[0];
297297
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
298298
});
@@ -306,24 +306,24 @@ describe("OAuth Authorization", () => {
306306

307307
const metadata = await discoverOAuthMetadata("https://auth.example.com");
308308
expect(metadata).toBeUndefined();
309-
309+
310310
const calls = mockFetch.mock.calls;
311311
expect(calls.length).toBe(1); // Should not attempt fallback
312-
312+
313313
const [url] = calls[0];
314314
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
315315
});
316316

317317
it("falls back when path-aware discovery encounters CORS error", async () => {
318318
// First call (path-aware) fails with TypeError (CORS)
319319
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
320-
320+
321321
// Retry path-aware without headers (simulating CORS retry)
322322
mockFetch.mockResolvedValueOnce({
323323
ok: false,
324324
status: 404,
325325
});
326-
326+
327327
// Second call (root fallback) succeeds
328328
mockFetch.mockResolvedValueOnce({
329329
ok: true,
@@ -333,10 +333,10 @@ describe("OAuth Authorization", () => {
333333

334334
const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path");
335335
expect(metadata).toEqual(validMetadata);
336-
336+
337337
const calls = mockFetch.mock.calls;
338338
expect(calls.length).toBe(3);
339-
339+
340340
// Final call should be root fallback
341341
const [lastUrl, lastOptions] = calls[2];
342342
expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
@@ -1463,5 +1463,211 @@ describe("OAuth Authorization", () => {
14631463
expect(body.get("grant_type")).toBe("refresh_token");
14641464
expect(body.get("refresh_token")).toBe("refresh123");
14651465
});
1466+
1467+
describe("delegateAuthorization", () => {
1468+
const validMetadata = {
1469+
issuer: "https://auth.example.com",
1470+
authorization_endpoint: "https://auth.example.com/authorize",
1471+
token_endpoint: "https://auth.example.com/token",
1472+
registration_endpoint: "https://auth.example.com/register",
1473+
response_types_supported: ["code"],
1474+
code_challenge_methods_supported: ["S256"],
1475+
};
1476+
1477+
const validClientInfo = {
1478+
client_id: "client123",
1479+
client_secret: "secret123",
1480+
redirect_uris: ["http://localhost:3000/callback"],
1481+
client_name: "Test Client",
1482+
};
1483+
1484+
const validTokens = {
1485+
access_token: "access123",
1486+
token_type: "Bearer",
1487+
expires_in: 3600,
1488+
refresh_token: "refresh123",
1489+
};
1490+
1491+
// Setup shared mock function for all tests
1492+
beforeEach(() => {
1493+
// Reset mockFetch implementation
1494+
mockFetch.mockReset();
1495+
1496+
// Set up the mockFetch to respond to all necessary API calls
1497+
mockFetch.mockImplementation((url) => {
1498+
const urlString = url.toString();
1499+
1500+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1501+
return Promise.resolve({
1502+
ok: false,
1503+
status: 404
1504+
});
1505+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1506+
return Promise.resolve({
1507+
ok: true,
1508+
status: 200,
1509+
json: async () => validMetadata
1510+
});
1511+
} else if (urlString.includes("/token")) {
1512+
return Promise.resolve({
1513+
ok: true,
1514+
status: 200,
1515+
json: async () => validTokens
1516+
});
1517+
}
1518+
1519+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
1520+
});
1521+
});
1522+
1523+
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
1524+
const mockProvider: OAuthClientProvider = {
1525+
redirectUrl: "http://localhost:3000/callback",
1526+
clientMetadata: {
1527+
redirect_uris: ["http://localhost:3000/callback"],
1528+
client_name: "Test Client"
1529+
},
1530+
clientInformation: () => validClientInfo,
1531+
tokens: () => validTokens,
1532+
saveTokens: jest.fn(),
1533+
redirectToAuthorization: jest.fn(),
1534+
saveCodeVerifier: jest.fn(),
1535+
codeVerifier: () => "test_verifier",
1536+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
1537+
};
1538+
1539+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1540+
1541+
expect(result).toBe("AUTHORIZED");
1542+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
1543+
"https://auth.example.com",
1544+
{
1545+
metadata: expect.objectContaining(validMetadata),
1546+
resource: undefined
1547+
}
1548+
);
1549+
expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
1550+
});
1551+
1552+
it("should fall back to standard flow when delegateAuthorization returns undefined", async () => {
1553+
const mockProvider: OAuthClientProvider = {
1554+
redirectUrl: "http://localhost:3000/callback",
1555+
clientMetadata: {
1556+
redirect_uris: ["http://localhost:3000/callback"],
1557+
client_name: "Test Client"
1558+
},
1559+
clientInformation: () => validClientInfo,
1560+
tokens: () => validTokens,
1561+
saveTokens: jest.fn(),
1562+
redirectToAuthorization: jest.fn(),
1563+
saveCodeVerifier: jest.fn(),
1564+
codeVerifier: () => "test_verifier",
1565+
delegateAuthorization: jest.fn().mockResolvedValue(undefined)
1566+
};
1567+
1568+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
1569+
1570+
expect(result).toBe("AUTHORIZED");
1571+
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
1572+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1573+
});
1574+
1575+
it("should not call delegateAuthorization when processing authorizationCode", async () => {
1576+
const mockProvider: OAuthClientProvider = {
1577+
redirectUrl: "http://localhost:3000/callback",
1578+
clientMetadata: {
1579+
redirect_uris: ["http://localhost:3000/callback"],
1580+
client_name: "Test Client"
1581+
},
1582+
clientInformation: () => validClientInfo,
1583+
tokens: jest.fn(),
1584+
saveTokens: jest.fn(),
1585+
redirectToAuthorization: jest.fn(),
1586+
saveCodeVerifier: jest.fn(),
1587+
codeVerifier: () => "test_verifier",
1588+
delegateAuthorization: jest.fn()
1589+
};
1590+
1591+
await auth(mockProvider, {
1592+
serverUrl: "https://auth.example.com",
1593+
authorizationCode: "code123"
1594+
});
1595+
1596+
expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
1597+
expect(mockProvider.saveTokens).toHaveBeenCalled();
1598+
});
1599+
1600+
it("should propagate errors from delegateAuthorization", async () => {
1601+
const mockProvider: OAuthClientProvider = {
1602+
redirectUrl: "http://localhost:3000/callback",
1603+
clientMetadata: {
1604+
redirect_uris: ["http://localhost:3000/callback"],
1605+
client_name: "Test Client"
1606+
},
1607+
clientInformation: () => validClientInfo,
1608+
tokens: jest.fn(),
1609+
saveTokens: jest.fn(),
1610+
redirectToAuthorization: jest.fn(),
1611+
saveCodeVerifier: jest.fn(),
1612+
codeVerifier: () => "test_verifier",
1613+
delegateAuthorization: jest.fn().mockRejectedValue(new Error("Delegation failed"))
1614+
};
1615+
1616+
await expect(auth(mockProvider, { serverUrl: "https://auth.example.com" }))
1617+
.rejects.toThrow("Delegation failed");
1618+
});
1619+
1620+
it("should pass both resource and metadata to delegateAuthorization when available", async () => {
1621+
// Mock resource metadata to be returned by the fetch
1622+
mockFetch.mockImplementation((url) => {
1623+
const urlString = url.toString();
1624+
1625+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1626+
return Promise.resolve({
1627+
ok: true,
1628+
status: 200,
1629+
json: async () => ({
1630+
resource: "https://api.example.com/",
1631+
authorization_servers: ["https://auth.example.com"]
1632+
})
1633+
});
1634+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1635+
return Promise.resolve({
1636+
ok: true,
1637+
status: 200,
1638+
json: async () => validMetadata
1639+
});
1640+
}
1641+
1642+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
1643+
});
1644+
1645+
const mockProvider: OAuthClientProvider = {
1646+
redirectUrl: "http://localhost:3000/callback",
1647+
clientMetadata: {
1648+
redirect_uris: ["http://localhost:3000/callback"],
1649+
client_name: "Test Client"
1650+
},
1651+
clientInformation: () => validClientInfo,
1652+
tokens: jest.fn(),
1653+
saveTokens: jest.fn(),
1654+
redirectToAuthorization: jest.fn(),
1655+
saveCodeVerifier: jest.fn(),
1656+
codeVerifier: () => "test_verifier",
1657+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
1658+
};
1659+
1660+
const result = await auth(mockProvider, { serverUrl: "https://api.example.com" });
1661+
1662+
expect(result).toBe("AUTHORIZED");
1663+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
1664+
"https://auth.example.com",
1665+
{
1666+
resource: new URL("https://api.example.com/"),
1667+
metadata: expect.objectContaining(validMetadata)
1668+
}
1669+
);
1670+
});
1671+
});
14661672
});
14671673
});

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)