From 5fad2975fe3b2d784ec804497fa880c26d7c5cc2 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 20 Jul 2026 15:35:01 +0200 Subject: [PATCH] fix(oauth): validate confidential revoke secrets --- src/commands/serve-http.ts | 40 +++++++++++++++++++++++++++++++ test/e2e/serve-http-oauth.test.ts | 22 +++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index ee8da69ba..7d9ebee2f 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -745,6 +745,46 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption } }); + // The SDK's /revoke handler compares the presented secret with + // client.client_secret as plaintext. GBrain stores only a SHA-256 hash, so + // confidential clients need the same hash-aware validation used above for + // authorization_code and refresh_token exchanges. Public clients present no + // secret and continue through to the SDK's PKCE-compatible handler. + app.post('/revoke', ccRateLimiter, express.urlencoded({ extended: false }), async (req, res, next) => { + const token = req.body?.token; + const bodySecret: string | undefined = req.body?.client_secret; + let clientId: string | undefined = req.body?.client_id; + let presentedSecret: string | undefined = bodySecret; + const authHeader = (req.headers.authorization ?? '').toString(); + if (!presentedSecret && authHeader.startsWith('Basic ')) { + try { + const decoded = Buffer.from(authHeader.slice('Basic '.length), 'base64').toString('utf8'); + const idx = decoded.indexOf(':'); + if (idx > -1) { + clientId ||= decodeURIComponent(decoded.slice(0, idx)); + presentedSecret = decodeURIComponent(decoded.slice(idx + 1)); + } + } catch { + // Let the SDK return its canonical response for malformed Basic auth. + } + } + if (!clientId || !presentedSecret) return next(); + + try { + const client = await oauthProvider.verifyConfidentialClientSecret(clientId, presentedSecret); + if (!token) { + res.status(400).json({ error: 'invalid_request', error_description: 'token required' }); + return; + } + await oauthProvider.revokeToken(client, { token }); + // RFC 7009 §2.2: successful revocation, including an unknown token, is 200. + res.status(200).end(); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid client'; + res.status(401).json({ error: 'invalid_client', error_description: msg }); + } + }); + // --------------------------------------------------------------------------- // MCP SDK Auth Router (OAuth endpoints) // --------------------------------------------------------------------------- diff --git a/test/e2e/serve-http-oauth.test.ts b/test/e2e/serve-http-oauth.test.ts index b7f29bae2..fbd3dafe7 100644 --- a/test/e2e/serve-http-oauth.test.ts +++ b/test/e2e/serve-http-oauth.test.ts @@ -407,6 +407,28 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1 + v0.26.2 + v0.26.3)', () => { expect(data.error).toBe('invalid_grant'); }); + test('confidential client can revoke its token only with its valid secret', async () => { + const { access_token } = await mintToken('read'); + const wrongSecret = await fetch(`${BASE}/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `token=${encodeURIComponent(access_token)}&client_id=${clientId}&client_secret=gbrain_cs_wrong_secret`, + }); + expect(wrongSecret.status).toBe(401); + expect((await wrongSecret.json() as any).error).toBe('invalid_client'); + + // A rejected revoke request must leave the token usable. + expect((await mcpCall(access_token, 'tools/list')).status).not.toBe(401); + + const revoke = await fetch(`${BASE}/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `token=${encodeURIComponent(access_token)}&client_id=${clientId}&client_secret=${clientSecret}`, + }); + expect(revoke.status).toBe(200); + expect((await mcpCall(access_token, 'tools/list')).status).toBeGreaterThanOrEqual(400); + }, 15_000); + // ========================================================================= // v0.26.2: DCR /register response shape (RFC 7591 §3.2.1 number contract) // =========================================================================