# CR-042　`JwtAuthFilter` 之 401 路徑拋 NPE（`Map.of` 不接受 null 值）——診斷與修補

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-042 |
| 版本 | 1.1 |
| 狀態 | **已立／已執行**（已修並實測通過）；★ **§8 之接線裁定已由 BEAM PMO 2026-10-15 核定為「接線」，並已執行**（見 §9） |
| 提出日期 | 2026-10-06 |
| 提出者 | 開發團隊（**批次 20-d 為 `common-security` 補測試時實測揭露**；見 `TRP-003 §6.11.35`） |
| 影響文件 | `CSP-003 §3`（統一回應封套）、`SAD-005 §4`（JWT 認證）、`TRP-003 §6.11.35` |
| 影響模組 | `platform/common-security`（`JwtAuthFilter`） |
| 優先級 | **P1（高）**——★ **認證失敗路徑完全失效**；★ 惟**目前全庫零接線**，故為**潛在缺陷**（見 §3） |
| 相關前例 | **`CR-004`**（錯誤映射）、**`CR-041 #A-184`**（「以 500 掩飾」之同型禁區） |

---

## 1. 摘要

為 `common-security` 補覆蓋率（批次 20-d）而首次為 `JwtAuthFilter` 撰寫**拒絕路徑**測試時，
實測揭露：★ **`sendUnauthorized()` 必拋 `NullPointerException`**——
即 **`JwtAuthFilter` 從未真正回過 401**。

> ★ **這正是「未測到的路徑＝未驗證的假設」**：本類別自建立以來，
> 其**核心職責**（未認證 → 401）**從未被執行過一次**。

---

## 2. 診斷

### 2.1 症狀（真機堆疊，逐字）

```
java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:233)
    at java.base/java.util.ImmutableCollections$MapN.<init>(ImmutableCollections.java:1193)
    at java.base/java.util.Map.of(Map.java:1407)
    at com.beam.cardcore.common.security.auth.JwtAuthFilter.sendUnauthorized(JwtAuthFilter.java:103)
    at com.beam.cardcore.common.security.auth.JwtAuthFilter.doFilterInternal(JwtAuthFilter.java:75)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
```

### 2.2 根因

```java
// JwtAuthFilter.java:103-109（修補前）
byte[] body = MAPPER.writeValueAsBytes(java.util.Map.of(
        "traceId", traceId == null ? "" : traceId,
        "data", (Object) null,          // ★★ 元凶：Map.of 不接受 null 值
        "error", java.util.Map.of(
                "code", ErrorCodes.CC_GW_1002,
                "message", "未認證：" + reason,
                "details", java.util.List.of())));
```

★ **`java.util.Map.of(...)` 對「任何 null 之鍵或值」皆拋 `NullPointerException`**
（`ImmutableCollections.MapN` 之建構子即 `Objects.requireNonNull`）。
★ 意圖是「封套之 `data` 為 `null`」（與 `common-spring` 之 `ApiResponse` 一致），
但 **`Map.of` 表達不了「值為 null」**——★ **這是一個「看起來很合理、編譯也過、但執行期必炸」的陷阱**。

---

## 3. 查證（影響範圍）

| 查證項 | 方法 | **結果** |
|---|---|---|
| 該路徑是否可達 | 撰寫拒絕路徑測試 | ★ **必然可達**——任何缺 token／壞 token 之請求皆會走到 |
| 是否已接線至服務 | `grep -rl "JwtAuthFilter" --include="*.java" backend/` | ★★ **全庫零接線**——僅 `CurrentUser`／`JwtValidationException` 之 **Javadoc** 提及（★ **2026-10-15 已接線，見 §9**） |
| 現行系統是否受影響 | 同上 | ★ **否**（未被任何服務註冊） |
| 服務之 401 由誰產生 | 實查 | ★ 由 Spring Security（`oauth2ResourceServer`）產生，**非**本過濾器 |

> ★★ **裁定之關鍵**：本缺陷**目前不影響運行中的系統**（未接線），
> ★ 但 `JwtAuthFilter` 是 `common-security` 之**公開 API**，其 Javadoc 明載
> 「失敗則回 401（`CC-GW-1002`）」——★ **一旦任何服務依文件接線，所有 401 立即變成 500**。
> ★ 依 `CR-041 #A-184` 之同一標準（「不得以 500 掩飾」），**仍須修**。

---

## 4. 裁定

★ **修**（真缺陷，非設計取捨）。★ **最小改動**：改用**可容納 null 值**之容器，
**JSON 形狀不變**（`data` 仍為 `null`）。

---

## 5. 實作

```java
// 修補後：以 LinkedHashMap 承載（可含 null 值），JSON 形狀與 ApiResponse 封套一致
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("traceId", traceId == null ? "" : traceId);
envelope.put("data", null);                       // ★ 可為 null
envelope.put("error", Map.of(
        "code", ErrorCodes.CC_GW_1002,
        "message", "未認證：" + reason,
        "details", List.of()));
byte[] body = MAPPER.writeValueAsBytes(envelope);
```

