fix(oauth): accept client_secret_basic on /oauth/token (RFC 6749 §2.3.1)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s

Sovereign-audit Phase 3 caught the next layer of the same bug:
form-urlencoded parsing now works, but the AS metadata advertises
both `client_secret_basic` and `client_secret_post` while the handler
only read credentials from the body. Claude Desktop (and most OAuth
SDKs) prefer Basic auth, so every token exchange landed at
"401 invalid_client" — visible in prod logs as POST /oauth/token from
160.79.106.37 returning 401 in <4ms (failing the missing-secret check).

Parse Authorization: Basic header, decode base64, percent-decode each
side (RFC 6749 §2.3.1 mandates pct-encoding of user/pass before the
base64 step), and treat the resulting credentials as if they came from
the body. Header takes precedence when both are present.
This commit is contained in:
Marco Sadjadi 2026-05-28 21:28:23 +02:00
parent 44cebc9fd8
commit b421457010

View File

@ -279,6 +279,33 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
const parsed = Body.safeParse(body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
// RFC 6749 §2.3.1: confidential clients MAY authenticate via HTTP Basic
// (preferred) OR via client_id+client_secret in the request body. Our AS
// metadata advertises both `client_secret_basic` and `client_secret_post`,
// and Claude Desktop / most SDKs default to Basic. Without parsing the
// header here every Basic-style POST hit the "missing client_secret"
// branch and returned 401 invalid_client right after DCR succeeded —
// exactly matching the production log signature.
//
// Header credentials take precedence when present; body credentials are
// only used when the header is absent. Either form must reach the same
// validation paths below.
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Basic ')) {
try {
const decoded = Buffer.from(authHeader.slice(6).trim(), 'base64').toString('utf8');
const sep = decoded.indexOf(':');
if (sep > 0) {
const headerClientId = decodeURIComponent(decoded.slice(0, sep));
const headerSecret = decodeURIComponent(decoded.slice(sep + 1));
if (headerClientId) parsed.data.client_id = headerClientId;
if (headerSecret) parsed.data.client_secret = headerSecret;
}
} catch {
return reply.code(401).send({ error: 'invalid_client' });
}
}
if (parsed.data.grant_type === 'authorization_code') {
const { code, code_verifier, client_id, client_secret, redirect_uri, resource } = parsed.data;
if (!code || !code_verifier || !client_id || !redirect_uri || !resource) {