Project

General

Profile

CR #201 » SCS-022_認證與授權程式設計規格.md

內容更新:內部稱謂去識別化(2026-10-18) - Redmine Admin, 10/18/2026 05:32 AM

 

SCS-022 認證與授權(SSO/JWT)程式設計規格

項目 內容
文件編號 SCS-022
版本 1.11(新增 §7.11 使用者權杖轉傳(Token Relay)——BEAM PMO 2026-09-21 17:10 核定採 B)⚠️ 1.10 版執行後未回填本欄(仍記 1.9),本次一併更正
狀態 已核定(規格先行·跨域)——依 BEAM PMO 2026-09-19 指示「接著依序完成 W-3 SSO」
對應需求 BRD-003:Authentication 21 項、SSO 1 項(皆對應 SAD-005 §3)
技術棧 Keycloak 26.4(OIDC)+ Spring Security 7.1.1(由 Spring Boot 4.1.1 BOM 管理,實測)
關聯文件 SAD-005 §3(身分驗證)、SAD-005 §4(授權)、CSP-003 §3(錯誤封套)、CR-004(請求層錯誤碼)、SDD-007 §5(頁面層級權限)
適用範圍 17 個領域服務 + api-gateway/bff-web(跨域規格,非單一服務)。後二者位於 backend/platform/(非 services/),自 2026-09-21 起組態已對齊(CR-010 v1.2/v1.3);惟其 oauth2-resource-server 依賴尚未引入,故入站驗證責任仍在領域服務

為何需要本文件:SCS-001~SCS-021 無一涵蓋認證/SSO/閘道,
僅 SAD-005 §3 有原則性記載。依 GOV-003 條件 9「無 SCS 不寫程式」,
W-3 SSO 接線前必須先立本規格。


1. 對應需求

來源 項目數 驗證方式
BRD-003 Authentication 21 登入、MFA、鎖定策略測試
BRD-003 SSO 1 SSO 登入流程端到端測試

⚠️ 本規格僅能驗證其中一部分。MFA、密碼政策、帳號鎖定屬
**身分提供者(Keycloak)**之組態,非本專案程式碼;
可驗證者為「服務端是否正確拒絕未授權請求」。


2. 服務職責與邊界

元件 職責 本期是否實作
Keycloak(IdP) 使用者認證、MFA、密碼政策、帳號鎖定、核發 JWT ✅ 容器已啟用(本期強化組態)
API 閘道 驗證簽章與有效期、轉發角色(SAD-005 §3.3 之 GW) ❌ 尚未建立(見 §3)
領域服務 驗證 JWT、抽取角色、端點層授權、資料層級權限 ✅ 本期
前端(React 18 + Vite) 導向 IdP、攜帶 Bearer Token ✅ 本期(見 §7.8,PKCE)

3. ⚠️ 與 SAD-005 §3.3 之偏差(誠實揭露)

SAD-005 §3.3 之時序圖假設存在 API 閘道(GW),由其驗證簽章與有效期後轉發角色。

⚠️ 本處原記載有誤,2026-09-21 更正

原寫「backend/services/ 下僅有 17 個領域服務,並無 gateway/bff 模組」——此敘述錯誤。
實測:二模組確實存在,位置為 backend/platform/api-gateway 與 backend/platform/bff-web
(平台層,與 common-spring 等並列,不在 services/ 下),且各有 application.yml/
application-local.yml 與 LayeringTest。

連鎖影響(為何要鄭重更正):本句連同 §2「適用範圍:全部 17 個領域服務」
使 gateway/bff 被排除在認證規格之外 → CR-010 v1.1 執行時僅套用 17 個領域服務,
二模組之組態未對齊(已於 CR-010 v1.2/v1.3 補正)。
錯誤陳述會讓後續工作重複漏掉同一批模組,故一併更正,並於下方明列二者之目前狀態。

模組 位置 目前狀態(2026-09-21 實測)
api-gateway(8100) backend/platform/api-gateway 骨架(僅 ApiGatewayApplication + LayeringTest);為 WebFlux(spring-cloud-starter-gateway-server-webflux);無 oauth2-resource-server 依賴
bff-web(8101) backend/platform/bff-web 骨架(僅 BffWebApplication + LayeringTest);為 Servlet(spring-boot-starter-web + OpenFeign);無 oauth2-resource-server 依賴

本期之替代設計:

SAD-005 §3.3 本期實作 偏差性質
GW 驗證簽章與有效期 各領域服務自建 OAuth2 Resource Server 驗證 架構偏差
GW 轉發角色給 SVC 服務自行由 JWT 之 realm_access.roles 抽取 同上
SVC 資料層級權限檢查 不變 —

處置:本期以「各服務自建 resource server」先行;
gateway/bff 模組已存在(見上方更正),但其路由/聚合邏輯尚未實作,
且尚未引入 oauth2-resource-server 依賴,故驗證責任仍留在各領域服務。
此偏差不影響安全性——JWT 驗證屬冪等純函式,
在閘道或服務端執行皆同等有效;差別僅在責任集中與否。

⚠️ 另一項已揭露之落差(非本節範圍,記於 CR-010 §9.5):前端(5173)目前
直接呼叫領域服務(如 statement-service 8092),未經 gateway(8100)——
即「藍圖裡有 gateway」≠「流量實際跑在 gateway 上」。


4. 身分提供者(Keycloak)組態

4.1 既有基礎設施(已實測 2026-09-19 22:50)

項目 實測結果
容器 cc-keycloak(quay.io/keycloak/keycloak:26.4,profile iam)
realm cardcore 匯入成功(Realm 'cardcore' imported)
OIDC discovery GET /realms/cardcore/.well-known/openid-configuration → HTTP 200
客戶端 cardcore-web(public,PKCE S256)、cardcore-services(M2M,service account)
受眾(audience) cardcore-api(由 protocol mapper 注入)

4.2 本期須對齊 SAD-005 §3.1 之組態修正

SAD-005 §3.1 要求 原 realm 組態 本期修正
JWT 有效期 15 分鐘 accessTokenLifespan: 900 ✅ 已符合
閒置逾時 15 分鐘 ssoSessionIdleTimeout: 1800(30 分) ⚠️ 改為 900
密碼長度 ≥ 12 無密碼政策 ⚠️ 新增 passwordPolicy
登入失敗 5 次鎖定 15 分鐘 無暴力破解防護 ⚠️ 新增 bruteForceProtected
MFA 無 ⬜ 未實作(見 §10)

