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
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:
parent
44cebc9fd8
commit
b421457010
@ -279,6 +279,33 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const parsed = Body.safeParse(body);
|
const parsed = Body.safeParse(body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
|
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') {
|
if (parsed.data.grant_type === 'authorization_code') {
|
||||||
const { code, code_verifier, client_id, client_secret, redirect_uri, resource } = parsed.data;
|
const { code, code_verifier, client_id, client_secret, redirect_uri, resource } = parsed.data;
|
||||||
if (!code || !code_verifier || !client_id || !redirect_uri || !resource) {
|
if (!code || !code_verifier || !client_id || !redirect_uri || !resource) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user