Skip to content

feature(auth): Allow delegating OAuth authorization to existing app-level implementations #485

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/client/auth.test.ts
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

افتح فئة DebugTree : Timber.Tree

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
exchangeAuthorization,
refreshAuthorization,
registerClient,
auth,
OAuthClientProvider
} from "./auth.js";

// Mock fetch globally
Expand Down Expand Up @@ -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();
});
});
});
34 changes: 29 additions & 5 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
*/
Expand Down Expand Up @@ -66,6 +66,22 @@ export interface OAuthClientProvider {
* the authorization result.
*/
codeVerifier(): string | Promise<string>;

/**
* 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";
Expand All @@ -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.
*/
Expand All @@ -94,6 +110,14 @@ export async function auth(
}): Promise<AuthResult> {
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) {
Expand Down Expand Up @@ -256,7 +280,7 @@ export async function startAuthorization(
codeChallengeMethod,
);
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));

if (scope) {
authorizationUrl.searchParams.set("scope", scope);
}
Expand Down Expand Up @@ -426,4 +450,4 @@ export async function registerClient(
}

return OAuthClientInformationFullSchema.parse(await response.json());
}
}