4.3 🔴 角色定義不一致(待 BEAM PMO 裁定,不擅自變更)

來源 角色
SAD-005 §4.2 角色權限矩陣(已核定文件) CSR、CSR_SUP、CREDIT、CREDIT_MGR、OPS、RISK、FIN、AUDIT、ADMIN(9 個)
ops/local/config/keycloak-realm.json cardcore-customer、cardcore-csr、cardcore-ops、cardcore-batch(4 個)

兩者對不上,且非單純命名差異(數量 9 vs 4、語意亦不同)。

  • 本期不擅自修改 SAD-005 §4.2(已核定文件),也不擅自擴充 realm 角色
    (角色屬業務授權模型,須由業務與資安共同定義)。
  • 本期之技術接線以 realm 既有 4 角色為準,僅驗證「服務端是否正確拒絕未授權請求」。
  • 待裁定事項(已列 HANDOVER §3.C):
    1. 以 SAD-005 §4.2 之 9 角色為準 → 須據此重建 realm 角色與群組;或
    2. 以 realm 之 4 角色為準 → 須回頭修訂 SAD-005 §4.2。

5. 程式結構

套件/類別 位置 職責
SecurityConfig platform/common-spring SecurityFilterChain:JWT 驗證、端點授權、錯誤回應
JwtRoleConverter platform/common-spring realm_access.roles → GrantedAuthority
SecurityProperties platform/common-spring cardcore.security.* 組態(啟用開關、公開路徑)
各服務 application*.yml 各服務 spring.security.oauth2.resourceserver.jwt.issuer-uri

為何放 common-spring:17 個服務皆需相同設定,比照 GlobalExceptionHandler
之作法(集中於平台模組,避免逐服務重工——同 W-1 KeyProvider 之教訓)。


6. 授權規則

6.1 公開路徑(無須認證)

