Adding invalidateCredentials() to OAuthClientProvider (#570)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Glen Maddern 2025-07-15 02:11:52 +10:00 • committed by GitHub
commit 4b2ad0385d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 508 additions and 83 deletions

View file

@ -10,6 +10,7 @@ import {
auth,
type OAuthClientProvider,
} from "./auth.js";
import {ServerError} from "../server/auth/errors.js";
import { OAuthMetadata } from '../shared/auth.js';
// Mock fetch globally
@ -596,10 +597,7 @@ describe("OAuth Authorization", () => {
});
it("throws on non-404 errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }));
await expect(
discoverOAuthMetadata("https://auth.example.com")
@ -607,14 +605,15 @@ describe("OAuth Authorization", () => {
});
it("validates metadata schema", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
// Missing required fields
issuer: "https://auth.example.com",
}),
});
mockFetch.mockResolvedValueOnce(
Response.json(
{
// Missing required fields
issuer: "https://auth.example.com",
},
{ status: 200 }
)
);
await expect(
discoverOAuthMetadata("https://auth.example.com")
@ -902,10 +901,12 @@ describe("OAuth Authorization", () => {
});
it("throws on error response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
});
mockFetch.mockResolvedValueOnce(
Response.json(
new ServerError("Token exchange failed").toResponseObject(),
{ status: 400 }
)
);
await expect(
exchangeAuthorization("https://auth.example.com", {
@ -1054,10 +1055,12 @@ describe("OAuth Authorization", () => {
});
it("throws on error response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
});
mockFetch.mockResolvedValueOnce(
Response.json(
new ServerError("Token refresh failed").toResponseObject(),
{ status: 400 }
)
);
await expect(
refreshAuthorization("https://auth.example.com", {
@ -1142,10 +1145,12 @@ describe("OAuth Authorization", () => {
});
it("throws on error response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
});
mockFetch.mockResolvedValueOnce(
Response.json(
new ServerError("Dynamic client registration failed").toResponseObject(),
{ status: 400 }
)
);
await expect(
registerClient("https://auth.example.com", {

View file

@ -1,8 +1,24 @@
import pkceChallenge from "pkce-challenge";
import { LATEST_PROTOCOL_VERSION } from "../types.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
import {
OAuthClientMetadata,
OAuthClientInformation,
OAuthTokens,
OAuthMetadata,
OAuthClientInformationFull,
OAuthProtectedResourceMetadata,
OAuthErrorResponseSchema
} from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
import {
InvalidClientError,
InvalidGrantError,
OAUTH_ERRORS,
OAuthError,
ServerError,
UnauthorizedClientError
} from "../server/auth/errors.js";
/**
* Implements an end-to-end OAuth client to be used with one MCP server.
@ -101,6 +117,13 @@ export interface OAuthClientProvider {
* Implementations must verify the returned resource matches the MCP server.
*/
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
/**
* If implemented, provides a way for the client to invalidate (e.g. delete) the specified
* credentials, in the case where the server has indicated that they are no longer valid.
* This avoids requiring the user to intervene manually.
*/
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
}
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@ -219,6 +242,33 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void {
params.set("client_id", clientId);
}
/**
* Parses an OAuth error response from a string or Response object.
*
* If the input is a standard OAuth2.0 error response, it will be parsed according to the spec
* and an instance of the appropriate OAuthError subclass will be returned.
* If parsing fails, it falls back to a generic ServerError that includes
* the response status (if available) and original content.
*
* @param input - A Response object or string containing the error response
* @returns A Promise that resolves to an OAuthError instance
*/
export async function parseErrorResponse(input: Response | string): Promise<OAuthError> {
const statusCode = input instanceof Response ? input.status : undefined;
const body = input instanceof Response ? await input.text() : input;
try {
const result = OAuthErrorResponseSchema.parse(JSON.parse(body));
const { error, error_description, error_uri } = result;
const errorClass = OAUTH_ERRORS[error] || ServerError;
return new errorClass(error_description || '', error_uri);
} catch (error) {
// Not a valid OAuth error response, but try to inform the user of the raw data anyway
const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`;
return new ServerError(errorMessage);
}
}
/**
* Orchestrates the full auth flow with a server.
*
@ -226,6 +276,31 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void {
* instead of linking together the other lower-level functions in this module.
*/
export async function auth(
provider: OAuthClientProvider,
options: {
serverUrl: string | URL;
authorizationCode?: string;
scope?: string;
resourceMetadataUrl?: URL }): Promise<AuthResult> {
try {
return await authInternal(provider, options);
} catch (error) {
// Handle recoverable error types by invalidating credentials and retrying
if (error instanceof InvalidClientError || error instanceof UnauthorizedClientError) {
await provider.invalidateCredentials?.('all');
return await authInternal(provider, options);
} else if (error instanceof InvalidGrantError) {
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
}
// Throw otherwise
throw error
}
}
async function authInternal(
provider: OAuthClientProvider,
{ serverUrl,
authorizationCode,
@ -289,7 +364,7 @@ export async function auth(
});
await provider.saveTokens(tokens);
return "AUTHORIZED";
return "AUTHORIZED"
}
const tokens = await provider.tokens();
@ -307,9 +382,15 @@ export async function auth(
});
await provider.saveTokens(newTokens);
return "AUTHORIZED";
} catch {
// Could not refresh OAuth tokens
return "AUTHORIZED"
} catch (error) {
// If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
if (!(error instanceof OAuthError) || error instanceof ServerError) {
// Could not refresh OAuth tokens
} else {
// Refresh failed for another reason, re-throw
throw error;
}
}
}
@ -327,7 +408,7 @@ export async function auth(
await provider.saveCodeVerifier(codeVerifier);
await provider.redirectToAuthorization(authorizationUrl);
return "REDIRECT";
return "REDIRECT"
}
export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
@ -707,7 +788,7 @@ export async function exchangeAuthorization(
});
if (!response.ok) {
throw new Error(`Token exchange failed: HTTP ${response.status}`);
throw await parseErrorResponse(response);
}
return OAuthTokensSchema.parse(await response.json());
@ -788,7 +869,7 @@ export async function refreshAuthorization(
body: params,
});
if (!response.ok) {
throw new Error(`Token refresh failed: HTTP ${response.status}`);
throw await parseErrorResponse(response);
}
return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) });
@ -828,7 +909,7 @@ export async function registerClient(
});
if (!response.ok) {
throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
throw await parseErrorResponse(response);
}
return OAuthClientInformationFullSchema.parse(await response.json());

View file

@ -4,6 +4,7 @@ import { JSONRPCMessage } from "../types.js";
import { SSEClientTransport } from "./sse.js";
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { OAuthTokens } from "../shared/auth.js";
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
describe("SSEClientTransport", () => {
let resourceServer: Server;
@ -363,6 +364,7 @@ describe("SSEClientTransport", () => {
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
invalidateCredentials: jest.fn(),
};
});
@ -934,5 +936,176 @@ describe("SSEClientTransport", () => {
await expect(() => transport.start()).rejects.toThrow(UnauthorizedError);
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
});
it("invalidates all credentials on InvalidClientError during token refresh", async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: "expired-token",
token_type: "Bearer",
refresh_token: "refresh-token"
});
let baseUrl = resourceBaseUrl;
// Create server that returns InvalidClientError on token refresh
const server = createServer((req, res) => {
lastServerRequest = req;
// Handle OAuth metadata discovery
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
issuer: baseUrl.href,
authorization_endpoint: `${baseUrl.href}authorize`,
token_endpoint: `${baseUrl.href}token`,
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}));
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request - return InvalidClientError
const error = new InvalidClientError("Client authentication failed");
res.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify(error.toResponseObject()));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
}
res.writeHead(401).end();
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
authProvider: mockAuthProvider,
});
await expect(() => transport.start()).rejects.toThrow(InvalidClientError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
});
it("invalidates all credentials on UnauthorizedClientError during token refresh", async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: "expired-token",
token_type: "Bearer",
refresh_token: "refresh-token"
});
let baseUrl = resourceBaseUrl;
const server = createServer((req, res) => {
lastServerRequest = req;
// Handle OAuth metadata discovery
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
issuer: baseUrl.href,
authorization_endpoint: `${baseUrl.href}authorize`,
token_endpoint: `${baseUrl.href}token`,
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}));
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request - return UnauthorizedClientError
const error = new UnauthorizedClientError("Client not authorized");
res.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify(error.toResponseObject()));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
}
res.writeHead(401).end();
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
authProvider: mockAuthProvider,
});
await expect(() => transport.start()).rejects.toThrow(UnauthorizedClientError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
});
it("invalidates tokens on InvalidGrantError during token refresh", async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: "expired-token",
token_type: "Bearer",
refresh_token: "refresh-token"
});
let baseUrl = resourceBaseUrl;
const server = createServer((req, res) => {
lastServerRequest = req;
// Handle OAuth metadata discovery
if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
issuer: baseUrl.href,
authorization_endpoint: `${baseUrl.href}authorize`,
token_endpoint: `${baseUrl.href}token`,
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}));
return;
}
if (req.url === "/token" && req.method === "POST") {
// Handle token refresh request - return InvalidGrantError
const error = new InvalidGrantError("Invalid refresh token");
res.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify(error.toResponseObject()));
return;
}
if (req.url !== "/") {
res.writeHead(404).end();
return;
}
res.writeHead(401).end();
});
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
baseUrl = new URL(`http://127.0.0.1:${addr.port}`);
resolve();
});
});
transport = new SSEClientTransport(baseUrl, {
authProvider: mockAuthProvider,
});
await expect(() => transport.start()).rejects.toThrow(InvalidGrantError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
});
});
});

