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) {