路徑 理由
/actuator/health、/actuator/health/** 健康檢查(K8s probe)
/actuator/info 版本資訊,無敏感資料
/actuator/prometheus ⚠️ 僅本地/內網——見 §10

6.2 需認證路徑

其餘一律須攜帶有效 Bearer Token。

6.3 角色與端點 —— ⚠️ 本期僅實作「認證」,不實作「角色授權」

原設計(本節初稿)擬依下表限制端點:

角色 可存取 備註
cardcore-csr 查詢類端點(GET) 對應 SAD-005 §4.2 之 CSR
cardcore-ops 全部端點 對應 OPS
cardcore-batch 批次相關寫入 服務帳號(M2M)
cardcore-customer 不開放 持卡人應經 BFF,不直連領域服務(見 §10)

實際未實作,理由(並經實作時再確認):

  1. 角色定義本身尚未定案(見 §4.3:realm 4 角色 vs SAD-005 §4.2 9 角色)。
    在角色尚未定案前寫死授權規則,等於把待裁定的業務決策偷偷寫進程式碼。
  2. SAD-005 §4.2 明載 CSR 可執行掛失/鎖卡等寫入操作
    (如「掛失/鎖卡:CSR ✅」),與上表「CSR 僅 GET」直接矛盾——
    若照上表實作,將造成與核定文件不一致。

故本期之授權規則僅有一條:anyRequest().authenticated()
(任何有效權杖皆可存取,角色僅抽出备用)。
CC-PLT-4030 與 AccessDeniedHandler 已就位,
待 §4.3 角色模型裁定後填入 hasRole(...) 規則即可生效。
此為刻意之保守設計——寧可暫時不限制,也不要限制錯。


7. 關鍵邏輯

7.1 JWT 驗證

  • 簽章:由 issuer-uri 自動取得 JWKS(spring.security.oauth2.resourceserver.jwt.issuer-uri)。
  • 驗證項目:簽章、exp/nbf 有效期、aud 須含 cardcore-api。
  • 演算法:Keycloak 預設 RS256。

7.2 角色抽取

  • Keycloak 之 realm 角色位於 realm_access.roles(非頂層 roles)。
  • 轉換為 GrantedAuthority 時加上 ROLE_ 前綴(Spring Security 慣例)。
  • client 角色(resource_access)本期不使用。

7.3 錯誤回應(對齊 CR-004)

情境 HTTP 錯誤碼
無 Authorization 標頭/格式非 Bearer/權杖過期/簽章不符 401 CC-PLT-4010(未認證)
已認證但權限不足 403 CC-PLT-4030(無權限)

CC-PLT-4010/CC-PLT-4030 為本期新增:CR-004 之 CC-PLT-4xxx
五碼僅涵蓋 400/404/405,無 401/403;且認證攔截發生在進入控制器之前,
不在 GlobalExceptionHandler 之 @ControllerAdvice 範圍內。
依 CSP-003 §2「不得自創錯誤碼,新增碼須先擴充 SDD-006 §3.4」程序辦理:
已補登 SDD-006 §3.4 並重跑 tools/gen_error_codes.py(112 → 114 碼)。

⚠️ 實測修正(重要):oauth2ResourceServer 自帶
BearerTokenAuthenticationEntryPoint,會蓋掉
exceptionHandling() 設定之 entry point,
且其預設回應主體為空(實測 Content-Length: 0)——
違反 CSP-003 §3「所有回應一律套用 ApiResponse 封套」。
故必須在 oauth2ResourceServer(...) 內再設一次相同 handler(見實作註解)。

WWW-Authenticate 回應 Bearer;失敗時另附 RFC 6750 錯誤碼
(如 Bearer error="invalid_token"),
不含 error_description——避免外洩函式庫內部訊息(CSP-004 §6)。

7.4 組態開關

cardcore:
  security:
    enabled: true              # **預設 true(fail-closed)**——見下方「v1.7 改為 fail-closed」
    permit-all:                # 公開路徑
      - /actuator/health/**
      - /actuator/info

⚠️ v1.7(2026-09-20,BEAM PMO 核定):改為 fail-closed(預設 true)

原設計刻意預設 false,理由為「避免服務引入 common-spring 後意外被要求認證」。
該理由本末倒置:其失敗模式是靜默放行——
與 Flyway 未顯式引入 starter、loki exporter 不存在、OTel 手動組合不匯出
同屬「靜默不做」型缺陷(見 SCS-020 §9.2),是最危險的一類。

改為 fail-closed 後之三種情境(皆已實測,見 §9.2)

情境 行為 實測
未設定(預設) 要求認證 → 無憑證回 401 CC-PLT-4010 ✅
啟用但無 issuer-uri 啟動即失敗:required a bean of type 'JwtDecoder'(大聲失敗,勝過靜默放行) ✅
本地開發 由 application-local.yml 顯式停用(enabled: ${CARDCORE_SECURITY_ENABLED:false}),並記 WARN ✅ 17/17

停用責任歸屬:停用只能顯式宣告,且僅限本地檔
(該檔「僅供本地,禁止流入 SIT/UAT/PROD」)。
前端 VITE_AUTH_ENABLED 亦同步改為預設 true(見 §7.8 組態表)——
兩端預設值必須同調,否則「前端關、後端開」會全部 401。

7.9 出站(服務間呼叫)憑證——client_credentials(CR-010,2026-09-20 BEAM PMO 核定採 A)

入站與出站是兩條腿。 §7.4 處理的是「別人打進來要不要認證」;
本節處理「我打給別人要不要帶憑證」。缺了後者,一啟用認證就全掛。

問題(實測,非推測):CARDCORE_SECURITY_ENABLED=true 下執行帳單週期 →
statement→posting、posting→pricing 全數 401;
批次因而回 GENERATED 但 statementCount=0(正常完成卻什麼都沒做)。
根因:ServiceClientHttpClient 之建構子僅收逾時組態,無任何憑證來源,
呼叫端亦從未帶 Authorization 標頭——端到端都沒有這個能力。

IdP 端其實早已備妥(實測):Keycloak realm cardcore 之
cardcore-services 為 confidential client、serviceAccountsEnabled=true、
具 audience-cardcore-api mapper。實測 client_credentials 取 token:
HTTP 200、expires_in=900、aud=["cardcore-api","account"]。

組態(cardcore.security.outbound.*)

鍵 預設 說明
token-uri 無(必填) ${KEYCLOAK_TOKEN_URI},本地預設 Keycloak token 端點
client-id cardcore-services M2M client
client-secret 無(必填) ${CARDCORE_M2M_CLIENT_SECRET},不給預設值、不得進 repo
cache-skew 30s 提前多久視為過期(吸收時鐘誤差)
connect-timeout/request-timeout 2s/5s 取 token 之逾時,與業務呼叫分開(見下)

⚠️ 逾時不得沿用業務呼叫之預設值:取 token 是基礎設施呼叫,
不應重試(4xx 表憑證有誤),且 IdP 可能冷啟動,故請求逾時較寬(5s vs 3s)。
呼應既有教訓——每接一個外部系統都要重新檢視預設值。

類別(platform/common-spring)

類別 職責
security.outbound.OutboundTokenProvider 介面:authorizationHeaderValue()
security.outbound.ClientCredentialsTokenProvider 取 token、快取、到期前續取;失敗拋錯不降級
security.outbound.NoOpOutboundTokenProvider 本地顯式停用時使用,記 WARN
security.outbound.OutboundTokenAutoConfiguration 組裝 bean 與 fail-closed 守門
client.ServiceClientHttpClient 送出前帶 Authorization;憑證來源 requireNonNull

fail-closed 三情境(皆已實測)

情境 行為
入站啟用 + 出站憑證齊備 取 token 並帶出
入站啟用 + 出站憑證缺漏 啟動期失敗(IllegalStateException,訊息直接列出缺什麼)
入站顯式停用(本地) 不帶憑證,記 WARN(可與「忘了設定」區分)

為何不是「取不到 token 就照發」:那正是失敗即放行(fail-open),
與 §7.6 已處置之「宣告啟用卻沒依賴 → 靜默放行」同型。

為何與入站共用 cardcore.security.enabled 而不另立開關:
兩邊必須同調——入站要認證、出站卻不帶憑證 → 全部 401;
反之「以為有鎖其實沒鎖」更危險。理由與前後端 VITE_AUTH_ENABLED 同調一致(§7.4)。

實測(2026-09-20,真實三服務)

以 CARDCORE_SECURITY_ENABLED=true 啟動 statement(8092)/posting(8087)/pricing(8086):

步驟 結果
三服務啟動日誌 三個皆見「服務間呼叫憑證已啟用:client_credentials」+「認證機制已啟用:JWT」
無 token 呼叫批次端點 401 CC-PLT-4010(入站確有鎖)
以 client_credentials 取 token 200,長 1,307
帶 token 執行 2026-12 週期 201 GENERATED、processed=1、statementCount=1
三個服務日誌之 401 次數 0/0/0
DB 新增 STMT2026120010(prev=71,309 正確銜接 11 月、new=72,341)

憑證管理

  • client-secret 一律由環境變數 CARDCORE_M2M_CLIENT_SECRET 提供。
  • 本地置於 ~/.config/cardcore/m2m.env(權限 600、在 repo 外),
    由 ops/local/env.sh「若存在才載入」。
  • SIT/UAT/PROD 須由金鑰管理系統(Vault/KMS)注入。
  • ⚠️ 既有狀況:ops/local/config/keycloak-realm.json 內含一組開發用
    cardcore-services-dev-secret(本機 Keycloak 匯入用,非本次引入),
    真實環境不得比照。

7.5 ⚠️ Spring Boot 4 自動配置模組化陷阱(實測,非推測)

SecurityAutoConfiguration 需以 ObjectMapper 寫出錯誤封套。
Spring Boot 4 已將自動配置與測試自動配置拆分為獨立模組,
套件路徑與 3.x 不同——逐項皆為解開 jar 實測所得:

類別 Spring Boot 3.x Spring Boot 4.1.1(實測) 所需依賴
JacksonAutoConfiguration org.springframework.boot.autoconfigure.jackson org.springframework.boot.jackson.autoconfigure spring-boot-jackson
@AutoConfigureMockMvc org.springframework.boot.test.autoconfigure.web.servlet org.springframework.boot.webmvc.test.autoconfigure spring-boot-webmvc-test

第二個陷阱(更危險):即使套件正確,以 @AutoConfiguration(after = JacksonAutoConfiguration.class)
排序仍無法保證 ObjectMapper bean 可取——實測啟動失敗
(ObjectMapper 尚未註冊)。
故改為 ObjectProvider<ObjectMapper> 惰性取得:
有應用程式之 ObjectMapper 就用它(序列化格式一致);沒有則退回內建實例。
徹底去除自動配置順序依賴——此類「靜默型」缺陷不會在編譯期報錯,
只會在執行期以奇怪的方式失敗(同 Spring Boot 4 + Flyway 之教訓)。

7.6 ⚠️ 依賴守門:宣告啟用卻沒依賴時,啟動期失敗(fail-closed)

問題(實測發現,非推測):SecurityAutoConfiguration 以
@ConditionalOnClass({SecurityFilterChain, HttpSecurity, Jwt}) 宣告。
服務若未引入 spring-boot-starter-oauth2-resource-server,該自動配置會被
靜默跳過——此時即使設定 cardcore.security.enabled=true,
所有請求仍全部放行,且啟動日誌不會有任何錯誤。

實測紀錄(2026-09-19):card-service 設 CARDCORE_SECURITY_ENABLED=true
但未引入依賴 → 無 token 亦回 HTTP 200,且日誌無任何警示。

此為失敗即放行(fail-open):認證宣告已啟用卻未生效,
等同系統在管理者不知情的情況下裸奔——信用卡核心不可接受。

對策:新增 SecurityDependencyGuardAutoConfiguration——
當「cardcore.security.enabled=true」且「classpath 無 Jwt」時,
於啟動期拋 IllegalStateException,並於訊息中直接給出處理方式。
依賴存在時本類不生效(由 SecurityAutoConfiguration 接手);
未宣告啟用時亦不生效(維持「預設關閉」之設計)。

⚠️ 本類不得 import 任何 Spring Security 類別——
其存在目的即在「依賴缺席」時仍能載入,故僅以類別名字串
(@ConditionalOnMissingClass)判定。

實測驗證:card-service 於缺依賴時重啟 → 啟動失敗並顯示上述訊息;
補上依賴後重啟 → 認證機制已啟用:JWT(OAuth2 Resource Server),
隨後 401/200/偽造 token/公開路徑四情境皆如預期。

7.7 單一服務之導入步驟(onboarding)

步驟 動作 本期狀態
① build.gradle.kts 加入 implementation("spring-boot-starter-oauth2-resource-server") ✅ 17/17 領域服務已完成(2026-09-20,本次補齊其餘 15 個)。⚠️ api-gateway/bff-web 仍未引入(見下表)
② application-local.yml 設定 spring.security.oauth2.resourceserver.jwt.issuer-uri ✅ 19/19 已完成(2026-09-21:api-gateway/bff-web 依下方「預先就位」慣例補齊)
③ 設 cardcore.security.enabled ✅ 預設即為 true(fail-closed,v1.7 起);本地由 application-local.yml 顯式停用(19/19);CARDCORE_SECURITY_ENABLED=true 可於本地開啟驗證

② 與 ① 分離之理由:組態先行就位,使「啟用」退化為單一開關;
未導入依賴者若誤設開關,由 §7.6 之守門於啟動期擋下,不會靜默放行。

api-gateway/bff-web 之導入狀態(2026-09-21 實測):

步驟 狀態 說明
② issuer-uri ✅ 已就位(19/19) 依「含尚未導入依賴者,預先就位」之既有慣例補上,使未來補 ① 時不遺漏
③ cardcore.security.enabled + outbound ✅ 已就位(19/19,CR-010 v1.2) 本地顯式停用;一旦啟用,由 SecurityDependencyGuardAutoConfiguration 啟動期失敗並明指缺 ①
① oauth2-resource-server 依賴 ⚪ 未導入 二模組仍為骨架;承擔入站驗證職責前須補。此為已知落差,非缺陷——守門使其「大聲失敗」而非靜默放行

⚠️ 非本地環境(SIT/UAT/PROD)刻意不在 application.yml 設 cardcore.security:
依 §7.4「停用只能顯式宣告,且僅限本地檔」,非本地即以預設 true(fail-closed) 運作。
實測 19 份 application.yml 皆無該區段——此為設計如此,不是缺漏;
憑證一律由環境變數(KMS)注入,不進 repo。

③ 之單一開關:CARDCORE_SECURITY_ENABLED 經 Spring Boot 寬鬆綁定對應
cardcore.security.enabled,故無須逐服務改 yml——一次環境變數即全域生效:

export CARDCORE_SECURITY_ENABLED=true   # 後端 17 服務
export VITE_AUTH_ENABLED=true           # 前端(必須同調,見 §7.8)

7.7.1 逐服務實測結果(2026-09-20,17/17)

以 CARDCORE_SECURITY_ENABLED=true 逐一起 jar 實測(探測路徑 /api/v1/sso-probe,
該路徑不存在,故帶 token 之 404 即代表通過認證、未達授權階段):

服務 埠 無 token 帶 token /actuator/health
authorization-service 8081 401 404 200
card-service 8082 401 404 200
limit-service 8083 401 404 200
party-service 8084 401 200(真實資料) 200
product-service 8085 401 404 200
pricing-service 8086 401 404 200
posting-service 8087 401 404 200
payment-service 8088 401 404 200
installment-service 8089 401 404 200
rewards-service 8091 401 404 200
statement-service 8092 401 404 200
dispute-service 8093 401 404 200
clearing-service 8094 401 404 200
merchant-service 8095 401 404 200
migration-service 8096 401 404 200
notification-service 8097 401 404 200
report-service 8098 401 404 200

party-service 係以真實存在的持卡人驗證(P001),故為 200 而非 404。


7.8 前端(WebUI)取得 Token——PKCE(HANDOVER #C-37,BEAM PMO 2026-09-20 核定採 A)

為何需要:後端 17 服務之驗證機制已於 §7.1~§7.7 完成並實測,
但 frontend/ 未帶任何 Authorization 標頭,
致 cardcore.security.enabled 常態必須維持 false——
否則 WebUI 全數 401。

⚠️ v1.7 起此限制已解除:本節之 PKCE 已完成,前端已可帶 token,
故上述「常態必須維持 false」之理由不再成立——
已於 v1.7 改為 fail-closed(預設 true),詳 §7.4。
本節為「前端如何取得 token」之規格。

已備妥之基礎設施(實測 2026-09-20 07:50,非推測)

項目 實測結果
Keycloak client cardcore-web 已存在:publicClient=true、standardFlowEnabled=true、
directAccessGrantsEnabled=**false**(不開 password grant)、webOrigins=http://localhost:5173
redirectUris http://localhost:5173/*——與 Vite dev server 埠號一致
發現端點 grant_types_supported 含 authorization_code;code_challenge_methods_supported 含 S256

→ IdP 端已就位,本期僅須於前端實作授權碼流程。

流程(Authorization Code + PKCE,RFC 7636)

步驟 內容
① 產生 code_verifier(43~128 字元之高熵亂數)與 state;
code_challenge = BASE64URL(SHA256(code_verifier)),方法固定 S256(不用 plain)
② 導向 authorization_endpoint,帶 response_type=code、code_challenge、code_challenge_method=S256、state
③ IdP 回導 redirect_uri?code=…&state=…;先比對 state(防 CSRF),不符即中止
④ 以 code + code_verifier 向 token_endpoint 交換 token(不帶 client_secret——public client 沒有)
⑤ 取得後立即移除 code_verifier(一次性,不得留存)

Token 存放(安全取捨)

項目 存放位置 理由
access token 記憶體(模組層變數) 不落地即不受 XSS 竊取持久憑證;重整頁面即失效
refresh token sessionStorage 關掉分頁即清除;若存記憶體則重整就必須重新登入,demo 體驗過差
code_verifier/state sessionStorage(用後即刪) 流程跨導頁,必須暫存;一次性

⚠️ 不得使用 localStorage——它跨分頁持久且同源任何腳本皆可讀,
一旦發生 XSS 即為長期憑證外洩。此為 SPA 之最低安全底線。

換發與失效

情境 處理
access token 將屆期(提前 60 秒) 自動以 refresh token 換發
收到後端 401 CC-PLT-4010 僅重試一次:先換發再重送原請求;仍 401 才導向登入
refresh 失敗 清除 token,導向 IdP 重新認證
登出 呼叫 end_session_endpoint 並清除本端 token

401 之所以「只重試一次」:若無條件重試,token 真的失效時會形成請求風暴。

不引第三方 OIDC 套件(刻意決定)

本期手寫精簡 PKCE 流程,不導入 keycloak-js/oidc-client-ts。理由:
① 銀行案資安審查,每多一個第三方套件就多一份待審相依;
② 所需僅「授權碼+PKCE+refresh+登出」,手寫約百餘行即可,可控性高於引入框架;
③ 前端既有風格為裸 fetch,引入套件會造成兩種呼叫風格並存。

組態開關

變數 預設 說明
VITE_AUTH_ENABLED true(v1.7 起,與後端同調) 關閉時不導向 IdP、不帶標頭——與後端 cardcore.security.enabled 必須同調;兩端預設值皆為 fail-closed,本地由 .env.development 顯式停用
VITE_KEYCLOAK_URL http://localhost:8180 IdP 根位址
VITE_KEYCLOAK_REALM cardcore realm
VITE_KEYCLOAK_CLIENT_ID cardcore-web public client

組態一律經 import.meta.env 注入,不得硬編於程式碼(CSP-001 §8 B6)。

實測證據(2026-09-20,真實 Keycloak + 真實 Oracle + 真實 Chrome)

① 協定層(tools/verify_sso_pkce.py,僅用標準函式庫):

情境 結果
無 token 401 CC-PLT-4010
帶 PKCE 取得之 token 200,回傳真實持卡人 P001(個資欄位皆為遮蔽值)
偽造 token(末 6 碼改寫) 401 CC-PLT-4010
refresh_token 換發 200,取得新 access token

該腳本並含反向驗證:對未啟用安全性之服務執行時會判定 ❌
(實測 posting-service 即為此情形——故其同時為 HANDOVER #C-34
「SSO 推廣至其餘 15 服務」之實證)。

② 瀏覽器層(真實 Chrome headless,Vite dev server 5173):

步驟 結果
未登入訪問前端 導向 Keycloak 授權端點
輸入 demo.holder 回導 5173,前端完成交換後清除網址上的 code
token 存放 refresh 於 sessionStorage ✅;localStorage 為空 ✅
執行前端自己的 fetchParty() 200,取回 P001/王小明;送出的 /party-api 請求帶 Bearer
重整(access token 存於記憶體已失) 以 refresh token 重建 session 成功,不必重打帳密

瀏覽器層之驗證係直接 import('/src/api/parties.ts') 執行前端程式碼,
非另寫一份等效邏輯——故驗證到的確實是交付出去的那份程式。


7.10 服務間呼叫 ACL(白名單)——CR-011 階段二(2026-09-21)

本節為「授權」之第二層:§7.1–7.4 解決「是不是有效權杖」(認證),
§7.9 解決「呼叫時有沒有帶權杖」(出站憑證),本節解決「這個權杖的持有者能不能打這個服務」。

7.10.1 為何需要

CR-010 之後,任何持有有效 M2M 權杖者可呼叫任何服務的任何端點——
等於「有鑰匙就能開所有的門」。SAD-002 §5 已定義服務互動矩陣(誰可以同步呼叫誰),
但該矩陣從未被程式讀取,形同具文。本節將其落實為可執行之白名單。

7.10.2 單一真實來源

SAD-002 §5 互動矩陣  ──(tools/gen_service_acl.py --write)──▶  cardcore-service-acl.yml
  • 輸出:backend/platform/common-spring/src/main/resources/cardcore-service-acl.yml(19 條目)
  • ⚠️ 本檔不得手改。改動流程:修 SAD-002 §5 → 重跑產生器 → 重跑測試。
    直接編輯 yml 會造成「規格與實作兩份真相」。
  • 轉置規則:矩陣為「呼叫者 \ 被呼叫者」,本檔為「被呼叫端 → 允許之 azp」;只取同步(S),
    事件(A)走 Kafka、不受本機制管制。

7.10.3 判斷規則(ServiceCallerAclManager)

順序 情境 結果
1 未認證/匿名 拒絕(401)
2 使用者 token(azp 非 cc- 開頭) 放行——交由角色授權,本 ACL 不管制
3 服務帳號(azp = cc-<模組名>)且列於本服務之允許清單 放行
4 服務帳號但不在允許清單 拒絕(403 CC-PLT-4030)

⚠️ 規則 2 之理由:若一併管制使用者 token,將擋掉所有使用者請求
(azp 為前端 client,不在互動矩陣內)。使用者能做什麼屬「角色授權」,與「誰打來」正交,須分開。

7.10.4 fail-closed

情境 行為
ACL 資源不存在 啟動期拋錯
ACL 解析失敗 啟動期拋錯(不靜默回傳空 ACL)
spring.application.name 未設定 啟動期拋錯
本服務不在 ACL 中(=SAD-002 §5 未涵蓋) 啟動期拋錯

「沒有設定」與「設定為空」必須可區分:後者代表「本服務不接受任何同步呼叫」(如 statement-service),
前者代表「規格有缺漏」——都當成空集合會讓缺漏靜默通過。

7.10.5 🔴 已知陷阱:AnonymousAuthenticationToken.isAuthenticated() 為 true

Spring Security 於無憑證請求時放入 AnonymousAuthenticationToken,且其 isAuthenticated() 回傳 true
(刻意設計,使其能參與授權判斷)。因此:

// ❌ 錯誤:匿名請求之 auth 非空且 isAuthenticated()==true,
//         會落入「非服務帳號一律放行」而被放行(實測回 200)
if (auth == null || !auth.isAuthenticated()) { return deny; }

// ✅ 正確:顯式排除匿名
if (auth == null || !auth.isAuthenticated()
        || auth instanceof AnonymousAuthenticationToken) { return deny; }

此缺陷係由過濾器層級測試(ServiceAclFilterTest ④)抓出——
單元測試之負樣本誤用 TestingAuthenticationToken(false),非真實型別,故未發現。
教訓:負樣本必須覆蓋所有判斷點,且必須用真實型別。

7.10.6 實測(2026-09-21,真實 Keycloak token → 真實 posting-service)

# 呼叫者 結果
① 無憑證 401 CC-PLT-4010
② cc-statement-service(矩陣 S) 404 CC-POST-4040 —— ACL 通過(查無分戶,業務錯誤)
③ cc-clearing-service(矩陣非 S) 403 CC-PLT-4030 ✅
④ cc-bff-web 403 CC-PLT-4030 ⚠️ —— 見 §10

啟動日誌:服務間呼叫 ACL 已載入(19 個條目,本服務允許 4 個呼叫者);
拒絕時記 ⛔ 服務間呼叫遭拒:本服務=posting-service 呼叫者=cc-clearing-service,traceId 與 HTTP 回應一致。

7.10.7 測試(判定力已驗證)

測試 例數 驗證層級
ServiceAclTest 10 單元(解析/fail-closed/AuthorizationManager 決策)
ServiceAclFilterTest 4 過濾器層級(走完整 filter chain,防「沒掛上 HttpSecurity」之假綠)

負樣本驗證:決策一律放行 → 5 例轉紅;一律拒絕 → 3 例轉紅。
common-spring 測試總數 71 → 85,全綠。


7.11 使用者權杖轉傳(Token Relay)——gateway/bff 採 B(BEAM PMO 2026-09-21 17:10 核定)

設計依據:SAD-005 §3.4(身分轉傳原則)/CR-011 §11。
本節為其程式設計規格;裁定內容見 CR-011 §11。

7.11.1 為何需要(問題陳述)

CR-011 階段二上線後,實測 cc-bff-web/cc-api-gateway 呼叫領域服務 → 403 CC-PLT-4030。
根因:SAD-002 §5 互動矩陣不含 gateway/bff,故二者之允許清單皆為 [](產生器轉置之正確結果)。

兩個選項中,BEAM PMO 核定採 B——不為其開 M2M 白名單,而令其轉傳使用者權杖:

做法 後果
A(不採) 於矩陣新增 gateway/bff 兩列 取得「可呼叫所有服務」之超級身分;服務端失去使用者身分
B(採) 轉傳使用者權杖,不以 M2M 呼叫領域服務 現行 ACL 無須修改;azp 非 cc- 本就不受管制(§7.10 規則 3)

7.11.2 組態模式(cardcore.security.outbound.mode)

模式 適用 憑證需求 行為
client_credentials(預設) 17 個領域服務;非使用者情境(批次/排程/事件消費) token-uri/client-id/client-secret 三項必填 由 ClientCredentialsTokenProvider 取 M2M 權杖
token-relay 僅 api-gateway/bff-web 無(不得也不需要設定 client 憑證) 由 RelayingOutboundTokenProvider 轉傳當前使用者權杖

⚠️ 為何以「模式切換」而非「刪除 gateway/bff 之出站組態」:
OutboundTokenAutoConfiguration 之 fail-closed 為「入站啟用 + 出站憑證未設定 → 啟動期失敗」。
直接刪除組態會使二者在 enabled=true 時起不來。故必須新增模式,讓「不需要 M2M 憑證」成為可被表達的合法狀態。

7.11.3 RelayingOutboundTokenProvider 行為規格

# 情境 行為
1 當前身分為 JwtAuthenticationToken 回 Bearer <原權杖字串>(原樣轉傳,不重新簽發、不改內容)
2 無身分/匿名(AnonymousAuthenticationToken) 拋 IllegalStateException(fail-closed)
3 身分型別非 JWT 拋 IllegalStateException——不靜默降級為不帶標頭

⚠️ 情境 2/3 之所以拋錯而非回 null:回 null 會讓呼叫「不帶 Authorization 照發」,
屬失敗即放行(fail-open),與 §7.6 已處置之「宣告啟用卻沒依賴 → 靜默放行」同型,不可接受。
批次情境下該錯誤還會被「跳過該帳戶、批次繼續」吸收成靜默零產出(CR-010 之 P0 實例)。

7.11.4 與服務間 ACL(§7.10)之關係

轉傳後領域服務見到之 azp 為 cardcore-web(非 cc-)→ 判定「非服務帳號」→ 放行,
交由角色授權。故白名單無須任何修改——這是 B 方案之關鍵優勢。

7.11.5 已知限制(誠實揭露)

# 限制
1 僅適用於同步、同執行緒之呼叫。SecurityContext 預設不跨執行緒傳遞;@Async/反應式(WebFlux)情境須另行處理
2 gateway/bff 目前仍為骨架(無路由、無 Feign client),本節係機制先行;實際轉傳行為尚未有端到端實測(無呼叫端可測)
3 轉傳之權杖若於飛行途中過期,被呼叫端將回 401;本機制不主動刷新(應由前端負責,見 §7.8)
4 領域服務無法區分「直接來自瀏覽器」與「經 bff 轉傳」——二者之 azp 皆為 cardcore-web。若日後需要區分,須另於權杖中加入來源宣告

8. 外部依賴與降級

依賴 失敗處理
Keycloak 不可用(JWKS 抓取失敗) 啟動期:JWKS 快取未建立 → 服務啟動失敗(fail-fast,不靜默放行)
Keycloak 事後不可用 運行期:已快取之 JWKS 仍可驗證 → 既有 token 仍可用;換 key 後始失效
時鐘偏移 依賴 NTP;exp 驗證容許預設之時鐘偏移

⚠️ 不提供「繞過認證」之降級——認證失敗一律拒絕,不得放行(資安底線)。


9. 測試案例

編號 情境 預期
UT-SEC-001 無 Authorization 標頭 401 CC-PLT-4010
UT-SEC-002 格式錯誤(非 Bearer) 401 CC-PLT-4010
UT-SEC-003 有效 token 200 且可取得資料(角色未納入判定,見 §6.3)
UT-SEC-004 有效 token、角色不符 ⬜ 未實作——見 §6.3,待角色模型裁定(CC-PLT-4030 目前不會被觸發)
UT-SEC-005 過期 token 401
UT-SEC-006 簽章不符(偽造 token) 401
UT-SEC-007 公開路徑 /actuator/health 200 無須 token
UT-SEC-008 關閉開關(enabled: false) 全部路徑放行(本地顯式停用用;本地/測試不得仰賴預設值)
UT-SEC-010 不設定開關(驗證 fail-closed) 401 CC-PLT-4010——證明「忘記設定」時是拒絕、不是放行。見 SecurityFailClosedTest(已含反向驗證:預設改回 false 時該測試必紅)
IT-SEC-001 向 Keycloak 取得真實 token 並呼叫服務 200(端到端,證明非自嗨)
UT-SEC-009 角色授權(403) ⬜ 未實作——見 §6.3,待角色模型裁定

9.1 實測證據(2026-09-19,向 Keycloak 取得真實權杖)

# 情境 實測結果
① 無 Authorization 標頭 401 CC-PLT-4010(含封套) ✅
② 有效權杖(Keycloak 核發) 200,回傳真實資料 P001/0912****78/k***@example.com ✅
③ 公開路徑 /actuator/health 200(無須權杖) ✅
④ 偽造權杖(簽章不符) 401 CC-PLT-4010;WWW-Authenticate: Bearer error="invalid_token";主體含封套 ✅
⑤ 格式錯誤(Basic 而非 Bearer) 401 CC-PLT-4010 ✅
⑥ 過期權杖(逾 15 分鐘) 401 CC-PLT-4010 ✅

權杖宣告實測:iss=http://localhost:8180/realms/cardcore、
aud=cardcore-api、realm_access.roles=["cardcore-csr"]
——確認角色位於巢狀之 realm_access.roles(見 §7.2)。


9.2 fail-closed 實測證據(HANDOVER #C-24,2026-09-20 12:10)

以 card-service(埠 8082)+ 真實 Oracle + 真實 Keycloak 26.4 實測三種情境:

# 情境 實測結果
① 本地預設(application-local.yml 顯式 enabled: false) 啟動日誌 認證機制【已顯式停用】;/actuator/health 200;發卡請求未被認證擋下(400/201,取決於請求內容,非 401) ✅
② CARDCORE_SECURITY_ENABLED=true 啟動日誌 認證機制已啟用:JWT(OAuth2 Resource Server);
無 token → 401 且主體 {"error":{"code":"CC-PLT-4010","message":"未提供有效之認證憑證"}};
帶真實 Keycloak token → 201(真的放行並建卡);/actuator/health(permitAll)200 ✅
③ 啟用但未給 issuer-uri 啟動失敗(APPLICATION FAILED TO START):
Method cardcoreSecurityFilterChain ... required a bean of type 'org.springframework.security.oauth2.jwt.JwtDecoder';
結束碼 1、埠無監聽 ✅

③ 之意義:fail-closed 有兩種表現——「拒絕請求(401)」與「啟動失敗」。
後者看似粗暴,但遠勝靜默放行:組態缺失會在部署當下被發現,
而不是等到被稽核或被打穿才發現。

守門測試:platform/common-spring 新增
SecurityFailClosedTest——刻意不設定 cardcore.security.enabled,
斷言無憑證請求為 401。
已做反向驗證:將 SecurityProperties.enabled 暫改回 false 後,
該測試確實轉紅(FAILED),故非假驗證。


10. 已知限制(誠實揭露)

  1. 無 API 閘道——見 §3,驗證責任分散於各服務。

  2. 角色定義不一致——見 §4.3,待 BEAM PMO 裁定。

  3. MFA 未啟用——SAD-005 §3.1 要求 MFA,Keycloak 需設定 Authentication Flow;
    本期未做(屬 IdP 組態,非程式碼)。

  4. 401 無專屬錯誤碼 → 已解除:本期已補登 CC-PLT-4010/CC-PLT-4030
    (SDD-006 §3.4,錯誤碼 112 → 114)。
    惟 CC-PLT-4030 目前不會被觸發——因角色授權未實作(見 §6.3)。

  5. /actuator/prometheus 未納入授權——監控抓取通常不帶 token;
    本地可接受,正式環境須由網路層(內網/Service Mesh)隔離,不得以授權放行。

  6. 持卡人(cardcore-customer)直連領域服務本期不開放——
    持卡人應經 BFF(SAD-001 AP-*),直連會繞過資料層級權限。

  7. 服務間認證之 mTLS 半邊仍未實作——SAD-005 §3.2 要求「mTLS + Service Token」;
    2026-09-20 之 CR-010 已補上 Service Token(client_credentials,見 §7.9),
    惟 mTLS 仍待基礎設施(憑證簽發與輪替),未因本次而宣稱完成。

  8. 敏感操作再驗證(SAD-005 §3.1)未實作——須搭配 SDD-007 §5 之頁面流程。

  9. 實測覆蓋僅 2/17 服務 → ✅ 已解除(17/17):本次補齊其餘 15 個服務之
    oauth2-resource-server 依賴並逐服務實測,詳 §7.7.1。
    其僅完成「認證」之實測——授權(角色)仍未實作(見 §6.3),
    故帶 token 之結果為 404(未達授權階段)而非角色判斷之結果。

  10. 🔴 前端(WebUI)無 token 來源 → ✅ 已解除:本期實作 §7.8 之 Authorization Code + PKCE(手寫、不引第三方套件),access token 存記憶體、refresh token 存 sessionStorage、不用 localStorage;401 僅重試一次、refresh 失敗才重新導向。
    ⚠️ 仍存之限制:SPA 於瀏覽器持有 token 非銀行生產之最終型態——最安全者為「token 不進瀏覽器」之 BFF/Gateway 模式(httpOnly cookie),待 C-12(api-gateway)/C-13(bff-web)建立後應將此責任上收。本期定位為 POC/展示環境之可用方案,已於此明列以供審查。

  11. Keycloak 健康端點 404 → 已於 v1.6 查明並修正(HANDOVER #C-35):
    端點本來就正常,問題是它不在主埠 8180,而在管理埠 9000(官方文件:
    「exposed on the management port 9000 by default」),而該埠未映射至主機
    → 由主機探測 /health* 一律 404,易被誤判為「健康端點壞掉」。
    實測容器內 9000/health/ready → 200 {"status":"UP"}(含資料庫連線檢查)。
    修正:①docker-compose.yml 映射 9000:9000 ②補 KC_METRICS_ENABLED=true
    ③prometheus.yml 新增 keycloak job(原完全沒有指標)。
    現況:/health//health/ready//health/live//health/started 皆 200;
    Prometheus up{job="keycloak"}=1、930 個序列。
    回歸驗證:容器重建後權杖簽發仍正常(demo.holder → cardcore-customer)。
    ⚠️ 資安提醒:管理埠不應對外暴露——本機可接受,SIT 以上應限監控網段,
    或改以容器內 HEALTHCHECK 探測(免映射埠)。

  12. ⚠️ 認證「機制已備」但「常態未啟用」 → 已於 v1.7 解除(BEAM PMO 2026-09-20 核定):
    SecurityProperties.enabled 預設改為 true(fail-closed)。
    原「忘記設環境變數即靜默放行」之風險已消除——
    未設定時為要求認證(401),連 issuer-uri 都沒給則啟動即失敗(§9.2 ③)。
    停用改為只能顯式宣告,且僅限 application-local.yml(17/17 皆已宣告),
    停用時記 WARN 使「刻意停用」與「忘了啟用」在日誌上可區分。
    前端 VITE_AUTH_ENABLED 同步改為預設 true(兩端必須同調,見 §7.4)。

  13. ✅ api-gateway/bff-web 之呼叫方式——BEAM PMO 2026-09-21 17:10 核定採 B(轉傳使用者權杖),並已執行完畢(CR-011 §11):
    實測以 cc-bff-web 之權杖呼叫 posting-service → 403 CC-PLT-4030。
    原因:SAD-002 §5 互動矩陣僅涵蓋 17 個領域服務,不含 gateway/bff →
    二者既不允許任何服務帳號呼叫,也未被任何服務允許。
    現況:兩模組皆為骨架(api-gateway 無路由設定、bff-web 無 Feign client),
    故無立即故障;但一旦 bff 以 M2M 呼叫領域服務即全數 403。
    建議採「轉傳使用者權杖」而非放行 M2M——gateway/BFF 係使用者之代理人,
    改用 M2M 會使服務端失去使用者身分,使用者層級授權與稽核皆無從實作;
    且現行設計已預留此路徑(使用者 token 之 azp 非 cc-,不受 ACL 管制)。
    ⚠️ 若採此方向,CR-010 §9.5 為二者補上的出站 M2M 組態即不應被使用,
    須於 SAD-005 明訂「轉傳原則」以免日後誤用。
    ★ 影響範圍複核(2026-09-21 15:50,實測):frontend/vite.config.ts 之 proxy 直連各領域服務、並未經過 gateway/bff,且其註解明載「正式環境應統一由 API Gateway(8100)出口」→ 目前影響為 0,但係已預定之地雷。api-gateway/bff-web 兩模組實為空殼(src/main/java 各僅 1 個 Application 類別,無路由、無 Feign client)。詳 CR-011 §11「影響範圍」與「附帶實測」(本 ACL 極嚴格:矩陣未畫到之同步呼叫一律 403)。

    ✅ 已執行:SAD-005 → 1.3(新增 §3.4 身分轉傳原則);本規格新增 §7.11;程式新增 OutboundMode/RelayingOutboundTokenProvider;gateway/bff 組態改為 mode: token-relay 並移除 client 憑證;common-spring 85 → 91 測試 0 失敗。


11. 修訂記錄

版本 日期 說明
1.0 2026-09-19 初版。依 GOV-003 條件 9 於 W-3 動工前建立;揭露 3 項偏差(無閘道、角色不一致、401 無專屬碼)
1.2 2026-09-20 推廣時發現 fail-open 缺陷並修復:新增 §7.6 依賴守門(cardcore.security.enabled=true 但 classpath 無 Spring Security 時啟動期失敗,實測:card-service 無 token 原回 200);新增 §7.7 單一服務導入步驟(依賴/組態/開關三段分離,組態已 17/17 就位);§10 更新實測覆蓋為 2/17,並新增第 10 項限制——前端無 token 來源,故常態不得全域啟用(否則 WebUI 8 頁全數 401)
1.1 2026-09-19 實作完成並實機實測後回寫:①§7.3 補登 CC-PLT-4010/CC-PLT-4030(SDD-006 112 → 114 碼)並記錄 oauth2ResourceServer 蓋掉 entry point 致回應主體為空之實測修正;②§7.5 新增 Spring Boot 4 自動配置模組化陷阱(套件路徑改置,及改用 ObjectProvider<ObjectMapper> 去除順序依賴);③§9 補實測證據與 7 個自動化測試(common-spring 41 → 48);④§10 解除第 4 項限制、新增第 9/10 項(僅 party-service 實測、Keycloak 健康端點 404)
1.3 2026-09-20 WorkBuddy AI
1.4 2026-09-20 WorkBuddy AI
1.5 2026-09-20 WorkBuddy AI
1.6 2026-09-20 WorkBuddy AI
1.7 2026-09-20 WorkBuddy AI
1.8 2026-09-20 WorkBuddy AI
1.9 2026-09-21 更正 §3 之錯誤陳述(原寫「並無 gateway/bff 模組」——實測二者位於 backend/platform/,確實存在),並補列其狀態表;因該句連同 §2 適用範圍使二模組被排除於認證規格外,連鎖導致 CR-010 v1.1 僅套用 17 個領域服務(已於 v1.2/v1.3 補正)。②§2 適用範圍改為「17 領域服務 + gateway/bff」。③§7.7 步驟 ② 改為 19/19 已就位(二模組依「預先就位」慣例補 issuer-uri)、③ 改為 19/19,並新增「gateway/bff 導入狀態」表(① 依賴仍未導入,已揭露)。④明載非本地環境刻意不在 application.yml 設 cardcore.security(依 §7.4 僅本地檔可顯式停用)→ 實測 19 份皆無該區段係設計如此,非缺漏,避免後人誤判為落差而補錯
1.10 2026-09-21 WorkBuddy AI
1.11 2026-09-21 WorkBuddy AI
(57-57/67)