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);
|
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 实现 **/
|
/** # 成员变量: Cloudflare API 实现 **/
|
||||||
export class RealCFApi implements CFApi {
|
export class RealCFApi implements CFApi {
|
||||||
/** # 成员变量: OAuth 客户端凭据(settings 或 env) **/
|
/** # 成员变量: OAuth 客户端凭据(settings 或 env) **/
|
||||||
constructor(private client: CFOAuthClient) {}
|
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) {
|
async exchangeToken(code: string, redirectUri: string) {
|
||||||
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
||||||
method: "POST",
|
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({
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code,
|
code,
|
||||||
client_id: this.client.clientId,
|
|
||||||
client_secret: this.client.clientSecret,
|
|
||||||
redirect_uri: redirectUri,
|
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 };
|
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 };
|
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.3.4",
|
"version": "2.3.5",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+15
-1
@@ -13,7 +13,7 @@ export interface CfCall {
|
|||||||
url: string;
|
url: string;
|
||||||
/** 路径(去掉 base) **/
|
/** 路径(去掉 base) **/
|
||||||
path: string;
|
path: string;
|
||||||
/** Authorization 头(OAuth Bearer token) **/
|
/** Authorization 头(Cloudflare API 是 OAuth Bearer token;token 端点是 Basic) **/
|
||||||
authorization: string | null;
|
authorization: string | null;
|
||||||
/** 请求体文本(URL 编码或 JSON);multipart 上传记 null **/
|
/** 请求体文本(URL 编码或 JSON);multipart 上传记 null **/
|
||||||
body: string | null;
|
body: string | null;
|
||||||
@@ -122,6 +122,20 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
|||||||
return new Response(null, { status: 302, headers: { Location: "https://dash.cloudflare.com/login" } });
|
return new Response(null, { status: 302, headers: { Location: "https://dash.cloudflare.com/login" } });
|
||||||
}
|
}
|
||||||
if (url === TOKEN_URL) {
|
if (url === TOKEN_URL) {
|
||||||
|
// 复刻 Cloudflare 的客户端认证要求:只接受 HTTP Basic(client_secret_basic)。
|
||||||
|
// 把 client_id/secret 放进 POST body(client_secret_post)会被 401 invalid_client 拒绝,
|
||||||
|
// 且错误描述会点名 "method 'client_secret_post' was requested"(2026-09-30 线上踩过)。
|
||||||
|
const auth = headers.get("authorization") ?? "";
|
||||||
|
if (!auth.startsWith("Basic ")) {
|
||||||
|
const desc =
|
||||||
|
"Client authentication failed (e.g., unknown client, no client authentication included, " +
|
||||||
|
"or unsupported authentication method). The OAuth 2.0 Client supports client authentication " +
|
||||||
|
"method 'client_secret_basic', but method 'client_secret_post' was requested.";
|
||||||
|
return new Response(JSON.stringify({ error: "invalid_client", error_description: desc }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
if ((opts.tokenStatus ?? 200) !== 200) {
|
if ((opts.tokenStatus ?? 200) !== 200) {
|
||||||
return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus });
|
return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus });
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-2
@@ -78,11 +78,19 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
expect(cf.hit("POST", "/oauth2/token")).toBe(true);
|
expect(cf.hit("POST", "/oauth2/token")).toBe(true);
|
||||||
expect(cf.hit("GET", "/zones?")).toBe(true);
|
expect(cf.hit("GET", "/zones?")).toBe(true);
|
||||||
expect(cf.hit("PUT", "/workers/scripts/")).toBe(true);
|
expect(cf.hit("PUT", "/workers/scripts/")).toBe(true);
|
||||||
// token 兑换请求带真实 code 与 client 凭据
|
// token 兑换请求:body 只带协议参数,客户端凭据走 HTTP Basic 头(RFC 6749 §2.3.1)
|
||||||
const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? "";
|
const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? "";
|
||||||
expect(tokenBody).toContain("grant_type=authorization_code");
|
expect(tokenBody).toContain("grant_type=authorization_code");
|
||||||
expect(tokenBody).toContain("code=auth-code-1");
|
expect(tokenBody).toContain("code=auth-code-1");
|
||||||
expect(tokenBody).toContain("client_secret=test-client-secret");
|
expect(tokenBody).toContain("redirect_uri=");
|
||||||
|
// 凭据不能出现在 body 里:Cloudflare 的 client_secret_basic 会判 client_secret_post 并 401
|
||||||
|
expect(tokenBody).not.toContain("client_secret");
|
||||||
|
expect(tokenBody).not.toContain("client_id");
|
||||||
|
const basic = (cf.calls.find((c) => c.path.includes("/oauth2/token"))?.authorization ?? "").replace(
|
||||||
|
/^Basic /,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
expect(atob(basic)).toBe("test-client-id:test-client-secret");
|
||||||
|
|
||||||
// OAuth 只同步域名,Email Routing 按域名手动触发
|
// OAuth 只同步域名,Email Routing 按域名手动触发
|
||||||
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["example.com", "demo.com"]));
|
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["example.com", "demo.com"]));
|
||||||
@@ -270,6 +278,44 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
expect(acct.results.length).toBe(1);
|
expect(acct.results.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("token 端点只接受 HTTP Basic:失败时把 Cloudflare 的原始描述带回页面", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
mock = installCloudflareMock();
|
||||||
|
const { state } = (await (
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
|
).json()) as { state: string };
|
||||||
|
|
||||||
|
// 把 token 端点换成「只认 client_secret_basic」的桩(与真实 Cloudflare 一致)
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url === "https://dash.cloudflare.com/oauth2/token") {
|
||||||
|
const headers = new Headers(init?.headers ?? {});
|
||||||
|
if (!(headers.get("authorization") ?? "").startsWith("Basic ")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "invalid_client",
|
||||||
|
error_description:
|
||||||
|
"Client authentication failed. The OAuth 2.0 Client supports client authentication " +
|
||||||
|
"method 'client_secret_basic', but method 'client_secret_post' was requested.",
|
||||||
|
}),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return realFetch(input, init);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const resp = await t.app.fetch(
|
||||||
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
||||||
|
t.env,
|
||||||
|
);
|
||||||
|
// 我们用的是 Basic,所以这里应当兑换成功而不是 401
|
||||||
|
expect(resp.status).toBe(302);
|
||||||
|
expect(resp.headers.get("Location")).toContain("ok=1");
|
||||||
|
});
|
||||||
|
|
||||||
test("rotate:Worker credential 轮换,secret 仅本次可见且旧 credential 撤销", async () => {
|
test("rotate:Worker credential 轮换,secret 仅本次可见且旧 credential 撤销", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
|
|||||||
Reference in New Issue
Block a user