* feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378) Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…` query string) with empty sessionStorage made every `/api/*` request 401 — the browser had no way to recover the backend's auth token. Embed the token into `index.html` on every page load so the browser no longer depends on the query string surviving navigation: - New shared helper `clients/web/server/inject-auth-token.ts` embeds `<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>` (escaped against `</script>` injection; no-op when auth is dangerously omitted). - Dev: the Vite plugin injects via `transformIndexHtml`. - Prod: the Hono server injects on the `/` route. - `App.tsx` `getAuthToken()` now reads the injected global first, then the query string, then sessionStorage (both fallbacks preserved). - Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL` (`core/mcp/remote/constants.ts`). Tests: helper unit coverage + an integration test exercising the real prod server's `/` → `/api/*` flow (injected token authenticates; missing token 401s). AGENTS.md documents the token-recovery order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): wire OAuth authorization-code flow into App.tsx (#1379) OAuth-protected MCP servers could not be connected to from the v2 web client: the core OAuth pipeline exists, but App.tsx never invoked it, so a connect attempt 401'd and surfaced "Remote send failed (401): … Missing Authorization header" as a toast. Wire the two missing entry points (all core primitives already in place): - Auto-trigger on 401: in onToggleConnection's catch, detect an upstream 401 (isUnauthorizedError) and call client.authenticate(), which runs discovery + DCR (backend-proxied) and redirects the page to the auth server via BrowserNavigation. The initiating server id is persisted to sessionStorage first, since the OAuth `state` carries only mode+authId and the full-page redirect wipes React state. - /oauth/callback handler: a mount effect that, once `servers` hydrate, parses the callback params, recovers the pending server, rebuilds its InspectorClient, runs completeOAuthFlow(code) (PKCE verifier + DCR client info survive in BrowserOAuthStorage), replaceState("/") so a reload can't replay the single-use code, then connect(). An `error=` callback toasts instead of retrying. connect() already attaches the OAuth provider to the transport (inspectorClient.ts), so once tokens land in BrowserOAuthStorage the outbound request carries the bearer token. Extracted the pure pieces (constants + isUnauthorizedError) to src/utils/oauthFlow.ts with unit tests. Verified end-to-end in a real browser against the MCP SDK demo OAuth server: Connect -> redirect -> auto-approve -> callback -> Connected, with the access token shown in the Connection Info modal (#1377). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384) After #1379, the Network tab showed only the post-redirect auth HTTP (discovery re-run + POST /token); the pre-redirect discovery and the DCR POST /register that run during authenticate() were lost when the page navigated to the auth server. Root causes: 1. Ordering — BrowserNavigation set `window.location.href` before the client's `saveSession` event fired (OAuthManager calls onBeforeOAuthRedirect *after* auth() already navigated), so the save raced the unload and was dropped. Fix: BrowserNavigation now runs a synchronous `beforeNavigate` hook immediately before assigning location.href; App wires it through createWebEnvironment to flush the active fetch log to RemoteInspectorClient Storage (keyed by the authId parsed from the auth URL) via a keepalive POST that outlives the unload. 2. Illegal invocation — RemoteInspectorClientStorage defaulted to `this.fetchFn = globalThis.fetch` and called `this.fetchFn(...)`, which re-binds `this` and makes native fetch throw "Illegal invocation" (swallowed by the catch). This silently broke *all* session save/load. Fix: default to a wrapper that preserves the global receiver. 3. Restore race — hydrateFetchRequests replaced the list, so a load that resolved after the resuming connect appended live entries would clobber them. Fix: merge restored (older) entries ahead of live ones, dedupe by id. saveSession also now uses keepalive: true. Verified end-to-end against the MCP SDK demo OAuth server: the connected page's Network tab shows the full handshake — pre-redirect discovery + DCR /register plus post-redirect discovery + /token as `auth`, alongside `transport`. Added unit tests for the beforeNavigate ordering and the hydrate merge/dedupe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): show auth response bodies with masked secrets + reveal toggle (#1386) Auth-category Network entries showed request body/headers/status but never the response body (rendered "(empty)"), because buildEffectiveAuthFetch deliberately skipped capturing it to avoid surfacing access_token / refresh_token. That hid the most useful thing to inspect when debugging OAuth — the token exchange. Capture auth response bodies, but mask sensitive OAuth fields by default behind a click-to-reveal toggle so they aren't exposed at a glance during a screen-share: - inspectorClient: wire updateResponseBody on the auth fetcher. - src/utils/maskSecrets.ts: maskSecretsInBody() masks access_token, refresh_token, id_token, client_secret (case-insensitive, nested) in JSON bodies; reports whether anything was masked. Non-JSON / secret-free bodies pass through untouched. - NetworkEntry BodyPreview: when a body has masked fields, render it masked with a Reveal/Hide toggle (copy honors the shown view). Masking is a UI concern; the raw entry is unchanged so reveal shows the real value. access_token / refresh_token live in the post-redirect /token response, which is never written to the session-restore files (#1384); only pre-redirect bodies (public discovery, DCR /register) persist, so no bearer token hits disk. Verified end-to-end: the /token response shows masked by default (access_token: "••••••••"), Reveal exposes the raw value, and discovery / the public DCR /register response (no secret) render with no toggle. Added util + component unit tests and a story play function. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378) Addresses code-review feedback on the token-injection PR: - Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously served the raw index.html off disk via serveStatic, bypassing injection — so a bookmark/reload at a non-`/` route with empty sessionStorage would 401. Route the SPA fallback through the same `serveIndexHtml` (inject) helper; real static assets (paths with a dot) still serve verbatim. Dev already injected on every HTML serve via Vite `transformIndexHtml`. - `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__` to sessionStorage (not just the URL-param branch), priming the backstop for any later navigation that loses the global. - Injected HTML responses now send `Cache-Control: no-store`, so a page carrying a token isn't cached and served stale after a restart regenerates the token. Integration tests added: SPA fallback (`/oauth/callback`) carries the token, `Cache-Control: no-store` on injected HTML, real assets served verbatim, and unknown `/api` routes 404 rather than falling through to the HTML shell. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): address #1383 review — split OAuth/connect toasts, tighten 401 match (#1379) Code-review feedback on the OAuth-wiring PR: - Callback effect: split completeOAuthFlow vs connect() into separate try/catch blocks. A token-exchange failure now reads "OAuth token exchange failed … Please try connecting again." (the single-use code is spent and the URL was cleared, so a reload can't retry); a post-OAuth connect failure reads "Failed to connect" since OAuth actually succeeded and re-clicking Connect reuses the persisted tokens. - isUnauthorizedError: anchor the message fallback on the transport's `failed …(401)` wording instead of a bare `(401)`, so an unrelated `(401)` spliced into an error message can't trip the OAuth flow. Added a test. - Documented that clearing the pending id + URL before the server lookup is intentional (deleted/renamed server mid-flow → require a fresh Connect). Also merges the squash-merged #1382 base from v2/main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(auth): address #1385 review — clarify double-save + keepalive cap + test fetch default (#1384) Code-review feedback on the OAuth Network-log persistence PR: - Documented the double-save: `FetchRequestLogState`'s `saveSession` listener is the backstop; `BrowserNavigation`'s `beforeNavigate` hook is the primary flush for the redirect case. Notes the listener may lose the navigation race and is harmless when it duplicates (last-writer-wins, identical payload). - Reworded the keepalive comment in `RemoteInspectorClientStorage.saveSession`: the 64KB cap is general (the method is also reachable from the listener with the full session log), so a long session could exceed it and drop silently — acceptable since the persisted log is best-effort, not load-bearing. - Added a regression test that constructs `RemoteInspectorClientStorage` without a `fetchFn`, stubs `globalThis.fetch`, and asserts the default wrapper calls it (locks in the "Illegal invocation" fix, which callers otherwise swallow). Optional items (logger.warn on swallowed save errors; setupClientForServer dep churn) acknowledged on the PR, not changed. Also merges the squash-merged #1383 base from v2/main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(network): address #1387 review — form-encoded masking, cleaner flag, a11y (#1386) Code-review feedback on the auth-response-body masking PR: - Extended masking to form-urlencoded bodies (finding #1): the token *request* is `application/x-www-form-urlencoded` and carries `code` / `code_verifier` / `client_secret` / `refresh_token`. `maskSecretsInBody` now masks those in form bodies too (preserving formatting; placeholder not percent-encoded). `code`/`code_verifier`/`client_assertion` are form-only sensitive keys — deliberately NOT masked in JSON, where `code` is usually an error/status code. - Replaced the double-stringify `hasSecrets` heuristic with an explicit masked-flag propagated out of `maskNode` (finding #2) — robust if the transform ever grows non-identity behavior, and one fewer serialization. - Reset reveal state on body change via `key={body}` remount instead of a setState-in-effect (finding #3; avoids the cascading-render lint rule). - a11y (finding #4): `aria-label` on the Reveal/Hide button and `aria-live` on the hidden/revealed status text. - Security tripwire comment near the `saveSession` listener (finding #6): captured auth bodies are unmasked at source (masking is UI-only), so any new post-token-exchange persistence path must redact first. Tests: form-encoded masking (token + refresh requests), JSON `code` NOT masked, non-object JSON pass-through, default-fetch wrapper. Story play + NetworkEntry tests updated for the new aria-labels and the now-masked form request body. Finding #5 (pretty-print asymmetry) is already handled by ContentViewer's JSON formatting; #4-aria and #6 are the doc/a11y bits. Also merges the squash-merged #1385 base from v2/main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(network): address #1387 third-pass review — content-type masking, wholesale mask, dedup (#1386) Third-pass code-review feedback on the auth-body masking PR: - maskSecretsInBody now takes the body's content-type (finding #1): `*json*` → JSON masking, form-urlencoded → form masking, any other known type (HTML/plaintext/XML) → no masking; absent/unknown → sniff as before. Removes the implicit "non-JSON ⇒ form" guess for error pages etc. NetworkEntry passes the request/response `content-type` header through to BodyPreview. - Mask any non-empty value under a sensitive key wholesale (finding #2): a non-standard object/array value under e.g. `access_token` is replaced rather than recursed into, so it can't leak. Empty-string still not flagged. - Extracted `isSensitiveKey(set, key)` to dedupe the JSON/form key checks (finding #3). - Reworded `MaskResult.masked` doc to cover the form (non-pretty-printed) case (finding #6). Tests: non-string-value-under-sensitive-key wholesale mask; repeated form param; empty form value not flagged; content-type honored (JSON type skips form masking, text/plain skips masking, explicit form type masks). Finding #5 (story re-hide) skipped per reviewer — unit test already covers re-mask. Finding #4 (form edge cases) added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(network): address #1387 fourth-pass nits — content-type contract, key, form test (#1386) Fourth-pass review (LGTM) — minor, non-blocking items: - Documented the content-type matching contract on `maskSecretsInBody`: substring match (`*json*` / `*x-www-form-urlencoded*`), and we trust the wire's own label (a mislabeled body renders raw — acceptable for the screen-share threat model) (finding #1). - Key `<BodyPreview>` by content-type + body (not body alone) so a header-only change would also reset the reveal state (finding #3; not reachable today). - Storybook `AuthSuccess`: assert revealed count `>=` reveal-button count to mirror the `hidden >= 2` check, so adding a non-masked body later can't drift the assertion silently (finding #4). - Added a `NetworkEntry` unit test for form-encoded request-body masking (code/code_verifier) — previously exercised only via the Storybook play. Finding #2 (silent decodeURIComponent fallback on malformed keys) acknowledged as a known quirk outside the threat model; no change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(network): address #1387 fifth-pass — memoize masking, mask DCR mgmt token (#1386) Fifth-pass review (LGTM): - Memoized `maskSecretsInBody` in `BodyPreview` (recommended item #1): a Reveal/Hide click no longer re-parses + re-walks the body; cost is once per mount, and the `key` remount re-runs it on body/content-type change. Guarded so a too-large body is never parsed (the hook runs unconditionally, with the size check inside the memo). - Added `registration_access_token` (RFC 7592 DCR management credential, same bearer class) to the masked key set, and a confidential-client `/register` response fixture test (#5) asserting client_secret + registration_access_token masked, client_id/metadata visible. - Tightened the `isMaskableValue` doc to state the exact contract: non-null, non-empty-string values are masked wholesale (#3). Items #2 (transport parse cost — capped by the memo) and #4 (test-only MASK_PLACEHOLDER export) left as-is per the review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(network): clarify body-update emits list event only (#1387 sixth pass) (#1386) Sixth-pass review (LGTM, all nits). Added a comment on `onFetchRequestBodyUpdate` noting it re-emits only `fetchRequestsChange`, not the per-entry `fetchRequest` event — list-reading consumers pick up the body on the next render; a future per-entry subscriber would need its own event. Other items left as-is per the review: full-string `BodyPreview` key (#1, micro-perf only if profiled), NaN under a sensitive key (#3, consistent with the mask-non-null-non-empty contract), `&`-only form separator (#4, OAuth never emits `;`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
98192fd86e
commit
5c5e5ab754
7 changed files with 592 additions and 13 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js";
|
||||
import { NetworkEntry } from "./NetworkEntry";
|
||||
|
||||
|
|
@ -38,7 +39,13 @@ const authEntry: FetchRequestEntry = {
|
|||
responseStatus: 200,
|
||||
responseStatusText: "OK",
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: '{"access_token":"x","token_type":"bearer"}',
|
||||
responseBody: JSON.stringify({
|
||||
access_token: "eyJhbGciOiJSUzI1NiwidHlwIjoiSldUIn0.payload.sig",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "rt_9f8e7d6c5b4a",
|
||||
scope: "mcp:tools",
|
||||
}),
|
||||
duration: 120,
|
||||
category: "auth",
|
||||
};
|
||||
|
|
@ -99,6 +106,30 @@ export const TransportSuccessExpanded: Story = {
|
|||
|
||||
export const AuthSuccess: Story = {
|
||||
args: { entry: authEntry, isListExpanded: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Both bodies carry secrets: the form request (`code=…`) and the JSON
|
||||
// response (`access_token`). Both are masked by default — the raw values
|
||||
// must not be visible until explicitly revealed.
|
||||
const hidden = canvas.getAllByText("Secrets hidden");
|
||||
await expect(hidden.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(canvasElement.textContent).not.toContain("eyJhbGciOiJSUzI1");
|
||||
await expect(canvasElement.textContent).toContain("••••••••");
|
||||
// Non-secret fields stay visible.
|
||||
await expect(canvasElement.textContent).toContain("Bearer");
|
||||
|
||||
// Reveal every masked body and confirm the raw response token appears.
|
||||
const revealButtons = canvas.getAllByRole("button", {
|
||||
name: "Reveal secrets in body",
|
||||
});
|
||||
for (const button of revealButtons) {
|
||||
await userEvent.click(button);
|
||||
}
|
||||
await expect(
|
||||
canvas.getAllByText("Secrets revealed").length,
|
||||
).toBeGreaterThanOrEqual(revealButtons.length);
|
||||
await expect(canvasElement.textContent).toContain("eyJhbGciOiJSUzI1");
|
||||
},
|
||||
};
|
||||
|
||||
export const HttpError: Story = {
|
||||
|
|
|
|||
|
|
@ -196,4 +196,79 @@ describe("NetworkEntry", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.getByText(/Body too large to preview/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("masks token-response secrets until revealed, then shows the raw value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const authEntry: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
category: "auth",
|
||||
url: "http://localhost:3001/token",
|
||||
requestBody: undefined,
|
||||
responseBody: JSON.stringify({
|
||||
access_token: "super-secret-token",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
};
|
||||
const { container } = renderWithMantine(
|
||||
<NetworkEntry entry={authEntry} isListExpanded={true} />,
|
||||
);
|
||||
// Masked by default: the reveal affordance is present and the raw secret
|
||||
// is nowhere in the DOM, but non-secret fields still render.
|
||||
expect(screen.getByText("Secrets hidden")).toBeInTheDocument();
|
||||
expect(container.textContent).not.toContain("super-secret-token");
|
||||
expect(container.textContent).toContain("••••••••");
|
||||
expect(container.textContent).toContain("Bearer");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Reveal secrets in body" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Secrets revealed")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("super-secret-token");
|
||||
|
||||
// Toggling back re-masks.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Hide secrets in body" }),
|
||||
);
|
||||
expect(container.textContent).not.toContain("super-secret-token");
|
||||
});
|
||||
|
||||
it("masks a form-encoded request body (code/verifier) until revealed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const authEntry: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
category: "auth",
|
||||
url: "http://localhost:3001/token",
|
||||
requestHeaders: { "content-type": "application/x-www-form-urlencoded" },
|
||||
requestBody:
|
||||
"grant_type=authorization_code&code=SECRETCODE&code_verifier=SECRETVERIFIER",
|
||||
responseStatus: undefined,
|
||||
responseStatusText: undefined,
|
||||
responseHeaders: undefined,
|
||||
responseBody: undefined,
|
||||
};
|
||||
const { container } = renderWithMantine(
|
||||
<NetworkEntry entry={authEntry} isListExpanded={true} />,
|
||||
);
|
||||
expect(screen.getByText("Secrets hidden")).toBeInTheDocument();
|
||||
expect(container.textContent).not.toContain("SECRETCODE");
|
||||
expect(container.textContent).not.toContain("SECRETVERIFIER");
|
||||
expect(container.textContent).toContain("••••••••");
|
||||
// Non-secret param stays visible.
|
||||
expect(container.textContent).toContain("grant_type=authorization_code");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Reveal secrets in body" }),
|
||||
);
|
||||
expect(container.textContent).toContain("SECRETCODE");
|
||||
expect(container.textContent).toContain("SECRETVERIFIER");
|
||||
});
|
||||
|
||||
it("does not add a reveal toggle for non-secret bodies", () => {
|
||||
renderWithMantine(<NetworkEntry entry={baseEntry} isListExpanded={true} />);
|
||||
expect(screen.queryByText("Secrets hidden")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Reveal secrets in body" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
|
||||
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { maskSecretsInBody } from "../../../utils/maskSecrets";
|
||||
|
||||
export interface NetworkEntryProps {
|
||||
entry: FetchRequestEntry;
|
||||
|
|
@ -126,8 +127,47 @@ function HeadersTable({ headers }: { headers: Record<string, string> }) {
|
|||
);
|
||||
}
|
||||
|
||||
function BodyPreview({ body }: { body: string }) {
|
||||
const RevealButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "compact-xs",
|
||||
});
|
||||
|
||||
function BodyPreview({
|
||||
body,
|
||||
contentType,
|
||||
}: {
|
||||
body: string;
|
||||
contentType?: string;
|
||||
}) {
|
||||
// Reveal state for masked secrets. Hooks run before any early return so the
|
||||
// order stays stable across the too-large / has-secrets branches. The reveal
|
||||
// state resets when the body or its content-type changes because callers key
|
||||
// `<BodyPreview>` by both (remounting on swap), so a previously-revealed view
|
||||
// never persists across a content (or masking) change.
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const tooLarge = body.length > MAX_INLINE_BODY_CHARS;
|
||||
|
||||
// OAuth responses (token exchange, DCR) and the token request carry
|
||||
// bearer-grade secrets. Mask them by default and gate the raw values behind
|
||||
// an explicit reveal so they aren't exposed at a glance during a
|
||||
// screen-share. The entry's content-type scopes which parser runs (so a
|
||||
// plaintext/HTML error body is never guessed at). Bodies without secrets
|
||||
// render as-is with no toggle.
|
||||
//
|
||||
// Memoized so a Reveal/Hide click (a re-render) doesn't re-parse and re-walk
|
||||
// the body; the cost is paid once per mount, and the `key={…}` remount on
|
||||
// body/content-type change re-runs it. Skipped for too-large bodies so we
|
||||
// never parse something we won't display (the hook must run unconditionally,
|
||||
// hence the in-memo guard rather than an early return above it).
|
||||
const { masked, hasSecrets } = useMemo(
|
||||
() =>
|
||||
tooLarge
|
||||
? { masked: body, hasSecrets: false }
|
||||
: maskSecretsInBody(body, contentType),
|
||||
[tooLarge, body, contentType],
|
||||
);
|
||||
|
||||
if (tooLarge) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
|
|
@ -135,7 +175,30 @@ function BodyPreview({ body }: { body: string }) {
|
|||
</Text>
|
||||
);
|
||||
}
|
||||
return <ContentViewer block={{ type: "text", text: body }} copyable />;
|
||||
|
||||
if (!hasSecrets) {
|
||||
return <ContentViewer block={{ type: "text", text: body }} copyable />;
|
||||
}
|
||||
|
||||
const shown = revealed ? body : masked;
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed" aria-live="polite">
|
||||
{revealed ? "Secrets revealed" : "Secrets hidden"}
|
||||
</Text>
|
||||
<RevealButton
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
aria-label={
|
||||
revealed ? "Hide secrets in body" : "Reveal secrets in body"
|
||||
}
|
||||
>
|
||||
{revealed ? "Hide" : "Reveal"}
|
||||
</RevealButton>
|
||||
</Group>
|
||||
<ContentViewer block={{ type: "text", text: shown }} copyable />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
||||
|
|
@ -192,7 +255,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
|||
<Text size="sm" fw={500}>
|
||||
Request Body
|
||||
</Text>
|
||||
<BodyPreview body={entry.requestBody} />
|
||||
<BodyPreview
|
||||
key={`${entry.requestHeaders["content-type"] ?? ""}|${entry.requestBody}`}
|
||||
body={entry.requestBody}
|
||||
contentType={entry.requestHeaders["content-type"]}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{entry.responseHeaders && (
|
||||
|
|
@ -209,7 +276,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
|||
Response Body
|
||||
</Text>
|
||||
{entry.responseBody ? (
|
||||
<BodyPreview body={entry.responseBody} />
|
||||
<BodyPreview
|
||||
key={`${entry.responseHeaders?.["content-type"] ?? ""}|${entry.responseBody}`}
|
||||
body={entry.responseBody}
|
||||
contentType={entry.responseHeaders?.["content-type"]}
|
||||
/>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{isLongLivedStream(entry)
|
||||
|
|
|
|||
205
clients/web/src/utils/maskSecrets.test.ts
Normal file
205
clients/web/src/utils/maskSecrets.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { maskSecretsInBody, MASK_PLACEHOLDER } from "./maskSecrets";
|
||||
|
||||
describe("maskSecretsInBody", () => {
|
||||
it("masks token fields in a token-exchange response and flags secrets", () => {
|
||||
const body = JSON.stringify({
|
||||
access_token: "abc.def.ghi",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "r3fr3sh",
|
||||
scope: "mcp:tools",
|
||||
});
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(true);
|
||||
const parsed = JSON.parse(masked);
|
||||
expect(parsed.access_token).toBe(MASK_PLACEHOLDER);
|
||||
expect(parsed.refresh_token).toBe(MASK_PLACEHOLDER);
|
||||
// Non-secret fields pass through untouched.
|
||||
expect(parsed.token_type).toBe("Bearer");
|
||||
expect(parsed.expires_in).toBe(3600);
|
||||
expect(parsed.scope).toBe("mcp:tools");
|
||||
// The raw secret never appears in the masked output.
|
||||
expect(masked).not.toContain("abc.def.ghi");
|
||||
expect(masked).not.toContain("r3fr3sh");
|
||||
});
|
||||
|
||||
it("masks id_token and client_secret (case-insensitive keys)", () => {
|
||||
const body = JSON.stringify({
|
||||
ID_Token: "jwt",
|
||||
Client_Secret: "shhh",
|
||||
client_id: "public-123",
|
||||
});
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(true);
|
||||
const parsed = JSON.parse(masked);
|
||||
expect(parsed.ID_Token).toBe(MASK_PLACEHOLDER);
|
||||
expect(parsed.Client_Secret).toBe(MASK_PLACEHOLDER);
|
||||
// client_id is not a secret.
|
||||
expect(parsed.client_id).toBe("public-123");
|
||||
});
|
||||
|
||||
it("masks secrets nested in objects and arrays", () => {
|
||||
const body = JSON.stringify({
|
||||
data: { tokens: [{ access_token: "deep" }] },
|
||||
});
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(true);
|
||||
expect(JSON.parse(masked).data.tokens[0].access_token).toBe(
|
||||
MASK_PLACEHOLDER,
|
||||
);
|
||||
});
|
||||
|
||||
it("masks a non-string value under a sensitive key wholesale (no leak via recursion)", () => {
|
||||
// A non-standard server wrapping the token in an object: the value must be
|
||||
// replaced wholesale rather than recursed into (where the inner `value`
|
||||
// key isn't sensitive and would otherwise leak).
|
||||
const body = JSON.stringify({
|
||||
access_token: { value: "leaky-secret", expires_in: 3600 },
|
||||
});
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(true);
|
||||
expect(masked).not.toContain("leaky-secret");
|
||||
expect(JSON.parse(masked).access_token).toBe(MASK_PLACEHOLDER);
|
||||
});
|
||||
|
||||
it("masks a confidential-client DCR (/register) response", () => {
|
||||
// RFC 7591/7592 happy path: client_secret and the registration management
|
||||
// token are bearer-grade and masked; client_id and metadata stay visible.
|
||||
const body = JSON.stringify({
|
||||
client_id: "dyn-123",
|
||||
client_secret: "REG-SECRET",
|
||||
registration_access_token: "REG-RAT",
|
||||
registration_client_uri: "http://localhost:3001/register/dyn-123",
|
||||
client_id_issued_at: 1735689600,
|
||||
});
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(true);
|
||||
const parsed = JSON.parse(masked);
|
||||
expect(parsed.client_secret).toBe(MASK_PLACEHOLDER);
|
||||
expect(parsed.registration_access_token).toBe(MASK_PLACEHOLDER);
|
||||
expect(masked).not.toContain("REG-SECRET");
|
||||
expect(masked).not.toContain("REG-RAT");
|
||||
// Non-secret fields untouched.
|
||||
expect(parsed.client_id).toBe("dyn-123");
|
||||
expect(parsed.registration_client_uri).toBe(
|
||||
"http://localhost:3001/register/dyn-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports no secrets (and leaves content intact) for discovery metadata", () => {
|
||||
const body = JSON.stringify({
|
||||
issuer: "http://localhost:3001/",
|
||||
authorization_endpoint: "http://localhost:3001/authorize",
|
||||
scopes_supported: ["mcp:tools"],
|
||||
});
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(false);
|
||||
expect(JSON.parse(masked).authorization_endpoint).toBe(
|
||||
"http://localhost:3001/authorize",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not flag an empty-string secret value", () => {
|
||||
const { hasSecrets } = maskSecretsInBody(
|
||||
JSON.stringify({ access_token: "" }),
|
||||
);
|
||||
expect(hasSecrets).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a form body with no sensitive params unchanged", () => {
|
||||
const body = "grant_type=authorization_code&redirect_uri=http://x/cb";
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(false);
|
||||
expect(masked).toBe(body);
|
||||
});
|
||||
|
||||
it("masks sensitive params in a form-encoded token request, preserving other params", () => {
|
||||
const body =
|
||||
"grant_type=authorization_code&code=AUTHCODE&code_verifier=VERIFIER&client_id=public-1&redirect_uri=http://x/cb";
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(true);
|
||||
expect(masked).toContain(`code=${MASK_PLACEHOLDER}`);
|
||||
expect(masked).toContain(`code_verifier=${MASK_PLACEHOLDER}`);
|
||||
expect(masked).not.toContain("AUTHCODE");
|
||||
expect(masked).not.toContain("VERIFIER");
|
||||
// Non-secret params are untouched (and formatting preserved).
|
||||
expect(masked).toContain("grant_type=authorization_code");
|
||||
expect(masked).toContain("client_id=public-1");
|
||||
expect(masked).toContain("redirect_uri=http://x/cb");
|
||||
});
|
||||
|
||||
it("masks refresh_token and client_secret in a form-encoded refresh request", () => {
|
||||
const body =
|
||||
"grant_type=refresh_token&refresh_token=RTVAL&client_secret=CSVAL";
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body);
|
||||
expect(hasSecrets).toBe(true);
|
||||
expect(masked).not.toContain("RTVAL");
|
||||
expect(masked).not.toContain("CSVAL");
|
||||
});
|
||||
|
||||
it("does NOT mask a JSON `code` field (e.g. a JSON-RPC error code)", () => {
|
||||
// `code` is form-only sensitive; in JSON it's usually an error/status code.
|
||||
const { masked, hasSecrets } = maskSecretsInBody(
|
||||
JSON.stringify({ code: "some-string-code", message: "boom" }),
|
||||
);
|
||||
expect(hasSecrets).toBe(false);
|
||||
expect(JSON.parse(masked).code).toBe("some-string-code");
|
||||
});
|
||||
|
||||
it("does not treat pure reformatting as masking", () => {
|
||||
// Minified JSON with no secret keys → reserialized but hasSecrets false.
|
||||
const { hasSecrets } = maskSecretsInBody('{"a":1,"b":[2,3]}');
|
||||
expect(hasSecrets).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through valid-but-non-object JSON (string / number / null) untouched", () => {
|
||||
for (const raw of ['"abc"', "42", "null", "true"]) {
|
||||
const { masked, hasSecrets } = maskSecretsInBody(raw);
|
||||
expect(hasSecrets).toBe(false);
|
||||
expect(JSON.parse(masked)).toEqual(JSON.parse(raw));
|
||||
}
|
||||
});
|
||||
|
||||
it("masks every occurrence of a repeated sensitive form param", () => {
|
||||
const { masked, hasSecrets } = maskSecretsInBody("code=AAA&code=BBB");
|
||||
expect(hasSecrets).toBe(true);
|
||||
expect(masked).toBe(`code=${MASK_PLACEHOLDER}&code=${MASK_PLACEHOLDER}`);
|
||||
});
|
||||
|
||||
it("does not flag an empty form param value", () => {
|
||||
const { masked, hasSecrets } = maskSecretsInBody(
|
||||
"grant_type=refresh_token&client_secret=",
|
||||
);
|
||||
expect(hasSecrets).toBe(false);
|
||||
expect(masked).toBe("grant_type=refresh_token&client_secret=");
|
||||
});
|
||||
|
||||
it("honors an explicit content-type: form params under a JSON type are not form-masked", () => {
|
||||
// `code` is form-only sensitive; with a JSON content-type the body goes
|
||||
// through the JSON parser (fails to parse → untouched), not form masking.
|
||||
const { hasSecrets } = maskSecretsInBody(
|
||||
"code=AAA&code_verifier=BBB",
|
||||
"application/json",
|
||||
);
|
||||
expect(hasSecrets).toBe(false);
|
||||
});
|
||||
|
||||
it("skips masking for a known non-JSON, non-form content-type", () => {
|
||||
// A plaintext/HTML error body that happens to contain `access_token=…`
|
||||
// must not be guessed at as form-encoded when the content-type says text.
|
||||
const body = "Error: access_token=leaked has expired";
|
||||
const { masked, hasSecrets } = maskSecretsInBody(body, "text/plain");
|
||||
expect(hasSecrets).toBe(false);
|
||||
expect(masked).toBe(body);
|
||||
});
|
||||
|
||||
it("uses the form parser for an explicit form content-type", () => {
|
||||
const { masked, hasSecrets } = maskSecretsInBody(
|
||||
"refresh_token=RT",
|
||||
"application/x-www-form-urlencoded",
|
||||
);
|
||||
expect(hasSecrets).toBe(true);
|
||||
expect(masked).toBe(`refresh_token=${MASK_PLACEHOLDER}`);
|
||||
});
|
||||
});
|
||||
181
clients/web/src/utils/maskSecrets.ts
Normal file
181
clients/web/src/utils/maskSecrets.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Masks sensitive OAuth values inside a captured HTTP body for display in the
|
||||
* Network tab. OAuth token-exchange / registration responses carry credentials
|
||||
* (`access_token`, `refresh_token`, …) and the token *request* (a
|
||||
* `application/x-www-form-urlencoded` body) carries `code` / `code_verifier` /
|
||||
* `client_secret`. We show the body so it's inspectable, but mask those values
|
||||
* by default so they aren't exposed at a glance during a screen-share. The raw
|
||||
* body is preserved by the caller and shown only when the user reveals it.
|
||||
*
|
||||
* Content-type selects the parser: `*json*` → JSON masking, form-urlencoded →
|
||||
* form masking, any other known type → no masking. When the content-type is
|
||||
* absent/unknown the body is sniffed (parse as JSON first, else treat as
|
||||
* form). See `maskSecretsInBody`.
|
||||
*/
|
||||
|
||||
// Keys masked in JSON bodies — bearer-grade secrets only. `code` is
|
||||
// deliberately NOT here: a JSON body's `code` is usually something else (e.g.
|
||||
// a JSON-RPC error `code`), and we don't want to mask those.
|
||||
// `registration_access_token` is the DCR management credential (RFC 7592),
|
||||
// same bearer class as `access_token`.
|
||||
const JSON_SENSITIVE_KEYS = new Set([
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"client_secret",
|
||||
"registration_access_token",
|
||||
]);
|
||||
|
||||
// Keys masked in form-encoded bodies — the JSON set plus the single-use OAuth
|
||||
// request material that only appears as form params (authorization code, PKCE
|
||||
// verifier, private-key-JWT client assertion).
|
||||
const FORM_SENSITIVE_KEYS = new Set([
|
||||
...JSON_SENSITIVE_KEYS,
|
||||
"code",
|
||||
"code_verifier",
|
||||
"client_assertion",
|
||||
]);
|
||||
|
||||
// What a masked value is replaced with. A fixed-width dotted string keeps the
|
||||
// shape recognizable as "a value was here" without hinting at its length.
|
||||
export const MASK_PLACEHOLDER = "••••••••";
|
||||
|
||||
function isSensitiveKey(set: ReadonlySet<string>, key: string): boolean {
|
||||
return set.has(key.toLowerCase());
|
||||
}
|
||||
|
||||
// Whether a value under a sensitive key should be masked. The contract is
|
||||
// "any non-null, non-empty-string value": strings are masked when non-empty
|
||||
// (an empty `access_token` carries nothing), and any non-string value
|
||||
// (object/array/number/boolean wrapper — pathological for OAuth, but a safe
|
||||
// default) is masked wholesale so it can't leak through the recursion.
|
||||
function isMaskableValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface MaskedNode {
|
||||
node: unknown;
|
||||
masked: boolean;
|
||||
}
|
||||
|
||||
// Recursively mask sensitive values in a parsed JSON node, tracking whether
|
||||
// anything was masked (so the caller never has to infer it by comparing
|
||||
// serializations — reformatting alone can't trip the flag, and it's robust if
|
||||
// this function ever grows non-identity transforms).
|
||||
function maskNode(node: unknown): MaskedNode {
|
||||
if (Array.isArray(node)) {
|
||||
let masked = false;
|
||||
const out = node.map((item) => {
|
||||
const r = maskNode(item);
|
||||
masked = masked || r.masked;
|
||||
return r.node;
|
||||
});
|
||||
return { node: out, masked };
|
||||
}
|
||||
if (node !== null && typeof node === "object") {
|
||||
let masked = false;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(
|
||||
node as Record<string, unknown>,
|
||||
)) {
|
||||
if (isSensitiveKey(JSON_SENSITIVE_KEYS, key) && isMaskableValue(value)) {
|
||||
out[key] = MASK_PLACEHOLDER;
|
||||
masked = true;
|
||||
} else {
|
||||
const r = maskNode(value);
|
||||
out[key] = r.node;
|
||||
masked = masked || r.masked;
|
||||
}
|
||||
}
|
||||
return { node: out, masked };
|
||||
}
|
||||
return { node, masked: false };
|
||||
}
|
||||
|
||||
export interface MaskResult {
|
||||
/** The body with sensitive values replaced; pretty-printed for JSON, otherwise the original shape with values substituted. */
|
||||
masked: string;
|
||||
/** True when at least one sensitive value was masked. */
|
||||
hasSecrets: boolean;
|
||||
}
|
||||
|
||||
function maskJsonBody(body: string): MaskResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return { masked: body, hasSecrets: false };
|
||||
}
|
||||
const { node, masked } = maskNode(parsed);
|
||||
return { masked: JSON.stringify(node, null, 2), hasSecrets: masked };
|
||||
}
|
||||
|
||||
// Mask sensitive params in a form-urlencoded body, preserving the original
|
||||
// formatting (we only swap the value, so the placeholder isn't percent-encoded
|
||||
// the way `URLSearchParams.toString()` would mangle it). A non-form string
|
||||
// (no `key=value` pairs with a sensitive key) falls through untouched.
|
||||
function maskFormBody(body: string): MaskResult {
|
||||
let hasSecrets = false;
|
||||
const masked = body
|
||||
.split("&")
|
||||
.map((pair) => {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq === -1) return pair;
|
||||
const rawKey = pair.slice(0, eq);
|
||||
const value = pair.slice(eq + 1);
|
||||
let key: string;
|
||||
try {
|
||||
key = decodeURIComponent(rawKey);
|
||||
} catch {
|
||||
key = rawKey;
|
||||
}
|
||||
if (isSensitiveKey(FORM_SENSITIVE_KEYS, key) && value.length > 0) {
|
||||
hasSecrets = true;
|
||||
return `${rawKey}=${MASK_PLACEHOLDER}`;
|
||||
}
|
||||
return pair;
|
||||
})
|
||||
.join("&");
|
||||
return { masked: hasSecrets ? masked : body, hasSecrets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive fields in an HTTP body for display.
|
||||
*
|
||||
* `contentType` (the body's `content-type` header, if known) picks the parser:
|
||||
* - `*json*` → JSON masking (re-serialized pretty)
|
||||
* - `application/x-www-form-urlencoded` → form masking (shape preserved)
|
||||
* - any other known type (HTML, plaintext, XML, …) → no masking
|
||||
* - absent/unknown → sniff: parse as JSON, else treat as form
|
||||
*
|
||||
* Bodies with no sensitive keys return unchanged with `hasSecrets: false` so
|
||||
* callers can skip the reveal affordance. The caller keeps the original string
|
||||
* for the revealed view.
|
||||
*
|
||||
* `contentType` is matched by substring (`*json*`, `*x-www-form-urlencoded*`)
|
||||
* and we trust the wire's own label — a body mislabeled by the server (e.g.
|
||||
* JSON sent as `text/html`) takes the "no masking" branch and renders raw.
|
||||
* That's acceptable: the threat model is a screen-share viewer, not an
|
||||
* adversary who controls the response's content-type.
|
||||
*/
|
||||
export function maskSecretsInBody(
|
||||
body: string,
|
||||
contentType?: string,
|
||||
): MaskResult {
|
||||
const ct = (contentType ?? "").toLowerCase();
|
||||
if (ct) {
|
||||
if (ct.includes("json")) return maskJsonBody(body);
|
||||
if (ct.includes("x-www-form-urlencoded")) return maskFormBody(body);
|
||||
// Known, non-JSON/non-form content type → don't guess; leave it alone.
|
||||
return { masked: body, hasSecrets: false };
|
||||
}
|
||||
// No content-type hint: sniff. Valid JSON → JSON masking; otherwise form.
|
||||
try {
|
||||
JSON.parse(body);
|
||||
} catch {
|
||||
return maskFormBody(body);
|
||||
}
|
||||
return maskJsonBody(body);
|
||||
}
|
||||
|
|
@ -315,16 +315,18 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
|
||||
private buildEffectiveAuthFetch(): typeof fetch {
|
||||
const base = this.fetchFn ?? fetch;
|
||||
// Note: we deliberately do NOT wire `updateResponseBody` for the auth
|
||||
// fetcher. OAuth token-exchange responses contain `access_token` and
|
||||
// `refresh_token`; capturing them into FetchRequestLogState would
|
||||
// surface live credentials in the Network tab body preview, which is
|
||||
// easy to leak during a screen-share. Headers + status are still
|
||||
// tracked. If a future need calls for inspecting auth bodies, add
|
||||
// explicit secret redaction first.
|
||||
// Capture auth response bodies (OAuth discovery, DCR, token exchange) so
|
||||
// they're inspectable in the Network tab. Token-exchange responses carry
|
||||
// `access_token` / `refresh_token`; the Network UI masks those (and other
|
||||
// known secret fields) behind a click-to-reveal toggle so they aren't
|
||||
// surfaced at a glance during a screen-share. Masking is a display
|
||||
// concern, kept in the UI layer rather than mutating the captured entry,
|
||||
// so the raw body stays available for the user who explicitly reveals it.
|
||||
return createFetchTracker(base, {
|
||||
trackRequest: (entry) =>
|
||||
this.dispatchFetchRequest({ ...entry, category: "auth" }),
|
||||
updateResponseBody: (id, body) =>
|
||||
this.dispatchFetchRequestBodyUpdate(id, body),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,11 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
|
|||
...this.fetchRequests[idx]!,
|
||||
responseBody,
|
||||
};
|
||||
// Body fill-in re-emits the list event only, not the per-entry
|
||||
// `fetchRequest` event (that one fires once, on append). Consumers read
|
||||
// the full list (`useFetchRequests`), so they pick up the body on the
|
||||
// next list re-render. A future per-entry subscriber wanting incremental
|
||||
// body updates would need its own event here.
|
||||
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
|
||||
};
|
||||
this.client.addEventListener(
|
||||
|
|
@ -112,6 +117,15 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
|
|||
// non-redirect `saveSession` caller (e.g. a future token-refresh save
|
||||
// point) and is harmless when it duplicates the primary flush —
|
||||
// last-writer-wins on an identical payload under the same id.
|
||||
//
|
||||
// SECURITY TRIPWIRE: captured `auth`-category response bodies in this log
|
||||
// are stored UNMASKED (masking is a Network-UI display concern only).
|
||||
// Today the only `saveSession` trigger is the pre-redirect flush, which
|
||||
// runs before the `/token` exchange — so no bearer token is ever
|
||||
// persisted. Any NEW `saveSession` trigger added after token exchange
|
||||
// (e.g. a periodic snapshot) would write `access_token` /
|
||||
// `refresh_token` to disk. Redact response bodies here before persisting
|
||||
// if that ever changes.
|
||||
const onSaveSession = (
|
||||
event: TypedEventGeneric<InspectorClientEventMap, "saveSession">,
|
||||
): void => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue