Skip to content

Commit 4b2ad03

Browse files
geelenpcarleton
andauthored
Adding invalidateCredentials() to OAuthClientProvider (#570)
Co-authored-by: Paul Carleton <[email protected]>
1 parent cc9ea7f commit 4b2ad03

File tree

6 files changed

+508
-83
lines changed

6 files changed

+508
-83
lines changed

src/client/auth.test.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
auth,
1111
type OAuthClientProvider,
1212
} from "./auth.js";
13+
import {ServerError} from "../server/auth/errors.js";
1314
import { OAuthMetadata } from '../shared/auth.js';
1415

1516
// Mock fetch globally
@@ -596,25 +597,23 @@ describe("OAuth Authorization", () => {
596597
});
597598

598599
it("throws on non-404 errors", async () => {
599-
mockFetch.mockResolvedValueOnce({
600-
ok: false,
601-
status: 500,
602-
});
600+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }));
603601

604602
await expect(
605603
discoverOAuthMetadata("https://auth.example.com")
606604
).rejects.toThrow("HTTP 500");
607605
});
608606

609607
it("validates metadata schema", async () => {
610-
mockFetch.mockResolvedValueOnce({
611-
ok: true,
612-
status: 200,
613-
json: async () => ({
614-
// Missing required fields
615-
issuer: "https://auth.example.com",
616-
}),
617-
});
608+
mockFetch.mockResolvedValueOnce(
609+
Response.json(
610+
{
611+
// Missing required fields
612+
issuer: "https://auth.example.com",
613+
},
614+
{ status: 200 }
615+
)
616+
);
618617

619618
await expect(
620619
discoverOAuthMetadata("https://auth.example.com")
@@ -902,10 +901,12 @@ describe("OAuth Authorization", () => {
902901
});
903902

904903
it("throws on error response", async () => {
905-
mockFetch.mockResolvedValueOnce({
906-
ok: false,
907-
status: 400,
908-
});
904+
mockFetch.mockResolvedValueOnce(
905+
Response.json(
906+
new ServerError("Token exchange failed").toResponseObject(),
907+
{ status: 400 }
908+
)
909+
);
909910

910911
await expect(
911912
exchangeAuthorization("https://auth.example.com", {
@@ -1054,10 +1055,12 @@ describe("OAuth Authorization", () => {
10541055
});
10551056

10561057
it("throws on error response", async () => {
1057-
mockFetch.mockResolvedValueOnce({
1058-
ok: false,
1059-
status: 400,
1060-
});
1058+
mockFetch.mockResolvedValueOnce(
1059+
Response.json(
1060+
new ServerError("Token refresh failed").toResponseObject(),
1061+
{ status: 400 }
1062+
)
1063+
);
10611064

10621065
await expect(
10631066
refreshAuthorization("https://auth.example.com", {
@@ -1142,10 +1145,12 @@ describe("OAuth Authorization", () => {
11421145
});
11431146

11441147
it("throws on error response", async () => {
1145-
mockFetch.mockResolvedValueOnce({
1146-
ok: false,
1147-
status: 400,
1148-
});
1148+
mockFetch.mockResolvedValueOnce(
1149+
Response.json(
1150+
new ServerError("Dynamic client registration failed").toResponseObject(),
1151+
{ status: 400 }
1152+
)
1153+
);
11491154

11501155
await expect(
11511156
registerClient("https://auth.example.com", {

src/client/auth.ts

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
11
import pkceChallenge from "pkce-challenge";
22
import { LATEST_PROTOCOL_VERSION } from "../types.js";
3-
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
3+
import {
4+
OAuthClientMetadata,
5+
OAuthClientInformation,
6+
OAuthTokens,
7+
OAuthMetadata,
8+
OAuthClientInformationFull,
9+
OAuthProtectedResourceMetadata,
10+
OAuthErrorResponseSchema
11+
} from "../shared/auth.js";
412
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
513
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
14+
import {
15+
InvalidClientError,
16+
InvalidGrantError,
17+
OAUTH_ERRORS,
18+
OAuthError,
19+
ServerError,
20+
UnauthorizedClientError
21+
} from "../server/auth/errors.js";
622

723
/**
824
* Implements an end-to-end OAuth client to be used with one MCP server.
@@ -101,6 +117,13 @@ export interface OAuthClientProvider {
101117
* Implementations must verify the returned resource matches the MCP server.
102118
*/
103119
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
120+
121+
/**
122+
* If implemented, provides a way for the client to invalidate (e.g. delete) the specified
123+
* credentials, in the case where the server has indicated that they are no longer valid.
124+
* This avoids requiring the user to intervene manually.
125+
*/
126+
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
104127
}
105128

106129
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@@ -219,13 +242,65 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void {
219242
params.set("client_id", clientId);
220243
}
221244

245+
/**
246+
* Parses an OAuth error response from a string or Response object.
247+
*
248+
* If the input is a standard OAuth2.0 error response, it will be parsed according to the spec
249+
* and an instance of the appropriate OAuthError subclass will be returned.
250+
* If parsing fails, it falls back to a generic ServerError that includes
251+
* the response status (if available) and original content.
252+
*
253+
* @param input - A Response object or string containing the error response
254+
* @returns A Promise that resolves to an OAuthError instance
255+
*/
256+
export async function parseErrorResponse(input: Response | string): Promise<OAuthError> {
257+
const statusCode = input instanceof Response ? input.status : undefined;
258+
const body = input instanceof Response ? await input.text() : input;
259+
260+
try {
261+
const result = OAuthErrorResponseSchema.parse(JSON.parse(body));
262+
const { error, error_description, error_uri } = result;
263+
const errorClass = OAUTH_ERRORS[error] || ServerError;
264+
return new errorClass(error_description || '', error_uri);
265+
} catch (error) {
266+
// Not a valid OAuth error response, but try to inform the user of the raw data anyway
267+
const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`;
268+
return new ServerError(errorMessage);
269+
}
270+
}
271+
222272
/**
223273
* Orchestrates the full auth flow with a server.
224274
*
225275
* This can be used as a single entry point for all authorization functionality,
226276
* instead of linking together the other lower-level functions in this module.
227277
*/
228278
export async function auth(
279+
provider: OAuthClientProvider,
280+
options: {
281+
serverUrl: string | URL;
282+
authorizationCode?: string;
283+
scope?: string;
284+
resourceMetadataUrl?: URL }): Promise<AuthResult> {
285+
286+
try {
287+
return await authInternal(provider, options);
288+
} catch (error) {
289+
// Handle recoverable error types by invalidating credentials and retrying
290+
if (error instanceof InvalidClientError || error instanceof UnauthorizedClientError) {
291+
await provider.invalidateCredentials?.('all');
292+
return await authInternal(provider, options);
293+
} else if (error instanceof InvalidGrantError) {
294+
await provider.invalidateCredentials?.('tokens');
295+
return await authInternal(provider, options);
296+
}
297+
298+
// Throw otherwise
299+
throw error
300+
}
301+
}
302+
303+
async function authInternal(
229304
provider: OAuthClientProvider,
230305
{ serverUrl,
231306
authorizationCode,
@@ -289,7 +364,7 @@ export async function auth(
289364
});
290365

291366
await provider.saveTokens(tokens);
292-
return "AUTHORIZED";
367+
return "AUTHORIZED"
293368
}
294369

295370
const tokens = await provider.tokens();
@@ -307,9 +382,15 @@ export async function auth(
307382
});
308383

309384
await provider.saveTokens(newTokens);
310-
return "AUTHORIZED";
311-
} catch {
312-
// Could not refresh OAuth tokens
385+
return "AUTHORIZED"
386+
} catch (error) {
387+
// 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.
388+
if (!(error instanceof OAuthError) || error instanceof ServerError) {
389+
// Could not refresh OAuth tokens
390+
} else {
391+
// Refresh failed for another reason, re-throw
392+
throw error;
393+
}
313394
}
314395
}
315396

@@ -327,7 +408,7 @@ export async function auth(
327408

328409
await provider.saveCodeVerifier(codeVerifier);
329410
await provider.redirectToAuthorization(authorizationUrl);
330-
return "REDIRECT";
411+
return "REDIRECT"
331412
}
332413

333414
export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
@@ -707,7 +788,7 @@ export async function exchangeAuthorization(
707788
});
708789

709790
if (!response.ok) {
710-
throw new Error(`Token exchange failed: HTTP ${response.status}`);
791+
throw await parseErrorResponse(response);
711792
}
712793

713794
return OAuthTokensSchema.parse(await response.json());
@@ -788,7 +869,7 @@ export async function refreshAuthorization(
788869
body: params,
789870
});
790871
if (!response.ok) {
791-
throw new Error(`Token refresh failed: HTTP ${response.status}`);
872+
throw await parseErrorResponse(response);
792873
}
793874

794875
return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) });
@@ -828,7 +909,7 @@ export async function registerClient(
828909
});
829910

830911
if (!response.ok) {
831-
throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
912+
throw await parseErrorResponse(response);
832913
}
833914

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

0 commit comments

Comments
 (0)