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
|
|
@ -117,7 +117,7 @@ export function setupOAuthRoutes(
|
|||
setupAuthorizationEndpoint(app, config);
|
||||
setupTokenEndpoint(app, config);
|
||||
if (config.supportRevocation !== false) {
|
||||
setupRevocationEndpoint(app);
|
||||
setupRevocationEndpoint(app, config);
|
||||
}
|
||||
if (config.supportDCR) {
|
||||
setupDCREndpoint(app);
|
||||
|
|
@ -711,21 +711,36 @@ function setupTokenEndpoint(
|
|||
* That is why the Inspector sends one request naming the refresh token, and a
|
||||
* fixture that ignored the linkage would let a regression through silently.
|
||||
*
|
||||
* Client authentication is accepted but not enforced: the point of the fixture
|
||||
* is the revocation semantics, and every client this repo drives it with is
|
||||
* either public or has already authenticated at the token endpoint.
|
||||
* Client authentication is **enforced**, not merely accepted. RFC 7009 §2.1
|
||||
* requires it of a confidential client, and a fixture that skipped the check
|
||||
* would answer 200 to a request carrying no `Authorization` header at all — at
|
||||
* which point the end-to-end test claiming to prove the Inspector authenticates
|
||||
* correctly proves nothing. Both RFC 6749 §2.3.1 forms are accepted (Basic and
|
||||
* the request body), as is a public client identifying itself by `client_id`.
|
||||
*/
|
||||
function setupRevocationEndpoint(app: express.Application): void {
|
||||
function setupRevocationEndpoint(
|
||||
app: express.Application,
|
||||
config: OAuthConfig,
|
||||
): void {
|
||||
app.post(
|
||||
"/oauth/revoke",
|
||||
express.urlencoded({ extended: true }),
|
||||
(req: Request, res: Response) => {
|
||||
async (req: Request, res: Response) => {
|
||||
const token: unknown = req.body?.token;
|
||||
if (typeof token !== "string" || token === "") {
|
||||
res.status(400).json({ error: "invalid_request" });
|
||||
return;
|
||||
}
|
||||
|
||||
const authenticated = await authenticateRevocationClient(req, config);
|
||||
if (!authenticated) {
|
||||
res
|
||||
.status(401)
|
||||
.set("WWW-Authenticate", 'Basic realm="revoke"')
|
||||
.json({ error: "invalid_client" });
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTokenData = refreshTokens.get(token);
|
||||
if (refreshTokenData) {
|
||||
for (const accessToken of refreshTokenData.accessTokens) {
|
||||
|
|
@ -744,6 +759,52 @@ function setupRevocationEndpoint(app: express.Application): void {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the caller of `/oauth/revoke` (RFC 7009 §2.1).
|
||||
*
|
||||
* Credentials may arrive either way RFC 6749 §2.3.1 allows — an `Authorization:
|
||||
* Basic` header or `client_id`/`client_secret` in the form body — because the
|
||||
* Inspector picks between them from the metadata, and a fixture that only read
|
||||
* one would silently pass a request whose credentials went to the other place.
|
||||
*
|
||||
* A request naming no client at all is rejected: the Inspector always sends at
|
||||
* least `client_id` once it holds any client information, so an unidentified
|
||||
* request means it lost track of its credentials.
|
||||
*/
|
||||
async function authenticateRevocationClient(
|
||||
req: Request,
|
||||
config: OAuthConfig,
|
||||
): Promise<boolean> {
|
||||
let clientId: string | undefined;
|
||||
let clientSecret: string | undefined;
|
||||
|
||||
const authorization = req.get("authorization");
|
||||
if (authorization?.startsWith("Basic ")) {
|
||||
const decoded = Buffer.from(
|
||||
authorization.slice("Basic ".length),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
const separator = decoded.indexOf(":");
|
||||
if (separator === -1) return false;
|
||||
clientId = decoded.slice(0, separator);
|
||||
clientSecret = decoded.slice(separator + 1);
|
||||
} else {
|
||||
const bodyId: unknown = req.body?.client_id;
|
||||
const bodySecret: unknown = req.body?.client_secret;
|
||||
if (typeof bodyId === "string") clientId = bodyId;
|
||||
if (typeof bodySecret === "string") clientSecret = bodySecret;
|
||||
}
|
||||
|
||||
if (!clientId) return false;
|
||||
const client = await findClient(clientId, config);
|
||||
if (!client) return false;
|
||||
// A client registered with a secret must present it; a public one must not be
|
||||
// asked for one it never had.
|
||||
return client.clientSecret === undefined
|
||||
? true
|
||||
: clientSecret === client.clientSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Dynamic Client Registration endpoint
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue