fix(client): treat HTTP 401/403 on the negotiation probe as auth failures, not legacy evidence (#2564)
This commit is contained in:
parent
a9835b4149
commit
faa7e2b470
9 changed files with 909 additions and 91 deletions
40
packages/client/src/client/authSeam.ts
Normal file
40
packages/client/src/client/authSeam.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Auth-seam provenance stamp (internal — not part of the public API).
|
||||
*
|
||||
* Errors escaping a transport auth seam — the `authProvider.token()` read,
|
||||
* an `onUnauthorized` invocation (SDK adapter or custom user callback), the
|
||||
* 403 step-up flow, the transport's own auth-failure constructions — are
|
||||
* stamped at the throw boundary. The version-negotiation probe routes stamped
|
||||
* errors as auth outcomes; provenance is recorded where it is known, never
|
||||
* reconstructed from error types downstream.
|
||||
*
|
||||
* `Symbol.for` uses the global symbol registry, so the stamp survives a
|
||||
* duplicated SDK copy in one process (bundler double-install, version skew)
|
||||
* by design: both copies resolve the same symbol.
|
||||
*/
|
||||
const AUTH_SEAM = Symbol.for('mcp.authSeamEscape');
|
||||
|
||||
/**
|
||||
* Stamp `error` as an auth-seam escape and return it — identity-preserving
|
||||
* (the same object flows on, `instanceof` and `.cause` chains intact). A
|
||||
* frozen/sealed object is returned unstamped rather than replaced: identity
|
||||
* outranks provenance. Primitive throws cannot carry the stamp.
|
||||
*/
|
||||
export function markAuthSeamEscape<T>(error: T): T {
|
||||
if ((typeof error === 'object' && error !== null) || typeof error === 'function') {
|
||||
try {
|
||||
Object.defineProperty(error, AUTH_SEAM, { value: true, configurable: true });
|
||||
} catch {
|
||||
// Frozen/sealed: leave unstamped.
|
||||
}
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Whether `error` escaped through a transport auth seam. */
|
||||
export function isAuthSeamEscape(error: unknown): boolean {
|
||||
return (
|
||||
((typeof error === 'object' && error !== null) || typeof error === 'function') &&
|
||||
(error as Record<PropertyKey, unknown>)[AUTH_SEAM] === true
|
||||
);
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@
|
|||
* sibling), or a typed connect error.
|
||||
*
|
||||
* The classifier is deliberately conservative: anything it does not positively
|
||||
* recognize as modern resolves to the legacy fallback, and a network outage is a
|
||||
* typed connect error, never an era verdict. The verdicts apply to the
|
||||
* recognize as modern resolves to the legacy fallback, and a network outage or
|
||||
* an auth-status rejection (HTTP 401/403) is a typed connect error, never an
|
||||
* era verdict. The verdicts apply to the
|
||||
* negotiation phase only — an established modern connection is never silently
|
||||
* demoted to `initialize` by a later failure.
|
||||
*/
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
modernProtocolVersions,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
UnsupportedProtocolVersionError
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
|
||||
|
|
@ -46,10 +48,10 @@ export type ProbeOutcome =
|
|||
| { kind: 'result'; result: unknown }
|
||||
/** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */
|
||||
| { kind: 'rpc-error'; code: number; message: string; data?: unknown }
|
||||
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */
|
||||
| { kind: 'http-error'; status: number; body?: string }
|
||||
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */
|
||||
| { kind: 'http-error'; status: number; body?: string; statusText?: string }
|
||||
| { kind: 'network-error'; error: unknown }
|
||||
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
|
||||
/** The transport's auth flow challenged or failed during the probe send — an error stamped at a transport auth seam, or an `UnauthorizedError` (the foreign-transport contract). `error` propagates unchanged. */
|
||||
| { kind: 'auth-required'; error: Error }
|
||||
/** The transport reported close while the probe awaited its reply. */
|
||||
| { kind: 'closed' }
|
||||
|
|
@ -241,21 +243,70 @@ function classifyRpcError(outcome: { code: number; message: string; data?: unkno
|
|||
return { kind: 'legacy' };
|
||||
}
|
||||
|
||||
function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict {
|
||||
function classifyHttpError(outcome: { status: number; body?: string; statusText?: string }, context: ProbeClassifierContext): ProbeVerdict {
|
||||
// Auth statuses are never era evidence, and a 401/403 body is the auth
|
||||
// layer's, not the server/discover handler's — this row ranks above the
|
||||
// JSON-RPC body parse. Parallel to the auth-required row: a typed failure
|
||||
// naming the status (response text on `data.text`), never a legacy
|
||||
// fallback. The codes are the auth ones, NOT EraNegotiationFailed: hosts
|
||||
// key era-recovery recipes (e.g. the gateway guide's cached-verdict flow)
|
||||
// on EraNegotiationFailed, and an auth wall must never enter era recovery
|
||||
// — persisting a legacy verdict for it would recreate the silent-legacy
|
||||
// bug at the fleet level.
|
||||
if (outcome.status === 401 || outcome.status === 403) {
|
||||
const isDenial = outcome.status === 403;
|
||||
return {
|
||||
kind: 'error',
|
||||
error: new SdkHttpError(
|
||||
isDenial ? SdkErrorCode.ClientHttpForbidden : SdkErrorCode.ClientHttpAuthentication,
|
||||
`Version negotiation failed: ${isDenial ? 'the server denied access (HTTP 403)' : 'the server requires authorization (HTTP 401)'}`,
|
||||
{
|
||||
status: outcome.status,
|
||||
statusText: outcome.statusText,
|
||||
text: outcome.body
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
if (outcome.status >= 500) {
|
||||
// A server failure is not era evidence, whatever the body says — this
|
||||
// row too ranks above the JSON-RPC body parse (a 5xx body is the
|
||||
// infrastructure's: a mid-deploy gateway's JSON error page, a crashed
|
||||
// handler's -32603, neither a server/discover verdict). The spec keys
|
||||
// the HTTP legacy signal to client-error rejections (a 2025 server
|
||||
// answers the unknown probe 4xx), never to 5xx.
|
||||
return {
|
||||
kind: 'error',
|
||||
error: new SdkHttpError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
`Version negotiation failed: the server answered the probe with HTTP ${outcome.status}`,
|
||||
{
|
||||
status: outcome.status,
|
||||
statusText: outcome.statusText,
|
||||
text: outcome.body
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
// HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error.
|
||||
const rpcError = parseJsonRpcErrorBody(outcome.body);
|
||||
if (rpcError !== undefined) {
|
||||
return classifyRpcError(rpcError, context);
|
||||
}
|
||||
// Unparseable or unrecognized HTTP rejection: conservative legacy fallback.
|
||||
// Unparseable or unrecognized 4xx rejection: conservative legacy fallback.
|
||||
// With the auth and 5xx rows above, this row's legacy set now equals the
|
||||
// spec-licensed set exactly — the 4xx a deployed 2025 server answers a
|
||||
// request it does not recognize.
|
||||
return { kind: 'legacy' };
|
||||
}
|
||||
|
||||
function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict {
|
||||
if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) {
|
||||
// A browser CORS-preflight rejection against a deployed 2025 server is an
|
||||
// opaque TypeError; the legacy fallback carries no custom headers (no
|
||||
// preflight), so it can proceed where the probe could not.
|
||||
// A browser CORS-preflight rejection against a deployed 2025 server is
|
||||
// an opaque TypeError. The legacy fallback also preflights (its JSON
|
||||
// Content-Type is non-simple), but carries only the pre-2026 headers a
|
||||
// deployed server's CORS allowlist already covers — so it can proceed
|
||||
// where the probe's 2026 headers could not.
|
||||
return { kind: 'legacy' };
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from './auth';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
|
||||
import type { IssuerMismatchError } from './authErrors';
|
||||
import { markAuthSeamEscape } from './authSeam';
|
||||
|
||||
export class SseError extends Error {
|
||||
static {
|
||||
|
|
@ -160,7 +161,14 @@ export class SSEClientTransport implements Transport {
|
|||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
const headers: RequestInit['headers'] & Record<string, string> = {};
|
||||
const token = await this._authProvider?.token();
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = await this._authProvider?.token();
|
||||
} catch (error) {
|
||||
// Auth-seam stamp: a throwing token() is an auth failure, never a
|
||||
// network failure.
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
|
@ -212,15 +220,18 @@ export class SSEClientTransport implements Transport {
|
|||
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
|
||||
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
|
||||
() => this._startOrAuth().then(resolve, reject),
|
||||
// onUnauthorized failed → not yet reported.
|
||||
error => {
|
||||
this.onerror?.(error);
|
||||
// onUnauthorized failed → not yet reported. Auth-seam
|
||||
// stamp: covers the SDK's OAuth flow and custom
|
||||
// callbacks alike.
|
||||
(error: unknown) => {
|
||||
markAuthSeamEscape(error);
|
||||
this.onerror?.(error as Error);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const error = new UnauthorizedError();
|
||||
const error = markAuthSeamEscape(new UnauthorizedError());
|
||||
reject(error);
|
||||
this.onerror?.(error);
|
||||
return;
|
||||
|
|
@ -362,23 +373,31 @@ export class SSEClientTransport implements Transport {
|
|||
}
|
||||
|
||||
if (this._authProvider.onUnauthorized && !isAuthRetry) {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
try {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
} catch (error) {
|
||||
// Auth-seam stamp: covers the SDK's OAuth flow and
|
||||
// custom onUnauthorized callbacks alike.
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this._send(message, true);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) {
|
||||
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401,
|
||||
statusText: response.statusText
|
||||
});
|
||||
throw markAuthSeamEscape(
|
||||
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401,
|
||||
statusText: response.statusText
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new UnauthorizedError();
|
||||
throw markAuthSeamEscape(new UnauthorizedError());
|
||||
}
|
||||
|
||||
const text = await response.text?.().catch(() => null);
|
||||
|
|
|
|||
|
|
@ -34,10 +34,14 @@ import {
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc
|
||||
import type { IssuerMismatchError } from './authErrors';
|
||||
import { InsufficientScopeError } from './authErrors';
|
||||
import { markAuthSeamEscape } from './authSeam';
|
||||
|
||||
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
|
||||
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
|
||||
|
||||
/** The parsed 403 `insufficient_scope` challenge handed to the step-up flow. */
|
||||
type StepUpChallenge = { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null };
|
||||
|
||||
// Default reconnection options for StreamableHTTP connections
|
||||
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
|
||||
initialReconnectionDelay: 1000,
|
||||
|
|
@ -367,10 +371,18 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
* `_startOrAuthSse` path so both apply the same `'throw'` short-circuit,
|
||||
* the same superset-gated refresh bypass, and the same retry cap.
|
||||
*/
|
||||
private async _stepUpAuthorize(
|
||||
challenge: { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null },
|
||||
stepUpRetries: number
|
||||
): Promise<'AUTHORIZED' | 'REDIRECT'> {
|
||||
private async _stepUpAuthorize(challenge: StepUpChallenge, stepUpRetries: number): Promise<'AUTHORIZED' | 'REDIRECT'> {
|
||||
// Auth-seam stamp, method-level: covers the InsufficientScopeError
|
||||
// throws, the retry-limit SdkHttpError, the tokens() read, and every
|
||||
// auth() escape (typed or not).
|
||||
try {
|
||||
return await this._stepUpAuthorizeInner(challenge, stepUpRetries);
|
||||
} catch (error) {
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async _stepUpAuthorizeInner(challenge: StepUpChallenge, stepUpRetries: number): Promise<'AUTHORIZED' | 'REDIRECT'> {
|
||||
if (this._onInsufficientScope === 'throw') {
|
||||
throw new InsufficientScopeError({
|
||||
requiredScope: challenge.scope,
|
||||
|
|
@ -422,7 +434,14 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
const headers: RequestInit['headers'] & Record<string, string> = {};
|
||||
const token = await this._authProvider?.token();
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = await this._authProvider?.token();
|
||||
} catch (error) {
|
||||
// Auth-seam stamp: a throwing token() is an auth failure, never a
|
||||
// network failure.
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
|
@ -543,23 +562,31 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
}
|
||||
|
||||
if (this._authProvider.onUnauthorized && !isAuthRetry) {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
try {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
} catch (error) {
|
||||
// Auth-seam stamp: covers the SDK's OAuth flow and
|
||||
// custom onUnauthorized callbacks alike.
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this._startOrAuthSse(options, true, stepUpRetries);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) {
|
||||
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401,
|
||||
statusText: response.statusText
|
||||
});
|
||||
throw markAuthSeamEscape(
|
||||
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401,
|
||||
statusText: response.statusText
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new UnauthorizedError();
|
||||
throw markAuthSeamEscape(new UnauthorizedError());
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
|
|
@ -571,7 +598,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
stepUpRetries
|
||||
);
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
throw markAuthSeamEscape(new UnauthorizedError());
|
||||
}
|
||||
return this._startOrAuthSse(options, isAuthRetry, stepUpRetries + 1);
|
||||
}
|
||||
|
|
@ -997,23 +1024,31 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
}
|
||||
|
||||
if (this._authProvider.onUnauthorized && !isAuthRetry) {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
try {
|
||||
await this._authProvider.onUnauthorized({
|
||||
response,
|
||||
serverUrl: this._url,
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
} catch (error) {
|
||||
// Auth-seam stamp: covers the SDK's OAuth flow and
|
||||
// custom onUnauthorized callbacks alike.
|
||||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this._send(message, options, true, stepUpRetries);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) {
|
||||
throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401,
|
||||
statusText: response.statusText
|
||||
});
|
||||
throw markAuthSeamEscape(
|
||||
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401,
|
||||
statusText: response.statusText
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new UnauthorizedError();
|
||||
throw markAuthSeamEscape(new UnauthorizedError());
|
||||
}
|
||||
|
||||
const text = await response.text?.().catch(() => null);
|
||||
|
|
@ -1027,7 +1062,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
stepUpRetries
|
||||
);
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
throw markAuthSeamEscape(new UnauthorizedError());
|
||||
}
|
||||
return this._send(message, options, isAuthRetry, stepUpRetries + 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
} from '@modelcontextprotocol/core-internal';
|
||||
|
||||
import { UnauthorizedError } from './auth';
|
||||
import { isAuthSeamEscape } from './authSeam';
|
||||
import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier';
|
||||
import { classifyProbeOutcome } from './probeClassifier';
|
||||
|
||||
|
|
@ -381,22 +382,32 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
|
|||
}
|
||||
case 'send-error': {
|
||||
const error = reply.error;
|
||||
const isAuthOutcome =
|
||||
// Provenance recorded at the throw boundary: the transport
|
||||
// stamps every error escaping one of its auth seams (the
|
||||
// token() read, onUnauthorized invocations — SDK flow and
|
||||
// custom callbacks alike — the 403 step-up method, and its
|
||||
// own auth-failure constructions). Never reconstructed here
|
||||
// from error types.
|
||||
isAuthSeamEscape(error) ||
|
||||
// The published contract for foreign transports: an
|
||||
// UnauthorizedError by brand, or by name for one thrown by a
|
||||
// differently bundled SDK copy at a skewed version or an auth
|
||||
// middleware's own class.
|
||||
error instanceof UnauthorizedError ||
|
||||
(error instanceof Error && error.name === 'UnauthorizedError');
|
||||
if (isAuthOutcome) {
|
||||
// Auth-gated server: propagate unchanged.
|
||||
return { kind: 'auth-required', error: error as Error };
|
||||
}
|
||||
if (error instanceof SdkHttpError) {
|
||||
const text = (error.data as { text?: unknown } | undefined)?.text;
|
||||
return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined };
|
||||
}
|
||||
const isUnauthorized =
|
||||
error instanceof UnauthorizedError ||
|
||||
// Name fallback for auth errors the brand cannot reach: an
|
||||
// UnauthorizedError from a differently bundled SDK copy at a
|
||||
// skewed version, or an auth middleware's own class.
|
||||
(error instanceof Error && error.name === 'UnauthorizedError');
|
||||
if (isUnauthorized) {
|
||||
// Auth-gated server. (The pre-branding name-string check alone
|
||||
// could never fire for the SDK's own class — it did not set
|
||||
// `.name` — so these send failures fell through to the generic
|
||||
// network-error wrap.)
|
||||
return { kind: 'auth-required', error: error as Error };
|
||||
return {
|
||||
kind: 'http-error',
|
||||
status: error.data.status,
|
||||
body: typeof text === 'string' ? text : undefined,
|
||||
statusText: error.data.statusText
|
||||
};
|
||||
}
|
||||
return { kind: 'network-error', error };
|
||||
}
|
||||
|
|
|
|||
288
packages/client/test/client/probeAuthSeam.test.ts
Normal file
288
packages/client/test/client/probeAuthSeam.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Fault injection per transport auth seam, adapted from the PR #2564
|
||||
* neutral-review measurement harness: each scenario drives negotiateEra()
|
||||
* with a REAL StreamableHTTPClientTransport and a mocked fetch, so the full
|
||||
* transport 401/403 handling and auth() flow execute for real.
|
||||
*
|
||||
* The invariant under test: any error escaping a stamped auth seam reaches
|
||||
* the caller IDENTITY-PRESERVED (same object; instanceof and diagnostics
|
||||
* intact) as an auth outcome — never a legacy verdict (the probe's browser
|
||||
* CORS row must not consume auth-flow TypeErrors), never a rewrap. Errors
|
||||
* thrown outside the stamped seams keep their pre-existing behavior.
|
||||
*/
|
||||
import { OAuthError, SdkError, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
|
||||
import { UnauthorizedError } from '../../src/client/auth';
|
||||
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
|
||||
import { negotiateEra } from '../../src/client/versionNegotiation';
|
||||
|
||||
const SERVER = 'https://server.example.com/mcp';
|
||||
const AS = 'https://auth.example.com';
|
||||
|
||||
const AS_METADATA = {
|
||||
issuer: AS,
|
||||
authorization_endpoint: `${AS}/authorize`,
|
||||
token_endpoint: `${AS}/token`,
|
||||
registration_endpoint: `${AS}/register`,
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256']
|
||||
};
|
||||
|
||||
function freshProvider(): OAuthClientProvider {
|
||||
return {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost:3000/callback';
|
||||
},
|
||||
get clientMetadata() {
|
||||
return { redirect_uris: ['http://localhost:3000/callback'] };
|
||||
},
|
||||
clientInformation: () => undefined,
|
||||
saveClientInformation: () => {},
|
||||
tokens: () => undefined,
|
||||
saveTokens: () => {},
|
||||
redirectToAuthorization: () => {},
|
||||
saveCodeVerifier: () => {},
|
||||
codeVerifier: () => 'verifier'
|
||||
};
|
||||
}
|
||||
|
||||
/** fetch mock: 401 wall on the MCP endpoint, real discovery, DCR delegated. */
|
||||
function authWallFetch(onRegister: () => Promise<Response>): typeof fetch {
|
||||
return (async (input: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
||||
const url = new URL(String(input instanceof Request ? input.url : input));
|
||||
if (url.href === SERVER || url.pathname === '/mcp') {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': `Bearer resource_metadata="https://server.example.com/.well-known/oauth-protected-resource/mcp"`
|
||||
}
|
||||
});
|
||||
}
|
||||
if (url.pathname.includes('oauth-protected-resource')) {
|
||||
return Response.json({ resource: SERVER, authorization_servers: [AS] });
|
||||
}
|
||||
if (url.pathname.includes('oauth-authorization-server') || url.pathname.includes('openid-configuration')) {
|
||||
return Response.json(AS_METADATA);
|
||||
}
|
||||
if (url.pathname === '/register') {
|
||||
return onRegister();
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function runProbe(transport: StreamableHTTPClientTransport, environment: 'node' | 'browser') {
|
||||
return negotiateEra(
|
||||
{ kind: 'auto', modernVersions: ['2026-07-28'], fallbackAvailable: true, probe: {} },
|
||||
{
|
||||
transport,
|
||||
clientInfo: { name: 'seam-test', version: '0' },
|
||||
capabilities: {},
|
||||
environment,
|
||||
transportKind: 'http',
|
||||
defaultTimeoutMs: 3000
|
||||
}
|
||||
).then(
|
||||
result => ({ settled: 'resolved' as const, result, error: undefined as unknown }),
|
||||
(error: unknown) => ({ settled: 'rejected' as const, result: undefined, error })
|
||||
);
|
||||
}
|
||||
|
||||
describe('stamped-seam fault injection (identity-preserving auth outcomes, never legacy)', () => {
|
||||
test('DCR POST throws TypeError inside the SDK auth flow — browser: the raw TypeError propagates, no CORS-legacy verdict', async () => {
|
||||
const dcrError = new TypeError('Failed to fetch');
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
|
||||
authProvider: freshProvider(),
|
||||
fetch: authWallFetch(() => Promise.reject(dcrError))
|
||||
});
|
||||
const out = await runProbe(transport, 'browser');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBe(dcrError);
|
||||
});
|
||||
|
||||
test('DCR POST throws TypeError — node: same raw propagation, no network-error rewrap', async () => {
|
||||
const dcrError = new TypeError('Failed to fetch');
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
|
||||
authProvider: freshProvider(),
|
||||
fetch: authWallFetch(() => Promise.reject(dcrError))
|
||||
});
|
||||
const out = await runProbe(transport, 'node');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBe(dcrError);
|
||||
});
|
||||
|
||||
test("401 persists after re-auth: the transport's ClientHttpAuthentication diagnostic reaches the caller intact", async () => {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
|
||||
authProvider: {
|
||||
token: async () => 'stale-token',
|
||||
onUnauthorized: async () => {}
|
||||
},
|
||||
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
|
||||
});
|
||||
const out = await runProbe(transport, 'node');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBeInstanceOf(SdkHttpError);
|
||||
expect((out.error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
|
||||
expect((out.error as Error).message).toContain('after re-authentication');
|
||||
});
|
||||
|
||||
test('403 insufficient_scope with no provider to drive step-up: the raw InsufficientScopeError propagates', async () => {
|
||||
const fetchMock = (async (input: string | URL | Request): Promise<Response> => {
|
||||
const url = new URL(String(input instanceof Request ? input.url : input));
|
||||
if (url.href === SERVER || url.pathname === '/mcp') {
|
||||
return new Response('Forbidden', {
|
||||
status: 403,
|
||||
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="mcp:tools"' }
|
||||
});
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { fetch: fetchMock });
|
||||
const out = await runProbe(transport, 'node');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect((out.error as Error).name).toBe('InsufficientScopeError');
|
||||
expect((out.error as Error).message).toContain('mcp:tools');
|
||||
});
|
||||
|
||||
test('user callback (clientInformation) throws a custom class inside auth(): the raw instance propagates — instanceof works', async () => {
|
||||
class UserDbError extends Error {
|
||||
override readonly name = 'UserDbError';
|
||||
}
|
||||
const userError = new UserDbError('user db exploded');
|
||||
const provider = freshProvider();
|
||||
provider.clientInformation = () => {
|
||||
throw userError;
|
||||
};
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
|
||||
authProvider: provider,
|
||||
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
|
||||
});
|
||||
const out = await runProbe(transport, 'node');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBe(userError);
|
||||
expect(out.error).toBeInstanceOf(UserDbError);
|
||||
});
|
||||
|
||||
test('token endpoint rejects with a non-recoverable OAuthError: the raw OAuthError (code intact) propagates', async () => {
|
||||
const fetchMock = (async (input: string | URL | Request): Promise<Response> => {
|
||||
const url = new URL(String(input instanceof Request ? input.url : input));
|
||||
if (url.href === SERVER || url.pathname === '/mcp') {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': `Bearer resource_metadata="https://server.example.com/.well-known/oauth-protected-resource/mcp"`
|
||||
}
|
||||
});
|
||||
}
|
||||
if (url.pathname.includes('oauth-protected-resource')) {
|
||||
return Response.json({ resource: SERVER, authorization_servers: [AS] });
|
||||
}
|
||||
if (url.pathname.includes('oauth-authorization-server') || url.pathname.includes('openid-configuration')) {
|
||||
return Response.json(AS_METADATA);
|
||||
}
|
||||
if (url.pathname === '/token') {
|
||||
return Response.json({ error: 'invalid_scope', error_description: 'scope not granted' }, { status: 400 });
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
const provider = freshProvider();
|
||||
provider.clientInformation = () => ({ client_id: 'abc', issuer: AS });
|
||||
provider.tokens = () => ({ access_token: 'tok', refresh_token: 'rt', token_type: 'Bearer', issuer: AS });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { authProvider: provider, fetch: fetchMock });
|
||||
const out = await runProbe(transport, 'node');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBeInstanceOf(OAuthError);
|
||||
expect((out.error as OAuthError).code).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
test('healthy flow ending in REDIRECT: UnauthorizedError propagates (the finishAuth contract)', async () => {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
|
||||
authProvider: freshProvider(),
|
||||
fetch: authWallFetch(() =>
|
||||
Promise.resolve(Response.json({ client_id: 'abc', redirect_uris: ['http://localhost:3000/callback'] }, { status: 201 }))
|
||||
)
|
||||
});
|
||||
const out = await runProbe(transport, 'node');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBeInstanceOf(UnauthorizedError);
|
||||
});
|
||||
|
||||
test('R6: token() throws at the _commonHeaders read — browser: raw TypeError propagates, never the CORS-legacy verdict', async () => {
|
||||
const tokenError = new TypeError("Cannot read properties of undefined (reading 'access_token')");
|
||||
const provider: AuthProvider = {
|
||||
token: () => {
|
||||
throw tokenError;
|
||||
}
|
||||
};
|
||||
let fetched = 0;
|
||||
const fetchMock = (async (): Promise<Response> => {
|
||||
fetched++;
|
||||
return new Response(null, { status: 500 });
|
||||
}) as typeof fetch;
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { authProvider: provider, fetch: fetchMock });
|
||||
const out = await runProbe(transport, 'browser');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBe(tokenError);
|
||||
// The failure happened before any network I/O — nothing was fetched,
|
||||
// and in particular no legacy initialize followed.
|
||||
expect(fetched).toBe(0);
|
||||
});
|
||||
|
||||
test('B12: a CUSTOM onUnauthorized callback throws TypeError — browser: raw propagation, never the CORS-legacy verdict', async () => {
|
||||
const cbError = new TypeError('custom re-auth crashed');
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
|
||||
authProvider: {
|
||||
token: async () => undefined,
|
||||
onUnauthorized: async () => {
|
||||
throw cbError;
|
||||
}
|
||||
},
|
||||
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
|
||||
});
|
||||
const out = await runProbe(transport, 'browser');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBe(cbError);
|
||||
});
|
||||
|
||||
test('B11 regression pin: on the NON-probe path a throwing custom onUnauthorized propagates the raw instance from send()', async () => {
|
||||
class CallbackError extends Error {
|
||||
override readonly name = 'CallbackError';
|
||||
}
|
||||
const cbError = new CallbackError('boom');
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
|
||||
authProvider: {
|
||||
token: async () => undefined,
|
||||
onUnauthorized: async () => {
|
||||
throw cbError;
|
||||
}
|
||||
},
|
||||
fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
|
||||
});
|
||||
await transport.start();
|
||||
const rejection = await transport
|
||||
.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
|
||||
.then(() => {
|
||||
throw new Error('send unexpectedly resolved');
|
||||
})
|
||||
.catch((e: unknown) => e);
|
||||
// Identity preserved: the stamp is a non-enumerable marker, not a wrap.
|
||||
expect(rejection).toBe(cbError);
|
||||
expect(rejection).toBeInstanceOf(CallbackError);
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
test('control: an error thrown outside every auth seam (the probe POST itself failing) stays a typed negotiation error', async () => {
|
||||
const netError = new Error('socket hang up');
|
||||
const fetchMock = (async (): Promise<Response> => {
|
||||
throw netError;
|
||||
}) as typeof fetch;
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER), { fetch: fetchMock });
|
||||
const out = await runProbe(transport, 'node');
|
||||
expect(out.settled).toBe('rejected');
|
||||
expect(out.error).toBeInstanceOf(SdkError);
|
||||
expect((out.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect(((out.error as SdkError).data as { cause?: unknown }).cause).toBe(netError);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
* probe wire shape (string id, `server/discover` first, never a real request).
|
||||
*/
|
||||
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
|
||||
import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
|
||||
import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Client } from '../../src/client/client';
|
||||
|
|
@ -161,6 +161,56 @@ const CORPUS: CorpusRow[] = [
|
|||
outcome: { kind: 'result', result: { content: [{ type: 'text', text: `supportedVersions: ["${MODERN}"]` }] } },
|
||||
expected: 'legacy'
|
||||
},
|
||||
// --- Auth statuses are never era evidence (#2561): an auth-protected
|
||||
// server is not a legacy server, whatever the body says — typed failure,
|
||||
// never initialize (fallbackAvailable is true in every row here).
|
||||
{
|
||||
name: 'auth: HTTP 401 challenge (WWW-Authenticate rides the header; body carries the OAuth error JSON) → typed auth failure, never legacy',
|
||||
outcome: { kind: 'http-error', status: 401, body: '{"error":"invalid_token","error_description":"Missing bearer token"}' },
|
||||
expected: 'error'
|
||||
},
|
||||
{
|
||||
name: 'auth: bare HTTP 401 (no body) → typed auth failure, never legacy',
|
||||
outcome: { kind: 'http-error', status: 401 },
|
||||
expected: 'error'
|
||||
},
|
||||
{
|
||||
name: 'auth: bare HTTP 403 denial (no body) → typed auth failure, never legacy',
|
||||
outcome: { kind: 'http-error', status: 403 },
|
||||
expected: 'error'
|
||||
},
|
||||
{
|
||||
name: 'auth: a 401 whose body parses as a JSON-RPC error is still an auth failure — the auth layer wrote that body, not server/discover',
|
||||
outcome: { kind: 'http-error', status: 401, body: DEPLOYED_SESSION_REQUIRED_BODY },
|
||||
expected: 'error'
|
||||
},
|
||||
// --- Server failures are never era evidence: the spec keys the HTTP
|
||||
// legacy signal to a 4xx rejection, so a 5xx (mid-deploy proxy, crashed
|
||||
// backend) rejects typed instead of demoting a modern server to legacy.
|
||||
{
|
||||
name: '5xx: HTTP 503 with an HTML error page (mid-deploy modern server) → typed error, never legacy',
|
||||
outcome: { kind: 'http-error', status: 503, body: '<html><body>Service Unavailable</body></html>' },
|
||||
expected: 'error'
|
||||
},
|
||||
{
|
||||
name: '5xx: bare HTTP 500 (no body) → typed error, never legacy',
|
||||
outcome: { kind: 'http-error', status: 500 },
|
||||
expected: 'error'
|
||||
},
|
||||
{
|
||||
name: '5xx: HTTP 502 with a JSON (but not JSON-RPC) gateway body → typed error, never legacy',
|
||||
outcome: { kind: 'http-error', status: 502, body: '{"message":"upstream connect error"}' },
|
||||
expected: 'error'
|
||||
},
|
||||
{
|
||||
name: '5xx: HTTP 500 whose body parses as a JSON-RPC error (-32603 from a crashed handler) is still a server failure — 5xx ranks above the body parse',
|
||||
outcome: {
|
||||
kind: 'http-error',
|
||||
status: 500,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32_603, message: 'Internal error' } })
|
||||
},
|
||||
expected: 'error'
|
||||
},
|
||||
// --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error).
|
||||
{
|
||||
name: 'timeout on stdio → legacy fallback (the stdio backward-compatibility rule)',
|
||||
|
|
@ -209,6 +259,40 @@ describe('T9/T11 merged probe fixture corpus (probe classifier)', () => {
|
|||
});
|
||||
}
|
||||
|
||||
it('the 401/403 typed failures carry the auth codes (never EraNegotiationFailed — auth walls must not enter era-recovery flows) plus status, reason phrase, and response text', () => {
|
||||
for (const [status, code, statusText, body] of [
|
||||
[401, SdkErrorCode.ClientHttpAuthentication, 'Unauthorized', '{"error":"invalid_token"}'],
|
||||
[403, SdkErrorCode.ClientHttpForbidden, 'Forbidden', 'nope']
|
||||
] as const) {
|
||||
const verdict = classifyProbeOutcome({ kind: 'http-error', status, statusText, body }, baseContext);
|
||||
expect(verdict.kind).toBe('error');
|
||||
if (verdict.kind === 'error') {
|
||||
expect(verdict.error).toBeInstanceOf(SdkHttpError);
|
||||
const error = verdict.error as SdkHttpError;
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.status).toBe(status);
|
||||
expect(error.statusText).toBe(statusText);
|
||||
expect(error.data.text).toBe(body);
|
||||
expect(error.message).toContain(String(status));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('the 5xx typed failure is EraNegotiationFailed (a genuine negotiation failure) carrying the status', () => {
|
||||
const verdict = classifyProbeOutcome(
|
||||
{ kind: 'http-error', status: 503, statusText: 'Service Unavailable', body: 'down' },
|
||||
baseContext
|
||||
);
|
||||
expect(verdict.kind).toBe('error');
|
||||
if (verdict.kind === 'error') {
|
||||
expect(verdict.error).toBeInstanceOf(SdkHttpError);
|
||||
const error = verdict.error as SdkHttpError;
|
||||
expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed);
|
||||
expect(error.status).toBe(503);
|
||||
expect(error.message).toContain('503');
|
||||
}
|
||||
});
|
||||
|
||||
it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => {
|
||||
const verdict = classifyProbeOutcome(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,14 +11,18 @@
|
|||
import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core-internal';
|
||||
import {
|
||||
isJSONRPCRequest,
|
||||
OAuthError,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
UnsupportedProtocolVersionError
|
||||
} from '@modelcontextprotocol/core-internal';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { UnauthorizedError } from '../../src/client/auth';
|
||||
import { InsufficientScopeError } from '../../src/client/authErrors';
|
||||
import { markAuthSeamEscape } from '../../src/client/authSeam';
|
||||
import { Client } from '../../src/client/client';
|
||||
import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp';
|
||||
import type { StdioServerParameters } from '../../src/client/stdio';
|
||||
|
|
@ -1103,6 +1107,53 @@ describe('probe send-error classification', () => {
|
|||
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
|
||||
});
|
||||
|
||||
// Any error stamped at a transport auth seam propagates unchanged —
|
||||
// identity preserved (same object, instanceof and diagnostics intact),
|
||||
// never a legacy fallback, never a rewrap. Provenance comes from the
|
||||
// stamp, not the error type: the rows deliberately span typed SDK
|
||||
// errors, foreign classes, and a bare TypeError.
|
||||
const seamEscapes: Array<[string, () => Error]> = [
|
||||
[
|
||||
"the transport's post-re-auth 401 (SdkHttpError ClientHttpAuthentication)",
|
||||
() =>
|
||||
new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized'
|
||||
})
|
||||
],
|
||||
[
|
||||
'an InsufficientScopeError from a step-up challenge with no provider',
|
||||
() => new InsufficientScopeError({ requiredScope: 'mcp:tools' })
|
||||
],
|
||||
['an OAuthError (invalid_grant on a revoked refresh token)', () => new OAuthError('invalid_grant', 'Refresh token was revoked')],
|
||||
['a bare TypeError from a DCR sub-fetch inside the auth flow', () => new TypeError('Failed to fetch')],
|
||||
[
|
||||
'a custom error class thrown by a user onUnauthorized callback',
|
||||
() => {
|
||||
class UserDbError extends Error {
|
||||
override readonly name = 'UserDbError';
|
||||
}
|
||||
return new UserDbError('user db exploded');
|
||||
}
|
||||
]
|
||||
];
|
||||
for (const [label, make] of seamEscapes) {
|
||||
test(`stamped seam escape propagates unchanged: ${label}`, async () => {
|
||||
const reason = markAuthSeamEscape(make());
|
||||
const transport = new AuthGatedTransport(reason);
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
||||
const rejection = await client.connect(transport).then(
|
||||
() => {
|
||||
throw new Error('connect unexpectedly resolved');
|
||||
},
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(rejection).toBe(reason);
|
||||
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test('a plain send failure stays a typed negotiation error — no fallback runs', async () => {
|
||||
const transport = new AuthGatedTransport(new Error('connection refused'));
|
||||
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
|
|
|
|||
|
|
@ -65,16 +65,34 @@ export enum SdkErrorCode {
|
|||
/**
|
||||
* Protocol-era negotiation at connect time failed without producing either a
|
||||
* usable modern (2026-07-28+) era or a definitive legacy fallback signal —
|
||||
* e.g. the negotiation mode forbids falling back (`pin`), or the probe hit a
|
||||
* network failure (a typed connect error, never an era verdict).
|
||||
* e.g. the negotiation mode forbids falling back (`pin`), the probe hit a
|
||||
* network failure, or the server answered the probe with a 5xx (a typed
|
||||
* connect error, never an era verdict).
|
||||
*
|
||||
* Negotiation-phase only: this code is never used once an era is established.
|
||||
* Negotiation-phase only: this code is never used once an era is
|
||||
* established. Auth walls never carry it: a 401/403 rejecting the probe
|
||||
* uses {@linkcode ClientHttpAuthentication} / {@linkcode ClientHttpForbidden}
|
||||
* instead, so era-recovery flows keyed on this code (e.g. cached-verdict
|
||||
* gateways) can never persist a verdict for an unauthorized exchange.
|
||||
*/
|
||||
EraNegotiationFailed = 'ERA_NEGOTIATION_FAILED',
|
||||
|
||||
// Transport errors
|
||||
ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED',
|
||||
/**
|
||||
* HTTP 401 authentication failure: the transport's re-auth retry still got
|
||||
* 401 (`Server returned 401 after re-authentication`), or the version
|
||||
* negotiation probe was rejected 401 with no `authProvider` configured
|
||||
* (`Version negotiation failed: the server requires authorization (HTTP 401)`).
|
||||
* Carried on an {@linkcode SdkHttpError} with `status: 401`.
|
||||
*/
|
||||
ClientHttpAuthentication = 'CLIENT_HTTP_AUTHENTICATION',
|
||||
/**
|
||||
* HTTP 403 denial: the step-up re-authorization retry limit was reached,
|
||||
* or the version negotiation probe was rejected 403
|
||||
* (`Version negotiation failed: the server denied access (HTTP 403)`).
|
||||
* Carried on an {@linkcode SdkHttpError} with `status: 403`.
|
||||
*/
|
||||
ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN',
|
||||
ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT',
|
||||
ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM',
|
||||
|
|
|
|||
Loading…
Reference in a new issue