refactor: migrate to RemoteOAuthStorage for web OAuth handling#1609
Conversation
|
@claude review |
|
Claude finished @BobDickinson's task in 3m 21s —— View job Code Review — PR #1609 (migrate web OAuth to
|
PR review response — async OAuth storage / callback raceRe: review feedback on The latest commit ( 1. Callback reads PKCE before persisted state is loaded
2. Writes can clobber
|
|
@claude review |
|
Claude finished @BobDickinson's task in 2m 57s —— View job Re-review after the
|
PR comment — settle
|
|
@claude review |
|
Claude finished @BobDickinson's task in 2m 6s —— View job Re-review after
|
|
Regarding the "eager rejection" recommendation - that entire class of problem goes away with the removal of Zustand storage, which will be a fast follow. I'm happy to go with what we have now so we can get to that work. |
🤖 Automated review — Claude review loopRan a multi-angle review of this diff (line-by-line, removed-behavior, cross-file tracing, plus reuse/simplification/altitude/conventions) with an adversarial verify pass, then a second confirmation pass. The refactor is solid — Confirmed issues1. 2. Eager 3. Inconsistent fetch-mock typing — 4. Doc comment overstates the cache — Reviewed and accepted (no change recommended)
Not a problem (checked)
Fixes for issues 1–4 to follow in a second comment. 🤖 Generated with Claude Code |
Review follow-ups on the RemoteOAuthStorage web migration: - useEmaIdpLoginState.logout(): add a .catch to the now-async clearEmaIdpSession call so a storage failure no longer surfaces as an unhandled rejection; leave loginState unchanged on failure (the session was not actually cleared). Add a test asserting no unhandled rejection. - createOAuthStore: attach a no-op catch to the eager persistLoadPromise so a hydration read that fails before any load() awaits it does not become an unhandled rejection; real awaiters still observe the rejection via the shared reference. - storage-remote.test: replace `as unknown as typeof fetch` double-casts with the typed vi.fn<typeof fetch>() pattern the same PR established. - remoteOAuthStorage: correct the doc comment to describe the cache-of-one accurately instead of implying a keyed map. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖 Fixes for the review findingsPrepared fixes for all four findings from the review above (commit 1. - void clearEmaIdpSession(storage, normalizedIssuer).then(() => {
- setLoginState("none");
- });
+ void clearEmaIdpSession(storage, normalizedIssuer)
+ .then(() => {
+ setLoginState("none");
+ })
+ .catch(() => {
+ // Clearing the persisted IdP session failed (e.g. the storage backend
+ // is unreachable). Swallow the rejection so it does not surface as an
+ // unhandled promise, and leave loginState unchanged so the UI keeps
+ // reflecting the still-present session rather than falsely showing
+ // signed-out.
+ });Added a test ( 2. Eager const persistLoadPromise = new Promise<void>((resolve, reject) => {
resolvePersistLoad = resolve;
rejectPersistLoad = reject;
});
+ // Zustand kicks off async hydration as soon as the store is created, before
+ // any consumer calls `OAuthStorageBase.load()`. If that initial read fails
+ // and no one is awaiting yet, `persistLoadPromise` would reject with no
+ // handler and surface as an unhandled rejection. Attach a no-op handler so
+ // the promise is always "handled"; real awaiters still observe the rejection
+ // through the reference returned by `waitForOAuthStorePersistLoad`.
+ void persistLoadPromise.catch(() => {});Attaching a 3. Fetch-mock typing — 4. Doc comment — Verification
🤖 Generated with Claude Code |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 0s —— View job Code Review — PR #1609 (re-review after
|
|
Thanks for the re-review — agreed on all points. Confirming alignment:
No actionable comments this round, so no further changes. 🟢 🤖 Generated with Claude Code |
✅ Merged
|
| Check | Result |
|---|---|
GET / (SPA) |
HTTP 200 |
GET /api/storage/oauth without token |
HTTP 401 (auth guard enforced) |
GET /api/storage/oauth with x-mcp-remote-auth: Bearer … |
HTTP 200 — returns the { state: { servers, idpSessions }, version } envelope read from disk |
POST /api/storage/oauth (token blob) |
{"ok":true} HTTP 200 |
GET back |
persisted value returned ✔ |
On-disk oauth.json |
blob written to ~/.mcp-inspector/storage/oauth.json ✔ |
This confirms the core behavioral change end-to-end: web OAuth state is now file-backed and shared with CLI/TUI, and survives a page refresh instead of living in the tab's sessionStorage.
🤖 Generated with Claude Code
Closes #1548
Summary
Wire the v2 web client through shared file-backed OAuth storage instead of tab-local
sessionStorage.RemoteOAuthStorage→GET/POST /api/storage/oauth→~/.mcp-inspector/storage/oauth.json(same on-disk file as CLI/TUI when using the default local backend).getWebRemoteOAuthStorage()(clients/web/src/lib/remoteOAuthStorage.ts) memoizes one in-memoryRemoteOAuthStorageper{ origin, authToken }so connect, EMA IdP session, and per-server “clear stored OAuth” all mutate the same Zustand view.core/unchanged — no Zustand removal, no async storage interface churn (Remove Zustand file-store wrappers; auto-convert to plain JSON on read #1549 / Web: migrate auth store to shared /store API (RemoteOAuthStorage parity with TUI/CLI) #1592-style work stays out of scope).Why
BrowserOAuthStorage→sessionStorage(tab-local)RemoteOAuthStorage→oauth.json(shared with CLI/TUI)getBrowserOAuthStorage)getWebRemoteOAuthStorage) over remote adapterCall chain
authTokenis forwarded on storage HTTP calls asx-mcp-remote-authwhen the backend requires it (same as transport/fetch/logger).Changes
New
clients/web/src/lib/remoteOAuthStorage.tsgetRemoteOAuthStorage/getWebRemoteOAuthStorageclients/web/src/lib/remoteOAuthStorage.test.tsclients/web/src/lib/environmentFactory.test.tsModified (web)
clients/web/src/lib/environmentFactory.tsgetWebRemoteOAuthStorage(authToken)instead ofgetBrowserOAuthStorage(); doc commentclients/web/src/App.tsxwebOAuthStorageuseMemo; EMA + clear OAuth use shared store; callback commentclients/web/src/utils/clearServerOAuthState.tsoauthStorage: OAuthStorage; active client narrowed toPick<InspectorClient, "clearOAuthTokens">clients/web/vite.config.tssrc/lib/**to coverageincludeTests / coverage
clearServerOAuthState.test.tsBrowserOAuthStorageinstances; typed client stub (noas never)storage-browser.test.tsgetBrowserOAuthStoragesingleton branch (coverage for core helper still used elsewhere)Spec
specification/v2_auth_ema.mdoauth.json; mark #1548 follow-up donespecification/v2_auth_mid_session.mdOut of scope (follow-ups)
navigator.locks— Cross-tab single-flight on silent refresh (noted in spec).Review notes
RemoteOAuthStorageinstance owns a separate in-memory Zustand store; memoization matches the oldgetBrowserOAuthStorage()pattern.clearServerOAuthStateplumbsoauthStoragefromAppso unit tests stay isolated without stubbingwindow/remote fetch; parameter types narrowed to what the helper actually calls.useMemo([], getAuthToken())matches existingsessionStorageAdapterassumption: API token is stable for the page lifetime (injected__INSPECTOR_API_TOKEN__on load).