diff --git a/.changeset/oauth-error-compat.md b/.changeset/oauth-error-compat.md new file mode 100644 index 0000000000..4585909a84 --- /dev/null +++ b/.changeset/oauth-error-compat.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': patch +--- + +Restore OAuth error backwards compatibility with SDK 1.x: the per-code error subclasses (`InvalidGrantError`, `ServerError`, …) are exported again as deprecated wrappers around `OAuthError`, and SDK-produced OAuth errors are constructed as the matching subclass so 1.x-style +`instanceof` classification keeps working. Adds `oauthErrorFromCode()`, `isTransientOAuthError()` (which treats unknown error codes as transient, matching 1.x retry semantics), a deprecated `errorCode` alias for `code`, and the deprecated `OAUTH_ERRORS` map. The OAuth +`InvalidRequestError` returns as `OAuthInvalidRequestError` (the original name is taken by a JSON-RPC protocol type). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..b97dab7e26 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -178,41 +178,33 @@ if (error instanceof SdkHttpError) { ### OAuth error consolidation -Individual OAuth error classes replaced with single `OAuthError` class and `OAuthErrorCode` enum: - -| v1 Class | v2 Equivalent | -| ------------------------------ | ---------------------------------------------------------- | -| `InvalidRequestError` | `OAuthError` with `OAuthErrorCode.InvalidRequest` | -| `InvalidClientError` | `OAuthError` with `OAuthErrorCode.InvalidClient` | -| `InvalidGrantError` | `OAuthError` with `OAuthErrorCode.InvalidGrant` | -| `UnauthorizedClientError` | `OAuthError` with `OAuthErrorCode.UnauthorizedClient` | -| `UnsupportedGrantTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedGrantType` | -| `InvalidScopeError` | `OAuthError` with `OAuthErrorCode.InvalidScope` | -| `AccessDeniedError` | `OAuthError` with `OAuthErrorCode.AccessDenied` | -| `ServerError` | `OAuthError` with `OAuthErrorCode.ServerError` | -| `TemporarilyUnavailableError` | `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable` | -| `UnsupportedResponseTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedResponseType` | -| `UnsupportedTokenTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedTokenType` | -| `InvalidTokenError` | `OAuthError` with `OAuthErrorCode.InvalidToken` | -| `MethodNotAllowedError` | `OAuthError` with `OAuthErrorCode.MethodNotAllowed` | -| `TooManyRequestsError` | `OAuthError` with `OAuthErrorCode.TooManyRequests` | -| `InvalidClientMetadataError` | `OAuthError` with `OAuthErrorCode.InvalidClientMetadata` | -| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` | -| `InvalidTargetError` | `OAuthError` with `OAuthErrorCode.InvalidTarget` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | - -Removed: `OAUTH_ERRORS` constant. - -Update OAuth error handling: +OAuth errors consolidated into `OAuthError` with `OAuthErrorCode` enum on `error.code`. The v1 subclasses remain exported as `@deprecated` compatibility wrappers, and SDK-produced errors are constructed as the matching subclass — `instanceof` checks from v1 continue to work +WITHOUT code changes, with ONE exception: + +| v1 Class | v2 status | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `InvalidRequestError` (OAuth) | RENAMED → `OAuthInvalidRequestError` (name now owned by a JSON-RPC protocol type). Rewrite `instanceof InvalidRequestError` → `error.code === OAuthErrorCode.InvalidRequest` | +| All 16 other subclasses (`InvalidGrantError`, `ServerError`, …) and `CustomOAuthError` | Unchanged names, deprecated; prefer `error.code === OAuthErrorCode.X` | +| `OAUTH_ERRORS` | Still exported, deprecated; prefer `oauthErrorFromCode(code, message, errorUri?)` | + +Semantics to preserve when rewriting v1 code: + +1. `error.errorCode` → `error.code` (deprecated alias exists). +2. `error.name` is `'OAuthError'` for ALL OAuth errors including subclasses (v1 used the subclass name). Never match on subclass names. +3. UNKNOWN-CODE TRAP: v1 collapsed unrecognized error codes into `ServerError` (transient/retryable). v2 preserves the raw code on a plain `OAuthError`. Rewriting `instanceof ServerError` retry checks as `error.code === OAuthErrorCode.ServerError` SILENTLY DROPS retry for + non-standard codes (e.g. `invalid_refresh_token`). Use `isTransientOAuthError(error)` instead — it returns true for `server_error`, `temporarily_unavailable`, `too_many_requests`, AND any code not in `OAuthErrorCode` (v1 parity). ```typescript // v1 -import { InvalidClientError, InvalidGrantError } from '@modelcontextprotocol/client'; -if (error instanceof InvalidClientError) { ... } +if (error instanceof ServerError) { + scheduleRetry(); +} -// v2 -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } +// v2 — equivalent semantics +import { isTransientOAuthError } from '@modelcontextprotocol/client'; +if (isTransientOAuthError(error)) { + scheduleRetry(); +} ``` **Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..74e94ed4b6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -798,85 +798,59 @@ The new design: ### OAuth error refactoring -The OAuth error classes have been consolidated into a single `OAuthError` class with an `OAuthErrorCode` enum. +OAuth errors are now built around a single `OAuthError` class with an `OAuthErrorCode` enum: `error.code` carries the OAuth error code (e.g. `'invalid_grant'`), including non-standard codes returned by real-world authorization servers. -#### Removed classes - -The following individual error classes have been removed in favor of `OAuthError` with the appropriate code: - -| v1 Class | v2 Equivalent | -| ------------------------------ | ----------------------------------------------------------------- | -| `InvalidRequestError` | `new OAuthError(OAuthErrorCode.InvalidRequest, message)` | -| `InvalidClientError` | `new OAuthError(OAuthErrorCode.InvalidClient, message)` | -| `InvalidGrantError` | `new OAuthError(OAuthErrorCode.InvalidGrant, message)` | -| `UnauthorizedClientError` | `new OAuthError(OAuthErrorCode.UnauthorizedClient, message)` | -| `UnsupportedGrantTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedGrantType, message)` | -| `InvalidScopeError` | `new OAuthError(OAuthErrorCode.InvalidScope, message)` | -| `AccessDeniedError` | `new OAuthError(OAuthErrorCode.AccessDenied, message)` | -| `ServerError` | `new OAuthError(OAuthErrorCode.ServerError, message)` | -| `TemporarilyUnavailableError` | `new OAuthError(OAuthErrorCode.TemporarilyUnavailable, message)` | -| `UnsupportedResponseTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedResponseType, message)` | -| `UnsupportedTokenTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedTokenType, message)` | -| `InvalidTokenError` | `new OAuthError(OAuthErrorCode.InvalidToken, message)` | -| `MethodNotAllowedError` | `new OAuthError(OAuthErrorCode.MethodNotAllowed, message)` | -| `TooManyRequestsError` | `new OAuthError(OAuthErrorCode.TooManyRequests, message)` | -| `InvalidClientMetadataError` | `new OAuthError(OAuthErrorCode.InvalidClientMetadata, message)` | -| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` | -| `InvalidTargetError` | `new OAuthError(OAuthErrorCode.InvalidTarget, message)` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | - -The `OAUTH_ERRORS` constant has also been removed. - -If you need the v1 OAuth error classes and `mcpAuthRouter` during migration, `@modelcontextprotocol/server-legacy/auth` provides a frozen copy: - -```typescript -import { mcpAuthRouter, InvalidClientError } from '@modelcontextprotocol/server-legacy/auth'; -``` - -This package is deprecated and will not receive new features. Use a dedicated OAuth provider in production. - -**Before (v1):** +For backwards compatibility, the v1 error subclasses are still exported (as `@deprecated` wrappers around `OAuthError`), and errors produced by the SDK from server error responses are constructed as the matching subclass — so v1-style `instanceof` classification keeps working: ```typescript -import { InvalidClientError, InvalidGrantError, ServerError } from '@modelcontextprotocol/client'; +import { InvalidGrantError, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; try { await refreshToken(); } catch (error) { - if (error instanceof InvalidClientError) { - // Handle invalid client - } else if (error instanceof InvalidGrantError) { - // Handle invalid grant - } else if (error instanceof ServerError) { - // Handle server error + // v1 style — still works: + if (error instanceof InvalidGrantError) { + // Drop stored tokens, re-authorize + } + // v2 style — preferred: + if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidGrant) { + // Drop stored tokens, re-authorize } } ``` -**After (v2):** +Differences to be aware of when migrating: + +- **One rename:** the OAuth `InvalidRequestError` class is now `OAuthInvalidRequestError`, because v2 exports a JSON-RPC `InvalidRequestError` interface from the protocol types under the same name. Check `error.code === OAuthErrorCode.InvalidRequest` instead. +- **`error.name` is always `'OAuthError'`**, including for the subclasses (v1 used the subclass name). Use `instanceof` or `error.code` to distinguish kinds. +- **`error.errorCode` is deprecated** — it remains available as an alias, but use `error.code`. +- **Unknown error codes are preserved.** v1 collapsed unrecognized codes (e.g. `invalid_refresh_token`) into `ServerError`, which made them look transient/retryable. v2 keeps the raw code on a plain `OAuthError`. If you had retry logic keyed on `instanceof ServerError`, use the + new `isTransientOAuthError(error)` helper, which treats the RFC transient codes _and_ unknown codes as retryable — matching v1 behavior: ```typescript -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; +import { isTransientOAuthError } from '@modelcontextprotocol/client'; try { await refreshToken(); } catch (error) { - if (error instanceof OAuthError) { - switch (error.code) { - case OAuthErrorCode.InvalidClient: - // Handle invalid client - break; - case OAuthErrorCode.InvalidGrant: - // Handle invalid grant - break; - case OAuthErrorCode.ServerError: - // Handle server error - break; - } + if (isTransientOAuthError(error)) { + scheduleRetry(); + } else { + throw error; } } ``` +- `OAUTH_ERRORS` (the code → class map) is still exported, `@deprecated`. `oauthErrorFromCode(code, message, errorUri?)` is the supported way to construct an error from a code. + +If you need the full v1 OAuth _server_ surface (`mcpAuthRouter` and friends) during migration, `@modelcontextprotocol/server-legacy/auth` provides a frozen copy: + +```typescript +import { mcpAuthRouter } from '@modelcontextprotocol/server-legacy/auth'; +``` + +This package is deprecated and will not receive new features. Use a dedicated OAuth provider in production. Note that error classes imported from `server-legacy/auth` are distinct from the `@modelcontextprotocol/client` ones — don't mix them in `instanceof` checks. + ### Experimental tasks interception removed The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement: diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a08..9a4e384484 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -12,10 +12,12 @@ import type { } from '@modelcontextprotocol/core'; import { checkResourceAllowed, + isTransientOAuthError, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, OAuthErrorCode, + oauthErrorFromCode, OAuthErrorResponseSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, @@ -527,7 +529,7 @@ export async function parseErrorResponse(input: Response | string): Promise { + it('returns the specific subclass for known OAuth error codes', async () => { + const response = new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'refresh token expired' }), { + status: 400 + }); + const error = await parseErrorResponse(response); + expect(error).toBeInstanceOf(InvalidGrantError); + expect(error.code).toBe('invalid_grant'); + expect(isTransientOAuthError(error)).toBe(false); + }); + + it('preserves unknown error codes on the base class and classifies them transient', async () => { + const response = new Response(JSON.stringify({ error: 'invalid_refresh_token', error_description: 'rotated' }), { + status: 400 + }); + const error = await parseErrorResponse(response); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('invalid_refresh_token'); + expect(isTransientOAuthError(error)).toBe(true); + }); + + it('falls back to ServerError for unparsable bodies, matching 1.x', async () => { + const response = new Response('gateway timeout', { status: 502 }); + const error = await parseErrorResponse(response); + expect(error).toBeInstanceOf(ServerError); + expect(isTransientOAuthError(error)).toBe(true); + }); +}); + +describe('refreshAuthorization error-class compatibility', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + const clientInformation = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'] + }; + + function refreshWith(fetchFn: typeof fetch) { + return refreshAuthorization('https://auth.example.com', { + metadata, + clientInformation, + refreshToken: 'refresh123', + fetchFn + }); + } + + it('throws InvalidGrantError when the token endpoint rejects the refresh token', async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'refresh token expired' }), { status: 400 }) + ); + const error = await refreshWith(fetchFn).catch(e => e); + expect(error).toBeInstanceOf(InvalidGrantError); + expect(error.code).toBe('invalid_grant'); + expect(isTransientOAuthError(error)).toBe(false); + }); + + it('preserves unknown token-endpoint error codes and classifies them transient', async () => { + const fetchFn = vi.fn( + async () => new Response(JSON.stringify({ error: 'invalid_refresh_token', error_description: 'rotated' }), { status: 400 }) + ); + const error = await refreshWith(fetchFn).catch(e => e); + expect(error).toBeInstanceOf(OAuthError); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('invalid_refresh_token'); + expect(isTransientOAuthError(error)).toBe(true); + }); + + it('throws TemporarilyUnavailableError for a 503 with the matching code', async () => { + const fetchFn = vi.fn(async () => new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { status: 503 })); + const error = await refreshWith(fetchFn).catch(e => e); + expect(error).toBeInstanceOf(TemporarilyUnavailableError); + expect(isTransientOAuthError(error)).toBe(true); + }); +}); +describe('auth() refresh-failure fallback', () => { + function makeProvider(): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost:3000/callback'], client_name: 'Test Client' }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'] + }), + tokens: vi.fn().mockResolvedValue({ access_token: 'stale', refresh_token: 'refresh123', token_type: 'bearer' }), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn() + }; + } + + function fetchRouter(tokenResponse: () => Response): typeof fetch { + return vi.fn(async (url: string | URL | Request) => { + const urlString = url.toString(); + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return new Response( + JSON.stringify({ resource: 'https://api.example.com/mcp', authorization_servers: ['https://auth.example.com'] }), + { status: 200 } + ); + } + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return new Response( + JSON.stringify({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }), + { status: 200 } + ); + } + if (urlString.includes('/token')) { + return tokenResponse(); + } + throw new Error(`Unexpected fetch call: ${urlString}`); + }) as unknown as typeof fetch; + } + + it('falls through to a fresh authorization flow when refresh fails with an unknown code', async () => { + const provider = makeProvider(); + const result = await auth(provider, { + serverUrl: 'https://api.example.com/mcp', + fetchFn: fetchRouter( + () => new Response(JSON.stringify({ error: 'invalid_refresh_token', error_description: 'rotated' }), { status: 400 }) + ) + }); + expect(result).toBe('REDIRECT'); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + expect(provider.saveTokens).not.toHaveBeenCalled(); + }); + + it('still escalates known non-transient refresh failures', async () => { + const provider = makeProvider(); + const error = await auth(provider, { + serverUrl: 'https://api.example.com/mcp', + fetchFn: fetchRouter( + () => new Response(JSON.stringify({ error: 'invalid_scope', error_description: 'scope revoked' }), { status: 400 }) + ) + }).catch(e => e); + expect(error).toBeInstanceOf(InvalidScopeError); + expect(error.code).toBe('invalid_scope'); + expect(provider.redirectToAuthorization).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts index 30c8741601..aed6ad0a28 100644 --- a/packages/core/src/auth/errors.ts +++ b/packages/core/src/auth/errors.ts @@ -107,6 +107,16 @@ export class OAuthError extends Error { this.name = 'OAuthError'; } + /** + * The OAuth error code. + * + * @deprecated Use {@linkcode OAuthError.code} instead. Provided for compatibility with + * SDK 1.x, where the error code was exposed as `errorCode`. + */ + get errorCode(): string { + return this.code; + } + /** * Converts the error to a standard OAuth error response object. */ @@ -125,8 +135,234 @@ export class OAuthError extends Error { /** * Creates an {@linkcode OAuthError} from an OAuth error response. + * + * Returns the specific subclass for known error codes (e.g. {@linkcode InvalidGrantError} + * for `invalid_grant`), so `instanceof` checks written against SDK 1.x keep working. */ static fromResponse(response: OAuthErrorResponse): OAuthError { - return new OAuthError(response.error as OAuthErrorCode, response.error_description ?? response.error, response.error_uri); + return oauthErrorFromCode(response.error, response.error_description ?? response.error, response.error_uri); + } +} + +/** + * Base shape shared by the deprecated SDK 1.x error subclasses below. + * + * In SDK 1.x every OAuth error code had its own `OAuthError` subclass and consumers + * classified errors with `instanceof` (e.g. `error instanceof InvalidGrantError` to drop + * stored tokens, `error instanceof ServerError` to retry). These subclasses are preserved + * so that classification keeps working after migrating to 2.x. + * + * Note: unlike 1.x, `error.name` is `'OAuthError'` for all subclasses, matching the 2.x + * base class. Code that needs the specific kind should check `error.code` or `instanceof`. + */ +type OAuthErrorSubclass = new (message: string, errorUri?: string) => OAuthError; + +/** + * The one 1.x OAuth error class that could not keep its original name: 2.x already exports a + * JSON-RPC `InvalidRequestError` interface from the protocol types. Code migrating from 1.x + * that checked `error instanceof InvalidRequestError` (uncommon in clients — this error is + * produced by request validation on the server side) should check + * `error.code === OAuthErrorCode.InvalidRequest` instead. + * + * @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidRequest`. + */ +export class OAuthInvalidRequestError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidRequest, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidClient`. */ +export class InvalidClientError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidClient, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidGrant`. */ +export class InvalidGrantError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidGrant, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnauthorizedClient`. */ +export class UnauthorizedClientError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnauthorizedClient, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnsupportedGrantType`. */ +export class UnsupportedGrantTypeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnsupportedGrantType, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidScope`. */ +export class InvalidScopeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidScope, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.AccessDenied`. */ +export class AccessDeniedError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.AccessDenied, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.ServerError`. */ +export class ServerError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.ServerError, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.TemporarilyUnavailable`. */ +export class TemporarilyUnavailableError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.TemporarilyUnavailable, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnsupportedResponseType`. */ +export class UnsupportedResponseTypeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnsupportedResponseType, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.UnsupportedTokenType`. */ +export class UnsupportedTokenTypeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.UnsupportedTokenType, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidToken`. */ +export class InvalidTokenError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidToken, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.MethodNotAllowed`. */ +export class MethodNotAllowedError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.MethodNotAllowed, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.TooManyRequests`. */ +export class TooManyRequestsError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.TooManyRequests, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidClientMetadata`. */ +export class InvalidClientMetadataError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidClientMetadata, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InsufficientScope`. */ +export class InsufficientScopeError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InsufficientScope, message, errorUri); + } +} + +/** @deprecated Use {@linkcode OAuthError} and check `code === OAuthErrorCode.InvalidTarget`. */ +export class InvalidTargetError extends OAuthError { + constructor(message: string, errorUri?: string) { + super(OAuthErrorCode.InvalidTarget, message, errorUri); + } +} + +/** + * An OAuth error with a non-standard error code. + * + * @deprecated Use {@linkcode OAuthError} directly — the 2.x base class carries arbitrary + * codes. Provided for compatibility with SDK 1.x. + */ +export class CustomOAuthError extends OAuthError { + constructor(code: string, message: string, errorUri?: string) { + super(code, message, errorUri); + } +} + +/** + * Maps known OAuth error codes to their corresponding error subclasses. + * + * @deprecated Use {@linkcode oauthErrorFromCode} to construct errors from codes, or check + * `error.code` directly. + */ +export const OAUTH_ERRORS: Readonly> = { + [OAuthErrorCode.InvalidRequest]: OAuthInvalidRequestError, + [OAuthErrorCode.InvalidClient]: InvalidClientError, + [OAuthErrorCode.InvalidGrant]: InvalidGrantError, + [OAuthErrorCode.UnauthorizedClient]: UnauthorizedClientError, + [OAuthErrorCode.UnsupportedGrantType]: UnsupportedGrantTypeError, + [OAuthErrorCode.InvalidScope]: InvalidScopeError, + [OAuthErrorCode.AccessDenied]: AccessDeniedError, + [OAuthErrorCode.ServerError]: ServerError, + [OAuthErrorCode.TemporarilyUnavailable]: TemporarilyUnavailableError, + [OAuthErrorCode.UnsupportedResponseType]: UnsupportedResponseTypeError, + [OAuthErrorCode.UnsupportedTokenType]: UnsupportedTokenTypeError, + [OAuthErrorCode.InvalidToken]: InvalidTokenError, + [OAuthErrorCode.MethodNotAllowed]: MethodNotAllowedError, + [OAuthErrorCode.TooManyRequests]: TooManyRequestsError, + [OAuthErrorCode.InvalidClientMetadata]: InvalidClientMetadataError, + [OAuthErrorCode.InsufficientScope]: InsufficientScopeError, + [OAuthErrorCode.InvalidTarget]: InvalidTargetError +}; + +/** + * Error codes that indicate a transient condition where retrying the request may succeed. + */ +const TRANSIENT_OAUTH_ERROR_CODES: ReadonlySet = new Set([ + OAuthErrorCode.ServerError, + OAuthErrorCode.TemporarilyUnavailable, + OAuthErrorCode.TooManyRequests +]); + +const KNOWN_OAUTH_ERROR_CODES: ReadonlySet = new Set(Object.values(OAuthErrorCode)); + +/** + * Creates an {@linkcode OAuthError} from an error code, returning the specific subclass for + * known codes so that `instanceof` checks (e.g. `error instanceof InvalidGrantError`) work. + * + * Unknown / non-standard codes produce a plain {@linkcode OAuthError} that preserves the + * raw code. + */ +export function oauthErrorFromCode(code: OAuthErrorCode | string, message: string, errorUri?: string): OAuthError { + // The code comes from untrusted server responses: guard against prototype-chain + // lookups (e.g. a server returning "constructor" or "__proto__" as the error code). + const ErrorClass = Object.hasOwn(OAUTH_ERRORS, code) ? OAUTH_ERRORS[code] : undefined; + if (ErrorClass) { + return new ErrorClass(message, errorUri); + } + return new OAuthError(code, message, errorUri); +} + +/** + * Returns `true` when an OAuth error indicates a transient condition that may succeed on + * retry: `server_error`, `temporarily_unavailable`, `too_many_requests` — or any error code + * not defined in {@linkcode OAuthErrorCode}. + * + * Treating unknown codes as transient matches SDK 1.x, which collapsed unrecognized error + * codes into `ServerError`. Authorization servers in the wild return non-standard codes + * (e.g. `invalid_refresh_token`) for conditions that are not permanent; treating them as + * permanent failures would stop retry/refresh loops that previously recovered. + */ +export function isTransientOAuthError(error: unknown): boolean { + if (!(error instanceof OAuthError)) { + return false; } + return TRANSIENT_OAUTH_ERROR_CODES.has(error.code) || !KNOWN_OAUTH_ERROR_CODES.has(error.code); } diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 729144f1a3..81ebd7c2ba 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -10,7 +10,31 @@ */ // Auth error classes -export { OAuthError, OAuthErrorCode } from '../../auth/errors.js'; +export { + AccessDeniedError, + CustomOAuthError, + InsufficientScopeError, + InvalidClientError, + InvalidClientMetadataError, + InvalidGrantError, + InvalidScopeError, + InvalidTargetError, + InvalidTokenError, + isTransientOAuthError, + MethodNotAllowedError, + OAUTH_ERRORS, + OAuthError, + OAuthErrorCode, + oauthErrorFromCode, + OAuthInvalidRequestError, + ServerError, + TemporarilyUnavailableError, + TooManyRequestsError, + UnauthorizedClientError, + UnsupportedGrantTypeError, + UnsupportedResponseTypeError, + UnsupportedTokenTypeError +} from '../../auth/errors.js'; // SDK error types (local errors that never cross the wire) export type { SdkHttpErrorData } from '../../errors/sdkErrors.js'; diff --git a/packages/core/test/auth/errors.test.ts b/packages/core/test/auth/errors.test.ts new file mode 100644 index 0000000000..9c160a00a2 --- /dev/null +++ b/packages/core/test/auth/errors.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import { + CustomOAuthError, + InvalidClientMetadataError, + InvalidGrantError, + isTransientOAuthError, + OAUTH_ERRORS, + OAuthError, + oauthErrorFromCode, + OAuthErrorCode, + ServerError, + TemporarilyUnavailableError, + TooManyRequestsError +} from '../../src/auth/errors.js'; + +describe('oauthErrorFromCode', () => { + it('returns the specific subclass for every known error code', () => { + for (const code of Object.values(OAuthErrorCode)) { + const error = oauthErrorFromCode(code, 'boom'); + expect(error).toBeInstanceOf(OAuthError); + expect(error).toBeInstanceOf(OAUTH_ERRORS[code]); + expect(error.code).toBe(code); + expect(error.message).toBe('boom'); + } + }); + + it('returns a plain OAuthError preserving the raw code for unknown codes', () => { + const error = oauthErrorFromCode('invalid_refresh_token', 'token rotated'); + expect(error).toBeInstanceOf(OAuthError); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('invalid_refresh_token'); + }); + + it('passes errorUri through', () => { + const error = oauthErrorFromCode(OAuthErrorCode.InvalidGrant, 'boom', 'https://example.com/error'); + expect(error.errorUri).toBe('https://example.com/error'); + }); +}); + +describe('oauthErrorFromCode prototype-chain safety', () => { + it.each(['constructor', '__proto__', 'toString', 'hasOwnProperty'])('treats Object.prototype member %s as an unknown code', code => { + const error = oauthErrorFromCode(code, 'server sent a hostile code'); + expect(error).toBeInstanceOf(OAuthError); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe(code); + expect(error.message).toContain('server sent a hostile code'); + }); +}); + +describe('OAuthError.fromResponse', () => { + it('produces subclass instances so 1.x instanceof checks keep working', () => { + const error = OAuthError.fromResponse({ error: 'invalid_grant', error_description: 'expired' }); + expect(error).toBeInstanceOf(InvalidGrantError); + expect(error.code).toBe(OAuthErrorCode.InvalidGrant); + expect(error.message).toBe('expired'); + }); + + it('preserves unknown codes on the base class', () => { + const error = OAuthError.fromResponse({ error: 'consent_required' }); + expect(error.constructor).toBe(OAuthError); + expect(error.code).toBe('consent_required'); + }); +}); + +describe('deprecated 1.x subclasses', () => { + it('construct with (message, errorUri) and carry the right code', () => { + const error = new InvalidGrantError('expired', 'https://example.com/error'); + expect(error.code).toBe(OAuthErrorCode.InvalidGrant); + expect(error.message).toBe('expired'); + expect(error.errorUri).toBe('https://example.com/error'); + expect(error).toBeInstanceOf(OAuthError); + }); + + it('keep name set to OAuthError for 2.x name-based checks', () => { + expect(new InvalidClientMetadataError('bad').name).toBe('OAuthError'); + }); + + it('CustomOAuthError carries an arbitrary code', () => { + const error = new CustomOAuthError('weird_code', 'odd'); + expect(error.code).toBe('weird_code'); + expect(error).toBeInstanceOf(OAuthError); + }); + + it('expose the deprecated errorCode alias', () => { + expect(new ServerError('boom').errorCode).toBe(OAuthErrorCode.ServerError); + expect(new OAuthError('custom_thing', 'boom').errorCode).toBe('custom_thing'); + }); + + it('OAUTH_ERRORS covers every OAuthErrorCode member', () => { + for (const code of Object.values(OAuthErrorCode)) { + expect(OAUTH_ERRORS[code]).toBeDefined(); + } + }); +}); + +describe('isTransientOAuthError', () => { + it('is true for the RFC transient codes', () => { + expect(isTransientOAuthError(new ServerError('boom'))).toBe(true); + expect(isTransientOAuthError(new TemporarilyUnavailableError('busy'))).toBe(true); + expect(isTransientOAuthError(new TooManyRequestsError('slow down'))).toBe(true); + }); + + it('is true for unknown codes (1.x collapsed them into ServerError, hence retryable)', () => { + expect(isTransientOAuthError(oauthErrorFromCode('invalid_refresh_token', 'rotated'))).toBe(true); + expect(isTransientOAuthError(new CustomOAuthError('proprietary_hiccup', 'try later'))).toBe(true); + }); + + it('is false for known permanent codes', () => { + expect(isTransientOAuthError(oauthErrorFromCode(OAuthErrorCode.InvalidGrant, 'expired'))).toBe(false); + expect(isTransientOAuthError(oauthErrorFromCode(OAuthErrorCode.AccessDenied, 'no'))).toBe(false); + }); + + it('is false for non-OAuth errors', () => { + expect(isTransientOAuthError(new Error('boom'))).toBe(false); + expect(isTransientOAuthError(undefined)).toBe(false); + }); +});