昨天決定把整個專案放進一個 Cloudflare Worker,登入交給 Cloudflare Access。程式在一個 PR 裡做完,deploy 也跑過了,但它只有 *.workers.dev 的網址,而且瀏覽器打任何 API 都是 401。原因是 Worker 被寫成一定要驗 Cloudflare Access 的 JWT,Access 卻還沒建,等於自己把門鎖死。
這個「鎖死」是刻意的。這個網站只給我一個人用,但上面有我去過哪裡、給幾分、寫了什麼筆記,不想公開;而 agent 每週會用 token 打 /api/ingest 寫資料,那條路又不能要求登入。所以需求是三件事:掛到自己的網域 kiwi-walk.com 底下、整站藏在登入後面、但 agent 走的幾條路徑不用登入,改由 Worker 自己驗 token。
子網域還是路徑。 kiwi-walk.com/art 看起來省一個網域,但要同時改 SPA 的 base path、PWA scope 和 service worker 路徑,還會跟個人網站的路由打架。子網域 art.kiwi-walk.com 只要在 wrangler 設定加一行 custom domain,DNS 記錄和憑證由 Wrangler 部署時自動建,前提是 zone 在同一個 Cloudflare 帳號。順手把預設的 workers.dev 關掉,Access 只需要保護一個入口。
Access 在邊緣擋,Worker 還要不要再驗一次。 Access 已經把沒登入的請求擋在 Cloudflare 邊緣,Worker 理論上收到的都是登入過的。但 Access 的設定在 dashboard 上,手一滑把 policy 刪掉或改成 Everyone,網站就變公開,而且不會有任何錯誤。所以 Worker 端一定再驗一次 Access 附上的 JWT,驗不過回 401;連環境變數只設一半都直接回 503。寧可鎖死,也不要意外公開。
One-time PIN 還是 Google 登入。 Google 要先去 Google Cloud Console 建 OAuth client 再貼回 Zero Trust;PIN 零設定,輸入 email 收驗證碼。先用 PIN 把整條流程打通,之後想加 Google 再加,policy 比對的是 email,不用改。
登入頁長這樣,「Sign in with Cloudflare」就是 One-time PIN,點下去輸入 email 收驗證碼。抬頭印的是 team domain,這張截圖是後來 team name 已改成 kiwi-walk 之後截的,網址已經換了,頁面上的字卻還是舊的隨機名稱,Cloudflare 那邊顯然有另一份快取:

Bypass 路徑怎麼設。 原本部署文件寫「一條 Bypass policy,Path 限定四條路徑」,但現在的 Zero Trust UI 把 policy 套在整個 application 上,不能限路徑。改成兩個 application:第一個 destination 是整站,Allow 我的 Gmail;第二個的 destinations 是四條 agent 路徑,Bypass Everyone。Access 用最精確的 destination 配對,所以第二個會先吃到。Bypass 不代表匿名可讀,那四條路徑到了 Worker 還是要過 Bearer token 或 Access cookie 其中之一。