View file

@ -1,6 +1,7 @@
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js";
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { JSONRPCMessage } from "../types.js";
import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js";
describe("StreamableHTTPClientTransport", () => {
@ -17,6 +18,7 @@ describe("StreamableHTTPClientTransport", () => {
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
invalidateCredentials: jest.fn(),
};
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider });
jest.spyOn(global, "fetch");
@ -592,4 +594,160 @@ describe("StreamableHTTPClientTransport", () => {
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
});
it("invalidates all credentials on InvalidClientError during auth", async () => {
const message: JSONRPCMessage = {
jsonrpc: "2.0",
method: "test",
params: {},
id: "test-id"
};
mockAuthProvider.tokens.mockResolvedValue({
access_token: "test-token",
token_type: "Bearer",
refresh_token: "test-refresh"
});
const unauthedResponse = {
ok: false,
status: 401,
statusText: "Unauthorized",
headers: new Headers()
};
(global.fetch as jest.Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: "http://localhost:1234",
authorization_endpoint: "http://localhost:1234/authorize",
token_endpoint: "http://localhost:1234/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
})
// Token refresh fails with InvalidClientError
.mockResolvedValueOnce(Response.json(
new InvalidClientError("Client authentication failed").toResponseObject(),
{ status: 400 }
))
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
status: 404
});
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
});
it("invalidates all credentials on UnauthorizedClientError during auth", async () => {
const message: JSONRPCMessage = {
jsonrpc: "2.0",
method: "test",
params: {},
id: "test-id"
};
mockAuthProvider.tokens.mockResolvedValue({
access_token: "test-token",
token_type: "Bearer",
refresh_token: "test-refresh"
});
const unauthedResponse = {
ok: false,
status: 401,
statusText: "Unauthorized",
headers: new Headers()
};
(global.fetch as jest.Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: "http://localhost:1234",
authorization_endpoint: "http://localhost:1234/authorize",
token_endpoint: "http://localhost:1234/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
})
// Token refresh fails with UnauthorizedClientError
.mockResolvedValueOnce(Response.json(
new UnauthorizedClientError("Client not authorized").toResponseObject(),
{ status: 400 }
))
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
status: 404
});
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
});
it("invalidates tokens on InvalidGrantError during auth", async () => {
const message: JSONRPCMessage = {
jsonrpc: "2.0",
method: "test",
params: {},
id: "test-id"
};
mockAuthProvider.tokens.mockResolvedValue({
access_token: "test-token",
token_type: "Bearer",
refresh_token: "test-refresh"
});
const unauthedResponse = {
ok: false,
status: 401,
statusText: "Unauthorized",
headers: new Headers()
};
(global.fetch as jest.Mock)
// Initial connection
.mockResolvedValueOnce(unauthedResponse)
// Resource discovery
.mockResolvedValueOnce(unauthedResponse)
// OAuth metadata discovery
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: "http://localhost:1234",
authorization_endpoint: "http://localhost:1234/authorize",
token_endpoint: "http://localhost:1234/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
})
// Token refresh fails with InvalidGrantError
.mockResolvedValueOnce(Response.json(
new InvalidGrantError("Invalid refresh token").toResponseObject(),
{ status: 400 }
))
// Fallback should fail to complete the flow
.mockResolvedValue({
ok: false,
status: 404
});
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
});
});