# ADR-013:OIDC 支援 Public PKCE-only Client(ClientSecret 變選填) ## 狀態 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-service(confidential)| `` | `` | client_credentials grant — visionA-backend 直接打 MC API(Phase 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.1(RFC 9700 BCP)+ RFC 8252 對「無法安全儲存 client_secret 的 client」(SPA、native app、且本案 — 雖然 visionA-backend 是 server,但 MC 把它分類為 redirect-flow client)的標準做法: 1. **PKCE(RFC 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 flow(client_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 = `,正常 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 設計** | ### 方案 B:visionA 自帶一組 client_secret,硬塞 MC | 項目 | 評估 | |------|------| | 優點 | — | | 缺點 | MC 端不認;token endpoint 會 401(unauthorized client) | | 排除原因 | **技術上跑不起來** | ### 方案 C:dev 用 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-only(confidential 主要留給 server-to-server) - **向下相容** — dev 既有流程零改動 - **預留 service client 鉤子** — Phase 1 接 MC API(client_credentials)時 config 欄位已經在,不必再改 ADR ### 負面影響(接受的取捨) - **失去一層防護**:public mode 沒了 client_secret,token endpoint 對「冒充 visionA client_id 的攻擊者」沒防護。緩解:PKCE 守住主要威脅(code interception);Member 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 review;secret 本身對 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 client(progress.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 7636(PKCE)、RFC 9700(OAuth 2.0 BCP)、RFC 8252(Native Apps BCP) ## 版本記錄 | 日期 | 版本 | 變更 | |------|------|------| | 2026-05-01 | 1.0 | 初版 — 反映 stage MC public PKCE-only client 配給;ClientSecret 變選填;預留 ServiceClient 欄位 |