★ **不動其他行為**：狀態碼 401、`Content-Type`、UTF-8、`error.code = CC-GW-1002` 皆原樣。

---

## 6. 驗證（實測）

新增 `JwtAuthFilterTest`（**14 測**，★ **零 mock**：Spring 官方
`MockHttpServletRequest`／`MockHttpServletResponse` ＋ `FilterChain` 函式介面 lambda；
★ token 由**真實 `JwtSigner` 簽發、真實 `JwtValidator` 驗證**）：

| 必驗 | 結果 |
|---|---|
| 缺 `Authorization` → 401 ＋ 封套（`CC-GW-1002`） | ✅ |
| 非 `Bearer` 前綴 → 401 | ✅ |
| `Bearer` 但 token 空白 → 401 | ✅ |
| 簽章不符 → 401，★ **訊息不含原始 token** | ✅ |
| 已過期 → 401 | ✅ |
| 格式錯誤（非三段）→ 401 | ✅ |
| 封套之 `traceId` 取自 MDC（有值／無值各一） | ✅ |
| ★ **有效 token → 放行，且 `CurrentUser`／MDC 於期間已寫入、★ 結束後清理** | ✅ |
| ★ 白名單為 null／單引數建構子 | ✅ |

★ **修補前**：14 測中 **11 測紅燈**（皆為同一 NPE；另 3 測不經 401 路徑故綠）。
★ **修補後**：**14 測全綠**；`common-security` 模組 **137 測／0 失敗**。

---

## 7. 誠實揭露

1. ★★ **本缺陷自 `JwtAuthFilter` 建立以來即存在**，且**其核心職責從未被執行**——
   ★ 根因是「**沒有拒絕路徑的測試**」，非「程式碼寫錯一行」。
2. ★ **本類別目前未接線**，故**不宣稱**「現行系統曾回錯」；★ 僅宣稱
   「**依文件接線後必然失敗**」。
3. ★ 併記：本次同一批測試亦揭露**我方之另一錯誤**——
   `AesGcmFieldEncryptorEdgeTest` 之「竄改密文」初版**改動 Base64 末字元**，
   其低 2 位被丟棄 → 有時不改變任何 byte → **認證反而通過**（★ 與 `#A-163`
   所載之 6.25% 間歇性失敗**同型**）。★ 已改為改動**最後一段之首字元**（6 位元全有效）。

---

## 8. 建議

1. ★★ ~~**接線決策待 BEAM PMO 裁定**：`JwtAuthFilter` 是否應由各服務註冊？~~
   → ★ **BEAM PMO 2026-10-15 裁定：「接線」**。執行方式見 **§9**（採**橋接模式**，
   非以獨立驗證模式接線——理由見 §9.1）。
2. ★ 建議將「**以 `Map.of` 表達 null 值**」列入 `CSP-001` 之常見陷阱清單
   （同型：`List.of(null)`／`Set.of(null)`）。

---

## 9. 接線之執行（BEAM PMO 2026-10-15 裁定「接線」後）

### 9.1 ⚠️ 動工前之事實查證——「接線」不能照字面做

裁定的**字面**是「`JwtAuthFilter` 由各服務接線」。惟動工前查證發現一個**規格層矛盾**，
若照字面以本類別之**原生模式**接線，將造成**系統倒退**：

| 機制 | 位置 | 驗證方式 | 錯誤碼 | 現況 |
|---|---|---|---|---|
| **OAuth2 Resource Server** | `common-spring` `SecurityAutoConfiguration` | `JwtDecoder`＋`issuer-uri` 之 **JWKS** | **`CC-PLT-4010`／`CC-PLT-4030`** | ★ **已接線，17 服務生效** |
| `JwtAuthFilter`（原生模式） | `common-security` | `JwtValidator`（**手動 HMAC／RSA 金鑰**） | `CC-GW-1002` | ★ **全庫零接線**（§3） |

- ★ `SCS-022 §5` 明載**認證機制即 `common-spring` 之 `SecurityConfig`**，
  `§7.3` 明定 401＝**`CC-PLT-4010`**。此為**已核定規格**。
- ★ `JwtAuthFilter` 之原生模式（自帶 `JwtValidator`）與上述**語意不相容**：
  它以**手動金鑰**驗證（不取 JWKS、不驗 `iss`／`aud`）、讀取 `roles`／`scope` 宣告
  （Keycloak 實際位於 **`realm_access.roles`**）。★ 若以原生模式接線，將：
  ① **雙重認證**；② **錯誤碼由 `CC-PLT-4010` 變為 `CC-GW-1002`**（**違反 `SCS-022 §7.3`**）；
  ③ 使既有之 `CC-PLT-4010` 相關測試（`SecurityFailClosedTest`、`ServiceAclFilterTest`）轉紅。
