feat(network): show auth response bodies with masked secrets + reveal toggle (#1386)#1387
Conversation
) 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>
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>
…#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>
… 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>
… + 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>
|
Claude finished @cliffhall's task in 2m 46s —— View job Claude Code ReviewReviewed the diff against
Summary
Security claim verificationThe PR description claims post-redirect
So once the browser redirects to the IdP, the Findings (none blocking)1. Form-encoded bodies bypass masking entirely —
If keeping JSON-only, consider renaming to 2. const maskedNode = maskNode(parsed);
const masked = JSON.stringify(maskedNode, null, 2);
const hasSecrets = JSON.stringify(maskedNode) !== JSON.stringify(parsed);The double-stringify is clever but relies on 3.
4. Minor a11y — The masked ↔ revealed transition swaps a large block of monospace text but isn't announced. For screen-reader users, the "Secrets revealed" label updates but the body change is silent. An 5. Pretty-print asymmetry — Masked view is always pretty-printed; revealed view shows the original raw body (often minified on the wire). Toggling between them therefore reflows the layout. Not wrong, just a slight UX jolt. If you want symmetry, pretty-print the revealed body too (it's already passing through 6. Nit: documentation tripwire The old comment in Test coverageUnit tests look complete for the happy paths. One suggestion:
Otherwise the test set (incl. the new VerdictLGTM with the small caveat that the security guarantee is currently a function of |
… 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>
# Conflicts: # clients/web/src/App.tsx
…p + 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>
|
Claude finished @cliffhall's task in 2m 30s —— View job Claude Code Review (second pass)Reviewed against
Fresh observations1. Masking is broader than the PR title implies — and that's good, but worth pinning down
2.
function maskValue(key: string, value: unknown): unknown {
if (isSensitiveKey(key) && typeof value === "string" && value.length > 0) {
return MASK_PLACEHOLDER;
}
return maskNode(value); // recurses
}If a server ever returned a sensitive key whose value is not a string (e.g. 3. Clipboard exfiltration is intentional but undocumented
4.
5. Mantine compliance check (per AGENTS.md) All passes: 6. Test gap: request-body masking branch The new Security claim — verified end-to-endTraced
Both capture the log at the moment of redirect — the VerdictLGTM. The implementation is solid; the security guarantee currently relies on |
…lag, 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>
|
Thanks for the thorough review — addressed the substantive items in 88aa583. 1. Form-encoded bodies bypassed masking — Implemented (didn't just document). 2. 3. Reveal state doesn't reset on body change — Done via 4. a11y — Added an 5. Pretty-print asymmetry — No change needed: 6. Tripwire comment — Added a Tests — Added: form-encoded masking (token + refresh requests, formatting preserved), JSON On the security verdict: agreed it's call-site discipline, not code-enforced redaction — the new tripwire comment makes that invariant explicit at the persistence path. |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 53s —— View job Claude Code Review (third pass)Reviewed against
Fresh observations1. The "non-JSON ⇒ treat as form" fallback is broader than the name suggests — export function maskSecretsInBody(body: string): MaskResult {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return maskFormBody(body);
}
...
}Any body that isn't valid JSON — HTML error pages, plaintext stack traces, XML, etc. — runs through
Either way, a one-line block-doc on 2. Sensitive-key-with-non-string-value still recurses (carried from prior pass, not addressed) — if (
JSON_SENSITIVE_KEYS.has(key.toLowerCase()) &&
typeof value === "string" &&
value.length > 0
) {
out[key] = MASK_PLACEHOLDER;
masked = true;
} else {
const r = maskNode(value); // recurses into objects/arrays under a sensitive key
...
}A non-standard server returning if (JSON_SENSITIVE_KEYS.has(key.toLowerCase())) {
out[key] = MASK_PLACEHOLDER;
masked = true;
}That also covers the related case where the value is a stringified JSON blob ( 3. Inline The JSON and form paths each have their own copy of the 4. Form-body test coverage gaps The form-side tests cover happy paths but skip:
These would add maybe four lines to 5. Storybook play function reveals but doesn't re-hide
6. Doc-string on
Re-verification of the security claimRe-traced
No post-redirect persistence path exists. The new VerdictLGTM. None of the above is blocking; items 1, 2, and 6 are the highest-value to tighten up before this lands. Items 3-5 are nice-to-haves. |
…king, 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>
|
Thanks for the third pass — addressed 1, 2, 3, 4, 6 in e499830; skipped 5 per your offer. 1. "non-JSON ⇒ form" was too broad — Made it explicit via content-type instead of a block-doc. 2. Non-string value under a sensitive key recursed — Fixed: any non-empty value under a sensitive key is now masked wholesale (replaced, not recursed), so a non-standard 3. Duplicated predicate — Extracted 4. Form test gaps — Added: repeated sensitive param ( 6. 5. Story doesn't re-hide — Skipped per your note; the Re the security claim: agreed it remains call-site discipline; the tripwire comment is the enforcement mechanism. |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 46s —— View job Claude Code Review (fourth pass)Reviewed against
Re-verifications
Fresh observations (all non-blocking; ignorable)1. Content-type matching is substring-based —
A one-line note on 2. Form-side A malformed percent-encoding ( 3.
4. Storybook
Test coverageThe four new test cases in VerdictLGTM. Items 1–4 above are nits; the substantive design has been worked through across three rounds and the code reflects that. The security guarantee remains call-site discipline around |
…, 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>
|
Thanks for the fourth pass — took the cheap ones in af7ad0d. 1. Content-type matching contract — Added a block-doc note on 3. Header-only change doesn't reset reveal — Now keying 4. Storybook assertion brittleness — Changed Test gap (form masking in NetworkEntry) — Added a unit test that mounts 2. Silent
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 15s —— View job Claude Code Review (fifth pass)Reviewed against
Fresh observations (all non-blocking)1. const { masked, hasSecrets } = maskSecretsInBody(body, contentType);This runs on every render of const { masked, hasSecrets } = useMemo(
() => maskSecretsInBody(body, contentType),
[body, contentType],
);Same hook-ordering rules — 2. Transport bodies now pay a JSON-parse cost they didn't before — Pre-PR, transport 3. function isMaskableValue(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.length > 0;
return true;
}Contract: "non-null, non-empty-string value under a sensitive key gets masked wholesale, regardless of type." So 4. Not referenced outside 5. Test coverage: full DCR-response shape isn't asserted as one fixture —
Re-verification
VerdictLGTM. The PR has been through four substantive review rounds, each addressed thoughtfully; what remains are micro-perf and contract-documentation nits. Worth taking item 1 (the |
…gmt 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>
|
Thanks for the fifth pass — took 1, 3, 5 in 2237d2d. 1. Re-masking on every render — Memoized: 3. 5. DCR happy-path fixture — Added a confidential-client 2. Transport parse cost — Left as-is; the memo from #1 caps it at one parse per mount, and the masking-by-default symmetry is the right call. 4.
|
|
@claude review |
|
Claude finished @cliffhall's task in 1m 38s —— View job Claude Code Review (sixth pass)Reviewed against
Fresh observations (all non-blocking; ignorable)1. key={`${entry.requestHeaders["content-type"] ?? ""}|${entry.requestBody}`}For a near-cap body (~100 KiB), this is a 100k-char React key. React stores keys as strings and compares them on every reconciliation pass — for one entry this is invisible; for a Network log with many large entries it adds up. A length+prefix hash ( 2. The handler patches the matching entry and dispatches only 3.
4. Form-encoded separator —
Re-verification of the security claimRe-traced
Mantine complianceSpot-checked against
Compliant. VerdictLGTM. The PR has been through five substantive review rounds, each addressed with care, and the implementation reflects that. Items 1-4 above are nits/observations; none warrant another round. The • • Branch: |
… 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>
|
Thanks for the sixth pass. All four are ignorable nits as you noted; took the one zero-risk doc improvement in 06fcae0. 2. Body-update emits list event only — Added a comment on Left as-is, intentionally:
|
|
@claude review |
|
Claude finished @cliffhall's task in 2m 14s —— View job Claude Code Review (seventh pass)Reviewed against
Re-verifications
Fresh observations (all non-blocking; ignorable)1. const idx = this.fetchRequests.findIndex((e) => e.id === id);
if (idx === -1) return;If the matching entry has been evicted by 2. Pretty-printed JSON can push a near-cap body over the cap downstream —
3. Storybook Confirming for the record: serial VerdictLGTM. After seven passes the PR has converged: the implementation is correct, the security guarantee has a tripwire where it needs one, the test set covers the meaningful branches (JSON masking, form masking, content-type branching, repeated/empty params, scalar-JSON pass-through, DCR management token, NetworkEntry reveal/hide for both body sides, Storybook play function), and the documentation reflects the design. Item 1 is the only fresh observation worth a follow-up issue if you want one; items 2-3 are FYI. |
|
Thanks — agreed it's converged after seven passes. No code change this round; the three items are FYI/out-of-scope: 1. Body silently dropped when the entry rotates out before 2. Pretty-printing can push a near-cap body over the cap downstream — Noted; the cap is intentionally measured at the raw input (its job is bounding the parse/walk/DOM), and a future cap tightening should measure post-masking. 3. Nothing further to change on my end — ready to merge whenever you are. |



Closes #1386.
Problem
authNetwork entries showed request body/headers/status but never the response body (rendered(empty)), becausebuildEffectiveAuthFetchdeliberately skipped capturing it to avoid surfacingaccess_token/refresh_token. That hid the most useful thing to inspect when debugging OAuth — the token exchange (see the reported/tokenscreenshot: content-length 115, but Response Body empty).Change
Capture auth response bodies, but mask sensitive OAuth fields by default behind a click-to-reveal toggle (per maintainer direction — safest for screen-shares):
inspectorClient— wireupdateResponseBodyon the auth fetcher so bodies are captured.src/utils/maskSecrets.ts—maskSecretsInBody()masksaccess_token,refresh_token,id_token,client_secret(case-insensitive, nested in objects/arrays) in JSON bodies and reports whether anything was masked. Non-JSON or secret-free bodies pass through untouched (no toggle).NetworkEntryBodyPreview— when a body has masked fields, render masked with a Reveal/Hide toggle and a "Secrets hidden/revealed" label. Copy honors the shown view. Masking is UI-only; the captured entry is unchanged so reveal shows the real value.Security note
access_token/refresh_tokenare in the post-redirect/tokenresponse, which is never written to the session-restore files (#1384). Only pre-redirect bodies (public discovery metadata, DCR/register) persist — for a public PKCE client/registerhas no secret — so no bearer token hits disk.Verification
End-to-end against the MCP SDK demo OAuth server: the
/tokenresponse now shows masked by default (access_token: "••••••••", "Secrets hidden"); Reveal exposes the raw value (51e64838-…, "Secrets revealed", button → "Hide"); discovery and the public/registerresponse render with no toggle (content-driven).npm run validate(1864),test:integration(491),test:storybook(322, incl. a newAuthSuccessplay function) all green.Acceptance criteria
/tokenexchange shows its JSON response body, with secrets masked until revealed.