Copilot review round 5.
- Deduplicating grants by token string alone could conflate two grants: a token
is only meaningful to the authorization server that minted it, so two issuers
minting the same opaque value are two grants. The second was dropped before
the issuer-mismatch check could report it, and `clear` then deleted it. The
key is now issuer+token; the ctx-less read stays suppressed only when its
token already came from a slot, which is exactly the active-issuer duplicate
and never the legacy one.
- A read failure in one issuer slot aborted `collectGrants`, so one corrupt
slot left every other revocable grant untouched while the clear deleted them
all. Each slot is now read independently and its failure carried as an
outcome alongside the grants that could still be revoked; the no-metadata and
no-endpoint returns carry them too, so a failure is never swallowed by a skip.
- The CLI no longer deduplicates its two key spellings. That could only be done
by pre-reading a single token, and `revokeStoredOAuthTokens` now enumerates
every issuer slot under a key — so a shared active token skipped the second
key entirely, taking any other issuer-bound grant under it with the local
delete. A duplicate RFC 7009 request is harmless (§2.2 makes an unknown token
a success); a missed one is the leak.
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>
Copilot review round 3.
`clear(serverUrl)` deletes EVERY `byIssuer` slot, but revocation read only the
context-free (active-issuer) token. A server that had authorized against
issuers A and B therefore revoked B, then destroyed the local record of A while
A's grant stayed live at its authorization server — the same leak this feature
exists to close, one level down.
- `OAuthStorage.listIssuers(serverUrl)` is the new seam (one implementation:
every backend extends `OAuthStorageBase`). `collectGrants` walks it plus the
ctx-less read, deduping by token, so every grant the clear will delete is
covered.
- Metadata is cached once per server, not per issuer, so a grant bound to an
issuer the cached document does not describe cannot be revoked: sending its
token to that endpoint would hand a credential to a server that never minted
it. That grant is reported as failed — dropped unrevoked — rather than
silently discarded or misdirected.
- `aggregateOutcomes` gives a failure precedence over another grant's success,
so one live grant cannot hide behind another's 200. Exported for its own
test, since `computeOutcome` returns early on the empty case.
Also from that round:
- The fixture's /oauth/revoke now verifies token ownership (RFC 7009 §2.1).
Access tokens record their owning `client_id`; a token belonging to another
client is left alone and still answered 200 (§2.2 — the response must not
tell one client whether another's token exists). New e2e case proves a
second registered client cannot revoke the first's grant.
- The TUI rendered the revocation-failure note in cyan, the informational
tone. It is a partial success — the local clear really happened — so it is
not an `error` status, but the grant may still be live and cyan understated
that. `AuthTab` takes an `oauthMessageTone`; the message says so explicitly.
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>
- RFC 8414 §2 defaults an omitted `revocation_endpoint_auth_methods_supported`
to `client_secret_basic`; it does not inherit the token endpoint's list.
Inheriting made metadata advertising only `client_secret_post` there send
POST credentials to an endpoint that never advertised the method.
- The 5s timeout was inert on the web paths: `createRemoteFetch` re-issues the
call as a POST to `/api/fetch` and drops `init.signal`, and the backend's
outbound fetch gets none either, so a wedged AS could hold the teardown open
indefinitely. A wall-clock race now enforces the deadline; the signal is kept
because it genuinely cancels the direct-fetch paths.
- The settings-modal Clear read `settingsModalTarget`, which comes from the
persisted `servers` list, while edits live in `settingsDraft` until the save
debounce. Toggling the checkbox and clearing immediately used the previous
value. It now merges the draft.
- `--relogin` clears both key spellings, so it now revokes from both rather
than stopping at the first populated one — a stale entry under the other
spelling is a live grant. The normalised key goes first, matching
`findStoredServerState`, and a second key holding the same token is skipped.
- The fixture's /oauth/revoke now ENFORCES RFC 7009 §2.1 client
authentication (both RFC 6749 §2.3.1 forms), so the e2e's claim that the
Inspector authenticates correctly is actually tested; two negative cases
assert a wrong secret and an unidentified client are refused.
- Dropped the `as unknown as` casts from the tests: a typed `InspectorLogger`
double, and a spy on the real storage instead of a spread-and-cast.
- Added CLI-level tests through `runCli` for `--no-revoke`, the per-server
`oauth.revokeOnClear`, and the stderr warning, and TUI tests for the
forwarded opt-out and the rendered failure.
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>