visionA/docs/autoflow/04-architecture/adr/adr-013-oidc-public-pkce-client.md
jim800121chen fb7da5d180 chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/
依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類
共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git),
讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等
per-branch 筆記。

- 02-prd/        21 個檔(PRD、features、market-analysis 等)
- 03-design/     18 個檔(design-spec、wireframes、flows 等)
- 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等)
- 07-delivery/   3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup)

合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv,
但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
2026-05-04 16:55:55 +08:00

195 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-013OIDC 支援 Public PKCE-only ClientClientSecret 變選填)
## 狀態
Accepted — 2026-05-01
## 上位文件
- [ADR-010](./adr-010-oidc-bff.md)OIDC 接入策略 — BFF + Authorization Code + PKCE
- [ADR-011](./adr-011-supersede-adr-005.md)OB5 拔除 StaticAuth、強制 OIDC
- [oidc-tdd.md](../oidc-tdd.md) §13.1(環境變數)
## 推翻
無 — 本 ADR 是對 ADR-010 的**擴充**(在原 confidential client 路徑外新增 public client 路徑),不是推翻。
## 背景 (Context)
ADR-010 在 2026-04-26 決定 visionA-backend 是 OIDC **confidential client**(持有 `client_secret`),理由是:
> Member Center 強制 OAuth client 必須是 confidential要求 client_secret— 這就排除了 SPA + PKCE。
到了 2026-05-01 Phase 0.7 stage 部署時情況變了。Innovedus 集團的 stage Member Center`https://stage-9527.innovedus.com:7850/`)配給 visionA 的 OAuth client 實際是兩組:
| Client | Client ID | Secret | 用途 |
|--------|-----------|--------|------|
| Login**public PKCE-only**| `b8093fea1a504a5d8f0e04bee9f78f2e` | **無** | OAuth Authorization Code + PKCE redirect flow |
| Service-to-serviceconfidential| `<see stage .env.stage>` | `<see stage .env.stage, never commit>` | client_credentials grant — visionA-backend 直接打 MC APIPhase 1 才會用) |
> **🔒 Secret hygiene 註記**2026-05-01 補):本 ADR 初版誤將真實 stage service client 的 id 與 secret 寫死進此表格review 階段發現後立刻移除。原始 secret **未進 git 歷史**(檔案從未被 commit、git log 已確認),但仍視為「曾於工作目錄中存在」處理 — 請 MC 團隊將該 service client_secret rotate 後再啟用。Login client_id 是 OAuth public 資訊spec 不視為 secret保留可接受service client 兩個值真正注入只發生在 stage host 的 `.env.stage`(已加入 `.gitignore`)。
也就是說 ADR-010 的前提「MC 強制 confidential」**對 login flow 不再成立** — MC 為 redirect flow 配的是 public client靠 PKCE 防 code interception沒給 secret。
直接後果visionA-backend 啟動時會 panic — `internal/config/config.go``Validate()``OIDC.ClientSecret` 列為必填env 沒給就 fail-fast。要在 stage 跑起來必須改。
### 觸發條件
- **A1 任務**Phase 0.7)需要把 stage MC 給的 public PKCE-only login client 跑起來
- 同時要保留現有 dev / 未來 prod 可能用到的 confidential client mode向下相容
- 為未來 Phase 1 接 MC API用 client_credentials預留 config 鉤子,但不啟用實作
### 為什麼 PKCE-only public client 對 redirect flow 是夠的
OAuth 2.1RFC 9700 BCP+ RFC 8252 對「無法安全儲存 client_secret 的 client」SPA、native app、且本案 — 雖然 visionA-backend 是 server但 MC 把它分類為 redirect-flow client的標準做法
1. **PKCERFC 7636防 authorization code interception**:攻擊者就算攔截到 authorization code沒有 `code_verifier` 換不到 token
2. **`code_challenge_method=S256`** 強制visionA 本來就只用 S256
3. **`state` 防 CSRF**visionA pending session 已實作)
4. **`nonce` 防 id_token replay**visionA pending session 已實作)
OWASP / IETF OAuth WG 結論(截至 2026對 redirect flow**PKCE 是必備、client_secret 對 public client 不是必備**。confidential client 的 secret 主要是給「沒 user redirect」的 server-to-server flowclient_credentials、refresh_token 等)做 client authentication 用的redirect flow 有 PKCE 已經夠。
換句話說visionA login 路徑從 confidential 變 public**安全等級沒掉太多** — 主要差異在 token endpoint 不附 client_secret但 PKCE 守住了 code interception 這個主要威脅。
## 決策 (Decision)
`OIDC.ClientSecret` **從必填變選填**。visionA-backend 同時支援兩種 mode
| Mode | 觸發條件 | Token endpoint 行為 | 安全模型 |
|------|---------|---------------------|---------|
| **Public PKCE-only**stage、未來可能 prod | `VISIONA_OIDC_CLIENT_SECRET` 為空 | `oauth2.Config.ClientSecret = ""`golang.org/x/oauth2 lib 自動不送 client_secret | PKCE + state + nonce |
| **Confidential**dev、向下相容 | `VISIONA_OIDC_CLIENT_SECRET` 非空 | `oauth2.Config.ClientSecret = <secret>`,正常 client_secret_post / basic auth | PKCE + state + nonce + client_secret |
### 具體做法
#### 1. `internal/config/config.go`
- `OIDCConfig.ClientSecret` 欄位保留,但 godoc 改為「選填 — 為空時走 public PKCE-only mode」
- `Validate()``VISIONA_OIDC_CLIENT_SECRET` 從 missing 檢查清單移除
- 其他 OIDC 必填欄位IssuerURL / ClientID / RedirectURL / PostLoginURL / SessionSecret不變
#### 2. `internal/oidc/provider.go`
- `validateConfig()``ClientSecret` 從必填檢查移除
- `oauth2.Config.ClientSecret` 直接傳 `cfg.ClientSecret`(空字串時 oauth2 lib 會自動省略該欄位,行為符合 RFC 6749 §3.2.1「The client MAY omit the parameter」
- `IDTokenVerifier` 不變(它只看 ClientID 做 audience check與 secret 無關)
#### 3. 預留 service client config
新增兩個 config 欄位(**Phase 0.7 不實作 client_credentials flow**,只是預留 env 不讓使用者設了沒用):
```go
type OIDCConfig struct {
// ...既有欄位
// ServiceClientID / ServiceClientSecret 是 client_credentials grant 用的
// confidential client給 visionA-backend 主動打 Member Center API例如查
// user metadata、發 webhook。Phase 0.7 不實作config 欄位先佔位,避免未來
// 加新 env 時要再過一輪文件 review。
ServiceClientID string // VISIONA_OIDC_SERVICE_CLIENT_ID選填
ServiceClientSecret string // VISIONA_OIDC_SERVICE_CLIENT_SECRET選填
}
```
`Validate()` 不檢查這兩個欄位 — 兩個都空Phase 0.7 / stage 不接 MC API也合法。
#### 4. 文件更新
- `oidc-tdd.md` §13.1 把 `VISIONA_OIDC_CLIENT_SECRET` 標為「選填」
- `oidc-tdd.md` §13.1 新增 dev / stage / prod 三種 client 模式的 env 範例段
- `ADR-011` 後記補一段,註記 stage 部署時發現 MC 給 public client 的事實
#### 5. 向下相容
既有 dev docker-compose`docker-compose.dev.yml`seed `demo@visionA.local` 帳號 + confidential client 完全不動,繼續走 confidential mode。沒有 breaking change。
## 考慮過的替代方案
### 方案 A保留 confidential-only請 MC 改發 confidential client 給 stage
| 項目 | 評估 |
|------|------|
| 優點 | 不用動 visionA 程式碼 |
| 缺點 | 違反 MC 的 client 分類設計login client 就應該是 public PKCE-only要等 MC 排期改;阻塞 stage 部署 |
| 排除原因 | **MC 的設計是對的symmetric 的事 OIDC 標準也偏好 public client for redirect flow不該為了省一次 visionA 文件更新而扭曲 IdP 設計** |
### 方案 BvisionA 自帶一組 client_secret硬塞 MC
| 項目 | 評估 |
|------|------|
| 優點 | — |
| 缺點 | MC 端不認token endpoint 會 401unauthorized client |
| 排除原因 | **技術上跑不起來** |
### 方案 Cdev 用 confidential、stage 用 public、用 build flag 切換
| 項目 | 評估 |
|------|------|
| 優點 | 編譯期保證每個環境只有一條路徑可走 |
| 缺點 | `+build` tag 增加 CI 複雜度;本質上是 runtime 決定的事不該綁 build flag違反 12-factor「同一個 binary 跑各環境」 |
| 排除原因 | **過度工程ClientSecret 是否為空是天然的 runtime switch** |
### 方案 D刻意不向下相容全部改用 public client
| 項目 | 評估 |
|------|------|
| 優點 | 程式碼稍微簡單 |
| 缺點 | dev 既有 docker-compose seed 流程要全改;未來 prod 若要強化confidential + PKCE 雙保險做不到ADR-010 的決策被無故推翻 |
| 排除原因 | **本案是擴充不是推翻,沒理由減功能** |
### 方案 E採用ClientSecret 變選填runtime 判斷走哪條路
| 項目 | 評估 |
|------|------|
| 優點 | 同一份程式碼 / binary 跑 dev / stage / prod 三種環境最小改動oauth2 lib 原生支援空 secret向下相容 |
| 缺點 | 要在文件 + ADR 多寫一段說明何時用哪 mode |
| 採用原因 | **正確抽象confidential vs public 是 client 配置屬性而非系統屬性;應由 IdP 註冊時決定visionA-backend 跟隨** |
## 後果 (Consequences)
### 正面影響
- **stage 部署解鎖** — A1 改造後 visionA-backend 可直接吃 stage MC 給的 public PKCE-only client
- **同一份 binary 多環境** — dev confidential / stage public / prod 視 IT 配置 — 不需重 build
- **跟隨 OAuth 2.1 趨勢** — IETF / OWASP 對 redirect flow 偏好 public PKCE-onlyconfidential 主要留給 server-to-server
- **向下相容** — dev 既有流程零改動
- **預留 service client 鉤子** — Phase 1 接 MC APIclient_credentials時 config 欄位已經在,不必再改 ADR
### 負面影響(接受的取捨)
- **失去一層防護**public mode 沒了 client_secrettoken endpoint 對「冒充 visionA client_id 的攻擊者」沒防護。緩解PKCE 守住主要威脅code interceptionMember Center 端會用 redirect_uri 白名單加碼防護
- **文件複雜度上升**oidc-tdd.md §13.1 要多寫一段「dev / stage / prod 的 client mode 差異」
- **可能誤設**:開發者忘記設 `VISIONA_OIDC_CLIENT_SECRET` 但 MC 端註冊的是 confidential client → token exchange 會 401。緩解oidc-tdd.md §13.1 範例與啟動 log 要明確顯示「啟動時偵測到 ClientSecret 為空,走 public PKCE-only mode」
### 風險
| 風險 | 緩解 |
|------|------|
| Member Center 改變 stage client 類型public ↔ confidential 切換)導致 visionA 起不來 | env 兩種 mode 都支援;切換只需改 `.env.stage` 加 / 移除 `VISIONA_OIDC_CLIENT_SECRET`,不需重 build |
| 開發者誤把 dev 的 confidential secret 帶進 stage env | 透過 stage host 的 `.env.stage` 不進 git + secrets reviewsecret 本身對 public client 是無害冗餘MC 會忽略),不會立即出問題 |
| oauth2 lib 版本升級行為改變client_secret 空字串時行為) | 在 backend test 加 `TestExchangeCode_PublicClient` 確認空 ClientSecret 走通CI 跑得到 |
| 攻擊者拿到 stage public client_id 自己模擬 visionA → 但 redirect_uri 必須符合 MC 白名單,且 PKCE verifier 守住 | redirect_uri 白名單由 MC 管理(已是 stage 配置PKCE 由 visionA-backend 每次新產 |
| Phase 1 同時用 redirect public client + service confidential client兩組 secret 管理混淆 | config 欄位命名清楚(`ClientSecret` vs `ServiceClientSecret`ADR 後續更新時記錄職責邊界 |
## 合規性
- [x] Architect 確認2026-05-01
- [x] 使用者確認 stage MC 配給 visionA 的是 public PKCE-only clientprogress.md OIDC Client 配置表)
- [ ] A1-1 / A1-2 backend 改造從必填變選填、ClientSecret 空走 public mode
- [ ] A1-3 補測試public + confidential 兩種 mode 都綠
- [ ] A1-5 oidc-tdd.md §13.1 + `.env.dev.example` 文字更新
- [ ] A1-6 預留 ServiceClientID / ServiceClientSecret config 欄位(不啟用實作)
- [ ] V-4 stage 實際走完一次 OIDC flow確認 callback → cookie session 完整成立
## 相關文件
- 上位:[ADR-010](./adr-010-oidc-bff.md)(本 ADR 是其擴充,非推翻)
- 同層:[ADR-011](./adr-011-supersede-adr-005.md)(後記補上 2026-05-01 stage 發現)
- 詳細實作 / env 規格:[oidc-tdd.md](../oidc-tdd.md) §13.1
- 部署架構:[stage-deployment.md](../stage-deployment.md)(同期建立)
- OAuth 規格RFC 7636PKCE、RFC 9700OAuth 2.0 BCP、RFC 8252Native Apps BCP
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-05-01 | 1.0 | 初版 — 反映 stage MC public PKCE-only client 配給ClientSecret 變選填;預留 ServiceClient 欄位 |