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_URL | 否 | SSO 登入中心 Base URL | https://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.ts | SSO 導向 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.ts → buildSsoLoginUrl()
處理 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 |
| 請求 Header | X-Client-Token: {SSO 授權碼} |
| 成功回應 Header | X-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 | 顯示名稱 |
picture | Google 頭像 URL |
role | 角色(如 ROLE_TEACHER_STUDENT) |
exp | 過期時間,用於判斷是否需重新登入 |
實作位置:lib/sso.ts → parseJwtPayload()、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 會自動:
- 從
localStorage讀取authToken - 附加
Authorization: Bearer {JWT} - 收到
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() 引導重新登入。
安全性考量
- Client Secret 不得出現在前端程式碼或環境變數中。
- 授權碼一次性:取得
code後應立即交換,並以router.replace清除 URL 參數。 - HTTPS:生產環境 SSO 與 API 通訊必須使用 HTTPS。
- Token 儲存:目前使用
localStorage儲存 JWT。若需更高安全性,可改為httpOnlyCookie(需後端配合 Set-Cookie)。 - 不要在日誌中輸出 JWT 或授權碼。
本地測試
啟動步驟
npm install
# 建立 .env.local 並設定 NEXT_PUBLIC_API_URL
npm run devCode language: CSS (css)
測試流程
- 確認後端已啟動,且
POST /api/auth-tokens/exchange可正常運作 - 開啟前端(預設
http://localhost:3000) - 點擊右上角「登入」
- 完成 Google SSO 登入
- 確認跳轉回前端後右上角顯示頭像
- 點擊頭像進入
/profile,確認名稱與信箱正確 - 開啟 DevTools → Application → Local Storage,確認存在
authToken
常見問題與解法
| 問題 | 可能原因 | 解法 |
|---|---|---|
| 登入後沒有 JWT | CORS 未暴露 X-Auth-Token | 確認後端 CORS exposedHeaders |
NEXT_PUBLIC_API_URL 未設定 | 缺少環境變數 | 建立 .env.local |
跳轉後出現 unauthorized | code 已使用或過期 | 重新點擊登入 |
| 頭像只顯示首字 | JWT 無 picture claim | 確認 SSO 回傳與後端 JWT 簽發邏輯 |
原始碼索引
| 檔案 | 職責 |
|---|---|
lib/sso.ts | SSO URL 建構、code 交換、JWT 解析、Token 存取 |
lib/api.ts | 附帶 Bearer Token 的 HTTP 請求 |
lib/hooks/AuthContext.tsx | React Context 認證狀態與回調 |
components/layout/Navbar.tsx | 登入按鈕與使用者頭像 UI |
app/layout.tsx | Provider 掛載點 |
docker-compose.prod.yml | 生產環境 NEXT_PUBLIC_API_URL 注入 |
與後端文件對照
| 後端文件章節 | 前端對應實作 |
|---|---|
| §3 整體登入流程 | AuthContext + exchangeCodeForToken() |
| §7.1 發起登入 | buildSsoLoginUrl() + startSsoLogin() |
| §7.2 處理回呼 | AuthProvider 的 useSearchParams effect |
| §7.3 交換 JWT | exchangeCodeForToken() |
| §7.4 受保護 API | apiFetch() / apiJson() |
| §7.5 CORS | 讀取 X-Auth-Token Response Header |
| §10 安全性 | 前端不呼叫 /sso/verify-code |
