Next.js – SSO in BIRC

本文件透過商業智慧研究中心 – 智慧校園:活動系統來說明商智中心的SSO架構

商智中心的SSO為自架伺服器,Google SSO,但原理大同小異。

該文章所述程式碼皆提供於文章最下方GitHub中。

版本:0.1.0
最後更新時間:06/10/2026
最後更新人員:陳泓毓


設計原則

原則說明
前端不持有 Client Secret前端絕不直接呼叫 SSO /sso/verify-code
授權碼僅用於交換SSO 回傳的 code 為一次性授權碼,應立即轉交後端交換 JWT
JWT 作為後續認證登入完成後,所有受保護 API 以 Authorization: Bearer {JWT} 存取
全站回調處理SSO 可回跳至任意頁面,AuthProvider 統一攔截 ?code= 參數

系統角色

flowchart LR
    User[使用者]
    FE[Campus Activity 前端]
    SSO[SSO 登入中心]
    BE[Campus Activity 後端]

    User -->|點擊登入| FE
    FE -->|GET /sso/prelogin| SSO
    SSO -->|302 ?code=| FE
    FE -->|POST /auth-tokens/exchange| BE
    BE -->|X-Auth-Token JWT| FE
    FE -->|Bearer JWT| BE
角色職責
SSO 登入中心Google 等 IdP 登入、核發一次性授權碼
前端導向 SSO、接收 code、向後端交換 JWT、儲存並附帶 JWT 呼叫 API
後端以 Client Secret 驗證 code、建立/更新使用者、核發 JWT

整體流程

sequenceDiagram
    participant User as 使用者
    participant Navbar as Navbar / AuthContext
    participant SSO as SSO 登入中心
    participant BE as Campus Activity 後端

    User->>Navbar: 點擊右上角「登入」
    Navbar->>SSO: GET /sso/prelogin?redirect_url={目前頁面網址}
    SSO->>User: 顯示 Google 登入頁
    User->>SSO: 完成身分驗證
    SSO->>Navbar: 302 跳轉 redirect_url?code={授權碼}
    Navbar->>BE: POST /auth-tokens/exchange<br/>Header: X-Client-Token: {code}
    BE->>SSO: POST /sso/verify-code(僅後端)
    SSO-->>BE: { email, name, picture }
    BE-->>Navbar: 200 OK<br/>Header: X-Auth-Token: {JWT}
    Navbar->>Navbar: 解析 JWT、儲存 authToken、更新 UI
    User->>Navbar: 右上角顯示頭像(Google 照片或名稱首字)
    User->>Navbar: 點擊頭像進入 /profile

環境變數

變數必填說明範例
NEXT_PUBLIC_API_URL後端 API Base URL(含 /api 前綴)https://api.example.com/api
NEXT_PUBLIC_SSO_BASE_URLSSO 登入中心 Base URLhttps://sso.ntubimdbirc.tw

.env.local 範例:

NEXT_PUBLIC_API_URL=http://localhost:8080/api
NEXT_PUBLIC_SSO_BASE_URL=https://sso.ntubimdbirc.twCode language: JavaScript (javascript)

前端實作

檔案職責

檔案職責
lib/sso.tsSSO 導向 URL、向後端交換 JWT、JWT 解析、Token 儲存
lib/api.ts附帶 Authorization: Bearer 的 API 請求封裝
lib/hooks/AuthContext.tsx全站認證狀態、SSO 回調處理、同步 EventsContext 使用者
components/layout/Navbar.tsx未登入顯示「登入」、已登入顯示頭像並連結 /profile
app/layout.tsx掛載 AuthProvider

發起登入

使用者點擊 Navbar「登入」時,呼叫 startSsoLogin()

const redirectUrl = window.location.origin + pathname;
window.location.href = buildSsoLoginUrl(redirectUrl);
// → https://sso.ntubimdbirc.tw/sso/prelogin?redirect_url={encodeURIComponent(redirectUrl)}Code language: JavaScript (javascript)

實作位置:lib/sso.tsbuildSsoLoginUrl()

處理 SSO 回調

AuthProvider 監聽 URL 查詢參數:

參數處理方式
?error=...顯示錯誤 Toast,清除 URL 參數
?code=...呼叫後端交換 JWT,成功後 router.replace(pathname) 清除 code
// lib/hooks/AuthContext.tsx(摘要)
const code = searchParams.get('code');
if (code) {
  const { token, user } = await exchangeCodeForToken(code);
  saveAuthToken(token);
  router.replace(pathname); // 清除 URL 中的 code
}Code language: JavaScript (javascript)

向後端交換 JWT

// lib/sso.ts
const response = await fetch(`${NEXT_PUBLIC_API_URL}/auth-tokens/exchange`, {
  method: 'POST',
  headers: {
    'X-Client-Token': code,
  },
});

const jwt = response.headers.get('X-Auth-Token');Code language: JavaScript (javascript)
項目說明
端點POST {NEXT_PUBLIC_API_URL}/auth-tokens/exchange
請求 HeaderX-Client-Token: {SSO 授權碼}
成功回應 HeaderX-Auth-Token: {JWT}
認證需求不需要 Bearer Token(公開端點)

