fix(oauth): validate confidential revoke secrets

This commit is contained in:
Robin
2026-07-20 15:35:01 +02:00
parent f72de97943
commit 5fad2975fe
2 changed files with 62 additions and 0 deletions
+40
View File
@@ -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)
// ---------------------------------------------------------------------------
+22
View File
@@ -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)
// =========================================================================