ADR-009: 認證授權架構——雙模式資源伺服器
ADR 編號: 009 狀態: 已接受 (Accepted) 決策日期: 2026-06-24 決策者: Development Team 取代: 無 被取代: 無
摘要
app-server 採「雙模式資源伺服器」架構:以單一資源伺服器(Resource Server)核心同時承載「對內 UI 人類操作」與「對外契約被授權系統(M2M)」兩種 surface;發 token 端可在 standalone(自簽) 與 federated(外部企業 IdP) 兩模式間以設定切換,共用同一套 JWT 驗證與 @PreAuthorize 授權。框架明確不經營完整授權伺服器(AS),強客戶端認證、MFA、SAML 一律委派企業 IdP。
背景 (Context)
問題陳述
app-server 的參考實作原本針對「有前端模組、人類操作」的情境,但實際也會有第三方系統以服務帳號(service account)連接呼叫 API 的情境(對外契約 surface,見方法論「兩條軌」與 ADR-003 Headless 軌)。現況已支援兩者——以單一 Account 實體 + serviceAccount 旗標區分人類與機器主體,並提供兩條發 token 路徑:
POST /api/v1/auth/login:人類登入(username/password → access + refresh + sessionId,服務帳號被擋)POST /api/v1/auth/token:M2Mclient_credentials(client_id/client_secret → 短效 JWT,不發 refresh)
兩者落到同一個資源伺服器、同一套 Role/Authority 授權。但整套是手刻的(自訂 filter chain、JsonAuthenticationEntryPoint、JwtTokenProvider 自簽),未走 Spring 標準 oauth2ResourceServer。手刻的動機是標準元件未提供的兩個功能:token 黑名單(登出/撤銷) 與 登入鎖定。
需要決定的問題:
- 對外暴露的 OAuth2 scope 詞彙與內部
Authority詞彙的關係 - 協定面錯誤格式(RFC 6749/6750)與應用面錯誤格式(RFC 7807)的分界
- M2M 客戶端認證強度(shared secret vs private_key_jwt / mTLS)
- 維持手刻 stack、自建完整 AS、還是當資源伺服器消費外部 IdP
限制條件
- 企業客戶 IdP:框架支援的應用程式類型會有企業客戶帶自己的 IdP(Keycloak / Okta,甚至 SAML)需要連接 → 「當資源伺服器消費外部 IdP token」必須是一級支援的模式
- 無未知第三方:對外 API 只有被授權客戶連接,不會有未知第三方以標準 OAuth2 library 接入 → 發 token 端不需要標準完整(不需公開 discovery / 嚴格 RFC 6749)
- 不經營 AS:「經營認證伺服器」不在框架要支援的應用程式類型內 → 不把力氣投資在把框架養成完整 Spring Authorization Server
- 框架定位:機制盡量上提到框架層讓應用快速開發;以 app-server 為範本 scaffold 亦可接受,兩者取彈性平衡
- 現有資產:
SecurityConfig已有JwtDecoder+OAuthJwtAuthenticationProvider(驗證外部簽發 token 的接縫已存在);TenantContext.configureWithDefaults()已支援從 JWT / OAuth2 claim 解析租戶 - 授權粒度:既有端點以
@PreAuthorize("hasAuthority('...')")強制,授權單位是細粒度Authority(非角色)
假設前提
- M2M 消費者皆為已知、可文件化的被授權客戶
- 黑名單(即時撤銷)、登入鎖定屬 standalone 模式關注;federated 模式的撤銷與 MFA 由 IdP 承擔
- 框架對「資料怎麼隔離/租戶從何而來」維持中性,tenant claim 解析兩模式共用(見 ADR-001)
考量的方案 (Options Considered)
方案 A: 維持全手刻 AS + RS,漸進補強
說明:
保留現有自訂 stack,手動補上 RFC 6749 token 錯誤格式、RFC 6750 bearer 挑戰,必要時自刻 private_key_jwt。
優點:
- ✅ 零遷移,保留現有黑名單 / 鎖定功能
- ✅ 團隊熟悉、立即可動
缺點:
- ❌ 持續重造標準輪子(錯誤格式、scope、private_key_jwt、JWKS…),每一項都是把協定/密碼學弄錯的機會
- ❌ 無法滿足企業客戶 IdP 連接的常態需求
- ❌ 自刻
private_key_jwt/mTLS高風險(重放、audience、金鑰輪替)
評分: 2/5
方案 B: 自建完整 Spring Authorization Server
說明: 把 app-server 做成標準完整的授權伺服器(discovery、JWKS、標準 token 端點、private_key_jwt、mTLS 全配齊)。
優點:
- ✅ 標準完整,第三方標準 library 即插即用
- ✅ 強客戶端認證為設定而非自刻
缺點:
- ❌ 違反「框架不經營 AS」的定位
- ❌ 對「無未知第三方」的實際需求過度投資
- ❌ 仍無法天然滿足「連企業既有 IdP」(你變成另一個 AS,而非消費客戶的 AS)
評分: 2/5
方案 C: 雙模式資源伺服器(採用方案)
說明:
以單一資源伺服器核心(標準 Spring oauth2ResourceServer().jwt() 驗證 + @PreAuthorize 授權 + TenantContext 解租戶)為兩模式共用基礎;發 token 來源靠設定切換:
- standalone(自簽):無企業 IdP 的應用沿用現有輕量自簽(login + client_credentials + 黑名單 + 鎖定),保持輕量、不鍍金成完整 AS
- federated(外部 IdP):有企業 IdP 的客戶讓 app-server 當純資源伺服器,驗證 IdP(Keycloak/Okta,OIDC)簽發的 token,人類與 M2M 皆在 IdP 認證
模式差異只在「token 來源」(本地金鑰 vs IdP issuer-uri/JWKS),不分叉業務碼——JwtDecoder + OAuthJwtAuthenticationProvider 就是現成接縫。
優點:
- ✅ 直接滿足企業客戶 IdP 連接(federated)
- ✅ 標準 RS 免費取得 RFC 6750 bearer 挑戰與 scope 對映
- ✅ 強認證 / MFA / SAML 複雜度外包給企業 IdP,框架不自建
- ✅ 同一套
@PreAuthorize兩模式共用;機制上框架、靠設定切換,應用快速開發 - ✅ 不過度投資(standalone 維持輕量,符合「不經營 AS」與「無未知第三方」)
缺點:
- ❌ standalone 自簽非標準完整 AS(無 discovery / 嚴格 RFC 6749)——但對已知客戶可接受
- ❌ 需維護與測試兩條設定路徑
評分: 5/5
決策 (Decision)
選擇方案: C(雙模式資源伺服器)
核心理由:
- 直接命中企業需求:federated 模式以純資源伺服器消費企業 IdP token,且驗證接縫(
JwtDecoder/OAuthJwtAuthenticationProvider)已存在。 - 不過度投資:standalone 維持輕量自簽,不鍍金成完整 AS——符合「框架不經營 AS」與「對外只有已知客戶」。
- 複雜度外包:
private_key_jwt/mTLS/MFA/SAML 等高風險機制由企業 IdP 承擔,框架不自刻。 - 機制上框架、政策留範本:雙模式驗證機制進框架 jar、靠設定切換;
Account/Authority詞彙/ServiceAccountController等政策與實體留 app-server reference impl 可 retarget——達成框架層 vs scaffold 的彈性平衡。
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- ✅ 企業 IdP 即插即用(federated 設定即可)
- ✅ 標準 RS 免費附帶 RFC 6750 bearer 挑戰 + scope→authority 對映
- ✅ 強客戶端認證 / MFA / SAML 零自建
- ✅ 兩模式共用單一授權核心,降低分歧
我們放棄什麼 (Losses)
- ❌ standalone 自簽不是標準完整 AS(無 discovery、不嚴格 RFC 6749)
- ❌ federated 的即時撤銷依賴 IdP(或短 token TTL + introspection)
風險與緩解措施 (Risks & Mitigations)
| 風險 | 嚴重性 | 機率 | 緩解措施 |
|---|---|---|---|
| 自簽 token 與 IdP token 的 claim 形狀分歧 | 中 | 中 | 統一的 authority/scope 轉換器 + TenantContext 統一解 tenant claim;兩模式對映到同一套 resource:action |
| 兩模式設定誤用(同時配本地金鑰與 IdP issuer) | 中 | 低 | 啟動時驗證兩者互斥;設定文件化 |
| 誤以為 app-server 要直接支援 SAML | 低 | 中 | 明定 SAML 經 IdP/broker 轉 OIDC,app-server 只驗 JWT |
| federated 模式撤銷不即時 | 中 | 中 | 短 token TTL;高敏感場景採 opaque token + introspection |
影響 (Consequences)
正面影響
- ➕ 企業客戶可用既有 IdP 聯邦登入與 M2M
- ➕ 協定標準合規(bearer 挑戰、scope)多為免費附帶
- ➕ 高風險認證機制外包,縮小框架自有攻擊面
負面影響
- ➖ standalone 與 federated 兩條設定路徑需維護與測試
- ➖ reference impl 需同時示範兩模式
中性影響
- 🔸 既有手刻黑名單 / 鎖定改定位為 standalone 模式關注點(保留,不廢除)
- 🔸 既有
Authority的R/W/X/D模型收斂為resource:action(見實作指南)
實作指南 (Implementation Guidelines)
必須遵守的規則
-
單一資源伺服器核心:採 Spring
oauth2ResourceServer().jwt()為唯一驗證/授權核心;模式差異只在 token 來源(本地金鑰 vs IdPissuer-uri/JWKS),靠設定切換、不分叉業務碼。 -
權限詞彙收斂為
resource:action(點 1):- 端到端單一詞彙——
@PreAuthorize、JWT claim、IdP scope 設定使用同一套字串(如order:read、product:write)。理由:federated 模式下授權資訊來自 IdP claim,同一套@PreAuthorize必須同時吃自簽 token 與 IdP token。 - 單一事實來源在後端權限常數;前端 mock 的權限映射為衍生鏡像(現已漂移,收斂時一併校正)。
:execute(狀態變更,原X)為非標準 OAuth2 動詞——內部保留;是否對外暴露由授予政策決定(不暴露就不發該 scope),不為它改詞彙。- 不另立與
Authority平行的 Scope 系統;「scope」即「token 攜帶 authority 的子集」。內外粒度若日後真分歧,再引入邊界映射層(YAGNI)。
- 端到端單一詞彙——
-
錯誤格式分界(點 2)——協定面遵循 OAuth2 RFC、應用面用 RFC 7807:
- token 端點(standalone
/auth/token):SHOULD 回 RFC 6749 §5.2 格式(頂層{"error","error_description"})。因客戶已知,列為 SHOULD 而非 MUST;federated 模式由 IdP 負責。 - 受保護資源被拒:MUST 帶 RFC 6750
WWW-Authenticate: Bearer error="invalid_token"/"insufficient_scope"標頭(隨標準 RS 免費);body 維持 RFC 7807。 - 人類 login/refresh/logout + 業務 4xx:RFC 7807 / 9457 ProblemDetail(不變)。
- token 端點(standalone
-
客戶端認證(點 3):
- standalone:維持 shared secret(已授權客戶足夠),配 bcrypt 雜湊、輪替、短效 token、一次性顯示。
- 強認證(
private_key_jwt/mTLS)、MFA、SAML:一律委派企業 IdP(federated),框架不自建。 - 認證方式為 per-service-account 屬性,預設 shared secret。
-
SAML 經 broker:app-server 永不直接講 SAML;企業 SAML 由 IdP/broker(如 Keycloak)轉 OIDC,app-server 只驗 JWT。
-
黑名單 / 鎖定定位:黑名單 filter(撤銷)置於驗證前、屬 standalone 關注;鎖定屬登入/發 token 路徑。federated 模式即時撤銷與 MFA 由 IdP 承擔(或短 TTL + introspection)。
建議的最佳實踐
- tenant claim 解析統一走
TenantContext(兩模式一致;federated 從 IdP claim 對映,見 ADR-001)。 - federated 高敏感場景可選 opaque token + introspection(RFC 7662)取得原生即時撤銷。
- authority/scope 轉換器集中一處,兩模式共用,避免 claim 對映散落。
檢查清單
- RS 核心採 Spring
oauth2ResourceServer().jwt(),模式靠設定(本地金鑰 vs IdP issuer) - 權限詞彙為單一
resource:action,SoT 在後端,前端 mock 為衍生鏡像 - 受保護資源回應帶 RFC 6750
WWW-Authenticate挑戰標頭 - standalone client 用 shared secret + bcrypt + 輪替 + 短效 token
- 強認證 / MFA / SAML 場景走 federated(IdP),未在 app-server 自刻
- SAML 不入 app-server(經 IdP broker 轉 OIDC)
- 兩模式設定互斥於啟動時驗證
參考實作 (Reference Implementation)
app-server 的 Account、Authority 詞彙、ServiceAccountController/ServiceAccountService、seed 帳號與角色定義屬 @reference-surface(見方法論 m-reference-code.md)——scaffold 後依專案領域 retarget(花店領域的 order/product/customer 換成目標領域)。reference impl 應同時示範 standalone 與 federated 兩模式的設定範例,作為下游應用的起點。
既有
Authority採{RESOURCE}_{R|W|X|D}模型(Read/Write/Execute/Delete),尚未被任何下游採用;本 ADR 將其收斂為resource:action形式(R→read、W→write、X→execute、D→delete),趁無採用者時建立單一詞彙。
相關文檔 (References)
內部文檔
- OAuth2 使用指南:
../guides/auth/oauth2.md - Bearer Token 認證:
../guides/auth/bearer-token-authentication.md - Spring Security 配置:
../guides/auth/security.md - Tenant 解析鏈:
../guides/auth/tenant.md - Authority 權限模型:
../guides/design/authority-model.md
外部資源
- RFC 6749 §5.2(OAuth2 token 端點錯誤回應)
- RFC 6750(Bearer Token 使用 /
WWW-Authenticate挑戰) - RFC 7523(JWT client authentication /
private_key_jwt) - RFC 8705(mTLS client authentication 與 sender-constrained token)
- RFC 7662(Token Introspection)
- RFC 9457(Problem Details,RFC 7807 之後繼)
相關 ADR
- ADR-001: 多租戶數據隔離策略(tenant claim 解析共用):
./001-multi-tenant-data-isolation.md - 方法論 ADR-003: Headless 契約驅動 API 軌(對外契約 surface 的軌道紀律):
../../docs-methodology/decision-records/003-headless-contract-driven-api-track.md
變更歷史 (Change Log)
| 日期 | 變更內容 | 變更者 |
|---|---|---|
| 2026-06-24 | 初版 | Development Team |
文檔維護者: Development Team + AI Assistant 最後審閱: 2026-06-24