CORS: 後端已將 X-Auth-Token 加入 exposedHeaders,前端才能從 Response Header 讀取 JWT。

JWT 儲存與解析

登入成功後,JWT 儲存於 localStorage

localStorage.setItem('authToken', jwt);Code language: JavaScript (javascript)

使用者資訊從 JWT Payload 解析(不需另外儲存使用者物件):

JWT Claim前端用途
sub學校信箱(email
id本地使用者 ID
name顯示名稱
pictureGoogle 頭像 URL
role角色(如 ROLE_TEACHER_STUDENT
exp過期時間,用於判斷是否需重新登入

實作位置:lib/sso.tsparseJwtPayload()authUserFromJwt()isTokenExpired()

呼叫受保護 API

使用 lib/api.ts 封裝:

import { apiJson, apiFetch } from '@/lib/api';

// GET 範例
const events = await apiJson<Event[]>('/events');

// 自訂請求
const response = await apiFetch('/events', { method: 'GET' });Code language: JavaScript (javascript)

apiFetch 會自動:

  1. localStorage 讀取 authToken
  2. 附加 Authorization: Bearer {JWT}
  3. 收到 401 時清除 Token 並拋出 ApiError

UI 行為

Navbar 右上角

狀態顯示行為
未登入「登入」按鈕觸發 startSsoLogin()
已登入圓形頭像picture 顯示 Google 照片,否則顯示名稱首字
已登入點擊頭像導向 /profile 個人頁

手機版 Overlay 選單亦有對應的登入按鈕與頭像連結。

個人頁同步

登入成功後,AuthContext 會將 JWT 中的使用者資料同步至 EventsContext,供 /profile 等頁面使用:

// role 對應
ROLE_ADMIN / ROLE_ACTIVITY_ADMIN / ROLE_VENUE_ADMIN → 'admin'
其餘 → 'user'Code language: JavaScript (javascript)

錯誤處理

SSO 回調錯誤

SSO 登入失敗時,會以 ?error={代碼} 跳轉回前端。AuthContext 以 Toast 提示使用者。

後端交換錯誤

後端回應格式(參考後端文件):

{
  "errorCode": "SSO_ERROR",
  "message": "unauthorized",
  "status": 401
}Code language: JSON / JSON with Comments (json)
message前端建議處理
missing_code提示「登入資訊不完整,請重試」
unauthorized提示「授權碼已過期,請重新登入」
error_login提示「SSO 服務暫時無法連線」
其他顯示 message 或通用「登入失敗」

JWT 過期

  • 頁面載入時:getStoredAuth() 檢查 exp,過期則視為未登入
  • API 請求時:apiFetch 收到 401 自動清除 Token

建議後續在 401 時自動觸發 startSsoLogin() 引導重新登入。


安全性考量

  1. Client Secret 不得出現在前端程式碼或環境變數中。
  2. 授權碼一次性:取得 code 後應立即交換,並以 router.replace 清除 URL 參數。
  3. HTTPS:生產環境 SSO 與 API 通訊必須使用 HTTPS。
  4. Token 儲存:目前使用 localStorage 儲存 JWT。若需更高安全性,可改為 httpOnly Cookie(需後端配合 Set-Cookie)。
  5. 不要在日誌中輸出 JWT 或授權碼。

本地測試

啟動步驟

npm install
# 建立 .env.local 並設定 NEXT_PUBLIC_API_URL
npm run devCode language: CSS (css)

測試流程

  1. 確認後端已啟動,且 POST /api/auth-tokens/exchange 可正常運作
  2. 開啟前端(預設 http://localhost:3000
  3. 點擊右上角「登入」
  4. 完成 Google SSO 登入
  5. 確認跳轉回前端後右上角顯示頭像
  6. 點擊頭像進入 /profile,確認名稱與信箱正確
  7. 開啟 DevTools → Application → Local Storage,確認存在 authToken

常見問題與解法

問題可能原因解法
登入後沒有 JWTCORS 未暴露 X-Auth-Token確認後端 CORS exposedHeaders
NEXT_PUBLIC_API_URL 未設定缺少環境變數建立 .env.local
跳轉後出現 unauthorizedcode 已使用或過期重新點擊登入
頭像只顯示首字JWT 無 picture claim確認 SSO 回傳與後端 JWT 簽發邏輯

原始碼索引

檔案職責
lib/sso.tsSSO URL 建構、code 交換、JWT 解析、Token 存取
lib/api.ts附帶 Bearer Token 的 HTTP 請求
lib/hooks/AuthContext.tsxReact Context 認證狀態與回調
components/layout/Navbar.tsx登入按鈕與使用者頭像 UI
app/layout.tsxProvider 掛載點
docker-compose.prod.yml生產環境 NEXT_PUBLIC_API_URL 注入

與後端文件對照

後端文件章節前端對應實作
§3 整體登入流程AuthContext + exchangeCodeForToken()
§7.1 發起登入buildSsoLoginUrl() + startSsoLogin()
§7.2 處理回呼AuthProvideruseSearchParams effect
§7.3 交換 JWTexchangeCodeForToken()
§7.4 受保護 APIapiFetch() / apiJson()
§7.5 CORS讀取 X-Auth-Token Response Header
§10 安全性前端不呼叫 /sso/verify-code