"routes": [{ "pattern": "art.kiwi-walk.com", "custom_domain": true }],
"workers_dev": false,
custom_domain: true 讓 Wrangler 在同帳號的 zone 裡自動建 DNS 與憑證;workers_dev: false 關掉預設網址。deploy 完 nslookup art.kiwi-walk.com 就有 A 記錄,舊的 workers.dev 網址回 404。
Access 的 team domain 與 AUD 從 GitHub repository variables 帶進部署。它們不是機密,放 variables 不放 secrets;但 deploy job 會先檢查兩個都有值,沒有就停:
- name: Apply D1 migrations
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: d1 migrations apply art-tracking --remote
- name: Check Access variables
run: |
test -n "${{ vars.ACCESS_TEAM_DOMAIN }}" || { echo "vars.ACCESS_TEAM_DOMAIN is not set"; exit 1; }
test -n "${{ vars.ACCESS_AUD }}" || { echo "vars.ACCESS_AUD is not set"; exit 1; }
- name: Deploy Worker
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --var ACCESS_TEAM_DOMAIN:${{ vars.ACCESS_TEAM_DOMAIN }} --var ACCESS_AUD:${{ vars.ACCESS_AUD }}
第一次部署時 Access 還沒建,variables 先填佔位值;Worker 拿到假的 AUD 一律 401,網站是鎖死狀態,等 Access 建好、填真值、重跑 deploy 才打得開。
路由分三類,各掛一個 middleware。requireBearer 給 /api/ingest,requireAccess 給瀏覽器用的所有路由,requireBearerOrAccess 給兩邊都要讀的 /api/crawl-sources:
export const requireAccess: MiddlewareHandler<AppEnv> = async (c, next) => {
const aud = c.env.ACCESS_AUD;
const team = c.env.ACCESS_TEAM_DOMAIN;
if (!aud && !team) {
c.set("auth", "open"); // local dev / tests
return next();
}
// Half a configuration is a deployment mistake; fail closed rather than open.
if (!aud || !team) throw new HTTPException(503, { message: "Access misconfigured: set both ACCESS_TEAM_DOMAIN and ACCESS_AUD (or neither)" });
const jwt = readAccessToken(c.req.header("cf-access-jwt-assertion"), c.req.header("cookie"));
if (!jwt) throw new HTTPException(401, { message: "access token required" });
const ok = await verifyAccessJwt(jwt, c.env.ACCESS_TEAM_DOMAIN, c.env.ACCESS_AUD);
if (!ok) throw new HTTPException(401, { message: "invalid access token" });
c.set("auth", "access");
await next();
};
/** Either works: the agent (bearer) and the UI (Access) both read crawl sources. */
export const requireBearerOrAccess: MiddlewareHandler<AppEnv> = async (c, next) => {
if (bearerOk(c)) {
c.set("auth", "bearer");
return next();
}
return requireAccess(c, next);
};
兩個變數都空白時完全不驗,這是給本機 wrangler dev 和測試用的;只設一個就是 503。JWT 從 cf-access-jwt-assertion header 拿,拿不到再看 CF_Authorization cookie,因為 Access 對瀏覽器是用 cookie 帶的。
Access 的 JWT 是 RS256,公鑰在 https://<team>.cloudflareaccess.com/cdn-cgi/access/certs。Workers 有 crypto.subtle,所以不用拉 jose 這類套件:
export async function verifyAccessJwt(token: string, teamDomain: string, aud: string): Promise<boolean> {
try {
const [h, p, s] = token.split(".");
if (!h || !p || !s) return false;
const header = decodeJson<{ alg: string; kid: string }>(h);
const payload = decodeJson<{ aud?: string | string[]; exp?: unknown; nbf?: unknown }>(p);
if (header.alg !== "RS256") return false;
const now = Math.floor(Date.now() / 1000);
// Claims are untrusted JSON: a missing exp must fail, not compare as false.
if (typeof payload.exp !== "number" || payload.exp <= now) return false;
if (payload.aud === undefined) return false;
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!auds.includes(aud)) return false;
let keys = await getKeys(teamDomain);
let jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) {
// Key rotation: refresh the JWKS once before rejecting.
keys = await getKeys(teamDomain, true);
jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) return false;
}
const key = await crypto.subtle.importKey(
"jwk",
{ kty: jwk.kty, n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"],
);
const data = new TextEncoder().encode(`${h}.${p}`);
return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, b64urlToBytes(s), data);
} catch {
return false;
}
}
幾個細節是後來 review 時補上的:exp 缺少要算失敗,不能讓 undefined <= now 靜靜回 false 就過;aud 可能是陣列;找不到 kid 時先重抓一次 JWKS 再拒絕,不然 Cloudflare 換金鑰那天整站會被鎖到快取過期。JWKS 抓取有 5 秒 timeout,快取一小時。
Access 的 session 到期後,瀏覽器打 API 拿到的不是 JSON,是被 302 到登入頁。前端的 fetch 包一層判斷,遇到就整頁 reload,讓瀏覽器自己走登入流程:
// Cloudflare Access session expired: the API answers 401/403 or redirects to
// the Access login page (HTML). Reload so the browser walks through login
// again. A same-origin JSON redirect is not treated as a login.
const html = (res.headers.get("content-type") ?? "").includes("text/html");
const accessLogin = res.redirected && (html || /cloudflareaccess\.com|\/cdn-cgi\/access\//.test(res.url));
if (res.status === 401 || res.status === 403 || accessLogin || (html && res.status !== 404)) {
window.location.reload();
throw new ApiError(res.status, "session expired");
}
兩個 application 建好、deploy 重跑後,用 curl 匿名打六條路徑:
| 路徑 | 預期 | 實際 |
|---|---|---|
/、/api/stats |
302 登入頁 | 302 |
/api/health |
200 | 302 |
/api/ingest、/api/venues、/api/crawl-sources |
401(Worker 擋) | 302 |
四條本該 bypass 的路徑也回 302。光看狀態碼分不出是「第二個 application 根本沒配對到」還是「配對到了但 policy 不對」,兩種修法完全不同:前者要回去改 destination 的 path,後者要改 policy。
線索在 302 的 Location 裡。登入頁 URL 長這樣:
https://<team>.cloudflareaccess.com/cdn-cgi/access/login/art.kiwi-walk.com?kid=487c3111…&redirect_url=%2Fapi%2Fstats
https://<team>.cloudflareaccess.com/cdn-cgi/access/login/art.kiwi-walk.com?kid=b78d99cb…&redirect_url=%2Fapi%2Fhealth
kid 就是 application 的 AUD。主站兩條是 487c3111…,跟 GitHub variable 裡的值一樣;四條 agent 路徑是 b78d99cb…,是另一個 application。所以第二個 application 有配對到,只是它的 policy action 是 Allow 不是 Bypass,還是要求登入。到 dashboard 把 policy 改成 Bypass,不用重新部署,再打一次:
| 路徑 | 結果 |
|---|---|
/、/api/stats |
302 登入頁 |
/api/health |
200 {"ok":true,"app":"art-tracking"} |
/api/ingest POST 沒帶 token |
401 |
/api/venues、/api/crawl-sources |
401 |
401 是 Worker 自己回的,代表請求真的穿過 Access 到了 Worker,再被 requireBearer 擋下。這才是設計上要的狀態。
另一個容易忽略的點:後來把 team name 從隨機字串改成 kiwi-walk,登入頁網址就變成 kiwi-walk.cloudflareaccess.com。這時 GitHub variable 要跟著改並重跑 deploy,否則 Worker 去舊網域抓 JWKS、驗 JWT 的 issuer 對不上,連已登入的瀏覽器都會 401。改完舊 session 全部失效,重新登入一次即可。
今天完成自訂網域、Access 登入、Worker 端 JWT 驗證,接著跑了第一次手動抓取(27 個來源、111 筆展覽)並建了每週日 22:00 的 routine。三個來源抓不到:也趣是 Angular SPA 沒有伺服器端內容、誠品畫廊回 403、水谷藝術的網域 DNS 已失效。