依 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)。
195 lines
12 KiB
Markdown
195 lines
12 KiB
Markdown
# 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)| `<see stage .env.stage>` | `<see stage .env.stage, never commit>` | 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 = <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 設計** |
|
||
|
||
### 方案 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 欄位 |
|