NITRA FE DOCS · OAUTH GATE

文件站 OAuth Gate:機制與架構

Cloudflare Worker 如何用 Google 登入,把 nitra-fe-docs 鎖在 @nitra.com 之內

背景:為什麼需要這個 gate

文件站部署在 Cloudflare 的 *.workers.dev 上,預設是公開的。但這是內部團隊文件,只能讓 @nitra.com 的人看。worker/ 這個資料夾就是一道自架的 Google OAuth 登入閘門,擋在整個文件站前面。

兩個關鍵設定讓它「沒有破口」:wrangler.jsoncrun_worker_first: true 讓 Worker 在服務任何靜態檔之前先跑,preview_urls: false 則關掉會繞過認證的 preview 連結。結果是每一個請求都必須先通過這道閘門。

程式碼分成兩個檔:index.js 負責路由與 HTTP 層(cookie、轉址),auth.js 負責純密碼學原語(JWT、PKCE、與 Google 換 token、claims 驗證)。整套設計是無狀態(stateless)的 —— 因為 Worker 在請求之間不保證保留記憶體,所有「狀態」都簽進使用者的 cookie,而不是存在伺服器。

架構總覽:一個入口攔截所有請求

Worker 沒有像 Express 那樣的路由表。它只有一個入口 fetch()所有請求都進這裡,再靠 if (url.pathname === …) 自己分流。/auth/* 是它親自處理的特例,其餘路徑一律「先驗 session、通過才放行靜態檔」。

瀏覽器 / 使用者 每個請求 Worker fetch() 唯一入口,攔截每個請求 /auth/login /auth/callback 其他全部路徑 handleLogin 產生 PKCE + state 302 轉向 Google 並 set oauth-state cookie handleCallback 驗證並發 session 驗 state → 換 token → 檢查 @nitra.com → set session cookie,302 回原頁 驗 session cookie verifyJWT() session 有效 無 session serveAsset 回傳靜態檔 → 使用者看到 VitePress 文件 302 → /auth/login actor — 瀏覽器 / 外部 gatekeeper — Worker 閘門 app — 路由處理 / 服務

兩個 Cookie:state 與 session

整套 gate 靠兩個簽名 cookie 記住狀態 —— 一個短期、給「登入過程中」用,一個長期、給「登入完成後」用。兩者都用同一把 SESSION_SECRET 以 HMAC-SHA256 簽名,且都帶 HttpOnly(JS 偷不到)。

Cookie oauth-state session
用途 登入「過程中」的暫存憑證 登入「完成後」的通行證
內容 簽名 JWT:state、verifier、returnTo 簽名 JWT:email、exp
壽命 STATE_TTL = 10 分鐘 SESSION_TTL = 24 小時
何時設 handleLogin 導去 Google 時 handleCallback 驗證通過時
何時清 callback 成功後立刻清掉 24 小時後過期,需重新登入
屬性 HttpOnly · SameSite=Lax · Path=/ · HTTPS 下加 Secure 與 __Host- 前綴

完整登入時序

從「使用者open受保護頁面」到「看到文件」,Browser、Worker、Google 之間依時間順序往返如下。注意 Cookie 的設定、PKCE 的 verifier、與 callback 換 token 都落在這條時間線上。

瀏覽器 Worker(gate) Google OAuth 查 session → 無 產 PKCE + state 驗 state → 換 token → 檢 @nitra.com → 發 session 驗 session → 取靜態檔 使用者登入 + 同意授權 驗 PKCE + secret → 發 token ① GET /某頁(無 session) ② 302 → /auth/login ③ GET /auth/login ④ 302 → Google + set state ⑤ 授權頁(code_challenge, state) ⑥ 登入後 302 → /callback?code,state ⑦ GET /auth/callback?code,state ⑧ POST /token ⑨ id_token ⑩ 302 回原頁 + set session ⑪ GET /某頁(帶 session) ⑫ 200 文件內容 actor · 瀏覽器 gatekeeper · Worker 閘門 app · Google OAuth
請求 回應 處理中

⑤⑥ 直接在 Browser↔Google 之間,跨過中間的 Worker lane(Worker 不參與)。

⑧⑨ 換 token 帶 code + verifier + client_secret;secret 只在 Worker、不進瀏覽器。

灰框 = 該 lane 的內部處理,不是訊息;head 顏色代表 role(見圖內 swatch)。

防護機制:PKCE、state 與 HMAC

登入流程裡有三個容易混淆但各司其職的安全機制:

PKCE(防 code 被攔截盜用)。登入前產生一個隨機 verifier(只留在自己的 cookie),把它的 SHA-256 challenge 送給 Google;換 token 時再出示原始 verifier,Google 自己 hash 比對。因為 hash 單向,攻擊者就算攔到 code 與 challenge 也反推不出 verifier,於是換不到 token。像是登入時交一個上鎖的盒子,換 token 時要出示鑰匙。

state(防 CSRF)。一個隨機值,簽進 oauth-state cookie,Google 原封帶回。callback 比對「回來的 state」是否等於「cookie 裡簽過的 state」,確認這個 callback 是我們自己發起的,不是攻擊者偽造的。

HMAC session(防偽造通行證)。session 是用 SESSION_SECRET 以 HMAC-SHA256 簽名的 JWT。要偽造別人的 session 就得算出正確簽章,而 HMAC 是單向 keyed hash,看再多 session 也反推不出 secret(只要 secret 夠長夠隨機)。安全性靠的是「secret 保密」,不是演算法保密。

安全設計總表

每一條威脅都有對應的防護與程式位置(S 編號對應 spec 的安全風險評估)。

威脅 防護 位置
非 @nitra.com 存取 五重 claims 檢查(aud / iss / email_verified / hd / email 結尾) auth.js isAllowedClaims
CSRF 簽名 state cookie + query 比對 index.js handleCallback
Code 攔截盜用 PKCE S256 auth.js pkceChallenge
JWT alg-confusion 忽略 token 的 alg,寫死 HS256 + timing-safe 比對 auth.js verifyJWT
Cookie 竊取 HttpOnly + Secure + __Host- 前綴 index.js cookieName / setCookie
Open-redirect safeReturnTo 只收同源絕對路徑 auth.js safeReturnTo
快取洩漏 覆寫 Cache-Control: private, max-age=0 index.js serveAsset
Secret 外洩 callback try/catch 一律回通用訊息,不洩漏細節 index.js handleCallback