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:
+48
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user