Commit graph inspector/clients/web/src/hooks/useOAuthRecovery.ts
Author SHA1 Message Date
cliffhall
b4b82493b1 fix: report a terminal OAuth token-endpoint refusal instead of a dead retry
Closes #2280

InsecureTokenEndpointError appeared nowhere in our source, so it fell through
to the generic auth-failure path and rendered a "Re-authentication required"
banner with a Re-authenticate button. That button can never work: the SDK's
assertSecureTokenEndpoint runs inside executeTokenRequest, the error does not
extend OAuthError, and auth() special-cases it to rethrow rather than start a
fresh /authorize redirect. Clicking re-ran the same flow to the same refusal,
under the raw SDK message, which names the three exempt host literals and
nothing actionable. A terminal configuration error was being presented as a
retryable auth error.

Not specific to *.localhost: it fires for any endpoint outside the SDK's
exemption — host.docker.internal (#1911), a LAN hostname, a reverse-proxy
name, a mistyped scheme.

It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring
issuerBinding.ts's brand-plus-name classifier and walking cause / data.cause,
since era negotiation and the transport wrappers bury the rejection) and
surfaced as the configuration error it is, naming the endpoint and both ways
out, with no action affordance.

Six paths reach the refusal and all six classify it — the connect handshake,
authenticate() during connect, the satisfied-challenge connect retry, the
post-redirect callback, the command/background path, the deferred tab-visible
resume (which re-armed on every focus, an unbounded loop on a terminal error),
and the banner action. They share one wrapper so a seventh cannot omit the
banner clear, and every clear is scoped to its own serverId: these paths are
asynchronous, so a late continuation for one server must not erase a banner
another raised in the meantime.

Adds oauth-insecure-token-endpoint-http.json to reproduce it, whose issuer is
http://localhost.:8091 — the root-anchored spelling every resolver sends to
loopback but which is none of the SDK's exempt literals. transport.strictPort
keeps that fixture honest, since its port is hard-coded inside the issuer.

