fix(oauth): token 兑换改用 HTTP Basic(client_secret_basic),修 401
Cloudflare OAuth 客户端默认 token_endpoint_auth_method = client_secret_basic, 只接受 Authorization: Basic 头。之前把 client_id/secret 放在 POST body (client_secret_post),token 端点直接 401 invalid_client,描述里点名 "method 'client_secret_post' was requested" —— 表现为 Cloudflare 授权成功、 回调却报 token exchange failed: 401。 - exchangeToken 改 Basic 认证,凭据按 RFC 6749 §2.3.1 先 form-urlencoded 再 base64 - 失败时带回 Cloudflare 原始 error/error_description;invalid_client 额外提示 「secret 只在创建时显示一次,轮换后旧值失效」 - cf-mock 复刻「只认 Basic」的行为,没有 Basic 头就返回 401(旧代码会被打回) - 断言 token body 不含 client_id/client_secret,Basic 头解出正确凭据 - 88/88 测试
This commit is contained in:
@@ -400,25 +400,72 @@ export async function getCFApi(env: Env): Promise<CFApi> {
|
||||
return new RealCFApi(client);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 生成 HTTP Basic 认证头的内容(RFC 6749 §2.3.1)
|
||||
* 参数: id - 客户端 ID;secret - 客户端密钥
|
||||
* 返回值: base64(`编码后的 id:编码后的 secret`)
|
||||
*/
|
||||
export function basicAuthValue(id: string, secret: string): string {
|
||||
// 先做 form-urlencoded 编码(Go 的 url.UserPassword 与 RFC 都是这个语义):
|
||||
// encodeURIComponent 不转义 !'()* ,这里补齐成与 unreserved 集一致
|
||||
const enc = (s: string) =>
|
||||
encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
return btoa(`${enc(id)}:${enc(secret)}`);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 把 token 端点的失败响应翻译成一句可读的说明(取 Cloudflare 的 error / error_description)
|
||||
*/
|
||||
async function describeTokenError(resp: Response): Promise<string> {
|
||||
const fallback = `HTTP ${resp.status}`;
|
||||
let detail = "";
|
||||
let code = "";
|
||||
try {
|
||||
const body = (await resp.json()) as { error?: string; error_description?: string };
|
||||
code = body.error ?? "";
|
||||
detail = [body.error, body.error_description].filter(Boolean).join(": ");
|
||||
} catch {
|
||||
// 非 JSON 响应,退回状态码
|
||||
}
|
||||
if (!detail) return fallback;
|
||||
// invalid_client 是最常见的失败:凭据不匹配或 secret 已轮换,直接给出可执行提示
|
||||
const hint =
|
||||
code === "invalid_client"
|
||||
? " —— 请到设置 → Cloudflare 接入 重新填写 Client ID / Client Secret(secret 只在创建时显示一次,轮换后旧值立即失效)"
|
||||
: "";
|
||||
return `${resp.status} ${detail.slice(0, 300)}${hint}`;
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare API 实现 **/
|
||||
export class RealCFApi implements CFApi {
|
||||
/** # 成员变量: OAuth 客户端凭据(settings 或 env) **/
|
||||
constructor(private client: CFOAuthClient) {}
|
||||
|
||||
/** 用 code 换 access token。
|
||||
* **认证方式必须是 HTTP Basic(client_secret_basic)**:Cloudflare OAuth 客户端默认
|
||||
* `token_endpoint_auth_method = client_secret_basic`,若把 client_id/secret 放在 POST body
|
||||
* (`client_secret_post`)会被拒,返回 401 invalid_client 且描述里点名
|
||||
* "method 'client_secret_post' was requested"。 */
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
// RFC 6749 §2.3.1:先对 id 与 secret 做 form-urlencoded 编码,再拼成 Basic 头
|
||||
Authorization: `Basic ${basicAuthValue(this.client.clientId, this.client.clientSecret)}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: this.client.clientId,
|
||||
client_secret: this.client.clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`cloudflare token exchange failed: ${resp.status}`);
|
||||
if (!resp.ok) {
|
||||
// 带上 Cloudflare 原始的 error / error_description,否则只能看到一个光秃秃的 401
|
||||
throw new Error(`cloudflare token exchange failed: ${await describeTokenError(resp)}`);
|
||||
}
|
||||
const data = (await resp.json()) as { access_token: string; expires_in?: number };
|
||||
if (!data.access_token) throw new Error("cloudflare token exchange returned no access_token");
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user