diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index 5af48a4..0021e42 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -400,25 +400,72 @@ export async function getCFApi(env: Env): Promise { 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 { + 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 }; } diff --git a/package.json b/package.json index 5b2de2b..715ac92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.3.4", + "version": "2.3.5", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "private": true, "type": "module", diff --git a/tests/cf-mock.ts b/tests/cf-mock.ts index 032fec2..b1820b4 100644 --- a/tests/cf-mock.ts +++ b/tests/cf-mock.ts @@ -13,7 +13,7 @@ export interface CfCall { url: string; /** 路径(去掉 base) **/ path: string; - /** Authorization 头(OAuth Bearer token) **/ + /** Authorization 头(Cloudflare API 是 OAuth Bearer token;token 端点是 Basic) **/ authorization: string | null; /** 请求体文本(URL 编码或 JSON);multipart 上传记 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" } }); } 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) { return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus }); } diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index a59a726..1374ad1 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -78,11 +78,19 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { expect(cf.hit("POST", "/oauth2/token")).toBe(true); expect(cf.hit("GET", "/zones?")).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") ?? ""; expect(tokenBody).toContain("grant_type=authorization_code"); 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 按域名手动触发 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); }); + 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 () => { const t = await makeTestEnv(); const cookie = await loginCookie(t);