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>
This commit is contained in:
parent
baff393675
commit
65fd2b9e35
2 changed files with 2159 additions and 46 deletions
|
|
@ -707,6 +707,7 @@ describe("ServerSettingsForm", () => {
|
|||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
requestRefreshToken: true,
|
||||
revokeOnClear: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -751,6 +752,47 @@ describe("ServerSettingsForm", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// #2144 — the RFC 7009 opt-out. On by default: leaving it off silently is
|
||||
// what leaves the authorization server holding a live grant.
|
||||
it("renders Revoke tokens on clear checked by default", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={emptySettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Revoke tokens on clear")).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders Revoke tokens on clear unchecked when the server opted out", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, oauthRevokeOnClear: false }}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Revoke tokens on clear")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("toggles Revoke tokens on clear through onOAuthChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={emptySettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText("Revoke tokens on clear"));
|
||||
expect(onOAuthChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ revokeOnClear: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when opting out while offline_access is still in Scopes", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
|
|
@ -1255,6 +1297,7 @@ describe("ServerSettingsForm", () => {
|
|||
tokenUrl: "",
|
||||
enterpriseManaged: true,
|
||||
requestRefreshToken: true,
|
||||
revokeOnClear: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1363,6 +1406,7 @@ describe("ServerSettingsForm", () => {
|
|||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
requestRefreshToken: true,
|
||||
revokeOnClear: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -521,6 +521,8 @@ export function ServerSettingsForm({
|
|||
onInsufficientScope: settings.oauthOnInsufficientScope,
|
||||
// Unset means the default, on — only an explicit opt-out is stored.
|
||||
requestRefreshToken: settings.oauthRequestRefreshToken ?? true,
|
||||
// Unset means the default, on — only an explicit opt-out is stored.
|
||||
revokeOnClear: settings.oauthRevokeOnClear ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -570,6 +572,15 @@ export function ServerSettingsForm({
|
|||
});
|
||||
}
|
||||
|
||||
function handleRevokeOnClearChange(
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
): void {
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
revokeOnClear: event.currentTarget.checked,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddAuthorizationParam(): void {
|
||||
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
|
||||
}
|
||||
|
|
@ -917,6 +928,12 @@ export function ServerSettingsForm({
|
|||
checked={settings.oauthRequestRefreshToken ?? true}
|
||||
onChange={handleRequestRefreshTokenChange}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Revoke tokens on clear"
|
||||
description="Calls the authorization server's RFC 7009 revocation endpoint before clearing the stored OAuth state, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
|
||||
checked={settings.oauthRevokeOnClear ?? true}
|
||||
onChange={handleRevokeOnClearChange}
|
||||
/>
|
||||
{refreshTokenOptedOut && scopesIncludeOfflineAccess ? (
|
||||
<Alert color="yellow" title="offline_access is still requested">
|
||||
The Scopes field above lists offline_access, and
|
||||
|
|
|
|||
Loading…
Reference in a new issue