Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 102 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,34 @@ function createInvalidOAuthRecoveryResponse(
);
}

/**
* Seconds advertised to the client after a failed credential check. Short
* enough that a working session recovers on the next attempt, long enough that
* a client retrying immediately does not amplify an upstream outage.
*/
const CREDENTIAL_VALIDATION_RETRY_AFTER_SECONDS = 5;

/**
* A failed credential check is a temporary server-side condition, not a verdict
* on the client's credential, so this stays a 503 (RFC 9110 15.6.4) and carries
* `Retry-After` to name a concrete wait. Two things are deliberately absent:
* `WWW-Authenticate`, which would push a still-valid session into a needless
* reauthorization, and an OAuth `error` code, which RFC 6749 reserves for 400
* and 401 responses and which clients surface as an authentication verdict. The
* body is the sentence alone; the reason for the failure goes to the server log.
*/
function createCredentialValidationUnavailableResponse(
error: CredentialValidationUnavailableError
): Response {
return new Response(error.message, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Retry-After': String(CREDENTIAL_VALIDATION_RETRY_AFTER_SECONDS),
},
status: 503,
});
}

function getOAuthIntrospectionEndpoint(): string {
return `${getOAuthIssuer()}/api/oauth/introspect`;
}
Expand Down Expand Up @@ -394,10 +422,16 @@ async function introspectToken(
expectedResource: string
): Promise<OAuthIntrospectionResponse> {
const introspectionSecret = getOAuthIntrospectionSecret();
if (!introspectionSecret) throw new CredentialValidationUnavailableError();
if (!introspectionSecret) {
throw new CredentialValidationUnavailableError({
reason: 'introspect_secret_missing',
});
}

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
const startedAt = Date.now();
const elapsedMs = () => Date.now() - startedAt;
let response: Response;
try {
response = await fetch(getOAuthIntrospectionEndpoint(), {
Expand All @@ -414,26 +448,52 @@ async function introspectToken(
signal: controller.signal,
});
} catch {
throw new CredentialValidationUnavailableError();
// Separating the budget abort from a genuine transport fault is the whole
// point of recording `aborted`: they need different operational responses.
throw new CredentialValidationUnavailableError({
aborted: controller.signal.aborted,
elapsedMs: elapsedMs(),
reason: 'introspect_transport_error',
});
} finally {
clearTimeout(timeout);
}
if (!response.ok) throw new CredentialValidationUnavailableError();
if (!response.ok) {
throw new CredentialValidationUnavailableError({
elapsedMs: elapsedMs(),
reason: 'introspect_http_status',
status: response.status,
});
}
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
if (!contentType.includes('application/json')) {
throw new CredentialValidationUnavailableError();
throw new CredentialValidationUnavailableError({
elapsedMs: elapsedMs(),
reason: 'introspect_content_type',
status: response.status,
});
}
const data = (await response.json()) as OAuthIntrospectionResponse;
if (typeof data.active !== 'boolean') {
throw new CredentialValidationUnavailableError();
throw new CredentialValidationUnavailableError({
elapsedMs: elapsedMs(),
reason: 'introspect_malformed_body',
status: response.status,
});
}
if (
data.active &&
(!data.api_key ||
!isOAuthCredentialPurpose(data.credential_purpose) ||
!values(data.scope).includes(MCP_GLOBAL_SCOPE))
) {
throw new CredentialValidationUnavailableError();
// Introspection answered cleanly; the credential it described cannot be
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// used here. Tagged apart from an outage so the two are never conflated.
throw new CredentialValidationUnavailableError({
elapsedMs: elapsedMs(),
reason: 'introspect_unusable_credential',
status: response.status,
});
}
return data;
}
Expand Down Expand Up @@ -682,6 +742,32 @@ function emitLegacyKeyPathTelemetry(
);
}

