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>
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>
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>
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>
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>
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>
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>
Copilot review round 4.
- `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that
issuer holds none — right for a connect, wrong for `collectGrants`, which
labelled the fallback with the issuer it happened to be iterating and paired
it with that issuer's credentials. During a partially migrated flow the clear
could have sent an old, unbound token to a newly discovered authorization
server. `OAuthStorage.getIssuerTokens` is the exact, no-fallback read; only
the ctx-less read still falls back, which is the one place it belongs.
- The TUI's warning tone was sticky. It lived in its own state while ~30 call
sites set ordinary OAuth notes without resetting it, so one revocation
failure left every later "Authorization updated" yellow, across server
switches. It is now DERIVED — `oauthMessageToneFor(message, warningText)` —
so it cannot outlive the message that earned it, with nothing to remember to
clear. Both setters stay plain `useState` setters: a `useCallback` wrapper
would be a value `react-hooks/exhaustive-deps` demands in seven dependency
arrays for an identity that never changes.
- Hook-level tests for the web wiring (previously missed): the per-server
opt-out and the backend-proxied fetch are now asserted to reach
`clearServerOAuthState`, so handing it the page-origin fetch — which a real
authorization server's missing CORS headers would reject — fails a test.
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>
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>
- 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>
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>