From b4214570103d279763ad0eb6ab5bb6e92397d7c4 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Thu, 28 May 2026 21:28:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(oauth):=20accept=20client=5Fsecret=5Fbasic?= =?UTF-8?q?=20on=20/oauth/token=20(RFC=206749=20=C2=A72.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/routes/oauth.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/api/src/routes/oauth.ts b/apps/api/src/routes/oauth.ts index f2959fb..9af545e 100644 --- a/apps/api/src/routes/oauth.ts +++ b/apps/api/src/routes/oauth.ts @@ -279,6 +279,33 @@ export async function oauthRoutes(app: FastifyInstance): Promise { 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) {