跳至主要内容

ADR-025: M2M 憑證發放上收 auth capability;線格式對齊 RFC 6749;api-key 為第二憑證呈遞

ADR 編號: 025 狀態: ✅ 已接受 (Accepted) 決策日期: 2026-07-23 決策者: Development Team 取代: 無(延續 ADR-023ADR-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_tokentoken_typeexpires_in 則符合 §4.4.3)。這不是疏忽——殼所在的那個世界的慣例就是 JSON + ProblemDetail,端點忠實跟隨了它,因為沒有任何東西提醒它該講 OAuth2。後果是標準 OAuth2 client library 接不上。

④ fleet 內缺「能講自家方言的出站 client」。 框架既有的 OAuth2Authenticatorio.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_tokentoken_typeexpires_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-accounts403 而非 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 不依賴 tenant feature。
  • 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 供給,預設 trueownership 取向傳 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 是 ClientCredentialCredential, 而 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 連動

遷移步驟

  1. jar:新增 M2M 發放操作與 RFC 6749 線格式支援(請求解析、Basic 認證、錯誤形狀);api-key 解析 SPI;ArchUnit 中性檢查涵蓋新類。
  2. Feature:建立 feature/serviceaccount/ServiceAccountConfig,catalog 登錄 optional service-account,依賴 required authcache
  3. 參考實作:將 wire/管理殼收進 serviceaccount reference domain,catalog 登錄 referenceDomains;授權 exact matcher、設定、seed 與測試均跟隨該 Feature。
  4. 測試:既有 JSON 契約測試續綠(迴歸守衛);新增 form-urlencoded + client_secret_basic + OAuth2 錯誤形狀、invalid_scope、速率限制的契約測試。
  5. 文檔:OpenAPI 補 form 方言;Bruno 加標準方言請求;應用端 M2M ADR 補修訂註記(線格式相容 ≠ 成為 authorization server);guides/auth/* 對齊。
  6. 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.authDevelopment 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 contractDevelopment 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