Making such an endpoint work has to land in the SDK (typescript-sdk#2591);
this changes the reporting, not the outcome.

Split out of #2282, where it was tangled with the *.localhost origin work for
#1944 — a separate question the reporter is still clarifying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 09:09:54 -04:00
Cliff Hall
7897d68cab
Merge branch 'v2/main' into v2/fix/2217-oauth-clear-shared-url 2026-09-04 08:52:42 -04:00
Cliff Hall
a37c06846e
Merge branch 'v2/main' into v2/fix/2221-oauth-refresh-unhandled-rejection 2026-09-04 08:40:07 -04:00
Cliff Hall
fb0b79540b
Merge branch 'v2/main' into v2/fix/2217-oauth-clear-shared-url 2026-09-04 08:39:16 -04:00
cliffhall
f78a00ea6c fix: resolve the clear's identity from the live client, in one place (#2217)
Copilot: the catalog entry is mutable and the live client is not. A card
can be edited while connected — `onConfigSubmit` writes the catalog and
does not rebuild the client — so after A connects to X and is edited to
Y, the entry reads Y while the session is still authorized against X.
Both consumers read the entry, so both got it wrong in the same way:

- the hook compared a cleared entry against A's *entry* URL, so clearing
  another entry still at X missed the match and deleted and revoked the
  live client's X-keyed credentials without disconnecting it — the exact
  failure this PR exists to prevent;
- `runClear` locked on the entry's key, so a clear of edited-A (`url:Y`)
  and a clear of entry B at X (`url:X`) took different locks while both
  performed the same X-keyed operation through the same live client.

`InspectorClient.getTransportConfig()` returns the config it was
constructed with, which is what its credentials are keyed under, so both
now resolve from that and fall back to the catalog entry only when there
is no client.

The rule moves into one exported function, `resolveOAuthClearIdentity`.
Two copies of it that disagree is precisely the class of bug #2217 is,
and the two consumers each re-deriving it is how this round's finding
came to exist at all.

Both fakes gain a `getTransportConfig`, and three tests cover the split:
the resolver's own two (the client's URL wins; an active clear locks on
the client's key) and a hook case asserting an edited active entry still
disconnects when the entry sharing the client's real URL is cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 00:02:45 -04:00
cliffhall
79751383da fix: only let the newest OAuth state read write, and harden its tests
Copilot review round 1.

Refreshes are concurrent — an `oauthComplete` can start a second read while
the first is still in flight — and nothing makes them settle in order. The
new catch made that visible: a slow earlier read could reject *after* a
newer one succeeded and clear the fresh result. Guard both handlers with a
monotonically increasing sequence so only the newest read writes.

The two tests were also too weak to detect their own guards, as the review
pointed out. Both started from an undefined panel, so neither could tell a
correct handler from one that merely swallowed the error:

- The rejection test now seeds a successful read first, then rejects an
  `oauthComplete` refresh and asserts the already-loaded details clear.
- The post-cleanup test now reconnects with a replacement client that
  populates details, then rejects the stale read, and asserts the current
  details survive.
- A third test covers the ordering guard directly.

Each of the three guards has exactly one test that fails without it,
verified by mutation: dropping the sequence check, dropping the clear, and
dropping the `cancelled` check each break one test and no others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBkPzbuxgyqiz39ytmQzMB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-03 22:54:56 -04:00
cliffhall
258e7f1526 refactor(web): let trySetPendingStepUp own the step-up retry
Clearing the retry from the mirror effect raced: an async command continuation
can open a prompt and install its retry before a previous commit's passive
effect flushes, and that effect's stale `pendingStepUp === null` snapshot would
then delete the new prompt's retry.

Install it in `trySetPendingStepUp` instead, in the same synchronous step that
opens the prompt — `null` for an ambient prompt, the operation for a
command-scoped one. Every prompt comes through there and `handleStepUpAuthorize`
is the only reader, so a slot cleared as a side effect can leave a stale closure
behind harmlessly: the next prompt overwrites it. A refused prompt still
installs nothing (#2165).

Copilot round 3 on #2237.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-03 20:49:40 -04:00
cliffhall
79896b6356 fix: state the credential impact, not the disconnect, in the shared-URL toast
Copilot: the stale-session guard deliberately skips `disconnect()` after a
switch, and `disconnect()` can also reject (which gets its own toast), so
claiming the active session "was disconnected too" is not always true.
What is always true is that its stored tokens went with the shared blob
and it must reconnect, so the message says that instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-03 20:28:53 -04:00
cliffhall
3c8fdb0814 fix(web): drop the step-up retry when its prompt is cleared
The retry closure belongs to the prompt, but the two paths that clear the
step-up slot as a *consequence* of something else — the server-switch reset
and `resetOAuthRecoveryState` on disconnect — left `pendingStepUpRetryRef`
installed. The next prompt to open without a retry of its own would inherit
it, so authorizing an ambient step-up on server B ran the command server A was
left mid-way through.

Clear it in the existing mirror effect whenever the slot is null, so no arm has
to remember. A ref write in an effect, since the render-phase reset must stay
pure.

Copilot on #2237.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-03 20:27:16 -04:00
cliffhall
2ba25d63e3 fix(web): reset pending OAuth slots during render, not in an effect
`useOAuthRecovery` cleared its two server-scoped pending-OAuth slots — the
deferred re-auth and the step-up prompt — in a `useEffect` keyed on
`activeServerId`. An effect runs after the commit, so a server switch painted
one frame still carrying the previous server's re-auth banner or step-up
prompt: an authorization affordance attached to the wrong server.

Reset both during render with `useValueChange` instead. `activeServerId` is a
primitive, so it satisfies the helper's `Object.is` stability requirement, and
the previous value is read from the `setState` updater argument rather than
from `sessionRef.current`, keeping the render-phase callback pure.

`react-hooks/set-state-in-effect` did not fire on the original because the
effect read the previous value through a ref rather than from a prop or state.

The test records one entry per committed render and asserts both slots are
empty in the first frame after the switch — the pre-existing `waitFor`-based
tests pass against the effect version too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-03 20:08:29 -04:00
cliffhall
19a39235a2 fix: terminate the OAuth connection-details refresh promise chain
The connection-details refresh effect in useOAuthRecovery voided
inspectorClient.getOAuthState() with a lone .then and no rejection
handler. In the browser that read goes through the remote OAuth store, so
it is a network round trip that can reject — a backend that is down or
restarting, a 401 on the API token, malformed stored state. void silences
no-floating-promises without terminating anything, so every such failure
became an unhandled rejection, on the initial refresh and on every
oauthComplete refresh.

Terminate the chain with a .catch that clears the panel details rather
than leaving the last successful read on screen: the panel reports the
current OAuth state, and a stale answer is indistinguishable from a fresh
one. The cancelled guard is repeated on the catch so a rejection arriving
after unmount does not write. The void stays, now with the one-line
justification AGENTS.md asks for — a synchronous useEffect body cannot
await.

Two tests cover it. Both fail against the unfixed source with unhandled
rejections, which is the failure mode itself: an unhandled rejection
fails the whole vitest run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBkPzbuxgyqiz39ytmQzMB
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-03 20:01:38 -04:00
cliffhall
fcec17f5d1 fix: key the OAuth clear's active-session check on the storage key (#2217)
OAuth state is keyed by server URL, but `clearServerOAuthAndDisconnect`
decided "is this the active connection" from the catalog entry id. Those
are different identities and nothing keeps them in sync: `serverList`
enforces no URL uniqueness, so two entries with distinct ids against one
URL are a supported state — and a natural one, since separate entries are
how a user keeps different names, headers or per-server settings against
the same server.

Clearing the inactive entry therefore deleted the shared URL-keyed blob —
the active session's tokens, DCR client id and PKCE state — and, with
#2144 in, revoked its grant at the authorization server, while the id
check took the inactive branch: no live-client clear, no disconnect, no
session cleanup. The active session was left connected on credentials
that no longer existed, with nothing told to the user; the break then
surfaced somewhere else entirely, on the next refresh, 401 or reload.

The branch now compares the OAuth storage key of the cleared entry
against the active entry's, and treats a match as affecting the active
session — live-client clear, disconnect, session cleanup — exactly as
clearing the active entry does. The existing id and client-identity
checks stay on top: they guard the separate stale-session race from
#2144, and the snapshotted id they revalidate is now the active one
rather than the cleared entry's, which for a shared-key clear are not the
same. The toast says why the session went away.

The alternative, prohibiting duplicate URLs in `serverList`, would remove
a legitimate workflow to fix a bug in the consumer and could not repair
catalogs that already hold duplicates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-03 19:42:37 -04:00
cliffhall
6f2e1faed2 fix(web): install a step-up's command retry only when its prompt opens
Copilot round 6. `handleCommandScopedAuthRecovery` wrote
`pendingStepUpRetryRef` before calling `trySetPendingStepUp`, which
REFUSES a second prompt while one is already open — so the refused
command's operation replaced the open prompt's, and authorizing that
prompt ran the command the user had just been told could not start.

The retry now goes in only on a prompt that actually opened, which makes
the pairing hold in both directions: the prompt and its retry are
installed together, and the EMA branch takes them out together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 03:15:42 -04:00
cliffhall
1634437b69 fix(web): give each EMA step-up attempt ownership of its command retry
Copilot round 5. `pendingStepUpRetryRef` is shared, and the EMA branch
dismisses the prompt *before* its awaits — so a newer command can open
its own step-up and install its own retry while the first authorization
is in flight. Every arm that read or cleared the ref afterwards was
acting on that newer operation: running it under the wrong
authorization and reporting its failure to the wrong source, or
deleting it outright.

The attempt now takes its retry out of the ref before any await, and no
arm touches the ref again. That covers all three sites at once and
removed the interactive-arm clear added last round — the value is
already out by then. The standard step-up path redirects and clears
synchronously, so it has no such window.

Also rewrites the `writeOAuthResumeSnapshot` docstring, which still
described the byte-comparison token it no longer returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 03:03:02 -04:00
cliffhall
d5ddaf8818 fix(web): clear the step-up retry on redirect handoff; justify the voids
Copilot round 4:

- the EMA `interactive` arm kept `pendingStepUpRetryRef`, on a comment
  claiming it was "still on its way to a redirect". That is wrong twice:
  `prepareOAuthRedirect` returns before the navigation is resolved, so a
  pre-navigation failure leaves the operation for a later, unrelated
  step-up to re-run; and the full-page navigation destroys the closure on
  the success path anyway. It now clears the ref, matching the standard
  step-up below.
- the two surviving `void (async () => …)()` sites now state why the
  caller cannot await, as AGENTS.md requires — the comments had described
  only what the catch handles.
- the `randomUUID` fallback test restored an own descriptor that never
  existed (it is inherited from `Crypto.prototype`), leaving an own
  `randomUUID: undefined` that would push every later test in the file
  onto the fallback path. It deletes the temporary property instead, and
  asserts the global is a function again afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 02:48:28 -04:00
cliffhall
a46a8a6cf8 fix(web): per-attempt snapshot ids, honest recovery copy, connect teardown
Copilot round 3, four items:

- the snapshot "attempt token" was the stored serialization, which is not
  unique: the snapshot records neither the authorization URL nor its
  `state`, so two concurrent redirects for one server with unchanged UI
  serialize identically. `OAuthResumeSnapshot` now carries an optional
  `attemptId`, stamped per write and matched on clear. Optional because
  an older build's snapshot, mid-redirect across an upgrade, has none —
  and treating that as a match would strand a live callback.
- the resume-failure toast promised a retry even when the session had
  ended and nothing was restored. Copy is now chosen by whether anything
  is left pending; `authRecoveryAbandonedMessage` covers the other case.
- a connect-scoped redirect that failed left the connection at
  "connecting" with the active-server lock held, since the redirect was
  what would have ended the attempt. It now disconnects first, as the
  sibling arm in `useConnectionLifecycle` does — scoped to that context,
  because an ambient or step-up failure does not mean the session is dead.
- both terminal EMA step-up failure arms now drop the stored retry. A
  later step-up does not overwrite the ref, so a retained operation could
  be re-run by an unrelated authorization. The `interactive` arm keeps
  it: that one is still on its way to a redirect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 02:32:05 -04:00
cliffhall
76b75d3759 fix(web): scope the OAuth recovery restores to the attempt that made them
Copilot round 2, both findings valid:

- `resumePendingReauth`'s restore could resurrect a challenge whose
  session ended during the awaits — a server switch or disconnect clears
  the slot on purpose. It is now gated on the attempt still targeting
  the live session, client identity included (reconnecting to the same
  server builds a replacement client an id-only check would admit), and
  uses a functional update so a newer deferral queued in the same batch
  wins rather than being read past.
- `prepareOAuthRedirect` cleared whichever resume snapshot was current,
  not the one it wrote. Nothing single-flights that callback, so a
  redirect failing before navigation could delete a later attempt's
  callback-routing state. `writeOAuthResumeSnapshot` now returns the
  serialization it stored, and `clearOwnOAuthResumeSnapshot` removes it
  only while it is still the current one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 02:12:47 -04:00
cliffhall
ecad587afe fix(web): address Copilot review on the OAuth recovery failure arms
Three findings, all valid:

- `beginInteractiveAuthorization` navigated *before* awaiting provider
  creation and flow-state recording, so a rejection could arrive after
  the browser had been handed over — and the web catch clears the resume
  snapshot on one. Fixed at the root: the navigation now resolves last,
  after every fallible step, which makes every rejection the method can
  produce a pre-navigation rejection. It is also better on its own
  terms — a provider that cannot be built means the callback could not
  have completed, so navigating first only bought a dead-end round trip.
- `resumePendingReauth` restored the pending slot unconditionally, which
  could overwrite a newer challenge deferred while it was awaiting.
  Restores only into an empty slot, so latest wins.
- the `authChallengeInteractive` catch re-read the active server rather
  than the one the challenge arrived for. `serverId` is now captured
  synchronously in the listener and used by both the body and the catch.

Three new tests, one per finding, each driving the interleaving rather
than the happy path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 01:54:53 -04:00
cliffhall
f74aa757d2 fix(web): terminate the five void-discarded OAuth recovery promises
Each of the five sites in `useOAuthRecovery` discarded a promise with
`void`, satisfying `@typescript-eslint/no-floating-promises` but not the
narrower rule AGENTS.md states — `void` only where the callee owns its
failures or the caller cannot await, with a comment saying which.

Each now picks a user-visible outcome:

- `prepareOAuthRedirect` clears the resume snapshot (so the next load
  cannot read a redirect that never started as an *abandoned* one),
  flags the server and raises the re-auth banner.
- the `authChallengeInteractive` handler flags and banners, where before
  a rejection drew no UI response at all.
- `resumePendingReauth` restores the pending slot and toasts, so the
  next trigger retries the deferred recovery instead of losing it.
- the `/oauth/callback` effect flags and banners what falls between its
  existing arms — the client rebuild and the post-resume scope check.
- `handleStepUpAuthorize`'s EMA branch reports exactly as its `failed`
  outcome arm does; the stored retry gets its own catch, since by then
  the permissions leg succeeded and the failure is the command's.

The two surviving `void`s carry the justification AGENTS.md asks for.

Closes #2165

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 01:34:40 -04:00
cliffhall
1f2e3e1744 fix: address the four suppressed findings from review round 20 (#2144)
Copilot reported "no new comments" again, with four real findings in its
suppressed block.

- The web in-flight lock was global, so a pending clear for server A silently
  swallowed a click on server B's — even though the callback explicitly
  supports clearing a non-active server. Keyed by server id now.
- `else if (!isActive)` skipped the resume-snapshot cleanup when the target IS
  active but has no client yet, which is reachable while a session is being
  built or torn down. The clear then emptied storage and left stale OAuth
  recovery state pointing at credentials that no longer existed.
- `revocationAuthMethods` returned `[]` for an omitted
  `revocation_endpoint_auth_methods_supported`, which does not enforce RFC
  8414's default: an empty list means "the metadata said nothing" to
  `selectClientAuthMethod`, which then honors the registration's own
  `token_endpoint_auth_method`. The default is named literally now.
- The `v8 ignore` on the CLI budget branch was wrong, and the challenge to it
  was right: an injected `budgetMs` of zero reaches it directly, through a seam
  I had added myself the round before. Removed, and covered by a test that
  asserts no request is made and the unattempted key is reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 08:20:42 -04:00
cliffhall
463227c947 fix(auth): make snapshot-and-clear one atomic storage step (#2144)
Copilot review round 18. Three of its findings were the same check-then-act:
`planOAuthRevocation` is asynchronous, so the gap between its reads and the
following `storage.clear()` is still a window — shorter than the network one
closed in round 13, but the same shape. An OAuth completion landing at any of
those awaits saved a grant the clear then destroyed.

`OAuthStorage.takeRevocationSnapshot(serverUrl)` reads what revocation needs
and clears the server in ONE synchronous pass over the in-memory state, with
the persist after the mutation. `clearAndPlanRevocation` replaces
`planOAuthRevocation`, and there is no separate `clear` call left in any
caller — the clear cannot be forgotten or reordered.

The snapshot returns values unparsed, deliberately: schema validation is pure
and belongs after the mutation, since running it inside would reintroduce the
`await` this exists to remove.

Two consequences worth naming. A failure of the take-and-clear now REJECTS
rather than returning an outcome, because the state was not cleared and the
caller's contract is that it was; both clients already have a rejection path.
And cross-process atomicity is explicitly not claimed — nothing in this store
takes a lock, every mutation is a read-modify-write over a loaded snapshot, and
`clear` alone always had that property.

Also from round 18:

- The CLI's budget check ran before considering `plan.outcome`, so a key that
  needed no network was reported as budget-exhausted — warning that a grant may
  be live when that key held none.
- A disconnect failure after a successful clear was reported as "could not
  clear OAuth state" in both clients. It now goes to the disconnect channel,
  and the success is still reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 07:40:52 -04:00
cliffhall
b9012da988 fix: close three stale-completion holes from the round-16 suppressed block (#2144)
Copilot's round 16 reported "no new comments", but its suppressed block carried
three real findings.

- `AuthTab` released `clearInFlightRef` before checking whether the completion
  was still current, so server A settling after the user moved to B and started
  a clear there dropped B's lock and let a second B clear run concurrently
  against the same store entry. `current()` is now checked before either shared
  ref is touched: a stale clear owns nothing.
- Switching servers retired the clear but left `ownClearRef` set. The retired
  clear returns before its `oauthRevision` bump, so the marker had no bump to
  skip and would swallow the first unrelated revision on the newly selected
  server, stranding that server's banner.
- The web session check compared only the server id, which a
  disconnect/reconnect to the SAME server passes — that flow builds a
  replacement `InspectorClient`, so the old clear would have run its
  session-wide cleanup against the new session. The client identity is part of
  the check now.

Both new tests were verified to fail with their fix reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 06:47:31 -04:00
cliffhall
5170cc484b fix(auth): close the fail-open issuer check and the remaining clear races (#2144)
Copilot review rounds 8 and 9.

- A cached metadata document with no `issuer` was letting every issuer-bound
  token go to its single revocation endpoint. Absence establishes nothing, so
  that is now a mismatch: an issuer-bound grant must match, and only a legacy
  unkeyed grant — which nothing binds to a different authorization server —
  proceeds without the comparison. This was a credential-disclosure risk, not
  just a correctness one.
- The ctx-less grant read was skipped whenever it carried an issuer stamp, on
  the assumption that slot had already been enumerated. If `listIssuers` fails
  there is no enumerated slot behind the stamp, so the one readable grant went
  unrevoked while `clear` deleted it. Both reads now share one issuer+token
  key, and the stamped issuer is retained rather than discarded.
- `AuthTab`'s repeat guard read `clearState`, which is not updated until React
  re-renders, so two `s` events in one input turn both started a clear against
  the same store entry. It is a ref now, set before the call.
- A rejected clear left `ownClearRef` set — `handleClearOAuth` never reached
  its `oauthRevision` bump — so the next unrelated revision was swallowed and
  the failure banner stranded. Cleared on that path.
- The web `clearServerOAuthAndDisconnect` had the staleness the TUI path was
  already fixed for: `isActive`/`inspectorClient` are captured before a
  five-second await, so a switch during it disconnected the session the user
  had just moved to and ran the session-wide cleanup against it. Revalidated
  against `sessionRef` after each await.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 04:38:38 -04:00
cliffhall
65fd2b9e35 feat(auth): revoke OAuth tokens at the authorization server on clear (#2144)
Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.

core/auth/revocation.ts builds and sends the request; the three clear paths
call it, so the behavior is written once — web's "Clear OAuth state and
disconnect" (both the active and stored-only paths), the TUI's Auth tab,
and the CLI's --relogin. It runs BEFORE the local clear, because the token,
the client credentials and the discovered `revocation_endpoint` all live in
the store the clear is about to empty. The request names the refresh token
when there is one: §2.1 asks the AS to also invalidate the access tokens
issued under the same grant, so one request covers both halves.

Best-effort by construction. Every path returns a TokenRevocationOutcome
rather than throwing: no advertised endpoint means nothing is sent and that
authorization server behaves exactly as before, and a network error, a
non-2xx, a 5s timeout or an unreadable store is reported and nothing more.
Forgetting the tokens is what the user asked for. `lost_authorization_state`
recovery deliberately skips it — it clears a half-finished flow to retry it,
and an authorization that never completed has no grant to revoke.

Client authentication resolves the preregistered slot first, mirroring
BaseOAuthClientProvider.clientInformation; reading only the dynamic slot
would send no authentication for a server configured with `oauth.clientId`.
The Basic credential is byte-identical to the SDK's applyBasicAuth so this
request and the token request cannot present the same secret differently.

Opt out per server with `oauth.revokeOnClear` (default on, only `false`
written to disk) or per CLI run with --no-revoke. Read at clear time rather
than connect time, so toggling needs no reconnect. Turning it off is a
testing affordance: a client that walks away still holding live tokens is a
case a server author may want to reproduce.

test-servers now advertises `revocation_endpoint` and serves POST
/oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1
(revoking a refresh token invalidates the access tokens under the same
grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as
showcase configs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-28 00:48:57 -04:00
cliffhall
5f875e3393 chore(web): address Copilot review on #2153
- Publish `setupClientForServerRef` from a layout effect rather than during
  render. React runs every layout effect before any passive effect of the same
  commit, and the /oauth/callback effect is passive, so the seam is still
  published before its first read — without the render-phase ref mutation the
  compiler rule rejects. This was the one `react-hooks/refs` finding the PR
  introduced, so its suppression is gone rather than deferred to #2161 under a
  justification that did not describe it.
- Narrow the hook's `fetchLogRef` option to `FetchRequestSource`, the
  `getFetchRequests` slice it actually consumes, mirroring
  `InspectorClientEventSource` in useProgressToasts. That removes the test
  harness's `as unknown as` cast, and the narrowed contract is real: it
  immediately rejected the loosely-typed fixture, which is now a properly typed
  `FetchRequestEntry`.
- Make the test harness hold both refs across renders and publish them from a
  layout effect, as App.tsx does. The deferred-publication test previously
  passed because `Probe` built a fresh ref each render and the rerender also
  changed `servers`; it now asserts what the guard is actually for — a run that
  cannot exchange must not spend the single-use code — and re-triggers on the
  `servers` change that carries it in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 23:19:49 -04:00
cliffhall
ed38a5e5fe chore(web): extract App.tsx's OAuth recovery cluster into useOAuthRecovery (#2153)
Phase 2 step 2 of the App.tsx decomposition (#2129, under #2126). App.tsx goes
from 4,267 to 3,178 lines; no behaviour change.

The new hook owns the step-up slot and its retry operation, the re-auth banner,
the deferred background-tab recovery, the redirect plumbing (webOAuthStorage,
sessionStorageAdapter, onBeforeOAuthRedirect, prepareOAuthRedirect), the
command-scoped recovery wrappers, the four OAuth effects, the abandoned-redirect
check, the /oauth/callback completion, the Connection Info OAuth details, and
the clear/finalize paths. All five once-only latches move with it unchanged.

Three seams could not be a straight move:

- useSessionRef no longer takes pendingStepUp/pendingReauth. They are this
  hook's own state, so App.tsx would have had to hold them purely to hand them
  back; instead the owner mirrors them into the ref in an effect of its own,
  following the shape activeServerName already had.
- setupClientForServer is injected through a ref, because it reads
  onBeforeOAuthRedirect and sessionStorageAdapter out of this hook and so
  cannot be declared before it. The callback effect reads the ref before
  burning its one-shot latch.
- The two clear-OAuth modal adapters and onReauthenticateFromBanner stay in
  App.tsx: they bind App-owned state, and the latter drives a reconnect, which
  step 3 owns.

Dropping ~1,100 lines took App.tsx under the React Compiler's bail-out, so the
compiler-backed react-hooks rules now analyse it and report nine pre-existing
findings that were previously invisible. Each is suppressed with a narrow
justified disable naming #2161, which is filed to resolve them; the one new
finding (exhaustive-deps on setReAuthBanner) is fixed rather than suppressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 23:00:12 -04:00