From f91ab14850f08842fe8d7564637f423fa3373b2a Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro Date: Mon, 12 May 2025 21:21:18 -0400 Subject: [PATCH] feature(auth): OAuthClientProvider.delegateAuthorization An optional method that clients can use whenever the authorization should be delegated to an existing implementation. --- src/client/auth.test.ts | 124 ++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 34 +++++++++-- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index eba7074b..392d8204 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -4,6 +4,8 @@ import { exchangeAuthorization, refreshAuthorization, registerClient, + auth, + OAuthClientProvider } from "./auth.js"; // Mock fetch globally @@ -503,4 +505,126 @@ describe("OAuth Authorization", () => { ).rejects.toThrow("Dynamic client registration failed"); }); }); + + describe("auth function", () => { + const validMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + registration_endpoint: "https://auth.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }; + + const validClientInfo = { + client_id: "client123", + client_secret: "secret123", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + + const validTokens = { + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }; + + beforeEach(() => { + // Mock discoverOAuthMetadata to return valid metadata + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + }); + + it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => { + const mockProvider: OAuthClientProvider = { + redirectUrl: "http://localhost:3000/callback", + clientMetadata: { + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client" + }, + clientInformation: () => validClientInfo, + tokens: () => validTokens, + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: () => "test_verifier", + delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED") + }; + + const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" }); + + expect(result).toBe("AUTHORIZED"); + expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith( + "https://auth.example.com", + expect.objectContaining(validMetadata) + ); + expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled(); + }); + + it("should fall back to standard flow when delegateAuthorization returns undefined", async () => { + // Mock refresh token endpoint + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const mockProvider: OAuthClientProvider = { + redirectUrl: "http://localhost:3000/callback", + clientMetadata: { + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client" + }, + clientInformation: () => validClientInfo, + tokens: () => validTokens, + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: () => "test_verifier", + delegateAuthorization: jest.fn().mockResolvedValue(undefined) + }; + + const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" }); + + expect(result).toBe("AUTHORIZED"); + expect(mockProvider.delegateAuthorization).toHaveBeenCalled(); + expect(mockProvider.saveTokens).toHaveBeenCalled(); + }); + + it("should not call delegateAuthorization when processing authorizationCode", async () => { + // Mock token exchange endpoint + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const mockProvider: OAuthClientProvider = { + redirectUrl: "http://localhost:3000/callback", + clientMetadata: { + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client" + }, + clientInformation: () => validClientInfo, + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: () => "test_verifier", + delegateAuthorization: jest.fn() + }; + + await auth(mockProvider, { + serverUrl: "https://auth.example.com", + authorizationCode: "code123" + }); + + expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled(); + expect(mockProvider.saveTokens).toHaveBeenCalled(); + }); + }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index e4941576..4cdbcfe6 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -5,7 +5,7 @@ import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchem /** * Implements an end-to-end OAuth client to be used with one MCP server. - * + * * This client relies upon a concept of an authorized "session," the exact * meaning of which is application-defined. Tokens, authorization codes, and * code verifiers should not cross different sessions. @@ -32,7 +32,7 @@ export interface OAuthClientProvider { * If implemented, this permits the OAuth client to dynamically register with * the server. Client information saved this way should later be read via * `clientInformation()`. - * + * * This method is not required to be implemented if client information is * statically known (e.g., pre-registered). */ @@ -66,6 +66,22 @@ export interface OAuthClientProvider { * the authorization result. */ codeVerifier(): string | Promise; + + /** + * Optional method that allows the OAuth client to delegate authorization + * to an existing implementation, such as a platform or app-level identity provider. + * + * If this method returns "AUTHORIZED", the standard authorization flow will be bypassed. + * If it returns `undefined`, the SDK will proceed with its default OAuth implementation. + * + * This method is useful when the host application already manages OAuth tokens or user sessions + * and does not need the SDK to handle entire authorization flow directly. + * + * @param serverUrl The URL of the authorization server. + * @param metadata The OAuth metadata if available. + * @returns "AUTHORIZED" if delegation succeeded and tokens are already available; otherwise `undefined`. + */ + delegateAuthorization?(serverUrl: string | URL, metadata: OAuthMetadata | undefined): "AUTHORIZED" | undefined | Promise<"AUTHORIZED" | undefined>; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -78,7 +94,7 @@ export class UnauthorizedError extends Error { /** * Orchestrates the full auth flow with a server. - * + * * This can be used as a single entry point for all authorization functionality, * instead of linking together the other lower-level functions in this module. */ @@ -94,6 +110,14 @@ export async function auth( }): Promise { const metadata = await discoverOAuthMetadata(serverUrl); + // Delegate the authorization if supported and if not already in the middle of the standard flow + if (provider.delegateAuthorization && authorizationCode === undefined) { + const result = await provider.delegateAuthorization(serverUrl, metadata); + if (result === "AUTHORIZED") { + return "AUTHORIZED"; + } + } + // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { @@ -256,7 +280,7 @@ export async function startAuthorization( codeChallengeMethod, ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); - + if (scope) { authorizationUrl.searchParams.set("scope", scope); } @@ -426,4 +450,4 @@ export async function registerClient( } return OAuthClientInformationFullSchema.parse(await response.json()); -} \ No newline at end of file +}