- ★ 依鐵則「**盤點矛盾**」，此**不應照字面執行**。經研判，BEAM PMO「接線」之**真意**
  應為「**勿使 `JwtAuthFilter`／`CurrentUser` 淪為死碼，使其於各服務生效**」，
  而非「以之取代既有認證鏈」。

### 9.2 執行方式——**橋接模式**（新增）

★ 為 `JwtAuthFilter` 新增**橋接模式**（無 `JwtValidator` 之建構子）：
**本身不做事前驗證、不改寫回應、不發 401**；僅在 `SecurityContext` 中已存在
由 OAuth2 Resource Server 驗證通過之 `JwtAuthenticationToken` 時，將其宣告
（`sub`／`realm_access.roles`／`scope`／`jti`／`tenant_id`）橋接至
`CurrentUser` 與 MDC，供領域服務經 `CurrentUser.claim()` 取用。

| 檔案 | 變更 |
|---|---|
| `JwtAuthFilter.java` | 新增 `JwtAuthFilter()`（橋接模式）＋ `bridgeDoFilterInternal`；★ **原生模式（帶 validator）行為完全不變**（CR-042 之 14 測不受影響） |
| `JwtAuthFilterAutoConfiguration.java`（**新增**） | `@AutoConfiguration`＋`@ConditionalOnWebApplication(SERVLET)`＋`@ConditionalOnClass({Jwt, JwtAuthenticationToken})`；以 `FilterRegistrationBean` 註冊，**順序 `Ordered.LOWEST_PRECEDENCE`**（**晚於** `SecurityAutoConfiguration` 之認證鏈） |
| `AutoConfiguration.imports` | 新增 `…auth.JwtAuthFilterAutoConfiguration` |
| `common-security/build.gradle.kts` | 新增 `compileOnly`／`testImplementation`：`spring-boot-starter-oauth2-resource-server`（比照 `common-spring`） |
| 開關 | `cardcore.security.current-user.enabled`（**預設 true**）——可顯式停用 |

### 9.3 驗證（實測，2026-10-15）

**單元／整合**（`common-security`，**147 測 0 失敗**；原 137 → +10）：

| 測試 | 例數 | 必驗 |
|---|---:|---|
| `JwtAuthFilterBridgeTest`（新增） | 6 | 有主體→寫入 `CurrentUser`／MDC 且結束清理；claim 對應正確；★ 無主體／匿名→**放行且不寫入**（認證交還 Spring Security）；★ 原生模式不變（401） |
| `JwtAuthFilterAutoConfigurationTest`（新增） | 4 | Servlet 即註冊、順序＝`LOWEST_PRECEDENCE`、可顯式停用、非 Web 不註冊、服務自帶 bean 讓位 |
| `JwtAuthFilterTest`（CR-042 原測） | 14 | ★ **不受影響，全綠** |

**真機端到端**（`party-service`，`CARDCORE_SECURITY_ENABLED=true`，真實 Keycloak ＋ Oracle）：

| # | 情境 | 結果 |
|---:|---|---|
| ① | 公開路徑 `/actuator/health` | **200** ✅ |
| ② | ★ 受保護端點、**無 token** | **401 `CC-PLT-4010`**（★ **錯誤碼未變**——證明橋接未劫持認證） ✅ |
| ③ | ★ 帶 **真實 M2M 權杖**（`cc-card-service`，於 party-service ACL 內） | **通過認證＋授權，進入控制器**（回業務錯誤 `CC-PTY-4001`，HTTP 400） ✅ |
| ④ | ★ 上列③之啟動日誌 | `已由 JWT 主體橋接至 CurrentUser：roles=[default-roles-cardcore, offline_access, uma_authorization] scopes=[profile, email]`——★ **證明橋接過濾器已由 `AutoConfiguration.imports` 載入、於認證鏈之後執行、並自真實 Keycloak 權杖寫入 `CurrentUser`** ✅ |

> ★ **為何③回 400 而非 200 仍算通過**：`/api/v1/parties/P001` 之 `P001` **非 UUID**，
> 於**業務層**回 `CC-PTY-4001`（`SCS-022 §7.7.1` 所載之 `P001` 係舊狀態）。
> ★ 重點在於：**請求已通過認證與授權、抵達控制器**——此即「認證鏈未受影響」之證據。

### 9.4 已知限制（誠實揭露）

1. ★ **`CurrentUser` 目前尚無業務程式碼取用**——本次接線使其**可用**（不再死碼），
   惟「已接線」≠「已被使用」；後續若需以 `CurrentUser.claim()` 作端點層授權，
   應另立規格（`SDD-007 §5`）。
2. ★ 橋接模式之角色（`realm_access.roles`）**不加 `ROLE_` 前綴**，與 Spring Security
   之 `GrantedAuthority`（加前綴）**語意不同**；兩者為**並存**之兩套視圖，非同一份資料。
3. ★ 本模式**僅適用同步、同執行緒**之請求（`SecurityContext` 不跨執行緒），
   與 `SCS-022 §7.11.5` 之限制同型。
