SPA 的 SSO 和身分驗證與授權

上課時談到 OAuth,學員問到 SPA 怎麼做安全的身分驗證。這其實是很多前端工程師都會碰到的問題:頁面已經是單頁應用,使用者體驗想要順,安全性又不能妥協,那到底該怎麼設計?

先說結論:以前常見的 The OAuth 2.0 implicit grant,現在已經不是建議解法。它把 access token 直接帶回前端,流程看起來簡單,實際上卻有 token 暴露、回傳路徑較難控管等問題;現在比較正確的做法,是用 Authorization Code Flow + PKCE 來完成 SPA 的登入。

如果你把這件事想成一個「前端能不能不靠傳統後端也安全登入」的題目,答案是可以,但前提是你要知道 PKCE 解決的是哪一段問題,也要知道它沒有解決哪些問題。

後端不是必須

你知道嗎,一個前端工程師其實真的可以在沒有傳統後端的情況下,實現一個符合現代 OAuth 建議做法的登入流程。我說的不是那種弄個假登入,而是真正的 OAuth 2.0 Authorization Code Flow + PKCE

聽起來很狂?其實邏輯很簡單。

關鍵在於:SPA 本來就不適合在前端保管祕密

傳統的 server-side web app,通常會由後端保管 client secret,後端負責跟授權伺服器交換 token,前端只需要拿到 session 或 cookie。這種模式很合理,因為祕密是放在伺服器上,不會直接暴露給使用者。

但 SPA(Single Page Application)不一樣。所有 JavaScript 都會跑在瀏覽器裡,使用者也拿得到前端程式碼,所以你不可能真的把 client secret 藏在前端。這也是為什麼 SPA 會被視為 public client。

Authorization Code Flow + PKCE 的核心思想,就是讓這種 public client 在不保管 client secret 的情況下,仍然可以比較安全地完成授權流程。

PKCE 的妙處

我先講個比喻。

假設你要委託朋友去銀行提錢,但你不放心他拿著提款單四處走,怕被人搶走。所以你想了個辦法:你先給銀行一個由暗號轉換而來的驗證碼,等真的要提錢時,再請朋友說出原本那個暗號。銀行拿到暗號後,重新算一次,看是不是和前面收到的驗證碼一致。

PKCE 大概就是這個概念

技術上講:前端產生一個高熵的隨機字串叫 code\_verifier,再用 SHA-256 把它雜湊後做 base64url 編碼,得到 code\_challenge。登入時把 code\_challenge 明文送給授權伺服器,但 code\_verifier 暫時留在前端,例如存在 sessionStorage 裡。

授權伺服器給你一個授權碼。前端用這個碼去換 token 時,把 code_verifier 一起送過去。授權伺服器驗證「你送來的 code_verifier 經過 SHA-256 後,是不是等於之前你送來的 code_challenge」。如果相符,就代表要求 token 的人,很可能就是最初發起登入流程的那個用戶端。

換句話說,就算有人在中途攔截到 authorization code,沒有 code\_verifier,也很難拿這個 code 去換 token。

不錯吧?

但這裡要講清楚:PKCE 主要防的是授權碼被攔截後拿去換 token,它不是拿來解決所有前端安全問題的萬靈丹。如果你的頁面本身被 XSS 攻擊,攻擊者能跑 JavaScript,那就已經是另一個層級的問題了。

實際的程式碼會長什麼樣?

我把整個流程寫成一個 200 多行的純 HTML + JavaScript,沒有任何框架、沒有後端、沒有 npm 套件。大概是這樣:

// 產生 PKCE 的兩個值
async function generatePKCE() {
  const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
  const codeChallenge = base64url(new Uint8Array(hash));
  return { codeVerifier, codeChallenge };
}

// 同時產生防 CSRF 的 state
const state = base64url(crypto.getRandomValues(new Uint8Array(32)));

// 暫存到 sessionStorage(登入重導期間才存在)
sessionStorage.setItem("code\_verifier", codeVerifier);
sessionStorage.setItem("state", state);

// 導向授權伺服器
const authUrl = new URL("https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize");
authUrl.searchParams.set("client\_id", clientId);
authUrl.searchParams.set("redirect\_uri", redirectUri);
authUrl.searchParams.set("response\_type", "code");
authUrl.searchParams.set("scope", scopes);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code\_challenge", codeChallenge);
authUrl.searchParams.set("code\_challenge\_method", "S256");

window.location = authUrl.toString();

使用者在微軟登入同意授權後,會被導回到你的頁面,網址上會帶著授權碼和 state。前端檢查 state 相不相符(防止 CSRF),然後用 code_verifier 去換 token:

