Skip to content

Commit bf08482

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 1fd6265 commit bf08482

File tree

2 files changed

+153
-5
lines changed

2 files changed

+153
-5
lines changed

src/client/auth.test.ts

+124
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
exchangeAuthorization,
55
refreshAuthorization,
66
registerClient,
7+
auth,
8+
OAuthClientProvider
79
} from "./auth.js";
810

911
// Mock fetch globally
@@ -503,4 +505,126 @@ describe("OAuth Authorization", () => {
503505
).rejects.toThrow("Dynamic client registration failed");
504506
});
505507
});
508+
509+
describe("auth function", () => {
510+
const validMetadata = {
511+
issuer: "https://auth.example.com",
512+
authorization_endpoint: "https://auth.example.com/authorize",
513+
token_endpoint: "https://auth.example.com/token",
514+
registration_endpoint: "https://auth.example.com/register",
515+
response_types_supported: ["code"],
516+
code_challenge_methods_supported: ["S256"],
517+
};
518+
519+
const validClientInfo = {
520+
client_id: "client123",
521+
client_secret: "secret123",
522+
redirect_uris: ["http://localhost:3000/callback"],
523+
client_name: "Test Client",
524+
};
525+
526+
const validTokens = {
527+
access_token: "access123",
528+
token_type: "Bearer",
529+
expires_in: 3600,
530+
refresh_token: "refresh123",
531+
};
532+
533+
beforeEach(() => {
534+
// Mock discoverOAuthMetadata to return valid metadata
535+
mockFetch.mockResolvedValueOnce({
536+
ok: true,
537+
status: 200,
538+
json: async () => validMetadata,
539+
});
540+
});
541+
542+
it("should use delegateAuthorization when implemented and return AUTHORIZED", async () => {
543+
const mockProvider: OAuthClientProvider = {
544+
redirectUrl: "http://localhost:3000/callback",
545+
clientMetadata: {
546+
redirect_uris: ["http://localhost:3000/callback"],
547+
client_name: "Test Client"
548+
},
549+
clientInformation: () => validClientInfo,
550+
tokens: () => validTokens,
551+
saveTokens: jest.fn(),
552+
redirectToAuthorization: jest.fn(),
553+
saveCodeVerifier: jest.fn(),
554+
codeVerifier: () => "test_verifier",
555+
delegateAuthorization: jest.fn().mockResolvedValue("AUTHORIZED")
556+
};
557+
558+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
559+
560+
expect(result).toBe("AUTHORIZED");
561+
expect(mockProvider.delegateAuthorization).toHaveBeenCalledWith(
562+
"https://auth.example.com",
563+
expect.objectContaining(validMetadata)
564+
);
565+
expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
566+
});
567+
568+
it("should fall back to standard flow when delegateAuthorization returns undefined", async () => {
569+
// Mock refresh token endpoint
570+
mockFetch.mockResolvedValueOnce({
571+
ok: true,
572+
status: 200,
573+
json: async () => validTokens,
574+
});
575+
576+
const mockProvider: OAuthClientProvider = {
577+
redirectUrl: "http://localhost:3000/callback",
578+
clientMetadata: {
579+
redirect_uris: ["http://localhost:3000/callback"],
580+
client_name: "Test Client"
581+
},
582+
clientInformation: () => validClientInfo,
583+
tokens: () => validTokens,
584+
saveTokens: jest.fn(),
585+
redirectToAuthorization: jest.fn(),
586+
saveCodeVerifier: jest.fn(),
587+
codeVerifier: () => "test_verifier",
588+
delegateAuthorization: jest.fn().mockResolvedValue(undefined)
589+
};
590+
591+
const result = await auth(mockProvider, { serverUrl: "https://auth.example.com" });
592+
593+
expect(result).toBe("AUTHORIZED");
594+
expect(mockProvider.delegateAuthorization).toHaveBeenCalled();
595+
expect(mockProvider.saveTokens).toHaveBeenCalled();
596+
});
597+
598+
it("should not call delegateAuthorization when processing authorizationCode", async () => {
599+
// Mock token exchange endpoint
600+
mockFetch.mockResolvedValueOnce({
601+
ok: true,
602+
status: 200,
603+
json: async () => validTokens,
604+
});
605+
606+
const mockProvider: OAuthClientProvider = {
607+
redirectUrl: "http://localhost:3000/callback",
608+
clientMetadata: {
609+
redirect_uris: ["http://localhost:3000/callback"],
610+
client_name: "Test Client"
611+
},
612+
clientInformation: () => validClientInfo,
613+
tokens: jest.fn(),
614+
saveTokens: jest.fn(),
615+
redirectToAuthorization: jest.fn(),
616+
saveCodeVerifier: jest.fn(),
617+
codeVerifier: () => "test_verifier",
618+
delegateAuthorization: jest.fn()
619+
};
620+
621+
await auth(mockProvider, {
622+
serverUrl: "https://auth.example.com",
623+
authorizationCode: "code123"
624+
});
625+
626+
expect(mockProvider.delegateAuthorization).not.toHaveBeenCalled();
627+
expect(mockProvider.saveTokens).toHaveBeenCalled();
628+
});
629+
});
506630
});

