Commit graph inspector/core/auth/revocation.ts
Author SHA1 Message Date
cliffhall
781aa69d19 fix(auth): revoke every issuer-bound grant, not just the active one (#2144)
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>
2026-08-28 02:40:28 -04:00
cliffhall
7f27d7e9c7 fix(auth): address Copilot review round 1 on the RFC 7009 revocation (#2144)
- 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>
2026-08-28 01:30:09 -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