Skip to content

Commit 96887d4

Browse files
committed
Invalidating credentials & retrying when server OAuth errors occur
1 parent e3307e5 commit 96887d4

File tree

1 file changed

+141
-60
lines changed

1 file changed

+141
-60
lines changed

src/client/auth.ts

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

625
/**
726
* Implements an end-to-end OAuth client to be used with one MCP server.
@@ -66,6 +85,13 @@ export interface OAuthClientProvider {
6685
* the authorization result.
6786
*/
6887
codeVerifier(): string | Promise<string>;
88+
89+
/**
90+
* If implemented, provides a way for the client to invalidate (e.g. delete) the specified
91+
* credentials, in the case where the server has indicated that they are no longer valid.
92+
* This avoids requiring the user to intervene manually.
93+
*/
94+
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise<void>;
6995
}
7096

7197
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@@ -76,6 +102,33 @@ export class UnauthorizedError extends Error {
76102
}
77103
}
78104

105+
/**
106+
* Parses an OAuth error response from a string or Response object.
107+
*
108+
* If the input is a standard OAuth2.0 error response, it will be parsed according to the spec
109+
* and an instance of the appropriate OAuthError subclass will be returned.
110+
* If parsing fails, it falls back to a generic ServerError that includes
111+
* the response status (if available) and original content.
112+
*
113+
* @param input - A Response object or string containing the error response
114+
* @returns A Promise that resolves to an OAuthError instance
115+
*/
116+
export async function parseErrorResponse(input: Response | string): Promise<OAuthError> {
117+
const statusCode = input instanceof Response ? input.status : undefined;
118+
const body = input instanceof Response ? await input.text() : input;
119+
120+
try {
121+
const result = OAuthErrorResponseSchema.parse(JSON.parse(body));
122+
const { error, error_description, error_uri } = result;
123+
const errorClass = OAUTH_ERRORS[error] || ServerError;
124+
return new errorClass(error_description || '', error_uri);
125+
} catch (error) {
126+
// Not a valid OAuth error response, but try to inform the user of the raw data anyway
127+
const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`;
128+
return new ServerError(errorMessage);
129+
}
130+
}
131+
79132
/**
80133
* Orchestrates the full auth flow with a server.
81134
*
@@ -84,74 +137,102 @@ export class UnauthorizedError extends Error {
84137
*/
85138
export async function auth(
86139
provider: OAuthClientProvider,
87-
{ serverUrl, authorizationCode }: { serverUrl: string | URL, authorizationCode?: string }): Promise<AuthResult> {
88-
const metadata = await discoverOAuthMetadata(serverUrl);
89-
90-
// Handle client registration if needed
91-
let clientInformation = await Promise.resolve(provider.clientInformation());
92-
if (!clientInformation) {
93-
if (authorizationCode !== undefined) {
94-
throw new Error("Existing OAuth client information is required when exchanging an authorization code");
95-
}
96-
97-
if (!provider.saveClientInformation) {
98-
throw new Error("OAuth client information must be saveable for dynamic registration");
99-
}
100-
101-
const fullInformation = await registerClient(serverUrl, {
102-
metadata,
103-
clientMetadata: provider.clientMetadata,
104-
});
140+
options: { serverUrl: string | URL, authorizationCode?: string },
141+
lastError?: Error
142+
): Promise<AuthResult> {
143+
const { serverUrl, authorizationCode } = options
144+
try {
145+
const metadata = await discoverOAuthMetadata(serverUrl);
105146

106-
await provider.saveClientInformation(fullInformation);
107-
clientInformation = fullInformation;
108-
}
147+
// Handle client registration if needed
148+
let clientInformation = await Promise.resolve(provider.clientInformation());
149+
if (!clientInformation) {
150+
if (authorizationCode !== undefined) {
151+
throw new Error("Existing OAuth client information is required when exchanging an authorization code");
152+
}
109153

110-
// Exchange authorization code for tokens
111-
if (authorizationCode !== undefined) {
112-
const codeVerifier = await provider.codeVerifier();
113-
const tokens = await exchangeAuthorization(serverUrl, {
114-
metadata,
115-
clientInformation,
116-
authorizationCode,
117-
codeVerifier,
118-
redirectUri: provider.redirectUrl,
119-
});
154+
if (!provider.saveClientInformation) {
155+
throw new Error("OAuth client information must be saveable for dynamic registration");
156+
}
120157

121-
await provider.saveTokens(tokens);
122-
return "AUTHORIZED";
123-
}
158+
const fullInformation = await registerClient(serverUrl, {
159+
metadata,
160+
clientMetadata: provider.clientMetadata,
161+
});
124162

125-
const tokens = await provider.tokens();
163+
await provider.saveClientInformation(fullInformation);
164+
clientInformation = fullInformation;
165+
}
126166

127-
// Handle token refresh or new authorization
128-
if (tokens?.refresh_token) {
129-
try {
130-
// Attempt to refresh the token
131-
const newTokens = await refreshAuthorization(serverUrl, {
167+
// Exchange authorization code for tokens
168+
if (authorizationCode !== undefined) {
169+
const codeVerifier = await provider.codeVerifier();
170+
const tokens = await exchangeAuthorization(serverUrl, {
132171
metadata,
133172
clientInformation,
134-
refreshToken: tokens.refresh_token,
173+
authorizationCode,
174+
codeVerifier,
175+
redirectUri: provider.redirectUrl,
135176
});
136177

137-
await provider.saveTokens(newTokens);
178+
await provider.saveTokens(tokens);
138179
return "AUTHORIZED";
139-
} catch (error) {
140-
console.error("Could not refresh OAuth tokens:", error);
141180
}
142-
}
143181

144-
// Start new authorization flow
145-
const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, {
146-
metadata,
147-
clientInformation,
148-
redirectUrl: provider.redirectUrl,
149-
scope: provider.clientMetadata.scope
150-
});
182+
const tokens = await provider.tokens();
183+
184+
// Handle token refresh or new authorization
185+
if (tokens?.refresh_token) {
186+
try {
187+
// Attempt to refresh the token
188+
const newTokens = await refreshAuthorization(serverUrl, {
189+
metadata,
190+
clientInformation,
191+
refreshToken: tokens.refresh_token,
192+
});
193+
194+
await provider.saveTokens(newTokens);
195+
return "AUTHORIZED";
196+
} catch (error) {
197+
// 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.
198+
if (!(error instanceof OAuthError) || error instanceof ServerError) {
199+
console.error("Could not refresh OAuth tokens:", error);
200+
} else {
201+
console.warn(`OAuth token refresh failed: ${JSON.stringify(error.toResponseObject())}`);
202+
throw error
203+
}
204+
}
205+
}
151206

152-
await provider.saveCodeVerifier(codeVerifier);
153-
await provider.redirectToAuthorization(authorizationUrl);
154-
return "REDIRECT";
207+
// Start new authorization flow
208+
const {authorizationUrl, codeVerifier} = await startAuthorization(serverUrl, {
209+
metadata,
210+
clientInformation,
211+
redirectUrl: provider.redirectUrl,
212+
scope: provider.clientMetadata.scope
213+
});
214+
215+
await provider.saveCodeVerifier(codeVerifier);
216+
await provider.redirectToAuthorization(authorizationUrl);
217+
return "REDIRECT";
218+
} catch (error) {
219+
switch ((error as Error).constructor) {
220+
// Don't loop forever if the same type of error recurs
221+
case lastError?.constructor:
222+
throw error;
223+
// Invalid clients mean the entire local state is now invalid, so clear it all then retry
224+
case InvalidClientError:
225+
case UnauthorizedClientError:
226+
await provider.invalidateCredentials?.('all')
227+
return await auth(provider, options, error as Error)
228+
// Invalid grants mean clear the tokens and retry
229+
case InvalidGrantError:
230+
await provider.invalidateCredentials?.('tokens')
231+
return await auth(provider, options, error as Error)
232+
default:
233+
throw error
234+
}
235+
}
155236
}
156237

157238
/**
@@ -316,7 +397,7 @@ export async function exchangeAuthorization(
316397
});
317398

318399
if (!response.ok) {
319-
throw new Error(`Token exchange failed: HTTP ${response.status}`);
400+
throw await parseErrorResponse(response);
320401
}
321402

322403
return OAuthTokensSchema.parse(await response.json());
@@ -375,7 +456,7 @@ export async function refreshAuthorization(
375456
});
376457

377458
if (!response.ok) {
378-
throw new Error(`Token refresh failed: HTTP ${response.status}`);
459+
throw await parseErrorResponse(response);
379460
}
380461

381462
return OAuthTokensSchema.parse(await response.json());
@@ -415,7 +496,7 @@ export async function registerClient(
415496
});
416497

417498
if (!response.ok) {
418-
throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
499+
throw await parseErrorResponse(response);
419500
}
420501

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

0 commit comments

Comments
 (0)