// 驗證 state
const returnedState = new URLSearchParams(window.location.search).get("state");
if (returnedState !== sessionStorage.getItem("state")) {
  throw new Error("State mismatch!可能是 CSRF 攻擊");
}

// 交換 token(對支援 SPA / CORS 的授權伺服器,這步驟可以在前端做,不需傳統後端)
const tokenResponse = await fetch("https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    client\_id: clientId,
    code: authCode,
    redirect\_uri: redirectUri,
    grant\_type: "authorization\_code",
    code\_verifier: sessionStorage.getItem("code\_verifier") // 這裡用上 code\_verifier
  }).toString()
});

const { access\_token } = await tokenResponse.json();

// 清理 session 暫存
sessionStorage.removeItem("code\_verifier");
sessionStorage.removeItem("state");

// 教學範例可以先暫存 token,但實務上要謹慎評估儲存位置
// localStorage 雖然方便,但一旦發生 XSS,token 就可能被讀走
sessionStorage.setItem("token", access\_token);

然後你就可以用 access\_token 呼叫 Microsoft Graph API 來讀取使用者資料了。

登出也簡單——清掉前端暫存的 token,然後導向微軟的登出端點。

為什麼這很重要

我知道你現在可能在想:「好啦,那又怎樣?我還是得用後端啊。」

不一定。

這個模式改變的,其實是架構的可能性

想像一下:你的前端應用可以完全獨立部署。不一定要有 server-side session 要同步,也不一定要由後端負責整個登入狀態。你的後端可以變成比較純粹的 API 服務,甚至在某些情境下完全無狀態。負載平衡變簡單,水平擴展也變簡單。

特別是對創業團隊、個人開發者,或是那種「我就是想快速驗證一個想法」的場景,這套方案確實解放了你。你可以用 GitHub Pages 或 Vercel 直接部署靜態站點,再搭配 Microsoft identity platform、Google、Auth0 這類 Identity Provider,快速做出一個能登入、能呼叫 API 的應用。

我甚至見過有人用這個方法,把一個 SPA 和一個無伺服器的 API(AWS Lambda、Firebase Function)組合在一起,整個維運負擔真的少很多。

但還是要小心

當然,這不是銀彈。有幾個事你得知道:

Token 的儲存位置。把 access_token 存在 localStorage 很方便,但它會被 JavaScript 存取。如果你的頁面被 XSS 攻擊,token 就可能洩露。實務上,如果安全性要求很高,可以考慮讓後端代為保管 token(例如 BFF 架構,搭配 Secure、HttpOnly cookie),或者至少避免把 token 長期放在 localStorage。

HTTPS 是必須的。所有的傳輸都要加密,否則暗號也沒用。

State 和 code_verifier 一定要檢查和驗證。這是防止 CSRF 和授權碼劫持的重要防線。

Token 過期怎麼辦。有些授權伺服器會給 SPA refresh token,但通常會有比較嚴格的限制,例如生命週期較短、瀏覽器隱私機制也可能影響靜默更新。比較實務的做法,是在 token 快過期時重新走授權流程;如果使用者仍然有有效登入狀態,體驗上可能不會太明顯。

不要把 PKCE 當成萬靈丹。PKCE 很重要,但它主要解決的是 authorization code 被攔截後遭濫用的問題。XSS、防止惡意第三方 script、CSP、redirect URI 設定、token scope 控制,這些還是要靠整體安全設計。

收尾

現在的網頁開發,已經不像十年前那樣一定要有個胖後端。前端可以更獨立、更輕量,而 OAuth 2.0 Authorization Code Flow + PKCE,就是讓 SPA 可以安全使用 OAuth 的重要拼圖。

你不需要像我一樣折騰一個老專案,才能意識到這一點。下次當你在想「到底要不要自己寫登入邏輯」時,不妨想想:也許前端可以單獨處理一部分,但安全邊界一定要想清楚

一旦你開始用 PKCE 的角度思考,整個世界就不一樣了。會發現有一堆原本以為「一定要後端才行」的東西,其實在合適的場景裡,真的可以變得更簡單。

就像 PKCE 的暗號一樣——看似複雜,其實聰明得很。

參考資源

留言

這個網誌中的熱門文章

開啟 teams 中的『會議轉錄(謄寫)』與Copilot會議記錄、摘要功能

使用LM Studio輕鬆在本地端以API呼叫大語言模型(LLM)

GitHub Copilot SDK:當你的程式碼有了自己的靈魂

VS Code的字體大小

原來使用 .net 寫個 MCP Server 如此簡單