ADR-025: M2M 憑證發放上收 auth capability;線格式對齊 RFC 6749;api-key 為第二憑證呈遞
ADR 編號: 025 狀態: ✅ 已接受 (Accepted) 決策日期: 2026-07-23 決策者: Development Team 取代: 無(延續 ADR-023、ADR-024) 被取代: 無
摘要
把 client_credentials(M2M)token 發放從 HTTP 殼上收 auth capability,成為登入引擎的第四
操作;線格式由 capability 承擔 RFC 6749,既有 JSON 方言以相容分支保留;api-key 定位為
同一服務帳號身分的第二種憑證呈遞。其後的 2026-07-24 邊界修訂保留這些 capability 決策,
但把服務帳號資源面改為 optional service-account Feature 附帶的 reference implementation,
而不是 required auth 的常駐 reference domain。
2026-07-24 修訂(取代原決策六的「併回 auth」生命週期):後續實作證明「M2M 通用機制 應在 auth capability」不等於「每個專案都必須出貨服務帳號資源」。新增 catalog
referenceDomains後,optional Feature 可以攜帶可客製 controller/service/entity/repository, 同時維持 core 中性。故service-account恢復為 optional Feature,requires: [auth, cache]; capability 決策一至五不變。
背景 (Context)
問題陳述
ADR-023 把 login/refresh/logout 三操作上收 capability,但第四個認證操作(M2M 憑證換 token)被遺漏,仍以 HTTP 殼的形式住在 service-account feature。六個可觀測的後果:
① capability 帶著這個概念,卻不擁有服務它的操作。 AuthPrincipal.interactiveLoginAllowed() 存在的唯一理由就是區分 M2M 主體(參考實作以 !account.isServiceAccount() 導出)。引擎認識「非互動主體」,服務非互動主體的操作卻在別處——契約與操作分居兩層。
② feature 邊界是名義上的。 在本 ADR 決策當時,token 端點的路徑與授權都由 auth
宣告(當時 SecurityConfig 以 /api/v1/auth/** 整段 permitAll);service-account
自己的 SecurityContributor 明文自承「ClientTokenController 不在此宣告」。依
21-feature-surface.md 紀律三(端點由 feature 啟用,授權就由 feature 宣告),一個宣告不了自己
surface 的 feature,邊界不成立。同一段註解另宣稱該端點「由其自身的 @PreAuthorize 授權」——
與實作不符(該 controller 明文不掛 @PreAuthorize),註解漂移本身即邊界模糊的症狀。
現況修訂(2026-07-24):wildcard 是歷史背景,不是現行建議。Reference implementation 現已改為 exact method + path 白名單;
POST /api/v1/auth/token由應用明確放行,新增在同一 prefix 下的端點預設受保護。具體位址與公開政策只屬 reference implementation,不屬 auth Feature 或 capability。
③ 無 engine 擁有 → 線格式漂移。 參考實作的 token 端點收 JSON(@RequestBody)、錯誤回 RFC 7807,兩者皆偏離 RFC 6749 §2.3.1/§5.2(回應體 access_token/token_type/expires_in 則符合 §4.4.3)。這不是疏忽——殼所在的那個世界的慣例就是 JSON + ProblemDetail,端點忠實跟隨了它,因為沒有任何東西提醒它該講 OAuth2。後果是標準 OAuth2 client library 接不上。
④ fleet 內缺「能講自家方言的出站 client」。 框架既有的 OAuth2Authenticator(io.leandev.appfuse.oauth2)送 form-urlencoded——它是為外部標準 IdP 寫的(實際消費者為 mail 的 Office365/Gmail OAuth2),對那些端點是正確的。但 fleet 內 app-to-app 的 M2M 沒有對應 client,AlmanacProperties.Mode.SERVICE_ACCOUNT 至今 throw new UnsupportedOperationException。若簽發是 capability,配對的 client 會自然長在它旁邊(如 signed-link 兩端同源);散在各下游 controller 就沒有任何位置是「該放 client 的地方」。
⑤ 憑證比對繞過 AuthenticationManager。 參考實作手寫 passwordEncoder.matches,因而無認證事件、無登入鎖定、無速率限制。端點為 permitAll、每請求一次 bcrypt(≈100ms),曝險是 CPU 耗盡的 DoS,而非憑證猜測(secret 為 32 bytes 隨機,暴力破解不可行)。
⑥ scope 無處安放。 請求 DTO 無此欄位,client 送 scope 會被靜默忽略——client 以為權限已收窄,實際未收窄。這比不支援更糟。
動機(為何是現在)
- 零外部消費端的窗口:現況查證——Bruno 集合、整合測試、seed 的
svc_demo、OpenAPI 皆為內部產物;兩個前端皆無服務帳號管理 UI。線格式的相容性成本此刻最低,且隨第一個外部消費端出現後單調上升(wire 契約發布即凍結)。 - 外部整合是每個下游的必要功能,不是選用:把它留成 optional feature + per-project wire 殼,等於讓每個下游各自實作 RFC 解析與錯誤映射——那正是漂移產生器。這與
m-server-common.md「框架先行原則」一致:通用機制歸框架。 - fleet 已有既定需求:almanac 把 M2M 定為 additive 升級、api-key 為永久後備 break-glass(其 api-key filter 的類註解)。M2M 不是假想需求。
- 與 ADR-023/024 同向收官:引擎歸框架、資源實作歸消費端。本 ADR 補上被遺漏的第四操作。
限制條件
- ADR-017:jar 不得擁有
@Entity、不得假設租戶欄位存在(租戶經TenantAwareUserDetails探詢)。 - ADR-014:不做 autoconfiguration;capability 以 SPI 暴露組合點。
- 既有 JSON 方言不得破壞:已由 OpenAPI、Bruno 與應用端 ADR(M2M 第三方接入)發布。
- 不成為 authorization server:應用端 ADR 已決「完整 OAuth2 AS 現階段過重」。本 ADR 只涉 token 端點的線格式相容,不引入 authorization_code/consent/discovery/client 註冊協定。
假設前提
- 服務帳號的身分承載(合併進帳號表 + 旗標)維持不變。Keycloak 的 service account 同樣以隱藏 user 承載,卻完全 RFC 相容——證明內部承載與線協定是兩個獨立的軸;規範只約束後者。
- 「非互動主體」的判定結果(
interactiveLoginAllowed)已足以表達 M2M 資格,無需在 jar 引入 client 概念。
考量的方案 (Options Considered)
方案 A: 維持現狀(wire 留在各下游的殼)
說明: 不上收,各下游自行決定 token 端點形狀。
優點:
- ✅ 零成本
- ✅ 下游對 wire 有完全自由度
缺點:
- ❌ 每個下游各自實作 RFC 解析/錯誤映射——漂移產生器,且多數會漂成「跟隨自家 REST 慣例」(已實證)
- ❌ 標準 client library 接不上;fleet 內 app-to-app M2M 恆需手刻
- ❌ ⑤⑥ 兩項缺陷在每個下游各自重現
評分: 1
方案 B: 只在參考實作補相容,不上收
說明: 在參考實作的 ClientTokenController 加 form-urlencoded 入口與 OAuth2 錯誤映射,capability 不動。
優點:
- ✅ 改動小、當下即可用
- ✅ 不觸及框架契約,無 deprecation cycle 負擔
缺點:
- ❌ 寫在錯的層:RFC 6749 是標準、不是 per-project 決策,放在殼裡等於要求每個下游各自複製一份正確實作
- ❌ ①②④⑤ 皆未解;
service-account的假邊界續存 - ❌ 下一個下游仍會漂
評分: 2
方案 C: 上收 capability + capability 承擔 RFC 6749 + api-key 第二呈遞(採用)
說明: M2M 發放成為引擎第四操作;線格式與錯誤形狀由 capability 承擔,殼只選方言;api-key 以 SPI 作為同一身分的第二種憑證呈遞。
優點:
- ✅ 一次寫對,全 fleet 受益;wire 不再是各下游的自由發揮
- ✅ ①②④⑤⑥ 一併解決;
service-account的假邊界消失 - ✅ 身分模型零變動(下游的
Account+ 旗架與治理面原樣保留) - ✅ 解鎖 fleet 內 app-to-app M2M(配對的出站 client 有了歸屬)
缺點:
- ❌ 契約凍結成本:新增的 capability API 在
4.0.0後受 deprecation cycle 約束 - ❌ 一次性遷移:jar 新增類、參考實作改接線、catalog 更新、文檔連動
- ❌ api-key 需要新的憑證雜湊策略(見決策五),非零設計成本
評分: 5
方案 D: 改採 Spring Authorization Server
說明: 以 RegisteredClient 取代服務帳號列,token 端點交給 SAS。
優點:
- ✅ 完整標準(discovery、JWKS、多種 client 認證、scope)
缺點:
- ❌ 與應用端 ADR「完整 OAuth2 AS 現階段過重」正面衝突
- ❌
RegisteredClient無租戶欄位(須塞ClientSettings+ token customizer)、無 enabled 旗標、無「依租戶列出」的 repository 方法——治理面幾乎全部重寫 - ❌ 一旦有 issuer,人類登入亦「應」走它,否則兩套簽發路徑並存——範圍遠超本問題
- ❌ 相容 OAuth2 並不需要它(Keycloak 即為反證)
評分: 2
決策 (Decision)
選擇方案: C
決策一:M2M token 發放上收 io.leandev.appfuse.security.auth
成為登入引擎的第四操作,與 login/refresh/logout 並列。它與既有三操作共用全部下層:AuthPrincipalLookup(主體載入)、AuthPrincipal#interactiveLoginAllowed(資格判定,此處反向——互動主體不可走 M2M)、TenantAwareUserDetails(租戶探詢)、JwtTokenProvider(簽發)、PasswordEncoder(憑證比對)。
引擎級輸出,wire DTO 仍歸消費端(比照 LoginService.LoginResult)。
決策二:線格式由 capability 承擔(本 ADR 的核心判準)
RFC 6749 是標準,不是 per-project 決策。
這是與 login 的不對稱之處,且不對稱是有理由的:「登入」沒有標準,故其路徑與 DTO 形狀歸殼(ADR-023 決策);client_credentials 有標準,讓每個下游的殼各自實作解析與錯誤映射,就是本 ADR 要消除的漂移來源。
capability 承擔:
| 面向 | 內容 |
|---|---|
| 請求 | application/x-www-form-urlencoded(§4.4.2);client 認證支援 client_secret_basic(§2.3.1,AS MUST 支援)與 client_secret_post;兩者同時出現 → invalid_request |
| 回應 | access_token/token_type/expires_in(§4.4.3);不發 refresh token(SHOULD NOT) |
| 錯誤 | {"error": "...", "error_description": "..."} + 對應狀態碼;invalid_client 回 401 + WWW-Authenticate |
既有 JSON 方言以相容分支保留:殼依請求形態選方言——form/Basic 進來回 OAuth2 錯誤形狀,JSON 進來維持 RFC 7807。兩個方言各自自洽,既有消費端零感知。方言選擇是殼的唯一 wire 職責。
決策三:scope 暫不支援,但不得靜默忽略
請求帶 scope 一律回 invalid_scope(400)。明確拒絕勝過讓 client 誤以為權限已收窄。
真正支援 scope 需先決定它與 role 導出的 authority 的關係(交集/取代/獨立軸),屬另一個決定;resource server 側已備妥(ScopeJwtAuthenticationConverter 原樣對映 scope 為 authority、不加前綴),屆時成本主要在簽發側與治理面。
決策四:憑證比對走 AuthenticationManager,並加速率限制
比照 login 的堆疊,使 M2M 亦取得認證事件與統一稽核。是否套用登入鎖定由消費端組裝決定——鎖定服務帳號可能被外部誤用觸發、造成整合中斷,故不預設啟用,但事件必須發出。
速率限制以 client_id(缺則來源位址)為鍵,置於憑證比對之前——目的是保護 bcrypt 的 CPU,而非防猜測。速率限制逾額回 HTTP 429(RFC 6749 §5.2 無對應錯誤碼,屬 HTTP 層關注,標準 client 依狀態碼處理)。
決策五:api-key 為同一身分的第二種憑證呈遞,非第二套身分(已實作)
外部系統確有做不了 token exchange 者;不提供則每個下游各自發明,且多半發明成「繞過租戶/權限鏈的共享 key」——那正是應用端 ADR 當初否決的形狀。
因此:key 掛在服務帳號主體上,解析後走完全相同的租戶與權限鏈;capability 以
ApiKeyPrincipalLookup SPI 表達「key → 主體」的解析,持久化歸消費端(比照 AuthPrincipalLookup)。
認證後放進 SecurityContext 的是同一個 AuthPrincipal,故 UserDetailsTenantIdResolver 自主體
解析租戶、@PreAuthorize 走同一套 authority——與 client_credentials 零分歧(整合測試以「用 key
呼叫 /products 通、/service-accounts 回 403 而非 401」實證同一條權限鏈)。
已遵守的約束:
- 不以 bcrypt 逐請求驗證(≈100ms/次 = 自我 DoS):
ApiKeyHash用 SHA-256。high-entropy key(256 bits)無字典攻擊面,慢雜湊擋的是不存在的威脅。 - 撤銷語意明確:
ApiKeyAuthenticationFilter每請求重新查找,停用主體或撤銷 key 即刻失效—— 無需等 token 過期,這正是相對 JWT 的優勢。 - last-used 追蹤:查找端節流更新(逐請求寫入過貴),供輪替衛生。
- 預設關閉:
app.security.api-key.enabled預設 false,未啟用即不註冊 filter;停用為 fail-closed(不放行,改走 bearer;兩者皆無則 401)。 - 不繞過租戶/權限鏈:見上「同一個
AuthPrincipal」。
實作階段的一項具體化(entity 佈局):明文 key 的雜湊不掛成 Account 的欄位,而是獨立的
ServiceAccountApiKey entity(參考實作,業務層)。理由是零停機輪替——真實整合的輪替是
「簽新 → 部署 → 撤舊」、需要重疊期,一對一欄位做不到(簽新即殺舊)。獨立 entity 另使 per-key
metadata(label/到期/last-used/軟撤銷)有處可放,且 account 表不因少數服務帳號才用的欄位
而變髒。判別欄位 serviceAccount(1:1、對每列都有意義)留在 Account 是對的;子型別專屬狀態
(1:N、多數列為 null)才該外置——兩者判準不同。
決策六(2026-07-24 修訂):service-account 是 optional Feature,資源面以 referenceDomains 隨附
auth(required)只保留通用認證組裝與 capability,不宣告 service-account 的 controller、 URL、authority、seed 或管理 bean。feature/serviceaccount/ServiceAccountConfig承擔 M2M authentication manager 與ClientCredentialsService的預設 bean 組裝;catalog 宣告requires: [auth, cache]。- wire 殼與管理面仍是 reference implementation,但透過
referenceDomains: ["serviceaccount"]隨 optional Feature 安裝;它們不是 Feature core,/feature sync不得盲覆蓋。 - token exact-public matcher 由
ServiceAccountSecurityContributor宣告,與ClientTokenController一起 provision;Feature 未安裝時 required auth 不預留/api/v1/auth/token。 - 管理面以
ServiceAccountManagementPolicy集中資料隔離差異: tenant base 驗證租戶、限縮查詢與自訂角色;ownership overlay 採全域範圍、建立 tenantless 帳號。共同的ServiceAccountManagementService不依賴tenantfeature。 - variant assembly 在 Feature 選擇前保留未排除 Feature 的
referenceDomains,故 ownership overlay 先形成有效來源;scaffold 若未選service-account,再把 core、reference domain、 設定、seed 與測試整組裁掉。 - Reference implementation 的路徑
/api/v1/auth/token不變(既有對外契約);這不是 capability 或 Feature 固定的位址。
決策七:CHANGELOG 與 API 契約義務
新增的 capability 型別與 SPI 屬公開 API,依 m-changelog-format.md 於 [Unreleased] 記 Added;service-account feature 併入屬下游 catalog 變更,一併記錄遷移指引。
修訂:實作階段的三項具體化(2026-07-24)
實作時三個決策點需要落到具體形狀,記錄於此(皆不改變上述決策的方向):
① 租戶要求是布林,不是 SPI。 「M2M 主體必須有租戶」原本實作成 ClientCredentialsPolicy
接點,隨即依 21-feature-surface.md 紀律四的處置順序自我否決——變異是一個布林值而非行為,
該落在「① delta 是一個值 → 設定」而非「③ 行為上的一個點 → SPI」。改為建構子參數
boolean requireTenant(參考實作經 app.security.m2m.require-tenant 供給,預設 true;
ownership 取向傳 false)。紀律四另一句「不要預先為想像中的變異開接點,第二個下游提出
同一需求時才開」是此次自我修正的直接依據。
② 憑證產生上收 capability(新增,決策一的延伸)。 ClientCredentialsFactory 固定
SecureRandom、url-safe base64、secret 256 bits 與前綴慣例。判準是「零產品變異、錯了就是
安全漏洞」——熵不足或用錯亂數源沒有任何產品理由,卻每個下游都可能各自踩一次。唯一性重試
留消費端(只有持久層知道 client_id 是否已被占用)。
③ 限流缺席時建構期發 WARN。 限流的門檻與 store 確屬 per-deployment(故維持 SPI),但 「沒接 = 靜默失去保護」不該——公開端點每請求一次密碼雜湊,無限流即為 CPU 耗盡面。依紀律四 「把『忘了就靜默出錯』的義務從消費端拿走」,缺席時大聲說出來。
次要偏差:決策四寫「限流以 client_id(缺則來源位址)為鍵」,實作改以固定的
m2m:unknown-client 承接無 client_id 的請求——來源位址可偽造亦可能被 NAT 共用,作為限流鍵
兩頭皆不可靠;缺 client_id 的請求本就會被 invalid_request 擋下,共用一個桶已足夠。
附帶清理:實作期間發現 io.leandev.appfuse.auth 與本 capability 所在的
io.leandev.appfuse.security.auth 同名不同義,且新增的 ClientCredentialsFactory 與該
package 的 PasswordGenerator 分居兩者、需要 javadoc 才能消歧義。查證後該 package 連同
oauth2.ClientCredential 全為死碼(全樹唯一 import 是 ClientCredential → Credential,
而 ClientCredential 的唯一「消費者」是 Credential 的 javadoc @see——互相引用的死對),
已整批移除。認證相關型別現一律在 security.auth。
後續邊界收斂(2026-07-24):
ResourceServerSecurity上收 blacklist、Bearer、API-key、Basic Auth 的固定組裝與 filter 順序,但刻意不接收任何 URL 或 authorization rule。DaoAuthenticationManagers上收 DAO provider 與 authentication event publisher 的固定 建構,避免登入鎖定因漏接事件而靜默失效。RsaKeyPairs上收 PKCS#8/X.509 decoding 與安全的 RSA 產生機制;金鑰來源與 ephemeral 政策仍由 reference implementation 決定。- API-key lookup adapter 位於
service/serviceaccount/ServiceAccountApiKeyPrincipalLookup,名稱同時表達來源資源與輸出契約; 它與 service-account entity/repository 同域,只實作 capability SPI,不使持久化成為 Feature。
後果 (Consequences)
正面
- ➕ 標準 OAuth2 client library 可直接接入,外部整合不再需要客製 client
- ➕ fleet 內 app-to-app M2M 解鎖(配對的出站 client 有了明確歸屬)
- ➕ 認證事件、速率限制、錯誤形狀一次寫對,全 fleet 受益
- ➕ 通用 M2M/API-key 機制仍由 capability 一次實作;不需要機器身分的專案可不出貨其資源面
- ➕
service-account的邊界可由「core + attached referenceDomains」完整移除,不再是假 optional - ➕ 下游身分模型與治理面零變動
負面/代價(誠實列出)
- ➖ 契約凍結:新增 API 於
4.0.0後受 deprecation cycle 約束;緩解為契約收窄(只出「發放操作 + key 解析 SPI」,不引入 client 概念) - ➖ 雙方言維護:JSON 與 OAuth2 兩套錯誤形狀需長期並存,直到確認無 JSON 消費端才能收斂
- ➖ api-key 的設計份量:雜湊策略、快取、last-used、撤銷語意皆需實作與測試,非「順手加一個 filter」
- ➖ 一次性遷移:jar 新增類、參考實作改接線與搬遷、catalog 更新、OpenAPI/Bruno/應用端 ADR 連動
遷移步驟
- jar:新增 M2M 發放操作與 RFC 6749 線格式支援(請求解析、Basic 認證、錯誤形狀);api-key 解析 SPI;ArchUnit 中性檢查涵蓋新類。
- Feature:建立
feature/serviceaccount/ServiceAccountConfig,catalog 登錄 optionalservice-account,依賴 requiredauth與cache。 - 參考實作:將 wire/管理殼收進
serviceaccountreference domain,catalog 登錄referenceDomains;授權 exact matcher、設定、seed 與測試均跟隨該 Feature。 - 測試:既有 JSON 契約測試續綠(迴歸守衛);新增 form-urlencoded +
client_secret_basic+ OAuth2 錯誤形狀、invalid_scope、速率限制的契約測試。 - 文檔:OpenAPI 補 form 方言;Bruno 加標準方言請求;應用端 M2M ADR 補修訂註記(線格式相容 ≠ 成為 authorization server);
guides/auth/*對齊。 - api-key:可與 1–5 分批——先落 M2M 上收與線格式,api-key 為第二批(其設計份量獨立)。
相關
- ADR-023(登入引擎上收 capability——本 ADR 補上被遺漏的第四操作;
AuthPrincipal#interactiveLoginAllowed的歸屬矛盾於此解消) - ADR-024(capability/feature/參考實作三層定位——本 ADR 依其判定句處置 M2M:wire 殼屬參考實作、發放屬 capability)
- ADR-009(雙模 resource server;FEDERATED 部署可整個不曝露本地 token 端點)
- ADR-014(capability ↔ core slice 對偶;不做 autoconfiguration)
- ADR-017(jar 不擁有 entity——api-key 的持久化歸消費端)
21-feature-surface.md紀律三(端點由 Feature 啟用,授權就由 Feature 的 reference domain 宣告——現由ServiceAccountSecurityContributor與 controller 一起 provision)m-server-common.md「框架先行原則」(通用機制歸框架,避免下游各自發明)
變更歷史 (Change Log)
| 日期 | 變更內容 | 變更者 |
|---|---|---|
| 2026-07-23 | 初版 | Development Team + AI Assistant |
| 2026-07-24 | 實作階段修訂:租戶要求改布林(非 SPI)、憑證產生上收、限流缺席發 WARN;附帶移除死碼 package io.leandev.appfuse.auth | Development Team + AI Assistant |
| 2026-07-24 | 決策五(api-key)實作完成:ApiKeyAuthenticationFilter + ApiKeyHash(SHA-256)+ ApiKeyPrincipalLookup SPI;明文雜湊外置為獨立 ServiceAccountApiKey entity(零停機輪替 + per-key metadata) | Development Team + AI Assistant |
| 2026-07-24 | 補記 exact public endpoint、ResourceServerSecurity、DAO manager、RSA key pair 與 API-key adapter 的最終分層;明確區分 reference path 與 capability contract | Development Team + AI Assistant |
| 2026-07-24 | 修訂決策六:以 referenceDomains 讓 service-account 成為真正可移除的 optional Feature;M2M/API-key capability 決策不變 | Development Team + AI Assistant |
文檔維護者: Development Team + AI Assistant 最後審閱: 2026-07-24