inspector/core/auth/utils.ts
cliffhall c299b6e9d8 feat(web): migrate OAuth store to shared /store API (RemoteOAuthStorage parity) (closes #1548)
Migrate the web client's OAuth storage from BrowserOAuthStorage
(sessionStorage) to RemoteOAuthStorage, which POSTs through the backend's
/api/storage/oauth route to ~/.mcp-inspector/storage/oauth.json (mode 0600).
A credential obtained in the browser is now the same blob the TUI/CLI read
on the same host, giving web ⇄ TUI ⇄ CLI parity (Wave 1 of #1579).

Core (shared with TUI/CLI):
- OAuthStorageBase now drives a single async hydration (`ready()`,
  `getHydrationError()`); every post-redirect read (getClientInformation,
  getTokens, getCodeVerifier, getServerMetadata, getIdpSession) and every
  save awaits it so a late hydration merge cannot clobber a fresh write.
  getCodeVerifier/getServerMetadata become async; the store is created with
  skipHydration:true so there is no auto-hydration to race the explicit one.
- store.ts adds normalizeServerUrl so a token saved under
  https://Example.com/mcp is found when the CLI asks for
  https://example.com/mcp/; getServerState falls back to the raw key.
- remote-storage adapter POSTs with keepalive so the write survives the
  OAuth authorize redirect, surfaces swallowed persist failures, and gives
  richer read/write error messages.
- generateOAuthState throws instead of silently degrading to Math.random.
- NodeOAuthStorage honors MCP_INSPECTOR_OAUTH_STATE_PATH for isolated
  fixtures. Provider codeVerifier()/getServerMetadata() + the EMA idpOidc and
  connection-state readers await the now-async storage.

Web:
- environmentFactory + a new shared lib/remoteOAuthStorage accessor
  (memoized per {baseUrl, authToken}) wire RemoteOAuthStorage into the
  connection path, the EMA IdP hook, and the per-server "clear OAuth" action
  so all three share one in-memory view of oauth.json.

Tests: async-hydration + normalizeServerUrl suite, keepalive/error-path
coverage, and the sync→async migration across existing storage/provider/EMA
tests. Web validate + coverage gate + integration suite green; CLI/TUI/
launcher validate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:39:17 -04:00

116 lines
3.9 KiB
TypeScript

import type { CallbackParams } from "./types.js";
/**
* Parse a string as an absolute URL. On failure, throws with `label` and the
* offending value so callers (and UI toasts) can show what to fix.
*/
export function parseHttpUrl(value: string, label: string): URL {
const trimmed = value.trim();
try {
return new URL(trimmed);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Invalid ${label}: "${trimmed}" (${detail})`);
}
}
/**
* Parses OAuth 2.1 callback parameters from a URL search string
* @param location The URL search string (e.g., "?code=abc123" or "?error=access_denied")
* @returns Parsed callback parameters with success/error information
*/
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
const params = new URLSearchParams(location);
const code = params.get("code");
if (code) {
return { successful: true, code };
}
const error = params.get("error");
const error_description = params.get("error_description");
const error_uri = params.get("error_uri");
if (error) {
return { successful: false, error, error_description, error_uri };
}
return {
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
};
};
/**
* Generate a random state for the OAuth 2.0 flow.
* Works in both browser and Node.js environments.
*
* @returns A random state for the OAuth 2.0 flow.
*/
export const generateOAuthState = (): string => {
// OAuth state is a CSRF token — it MUST be unpredictable. crypto.getRandomValues
// is available in every supported runtime (browsers, Node ≥15); if it's somehow
// missing, fail loudly rather than silently degrading to Math.random (whose
// output is predictable from a small amount of observed state).
if (typeof crypto === "undefined" || !crypto.getRandomValues) {
throw new Error(
"crypto.getRandomValues is not available; refusing to generate an OAuth state with a non-cryptographic RNG.",
);
}
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
};
/**
* Parse OAuth `state` to extract the auth session id (CSRF token).
* Must be the 64-char hex value from {@link generateOAuthState}.
*/
export const parseOAuthState = (state: string): { authId: string } | null => {
if (!state || typeof state !== "string") return null;
if (/^[a-f0-9]{64}$/i.test(state)) {
return { authId: state };
}
return null;
};
/**
* Generates a human-readable error description from OAuth callback error parameters
* @param params OAuth error callback parameters containing error details
* @returns Formatted multiline error message with error code, description, and optional URI
*/
export const generateOAuthErrorDescription = (
params: Extract<CallbackParams, { successful: false }>,
): string => {
const error = params.error;
const errorDescription = params.error_description;
const errorUri = params.error_uri;
return [
`Error: ${error}.`,
errorDescription ? `Details: ${errorDescription}.` : "",
errorUri ? `More info: ${errorUri}.` : "",
]
.filter(Boolean)
.join("\n");
};
/**
* True when a thrown connect error represents an upstream 401. The remote
* transport preserves the status on the error object; as a fallback, match
* transport wording `"failed …(401)"` so unrelated `(401)` in messages does
* not trigger OAuth.
*/
export function isUnauthorizedError(err: unknown): boolean {
if (typeof err === "object" && err !== null) {
const status = (err as { status?: number; code?: number }).status;
const code = (err as { code?: number }).code;
if (status === 401 || code === 401) return true;
}
const message = err instanceof Error ? err.message : String(err);
return /\bfailed\b[^\n]*\(401\)/i.test(message);
}