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>
This commit is contained in:
parent
15f381c6ad
commit
7f27d7e9c7
1 changed files with 617 additions and 87 deletions
|
|
@ -87,20 +87,23 @@ export function selectRevocableToken(
|
|||
}
|
||||
|
||||
/**
|
||||
* The client-authentication methods to consider for the revocation endpoint.
|
||||
* The client-authentication methods the revocation endpoint advertises.
|
||||
*
|
||||
* RFC 8414 gives `revocation_endpoint_auth_methods_supported` the same default
|
||||
* as the token endpoint's when it is omitted, so fall through to
|
||||
* `token_endpoint_auth_methods_supported` before giving up. An empty result
|
||||
* lets {@link selectClientAuthMethod} apply the SDK's own default rather than
|
||||
* this module inventing a second one.
|
||||
* RFC 8414 §2 gives `revocation_endpoint_auth_methods_supported` a default of
|
||||
* **`client_secret_basic`** when it is omitted — it does *not* inherit
|
||||
* `token_endpoint_auth_methods_supported`. Falling through to the token
|
||||
* endpoint's list would make metadata advertising only `client_secret_post`
|
||||
* there send POST credentials to a revocation endpoint that never advertised
|
||||
* that method, which a strict server rejects.
|
||||
*
|
||||
* An empty result is returned rather than a literal `["client_secret_basic"]`
|
||||
* because that is what makes {@link selectClientAuthMethod} apply exactly the
|
||||
* RFC's default — `client_secret_basic` when the client holds a secret, `none`
|
||||
* when it does not — while still honoring a `token_endpoint_auth_method` the
|
||||
* client's own registration declares.
|
||||
*/
|
||||
export function revocationAuthMethods(metadata: OAuthMetadata): string[] {
|
||||
return (
|
||||
metadata.revocation_endpoint_auth_methods_supported ??
|
||||
metadata.token_endpoint_auth_methods_supported ??
|
||||
[]
|
||||
);
|
||||
return metadata.revocation_endpoint_auth_methods_supported ?? [];
|
||||
}
|
||||
|
||||
export interface RevocationRequestParams {
|
||||
|
|
@ -195,10 +198,18 @@ export async function revokeToken(
|
|||
const { url, init } = buildRevocationRequest(params);
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
|
||||
try {
|
||||
const response = await params.fetchFn(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
// The signal alone is not enough to bound this. In the browser the fetch is
|
||||
// `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch`
|
||||
// and does not forward `init.signal`; the backend's outbound fetch gets no
|
||||
// signal either. So a wedged authorization server would hold the teardown
|
||||
// open indefinitely on exactly the path the timeout exists for. The race is
|
||||
// what actually enforces the deadline; the signal is kept because it does
|
||||
// cancel the direct-fetch paths (CLI, TUI, backend) rather than merely
|
||||
// abandoning them.
|
||||
const response = await withDeadline(
|
||||
params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
|
||||
timeoutMs,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: "failed",
|
||||
|
|
@ -221,6 +232,34 @@ export async function revokeToken(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject with a timeout error if `promise` has not settled within `timeoutMs`.
|
||||
*
|
||||
* The underlying request is abandoned rather than cancelled — nothing here can
|
||||
* cancel a fetch the proxy already stripped the signal from. That is the right
|
||||
* trade for this caller: the point is that the *teardown* proceeds, and the
|
||||
* response, if it ever arrives, is a revocation we no longer need to wait for.
|
||||
* The timer is cleared on the settled path so a caller is never held awake by it.
|
||||
*/
|
||||
async function withDeadline<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(new Error(`revocation request timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, deadline]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RevokeStoredOAuthTokensParams {
|
||||
serverUrl: string;
|
||||
storage: OAuthStorage;
|
||||
|
|
|
|||
Loading…
Reference in a new issue