/**
* Records which credential check failed, for operators only. The client body is
* one fixed sentence for every one of these, so without this line an upstream
* introspection outage, a missing secret, and a credential that introspected
* cleanly but cannot be used here are indistinguishable after the fact.
*
* Intentionally low cardinality. Never add the token, the resolved API key, the
* upstream response body, request URLs, user agents, or hashes of any of them.
*/
function emitCredentialValidationFailure(
profile: ServerProfile,
error: CredentialValidationUnavailableError
): void {
const { aborted, elapsedMs, reason, status } = error.diagnostics;
console.error(
'[MCP_CREDENTIAL_VALIDATION]',
JSON.stringify({
aborted: aborted ?? null,
elapsed_ms: elapsedMs ?? null,
introspect_status: status ?? null,
profile: profile.id,
reason,
})
);
}

/**
* Builds the `authenticate` hook for one profile. FastMCP runs it on every
* request (including `tools/list`), so a rejection here yields a 401 with the
Expand Down Expand Up @@ -727,16 +813,8 @@ function makeAuthenticate(profile: ServerProfile) {
throw oauthChallenge ?? createInvalidOAuthRecoveryResponse(recovery);
}
if (error instanceof CredentialValidationUnavailableError) {
throw new Response(
JSON.stringify({
error: 'temporarily_unavailable',
error_description: error.message,
}),
{
headers: { 'Content-Type': 'application/json' },
status: 503,
}
);
emitCredentialValidationFailure(profile, error);
throw createCredentialValidationUnavailableResponse(error);
}
const shouldChallenge = requestShouldReceiveOAuthChallenge(request, profile);
const oauthChallenge = shouldChallenge
Expand Down Expand Up @@ -1386,11 +1464,17 @@ function getClient(session?: SessionData): FirecrawlApp {
const client = createClient('request-scoped-hosted-oauth');
const axiosInstance = (client as any).http?.instance;
if (!axiosInstance?.interceptors?.request?.use) {
throw new CredentialValidationUnavailableError();
throw new CredentialValidationUnavailableError({
reason: 'outbound_client_uninstrumented',
});
}
axiosInstance.interceptors.request.use((config: any) => {
const credential = credentialForOutboundRequest(session);
if (!credential) throw new CredentialValidationUnavailableError();
if (!credential) {
throw new CredentialValidationUnavailableError({
reason: 'delegated_credential_unavailable',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
});
}
config.headers = {
...(config.headers ?? {}),
Authorization: `Bearer ${credential}`,
Expand Down
42 changes: 40 additions & 2 deletions src/session-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,44 @@ export interface CredentialSession {
[managedOAuthApiKey]?: string;
}

/**
* Names the validation step that failed. Deliberately low cardinality and
* server-side only: these tags are for operators triaging a credential
* validation outage, and they never carry credential material.
*/
export type CredentialValidationReason =
| 'introspect_secret_missing'
| 'introspect_transport_error'
| 'introspect_http_status'
| 'introspect_content_type'
| 'introspect_malformed_body'
| 'introspect_unusable_credential'
| 'delegated_signing_secret_missing'
| 'delegated_credential_unavailable'
| 'outbound_client_uninstrumented';

export type CredentialValidationDiagnostics = {
reason: CredentialValidationReason;
/** Introspection response status, when a response was actually received. */
status?: number;
/** Wall time spent on the introspection attempt, in milliseconds. */
elapsedMs?: number;
/** True when the introspection request was cut short by its own budget. */
aborted?: boolean;
};

/**
* Every failed credential check funnels through this one error, so the client
* sees a single stable sentence. The diagnostics ride along for the server log
* and are never rendered into the response.
*/
export class CredentialValidationUnavailableError extends Error {
constructor() {
readonly diagnostics: CredentialValidationDiagnostics;

constructor(diagnostics: CredentialValidationDiagnostics) {
super('Firecrawl credential validation is temporarily unavailable');
this.name = 'CredentialValidationUnavailableError';
this.diagnostics = diagnostics;
}
}

Expand All @@ -31,7 +65,11 @@ type McpDelegatedCredentialPayload = {

function delegationSecret(): string {
const secret = process.env.MCP_DELEGATED_CREDENTIAL_SECRET?.trim();
if (!secret) throw new CredentialValidationUnavailableError();
if (!secret) {
throw new CredentialValidationUnavailableError({
reason: 'delegated_signing_secret_missing',
});
}
return secret;
}

Expand Down
Loading
Loading