src/client/auth.ts

+29-5
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchem
55

66
/**
77
* Implements an end-to-end OAuth client to be used with one MCP server.
8-
*
8+
*
99
* This client relies upon a concept of an authorized "session," the exact
1010
* meaning of which is application-defined. Tokens, authorization codes, and
1111
* code verifiers should not cross different sessions.
@@ -32,7 +32,7 @@ export interface OAuthClientProvider {
3232
* If implemented, this permits the OAuth client to dynamically register with
3333
* the server. Client information saved this way should later be read via
3434
* `clientInformation()`.
35-
*
35+
*
3636
* This method is not required to be implemented if client information is
3737
* statically known (e.g., pre-registered).
3838
*/
@@ -66,6 +66,22 @@ export interface OAuthClientProvider {
6666
* the authorization result.
6767
*/
6868
codeVerifier(): string | Promise<string>;
69+
70+
/**
71+
* Optional method that allows the OAuth client to delegate authorization
72+
* to an existing implementation, such as a platform or app-level identity provider.
73+
*
74+
* If this method returns "AUTHORIZED", the standard authorization flow will be bypassed.
75+
* If it returns `undefined`, the SDK will proceed with its default OAuth implementation.
76+
*
77+
* This method is useful when the host application already manages OAuth tokens or user sessions
78+
* and does not need the SDK to handle entire authorization flow directly.
79+
*
80+
* @param serverUrl The URL of the authorization server.
81+
* @param metadata The OAuth metadata if available.
82+
* @returns "AUTHORIZED" if delegation succeeded and tokens are already available; otherwise `undefined`.
83+
*/
84+
delegateAuthorization?(serverUrl: string | URL, metadata: OAuthMetadata | undefined): "AUTHORIZED" | undefined | Promise<"AUTHORIZED" | undefined>;
6985
}
7086

7187
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@@ -78,7 +94,7 @@ export class UnauthorizedError extends Error {
7894

7995
/**
8096
* Orchestrates the full auth flow with a server.
81-
*
97+
*
8298
* This can be used as a single entry point for all authorization functionality,
8399
* instead of linking together the other lower-level functions in this module.
84100
*/
@@ -94,6 +110,14 @@ export async function auth(
94110
}): Promise<AuthResult> {
95111
const metadata = await discoverOAuthMetadata(serverUrl);
96112

113+
// Delegate the authorization if supported and if not already in the middle of the standard flow
114+
if (provider.delegateAuthorization && authorizationCode === undefined) {
115+
const result = await provider.delegateAuthorization(serverUrl, metadata);
116+
if (result === "AUTHORIZED") {
117+
return "AUTHORIZED";
118+
}
119+
}
120+
97121
// Handle client registration if needed
98122
let clientInformation = await Promise.resolve(provider.clientInformation());
99123
if (!clientInformation) {
@@ -256,7 +280,7 @@ export async function startAuthorization(
256280
codeChallengeMethod,
257281
);
258282
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
259-
283+
260284
if (scope) {
261285
authorizationUrl.searchParams.set("scope", scope);
262286
}
@@ -426,4 +450,4 @@ export async function registerClient(
426450
}
427451

428452
return OAuthClientInformationFullSchema.parse(await response.json());
429-
}
453+
}

0 commit comments

Comments
 (0)