致命發現(grep MC + FAA source 確認):
- MC source 沒有 issue delegated download token endpoint
- MC source 沒有 validate delegated download token endpoint
- FAA MemberCenterDelegatedDownloadTokenValidator.cs 假設的 MC introspection endpoint 不存在
- ADR-014 §2 從 5/2 寫完到現在這條鏈一直是斷的、只是因為從未實際 e2e 跑通過所以沒被發現
使用者拍板硬約束:不動 MC + 不動 FAA
新增 ADR-016:
- visionA download 改用 converter GET /api/v1/jobs/{id}/result(新 endpoint)
- visionA backend 用既有 ConverterAPIKey 認證(不需新增 secret)
- 維持 T4 已實作的 stream proxy 結構(io.CopyN + Content-Disposition + size cap)
- promote 仍 PUT FAA(converter 內部用自己的 OAuth、與 visionA 無關)
- 不需動 MC + FAA + warrenchen
- 6 個替代方案逐一說明排除理由
修訂既有文件:
- ADR-014 v1.1 → v1.2:§2 download flow 標註被 ADR-016 部分 supersede
- ADR-015 v2.0 → v2.1:§2 visionA → FAA delegated token 設計(v2.0 從 v1.x 撤回的設計)再次撤回;§9 env 表撤回 v2.0 加回的 OIDC ServiceClient* / TenantID / FAABaseURL;visionA 端 server-to-server 只剩 ConverterAPIKey 一把
- conversion.md v0.5 → v0.6:§1 sequence diagram 重畫(移除 MC node)、§2 模組設計(mc_token_client.go 整檔刪除確認、faa_client.go 改名 converter_result_client.go)、§3.2 visionA → FAA 整段標撤回、§4.1 download handler 改 converter.GetResult、§6 錯誤碼撤回 mc/faa 三個 code 加 result_not_found / result_expired
- api-conversion.md v0.5 → v0.6:檔頭 Auth 段落改寫、§4 download endpoint 改述、error code 表撤回 mc_token_unavailable / download_token_failed
- oidc-tdd.md v0.3 → v0.4:§13.1 環境變數表 OIDC ServiceClient* / TenantID / FAABaseURL 從「重新啟用」改回「再次廢棄」、§13.1.1 stage env 範例移除 service client / tenant_id / FAA URL、§13.1.3 改寫為「v0.4 單線設計」說明
整體影響:
- 不需復活 mc_token_client.go(commit 86b7175 砍除狀態維持)
- 不需復活 OIDCConfig.ServiceClientID/Secret/TenantID(commit 86b7175 移除狀態維持)
- visionA backend faa_client.go 要改名為 converter_result_client.go、改呼叫 converter.GetResult
- visionA backend flow.go DownloadStream / PromoteToModels 改用 converter.GetResult
- jimchen 跨 repo 任務:converter scheduler 加 GET /api/v1/jobs/{id}/result endpoint
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1873 lines
80 KiB
Markdown
1873 lines
80 KiB
Markdown
# OIDC 接入 TDD — visionA Cloud × Innovedus Member Center
|
||
|
||
## Metadata
|
||
- **作者**:Architect Agent
|
||
- **狀態**:Phase 0.8b v0.4 修訂(converter 線改 API key;FAA 線**完全撤回**改走 ADR-016 converter 中轉;user login 部分不變)
|
||
- **最後更新**:2026-05-16
|
||
- **文件角色**:Phase 0.6 把 visionA-backend 的 `StaticAuthProvider` 替換為 OIDC 接 Innovedus Member Center
|
||
- **上位文件**:`TDD.md`、`security.md`、`adr/adr-005-no-db-auth-in-prototype.md`、`adr/adr-010-oidc-bff.md`、`adr/adr-013-public-client.md`、[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md) **v2.1**(converter 線改 API key 維持;§2 visionA → FAA 整段被 ADR-016 supersede)、[`adr/adr-016-download-via-converter.md`](./adr/adr-016-download-via-converter.md)(v0.6/v0.4 新增上位:visionA → FAA / MC 路徑完全撤回、改走 converter 中轉)
|
||
- **下位文件**:`adr/adr-010-oidc-bff.md`(本文件 §16)
|
||
- **讀者**:Backend / Frontend / DevOps / Testing Agents
|
||
|
||
> **Phase 0.8b v0.4 範圍說明(重要)**:
|
||
>
|
||
> - **user login(browser → visionA backend)**:完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token,本文件 §1-§12、§14-§17 全部仍有效。
|
||
> - **server-to-server(visionA backend → converter)**:v1.0 改用 pre-shared API key 取代 OAuth `client_credentials` grant;v2.0 / v0.4 維持。詳見 ADR-015 §1。
|
||
> - **server-to-server(visionA backend → FAA / MC)**:v1.x 改 API key 已於 v2.0 撤回;v2.0 規劃回到 ADR-014 §2 原設計(MC service token + delegated download token)— **本文件 v0.4 整段再次撤回**。對 MC source 驗證後確認該 endpoint 從未存在;visionA download 改走 [ADR-016](./adr/adr-016-download-via-converter.md)(converter 新增 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉)。本文件 §13.1 「Service Client」相關欄位(v1.x 標廢棄 → v2.0 規劃啟用)**v0.4 維持廢棄**。
|
||
|
||
---
|
||
|
||
## 索引
|
||
|
||
1. [為什麼接 Member Center](#1-為什麼接-member-center)
|
||
2. [整體架構圖](#2-整體架構圖)
|
||
3. [BFF Flow 詳細時序圖](#3-bff-flow-詳細時序圖)
|
||
4. [Backend 模組設計](#4-backend-模組設計)
|
||
5. [Session 設計(cookie)](#5-session-設計cookie)
|
||
6. [PKCE 實作細節](#6-pkce-實作細節)
|
||
7. [id_token 驗證](#7-id_token-驗證)
|
||
8. [UserContext 改造](#8-usercontext-改造)
|
||
9. [Pairing 流程確認 user binding 仍正確](#9-pairing-流程確認-user-binding-仍正確)
|
||
10. [Frontend 改造](#10-frontend-改造)
|
||
11. [Member Center 端設定](#11-member-center-端設定)
|
||
12. [docker-compose dev 環境](#12-docker-compose-dev-環境)
|
||
13. [環境變數新增](#13-環境變數新增)
|
||
14. [安全考量](#14-安全考量)
|
||
15. [取代 StaticAuth 的影響範圍](#15-取代-staticauth-的影響範圍)
|
||
16. [ADR-010 摘要 — OIDC 接入策略](#16-adr-010-摘要)
|
||
17. [ADR-005 處理(更新還是新 ADR-011)](#17-adr-005-處理)
|
||
18. [開發任務拆分](#18-開發任務拆分)
|
||
|
||
---
|
||
|
||
## 1. 為什麼接 Member Center
|
||
|
||
### 1.1 取代 StaticAuthProvider 的時機
|
||
|
||
Phase 0 雛形採用 `StaticAuthProvider`(任何帳密都通過、永遠回 `demo-user`),出發點是:
|
||
- 不希望 Auth 細節阻擋雛形端對端驗證(tunnel / pairing / forward 路徑)
|
||
- 介面已切乾淨(`AuthProvider` + `AuthService`),未來換實作零業務邏輯改動
|
||
|
||
雛形已交付(Phase 0 + Phase 0.5 全綠),現在進到 Phase 0.6:
|
||
- 端對端路徑、tunnel session、pairing exchange、token 持久化都已驗證
|
||
- **下一個必須補的洞就是「真實使用者」** — 否則無法做多用戶測試、無法進入 Phase 1
|
||
- 同期間 Innovedus 集團另一條線(Member Center)已經把 OAuth2 / OIDC / OpenIddict 弄到能用的狀態
|
||
- **時機剛好** — 不必自刻 Auth、不必綁第三方 vendor(Clerk / Auth0)
|
||
|
||
### 1.2 跨 Innovedus 產品 SSO 的價值
|
||
|
||
visionA 不是孤立產品。Innovedus 之後會有多條線:
|
||
- visionA(雲端 AI 推論平台)
|
||
- kneron_model_converter(模型轉檔網站)
|
||
- 其他產品線
|
||
|
||
**每個產品都自刻 Auth = 每個產品都要做密碼重設、Email 驗證、2FA、社交登入**。
|
||
Member Center 統一處理一次,所有產品線的使用者都共用一套帳號 → 降低總體維運成本,使用者體驗也好。
|
||
|
||
### 1.3 為什麼不自己刻 auth
|
||
|
||
| 選項 | 排除原因 |
|
||
|------|---------|
|
||
| 自刻 email + password + JWT | 要做密碼重設、email 驗證、2FA、暴力破解防禦 — 全部都要從零;維運成本高 |
|
||
| Clerk / Auth0 / Supabase Auth | vendor lock-in;跨 Innovedus 產品線 SSO 需要他們的企業方案;成本隨 MAU 線性上升 |
|
||
| Keycloak / Ory Kratos 自架 | 維運成本,且還是要刻 UI;不如直接用 Member Center |
|
||
| **Member Center**(本案)| 已存在、Innovedus 內部、跨產品 SSO、可控 |
|
||
|
||
### 1.4 Phase 0.6 範圍邊界
|
||
|
||
| 在範圍內 | 不在範圍內(Phase 1+) |
|
||
|---------|----------------------|
|
||
| Authorization Code + PKCE redirect flow | RP-initiated logout(單點登出到所有產品線) |
|
||
| BFF Pattern(backend 持 token、frontend 用 cookie) | Refresh token rotation(Member Center 暫無 refresh) |
|
||
| id_token 驗簽(JWKS) | Member Center webhook(user 刪除 / 停用通知) |
|
||
| In-memory session store | Redis / DB session store |
|
||
| 取代 `StaticAuthProvider` | 取代 `StaticPairingStore`(Pairing 走另一條線,本次不動) |
|
||
| docker-compose 一鍵起 | k8s 部署 |
|
||
|
||
---
|
||
|
||
## 2. 整體架構圖
|
||
|
||
```
|
||
┌──────────────────────┐
|
||
│ Browser │
|
||
│ (visionA-frontend, │
|
||
│ Next.js, CDN / │
|
||
│ next start) │
|
||
└──────────┬───────────┘
|
||
│ HTTPS (prod) / HTTP (dev)
|
||
│ fetch with cookie (visiona_session)
|
||
▼
|
||
┌────────────────────────────────────────────────┐
|
||
│ visionA-backend / api-server │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────┐ │
|
||
│ │ internal/api/handlers/auth.go │ │
|
||
│ │ GET /api/auth/login → 302 to MC │ │
|
||
│ │ GET /api/auth/callback ← code+state │ │
|
||
│ │ POST /api/auth/logout │ │
|
||
│ │ GET /api/auth/me │ │
|
||
│ └──────────┬───────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌──────────▼─────────┐ ┌──────────────────┐ │
|
||
│ │ internal/oidc/ │ │ internal/ │ │
|
||
│ │ - Discovery │ │ usersession/ │ │
|
||
│ │ - PKCE helper │ │ (cookie session)│ │
|
||
│ │ - Token exchange │ │ │ │
|
||
│ │ - JWKS verify │ │ InMemoryStore │ │
|
||
│ │ (confidential │ │ + interface │ │
|
||
│ │ client_id+secret)│ │ │ │
|
||
│ └──────────┬─────────┘ └──────────────────┘ │
|
||
│ │ │
|
||
│ ┌──────────▼─────────┐ │
|
||
│ │ internal/auth/ │ │
|
||
│ │ OIDCAuthProvider │ 取代 StaticAuthProvider│
|
||
│ │ OIDCAuthService │ 取代 StaticAuthService │
|
||
│ └────────────────────┘ │
|
||
└─────────────┬──────────────────────────────────┘
|
||
│ confidential client
|
||
│ (client_id + client_secret)
|
||
│ HTTPS (prod) / HTTP localhost (dev)
|
||
▼
|
||
┌────────────────────────────────────────────────┐
|
||
│ Innovedus Member Center │
|
||
│ (C# .NET Core + OpenIddict + PostgreSQL) │
|
||
│ │
|
||
│ GET /oauth/authorize │
|
||
│ POST /oauth/token │
|
||
│ GET /jwks │
|
||
│ GET /.well-known/openid-configuration │
|
||
│ │
|
||
│ port 5050 (dev) / https://members.innovedus...│
|
||
└────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**關鍵設計點**:
|
||
- visionA-frontend **完全不接觸 OIDC token**(access_token / id_token / refresh_token 都在 backend)
|
||
- visionA-frontend 只看到一個 `visiona_session` cookie(HttpOnly)
|
||
- backend 是 OIDC **confidential client**(持有 client_secret)
|
||
- **這就是為什麼必須是 BFF 而不是 SPA + PKCE**:Member Center 不支援 public client
|
||
|
||
---
|
||
|
||
## 3. BFF Flow 詳細時序圖
|
||
|
||
### 3.1 首次登入流程
|
||
|
||
```
|
||
Browser visionA-backend Member Center
|
||
│ │ │
|
||
│ 1. GET /login │ │
|
||
├─────────────────────────►│ (frontend 路由,純 SSR) │
|
||
│ │ │
|
||
│ 2. 點「用 Innovedus 登入」│ │
|
||
│ window.location = │ │
|
||
│ '/api/auth/login' │ │
|
||
│ │ │
|
||
│ 3. GET /api/auth/login │ │
|
||
├─────────────────────────►│ │
|
||
│ │ - 產 PKCE (verifier+challenge)
|
||
│ │ - 產 state (CSRF) │
|
||
│ │ - 產 nonce (replay) │
|
||
│ │ - 產 pending_session_id │
|
||
│ │ - usersession.PutPending( │
|
||
│ │ pending_session_id, │
|
||
│ │ {verifier,state,nonce,│
|
||
│ │ return_to}) │
|
||
│ │ - Set-Cookie: │
|
||
│ │ visiona_pending_sid=… │
|
||
│ │ (HttpOnly, 10min TTL) │
|
||
│ │ │
|
||
│ 4. 302 to MC /oauth/authorize?
|
||
│ response_type=code
|
||
│ &client_id=visionA
|
||
│ &redirect_uri=http://localhost:3721/api/auth/callback
|
||
│ &scope=openid email profile
|
||
│ &state=<state>
|
||
│ &code_challenge=<challenge>
|
||
│ &code_challenge_method=S256
|
||
│ &nonce=<nonce>
|
||
│◄─────────────────────────┤ │
|
||
│ │
|
||
│ 5. 跟隨 302 │ │
|
||
├─────────────────────────────────────────────────────►│
|
||
│ │ │
|
||
│ 6. MC 顯示登入頁 │ │
|
||
│◄─────────────────────────────────────────────────────┤
|
||
│ │ │
|
||
│ 7. 輸入帳密、submit │ │
|
||
├─────────────────────────────────────────────────────►│
|
||
│ │ │
|
||
│ 8. 302 to │ │
|
||
│ redirect_uri?code=xxx&state=<state> │
|
||
│◄─────────────────────────────────────────────────────┤
|
||
│ │ │
|
||
│ 9. GET /api/auth/callback?code=xxx&state=<state> │
|
||
│ Cookie: visiona_pending_sid=… │
|
||
├─────────────────────────►│ │
|
||
│ │ - usersession.PopPending( │
|
||
│ │ pending_session_id) │
|
||
│ │ - 驗 state == saved.state │
|
||
│ │ - 取出 saved.verifier │
|
||
│ │ │
|
||
│ │ 10. POST /oauth/token │
|
||
│ │ grant_type=authorization_code
|
||
│ │ code=xxx │
|
||
│ │ redirect_uri=… │
|
||
│ │ code_verifier=<verifier>│
|
||
│ │ client_id=visionA │
|
||
│ │ client_secret=<secret> │
|
||
│ ├──────────────────────────►│
|
||
│ │ │
|
||
│ │ 11. 200 OK │
|
||
│ │ { access_token, id_token,│
|
||
│ │ token_type, expires_in}│
|
||
│ │◄──────────────────────────┤
|
||
│ │ │
|
||
│ │ - 取 JWKS (cached) │
|
||
│ │ - 驗 id_token (sig+iss+aud│
|
||
│ │ +exp+nonce) │
|
||
│ │ - 解 claims: │
|
||
│ │ sub, email, name │
|
||
│ │ - usersession.Create( │
|
||
│ │ {user_id=sub, │
|
||
│ │ email, name, │
|
||
│ │ access_token, │
|
||
│ │ id_token, │
|
||
│ │ expires_at}) │
|
||
│ │ → session_id │
|
||
│ │ - Set-Cookie: │
|
||
│ │ visiona_session=<sid> │
|
||
│ │ HttpOnly Secure │
|
||
│ │ SameSite=Lax │
|
||
│ │ Max-Age=86400 │
|
||
│ │ - Clear-Cookie: │
|
||
│ │ visiona_pending_sid │
|
||
│ │ │
|
||
│ 12. 302 to │ │
|
||
│ {VISIONA_FRONTEND_URL}{return_to} │
|
||
│◄─────────────────────────┤ │
|
||
│ │ │
|
||
│ 13. GET / │ │
|
||
│ Cookie: visiona_session=<sid> │
|
||
├─────────────────────────►│ │
|
||
│ │ - usersession.Get(sid) │
|
||
│ │ - inject UserContext │
|
||
│ │ - middleware 注入 c.MustGet│
|
||
│ 14. 200 OK + HTML │ │
|
||
│◄─────────────────────────┤ │
|
||
```
|
||
|
||
### 3.2 後續 API 呼叫
|
||
|
||
```
|
||
Browser visionA-backend
|
||
│ │
|
||
│ GET /api/devices │
|
||
│ Cookie: visiona_session │
|
||
├─────────────────────────►│
|
||
│ │ AuthMiddleware:
|
||
│ │ 1. cookie -> session_id
|
||
│ │ 2. usersession.Get(sid)
|
||
│ │ 3. 若 expired → 401
|
||
│ │ 4. UserContext 注入 gin.Context
|
||
│ │ Handler 用 auth.FromContext(c)
|
||
│ │
|
||
│ 200 OK │
|
||
│◄─────────────────────────┤
|
||
```
|
||
|
||
### 3.3 登出
|
||
|
||
```
|
||
Browser visionA-backend
|
||
│ │
|
||
│ POST /api/auth/logout │
|
||
│ Cookie: visiona_session │
|
||
├─────────────────────────►│
|
||
│ │ - usersession.Delete(sid)
|
||
│ │ - Clear-Cookie: visiona_session
|
||
│ │ (Phase 0.6 不做 RP-initiated
|
||
│ │ logout 到 Member Center)
|
||
│ 204 No Content │
|
||
│◄─────────────────────────┤
|
||
│ │
|
||
│ 前端 auth-store.clear() │
|
||
│ window.location='/login' │
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Backend 模組設計
|
||
|
||
### 4.1 模組總覽
|
||
|
||
```
|
||
visionA-backend/
|
||
└── internal/
|
||
├── oidc/ ⬅ 新增
|
||
│ ├── client.go # OIDC client 封裝
|
||
│ ├── discovery.go # /.well-known/openid-configuration 抓取 + 快取
|
||
│ ├── pkce.go # code_verifier + code_challenge 產生
|
||
│ ├── jwks.go # JWKS 抓取 + 快取 + 驗簽
|
||
│ └── claims.go # id_token claims struct
|
||
│
|
||
├── usersession/ ⬅ 新增(避免與 internal/session 衝突)
|
||
│ ├── store.go # SessionStore interface
|
||
│ ├── inmemory.go # InMemoryStore 實作
|
||
│ ├── types.go # Session struct + PendingSession
|
||
│ └── cookie.go # cookie 編解碼 + 簽章
|
||
│
|
||
├── auth/ ⬅ 修改
|
||
│ ├── service.go # AuthService interface(既有)
|
||
│ ├── provider.go # AuthProvider interface(既有)
|
||
│ ├── static.go # StaticAuthService(既有,**移除**)
|
||
│ ├── static_provider.go # StaticAuthProvider(既有,**移除**)
|
||
│ ├── oidc_service.go ⬅ 新增 OIDCAuthService
|
||
│ ├── oidc_provider.go ⬅ 新增 OIDCAuthProvider
|
||
│ └── pairing.go # 不變(Pairing token 是另一條線)
|
||
│
|
||
├── api/
|
||
│ ├── middleware/
|
||
│ │ └── auth.go ⬅ 修改:從 cookie 取 session(不再從 Bearer header)
|
||
│ └── handlers/
|
||
│ └── auth.go ⬅ 大改造(見 §4.5)
|
||
│
|
||
├── session/ # 既有 tunnel session 管理(**不動**)
|
||
└── ...
|
||
```
|
||
|
||
> **命名衝突澄清**:
|
||
> - `internal/session/` = **tunnel session**(remote-proxy 持有的 yamux session,與 local agent 一對一)— 已存在,**不動**
|
||
> - `internal/usersession/` = **HTTP user session**(browser cookie 對應的使用者 session) — 新增
|
||
> - 兩者完全不同概念,分開放兩個 package 避免混淆
|
||
|
||
### 4.2 `internal/oidc/`
|
||
|
||
```go
|
||
// client.go
|
||
package oidc
|
||
|
||
type Client struct {
|
||
issuerURL string
|
||
clientID string
|
||
clientSecret string
|
||
redirectURL string
|
||
scopes []string // 預設 ["openid", "email", "profile"]
|
||
|
||
discovery *DiscoveryDoc // cached
|
||
discoveryAt time.Time
|
||
discoveryMu sync.RWMutex
|
||
|
||
jwks *JWKS // cached
|
||
jwksAt time.Time
|
||
jwksMu sync.RWMutex
|
||
|
||
httpClient *http.Client
|
||
}
|
||
|
||
type Config struct {
|
||
IssuerURL string
|
||
ClientID string
|
||
ClientSecret string
|
||
RedirectURL string
|
||
Scopes []string
|
||
}
|
||
|
||
func New(cfg Config) (*Client, error) { /* ... */ }
|
||
|
||
// 主要方法
|
||
func (c *Client) Discovery(ctx context.Context) (*DiscoveryDoc, error)
|
||
func (c *Client) AuthURL(state, nonce, codeChallenge string) (string, error)
|
||
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error)
|
||
func (c *Client) VerifyIDToken(ctx context.Context, rawIDToken, expectedNonce string) (*Claims, error)
|
||
```
|
||
|
||
```go
|
||
// discovery.go
|
||
package oidc
|
||
|
||
type DiscoveryDoc struct {
|
||
Issuer string `json:"issuer"`
|
||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||
TokenEndpoint string `json:"token_endpoint"`
|
||
JWKSURI string `json:"jwks_uri"`
|
||
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
|
||
ScopesSupported []string `json:"scopes_supported"`
|
||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||
}
|
||
|
||
// 快取 1 小時
|
||
const discoveryTTL = 1 * time.Hour
|
||
|
||
func (c *Client) Discovery(ctx context.Context) (*DiscoveryDoc, error) {
|
||
c.discoveryMu.RLock()
|
||
if c.discovery != nil && time.Since(c.discoveryAt) < discoveryTTL {
|
||
defer c.discoveryMu.RUnlock()
|
||
return c.discovery, nil
|
||
}
|
||
c.discoveryMu.RUnlock()
|
||
// fetch + cache
|
||
}
|
||
```
|
||
|
||
```go
|
||
// pkce.go
|
||
package oidc
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
)
|
||
|
||
type PKCE struct {
|
||
Verifier string
|
||
Challenge string
|
||
Method string // "S256"
|
||
}
|
||
|
||
func GeneratePKCE() (*PKCE, error) {
|
||
// RFC 7636: code_verifier 是 43-128 字元的 [A-Z a-z 0-9 - . _ ~]
|
||
b := make([]byte, 32) // 32 bytes → base64url 後 43 字元
|
||
if _, err := rand.Read(b); err != nil {
|
||
return nil, err
|
||
}
|
||
verifier := base64.RawURLEncoding.EncodeToString(b)
|
||
sum := sha256.Sum256([]byte(verifier))
|
||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||
return &PKCE{Verifier: verifier, Challenge: challenge, Method: "S256"}, nil
|
||
}
|
||
|
||
func GenerateState() (string, error) { /* random 32 bytes → base64url */ }
|
||
func GenerateNonce() (string, error) { /* random 32 bytes → base64url */ }
|
||
```
|
||
|
||
```go
|
||
// jwks.go
|
||
package oidc
|
||
|
||
import "github.com/golang-jwt/jwt/v5"
|
||
|
||
type JWKS struct {
|
||
Keys []jwk.Key
|
||
}
|
||
|
||
const jwksTTL = 1 * time.Hour
|
||
|
||
func (c *Client) JWKS(ctx context.Context) (*JWKS, error) { /* fetch + cache */ }
|
||
|
||
func (c *Client) VerifyIDToken(ctx context.Context, raw, expectedNonce string) (*Claims, error) {
|
||
parsed, err := jwt.ParseWithClaims(raw, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||
kid, _ := t.Header["kid"].(string)
|
||
jwks, err := c.JWKS(ctx)
|
||
if err != nil { return nil, err }
|
||
key, ok := jwks.Lookup(kid)
|
||
if !ok { return nil, ErrUnknownKey }
|
||
return key.PublicKey()
|
||
})
|
||
if err != nil { return nil, err }
|
||
|
||
claims := parsed.Claims.(*Claims)
|
||
|
||
// 驗 issuer
|
||
if claims.Issuer != c.issuerURL { return nil, ErrInvalidIssuer }
|
||
// 驗 audience
|
||
if !claims.HasAudience(c.clientID) { return nil, ErrInvalidAudience }
|
||
// 驗 expiration(jwt-go 已驗)
|
||
// 驗 nonce
|
||
if claims.Nonce != expectedNonce { return nil, ErrInvalidNonce }
|
||
|
||
return claims, nil
|
||
}
|
||
```
|
||
|
||
```go
|
||
// claims.go
|
||
package oidc
|
||
|
||
type Claims struct {
|
||
Subject string `json:"sub"`
|
||
Issuer string `json:"iss"`
|
||
Audience []string `json:"aud"`
|
||
Expiry int64 `json:"exp"`
|
||
IssuedAt int64 `json:"iat"`
|
||
Nonce string `json:"nonce,omitempty"`
|
||
|
||
// 自定 claims(標準 OIDC scope=email profile)
|
||
Email string `json:"email,omitempty"`
|
||
EmailVerified bool `json:"email_verified,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
Picture string `json:"picture,omitempty"`
|
||
PreferredName string `json:"preferred_username,omitempty"`
|
||
|
||
// 從 access_token 才會有的(visionA 不需要 tenant_id 在 id_token,
|
||
// 因為 visionA 自己就是 tenant,不會跨 tenant)
|
||
}
|
||
|
||
func (c *Claims) Valid() error { /* jwt.Claims interface */ }
|
||
func (c *Claims) HasAudience(aud string) bool { /* ... */ }
|
||
```
|
||
|
||
**雛形實作**:以上所有。
|
||
**未來擴展**:JWKS 拿到 kid 不在 cache 內時 force-refresh、token 加密後再放 session、refresh token rotation。
|
||
|
||
### 4.3 `internal/usersession/`
|
||
|
||
```go
|
||
// types.go
|
||
package usersession
|
||
|
||
import "time"
|
||
|
||
type Session struct {
|
||
ID string // 隨機 32 byte hex(cookie 帶這個)
|
||
UserID string // OIDC sub
|
||
Email string
|
||
Name string
|
||
AccessToken string // OIDC access_token(給 visionA 後續未來呼叫 MC API 用,雛形先存著但不用)
|
||
IDToken string // 可選,雛形不存
|
||
CreatedAt time.Time
|
||
LastSeenAt time.Time
|
||
ExpiresAt time.Time // absolute(7 天)
|
||
IdleExpireAt time.Time // idle(24 小時,每次存取更新)
|
||
}
|
||
|
||
type PendingSession struct {
|
||
ID string // 隨機 32 byte hex(pending cookie 帶這個)
|
||
State string // CSRF state
|
||
Nonce string // OIDC nonce
|
||
CodeVerifier string // PKCE verifier
|
||
ReturnTo string // 登入後要回的前端路徑
|
||
CreatedAt time.Time
|
||
ExpiresAt time.Time // 10 分鐘
|
||
}
|
||
```
|
||
|
||
```go
|
||
// store.go
|
||
package usersession
|
||
|
||
type Store interface {
|
||
// 已登入 session
|
||
Create(ctx context.Context, s *Session) error
|
||
Get(ctx context.Context, id string) (*Session, error)
|
||
Touch(ctx context.Context, id string) error // 更新 LastSeenAt + IdleExpireAt
|
||
Delete(ctx context.Context, id string) error
|
||
CleanupExpired(ctx context.Context) (removed int, err error)
|
||
|
||
// 登入中(pending)— 在 callback 完成前暫存 PKCE / state / nonce
|
||
PutPending(ctx context.Context, p *PendingSession) error
|
||
PopPending(ctx context.Context, id string) (*PendingSession, error) // 取出後立刻刪
|
||
}
|
||
```
|
||
|
||
```go
|
||
// inmemory.go
|
||
package usersession
|
||
|
||
type InMemoryStore struct {
|
||
mu sync.RWMutex
|
||
sessions map[string]*Session
|
||
pending map[string]*PendingSession
|
||
}
|
||
|
||
// Create / Get / Touch / Delete: 標準 map 操作 + lock
|
||
// CleanupExpired: 由 background goroutine 每 60 秒呼叫
|
||
```
|
||
|
||
```go
|
||
// cookie.go — cookie 簽章 / 驗證
|
||
package usersession
|
||
|
||
// Cookie 內容只放 session_id(不放 token、不放 user info)
|
||
// 用 HMAC-SHA256 簽章避免被竄改
|
||
// Format: <session_id>.<hmac>
|
||
|
||
func SignCookie(sessionID, secret string) string
|
||
func VerifyCookie(value, secret string) (sessionID string, err error)
|
||
```
|
||
|
||
**雛形實作**:以上 InMemoryStore 全部,CleanupExpired 由 background goroutine 每 60 秒跑一次。
|
||
**未來擴展**:`RedisStore` 實作同 interface(Phase 1)。
|
||
|
||
**重啟即消失的取捨**:雛形 backend 重啟 → 所有使用者要重登。Phase 0.6 階段使用者是內部測試者,可接受。
|
||
|
||
### 4.4 `internal/auth/oidc_service.go` + `oidc_provider.go`
|
||
|
||
```go
|
||
// oidc_service.go — middleware 層
|
||
package auth
|
||
|
||
type OIDCAuthService struct {
|
||
sessions usersession.Store
|
||
cookieSecret string
|
||
cookieName string
|
||
}
|
||
|
||
func (s *OIDCAuthService) Authenticate(ctx context.Context, req *http.Request) (*UserContext, error) {
|
||
cookie, err := req.Cookie(s.cookieName)
|
||
if err != nil { return nil, ErrNotAuthenticated }
|
||
sid, err := usersession.VerifyCookie(cookie.Value, s.cookieSecret)
|
||
if err != nil { return nil, ErrInvalidCookie }
|
||
sess, err := s.sessions.Get(ctx, sid)
|
||
if err != nil { return nil, ErrSessionNotFound }
|
||
if time.Now().After(sess.ExpiresAt) || time.Now().After(sess.IdleExpireAt) {
|
||
_ = s.sessions.Delete(ctx, sid)
|
||
return nil, ErrSessionExpired
|
||
}
|
||
_ = s.sessions.Touch(ctx, sid)
|
||
return &UserContext{
|
||
UserID: sess.UserID,
|
||
Email: sess.Email,
|
||
// Name 暫時不放 UserContext(既有 struct 沒這欄位,handler 需要時從 session 取)
|
||
}, nil
|
||
}
|
||
|
||
func (s *OIDCAuthService) Authorize(ctx context.Context, uc *UserContext, resource, action string) error {
|
||
// Phase 0.6 沒有 RBAC,全部允許(與 StaticAuthService 一致)
|
||
return nil
|
||
}
|
||
```
|
||
|
||
```go
|
||
// oidc_provider.go — handler 層
|
||
package auth
|
||
|
||
type OIDCAuthProvider struct {
|
||
sessions usersession.Store
|
||
// OIDC 相關操作放 oidc.Client,Provider 主要是 ValidateToken / GetUser
|
||
}
|
||
|
||
func (p *OIDCAuthProvider) Login(ctx context.Context, req *LoginRequest) (*LoginResult, error) {
|
||
// **不適用** — OIDC 不接受 username/password 直登
|
||
return nil, ErrUseOIDCRedirect
|
||
}
|
||
|
||
func (p *OIDCAuthProvider) Register(ctx context.Context, req *RegisterRequest) (*User, error) {
|
||
// **不適用** — 註冊在 Member Center 完成
|
||
return nil, ErrUseMemberCenterSignup
|
||
}
|
||
|
||
func (p *OIDCAuthProvider) Logout(ctx context.Context, sessionID string) error {
|
||
return p.sessions.Delete(ctx, sessionID)
|
||
}
|
||
|
||
func (p *OIDCAuthProvider) ValidateToken(ctx context.Context, sessionID string) (*UserContext, error) {
|
||
sess, err := p.sessions.Get(ctx, sessionID)
|
||
if err != nil { return nil, err }
|
||
return &UserContext{UserID: sess.UserID, Email: sess.Email}, nil
|
||
}
|
||
|
||
func (p *OIDCAuthProvider) GetUser(ctx context.Context, userID string) (*User, error) {
|
||
// 雛形無 user DB,只能回 session 裡的資料;要找 userID 對應的 user,
|
||
// 必須先從 session 反查(雛形先回 ErrNotImplemented;handler 會用 sessionID)
|
||
return nil, ErrNotImplemented
|
||
}
|
||
```
|
||
|
||
> **介面 mismatch 處理**:
|
||
> 既有 `AuthProvider` interface 是「username + password」風格,跟 OIDC redirect flow 不完全契合。
|
||
> Phase 0.6 的處理:
|
||
> - `Login` / `Register` 回 `ErrUseOIDCRedirect` / `ErrUseMemberCenterSignup`
|
||
> - 真正的 login/callback 流程在 `handlers/auth.go` 直接呼叫 `oidc.Client`,**不走 `AuthProvider.Login`**
|
||
> - `AuthProvider` 主要功能變成 `Logout` + `ValidateToken`(從 cookie session 拿 user)
|
||
>
|
||
> 這樣**保留 interface 不破壞**(pairing/storage/etc handler 仍然透過 provider 拿 user),但 Login flow 改走 redirect handler。
|
||
>
|
||
> **Phase 1 重構建議**:把 `AuthProvider` 拆成 `AuthProviderPasswordBased`(雛形可移除)+ `AuthProviderRedirectBased`(OIDC),更乾淨。Phase 0.6 不動,避免改動範圍擴大。
|
||
|
||
### 4.5 `internal/api/handlers/auth.go`
|
||
|
||
```go
|
||
package handlers
|
||
|
||
type AuthHandler struct {
|
||
oidc *oidc.Client
|
||
sessions usersession.Store
|
||
cookieCfg CookieConfig // name, secret, domain, secure, samesite
|
||
frontendURL string // VISIONA_FRONTEND_URL
|
||
}
|
||
|
||
// GET /api/auth/login?return_to=/dashboard
|
||
func (h *AuthHandler) Login(c *gin.Context) {
|
||
returnTo := c.Query("return_to")
|
||
if returnTo == "" || !strings.HasPrefix(returnTo, "/") {
|
||
returnTo = "/"
|
||
}
|
||
|
||
pkce, _ := oidc.GeneratePKCE()
|
||
state, _ := oidc.GenerateState()
|
||
nonce, _ := oidc.GenerateNonce()
|
||
pendingID, _ := randomHex(32)
|
||
|
||
pending := &usersession.PendingSession{
|
||
ID: pendingID,
|
||
State: state,
|
||
Nonce: nonce,
|
||
CodeVerifier: pkce.Verifier,
|
||
ReturnTo: returnTo,
|
||
CreatedAt: time.Now(),
|
||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||
}
|
||
if err := h.sessions.PutPending(c.Request.Context(), pending); err != nil {
|
||
c.AbortWithStatusJSON(500, gin.H{"error": "internal_error"})
|
||
return
|
||
}
|
||
|
||
// pending cookie(短 TTL)
|
||
setCookie(c, "visiona_pending_sid", usersession.SignCookie(pendingID, h.cookieCfg.Secret),
|
||
10*60, h.cookieCfg)
|
||
|
||
authURL, _ := h.oidc.AuthURL(state, nonce, pkce.Challenge)
|
||
c.Redirect(302, authURL)
|
||
}
|
||
|
||
// GET /api/auth/callback?code=...&state=...
|
||
func (h *AuthHandler) Callback(c *gin.Context) {
|
||
code := c.Query("code")
|
||
state := c.Query("state")
|
||
if code == "" || state == "" {
|
||
c.AbortWithStatusJSON(400, gin.H{"error": "invalid_request"})
|
||
return
|
||
}
|
||
|
||
// 取 pending
|
||
pendingCookie, err := c.Cookie("visiona_pending_sid")
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(400, gin.H{"error": "no_pending_session"})
|
||
return
|
||
}
|
||
pendingID, err := usersession.VerifyCookie(pendingCookie, h.cookieCfg.Secret)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(400, gin.H{"error": "invalid_cookie"})
|
||
return
|
||
}
|
||
pending, err := h.sessions.PopPending(c.Request.Context(), pendingID)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(400, gin.H{"error": "pending_session_not_found"})
|
||
return
|
||
}
|
||
|
||
// 驗 state(CSRF 防護)
|
||
if subtle.ConstantTimeCompare([]byte(pending.State), []byte(state)) != 1 {
|
||
c.AbortWithStatusJSON(400, gin.H{"error": "state_mismatch"})
|
||
return
|
||
}
|
||
|
||
// 換 token
|
||
tok, err := h.oidc.ExchangeCode(c.Request.Context(), code, pending.CodeVerifier)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(502, gin.H{"error": "token_exchange_failed", "detail": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 驗 id_token
|
||
claims, err := h.oidc.VerifyIDToken(c.Request.Context(), tok.IDToken, pending.Nonce)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(401, gin.H{"error": "id_token_invalid", "detail": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 建 session
|
||
sid, _ := randomHex(32)
|
||
sess := &usersession.Session{
|
||
ID: sid,
|
||
UserID: claims.Subject,
|
||
Email: claims.Email,
|
||
Name: claims.Name,
|
||
AccessToken: tok.AccessToken,
|
||
CreatedAt: time.Now(),
|
||
LastSeenAt: time.Now(),
|
||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||
IdleExpireAt: time.Now().Add(24 * time.Hour),
|
||
}
|
||
if err := h.sessions.Create(c.Request.Context(), sess); err != nil {
|
||
c.AbortWithStatusJSON(500, gin.H{"error": "internal_error"})
|
||
return
|
||
}
|
||
|
||
// 設 session cookie,清 pending cookie
|
||
setCookie(c, h.cookieCfg.Name, usersession.SignCookie(sid, h.cookieCfg.Secret),
|
||
7*24*60*60, h.cookieCfg)
|
||
clearCookie(c, "visiona_pending_sid", h.cookieCfg)
|
||
|
||
// 回前端
|
||
c.Redirect(302, h.frontendURL+pending.ReturnTo)
|
||
}
|
||
|
||
// POST /api/auth/logout
|
||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||
cookie, err := c.Cookie(h.cookieCfg.Name)
|
||
if err == nil {
|
||
if sid, err := usersession.VerifyCookie(cookie, h.cookieCfg.Secret); err == nil {
|
||
_ = h.sessions.Delete(c.Request.Context(), sid)
|
||
}
|
||
}
|
||
clearCookie(c, h.cookieCfg.Name, h.cookieCfg)
|
||
c.Status(204)
|
||
}
|
||
|
||
// GET /api/auth/me
|
||
func (h *AuthHandler) Me(c *gin.Context) {
|
||
// AuthMiddleware 已驗過 cookie + 注入 UserContext
|
||
uc := auth.FromContext(c)
|
||
// 從 session 拿完整 info(middleware 只放 UserContext,沒 Name)
|
||
cookie, _ := c.Cookie(h.cookieCfg.Name)
|
||
sid, _ := usersession.VerifyCookie(cookie, h.cookieCfg.Secret)
|
||
sess, err := h.sessions.Get(c.Request.Context(), sid)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(404, gin.H{"error": "session_not_found"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"user_id": uc.UserID,
|
||
"email": sess.Email,
|
||
"name": sess.Name,
|
||
"expires_at": sess.ExpiresAt,
|
||
})
|
||
}
|
||
```
|
||
|
||
### 4.6 `internal/api/middleware/auth.go`
|
||
|
||
```go
|
||
// 既有 middleware 從 Authorization: Bearer header 拿 token
|
||
// 新版:從 cookie 取 sid → usersession 取 → UserContext 注入
|
||
|
||
func RequireAuth(authSvc auth.AuthService) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
uc, err := authSvc.Authenticate(c.Request.Context(), c.Request)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized", "detail": err.Error()})
|
||
return
|
||
}
|
||
c.Set("user", uc)
|
||
c.Next()
|
||
}
|
||
}
|
||
```
|
||
|
||
具體邏輯都在 `OIDCAuthService.Authenticate`(§4.4),middleware 不變。
|
||
**handler 程式碼完全不用改** — 還是用 `auth.FromContext(c)` 拿 UserContext。
|
||
|
||
---
|
||
|
||
## 5. Session 設計(cookie)
|
||
|
||
### 5.1 Cookie 屬性
|
||
|
||
| 屬性 | 值 | 理由 |
|
||
|------|----|----|
|
||
| Name | `visiona_session` | 與 backend 其他 cookie 區分 |
|
||
| Value | `<session_id>.<hmac>` | 32 byte hex + HMAC-SHA256 簽章 |
|
||
| HttpOnly | `true` | 防 XSS 讀取 |
|
||
| Secure | `true`(prod)/ `false`(dev http) | 強制 HTTPS |
|
||
| SameSite | `Lax` | 防 CSRF;OIDC redirect 從 MC 回 callback 是 GET,Lax 允許 |
|
||
| Domain | `.visiona.cloud`(prod)/ 不設(dev) | 前端與 backend 同 domain 才共享 |
|
||
| Path | `/` | 全站 |
|
||
| Max-Age | `604800`(7 天) | 與 session.ExpiresAt 一致 |
|
||
|
||
### 5.2 Session 生命週期
|
||
|
||
| 事件 | 行為 |
|
||
|------|------|
|
||
| Create | `ExpiresAt = now + 7d`、`IdleExpireAt = now + 24h` |
|
||
| Get(middleware 每次)| 檢查 `now < ExpiresAt && now < IdleExpireAt`,否則刪 + 401 |
|
||
| Touch | `LastSeenAt = now`、`IdleExpireAt = now + 24h`(absolute 不變)|
|
||
| Delete | 從 store 移除 |
|
||
| CleanupExpired | background goroutine 每 60s 跑,刪所有過期 session |
|
||
|
||
**雛形 in-memory 重啟即消失**:可接受。Phase 1 換 Redis / DB。
|
||
|
||
### 5.3 Pending session(登入中暫存)
|
||
|
||
獨立 cookie(`visiona_pending_sid`),10 分鐘 TTL,**callback 完成或過期即刪**。
|
||
這是為了:
|
||
- PKCE verifier 必須在 token exchange 前存在 server side(不能放 cookie 裡,會被 JS 讀走)
|
||
- state 防 CSRF
|
||
- nonce 防 replay
|
||
- return_to 記住使用者原本想去哪
|
||
|
||
### 5.4 Cookie 簽章
|
||
|
||
```go
|
||
// HMAC-SHA256 簽章避免 cookie 被竄改
|
||
// 不加密(cookie value 只是個 random session_id,沒什麼好藏的)
|
||
|
||
func SignCookie(sid, secret string) string {
|
||
h := hmac.New(sha256.New, []byte(secret))
|
||
h.Write([]byte(sid))
|
||
sig := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||
return sid + "." + sig
|
||
}
|
||
|
||
func VerifyCookie(value, secret string) (string, error) {
|
||
parts := strings.SplitN(value, ".", 2)
|
||
if len(parts) != 2 { return "", ErrMalformed }
|
||
sid, sig := parts[0], parts[1]
|
||
h := hmac.New(sha256.New, []byte(secret))
|
||
h.Write([]byte(sid))
|
||
expected := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||
if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 {
|
||
return "", ErrSignatureInvalid
|
||
}
|
||
return sid, nil
|
||
}
|
||
```
|
||
|
||
`VISIONA_SESSION_SECRET` 至少 32 byte 隨機字串,env 提供。
|
||
|
||
---
|
||
|
||
## 6. PKCE 實作細節
|
||
|
||
### 6.1 規範對齊
|
||
|
||
依 RFC 7636 Section 4:
|
||
|
||
| 欄位 | 規格 | 實作 |
|
||
|------|------|------|
|
||
| `code_verifier` | 43-128 字元 [A-Z a-z 0-9 - . _ ~] | 32 byte random → base64url(43 字元) |
|
||
| `code_challenge_method` | `plain` 或 `S256` | **強制 `S256`** |
|
||
| `code_challenge` | base64url(SHA256(verifier)) | — |
|
||
|
||
### 6.2 三個隨機值
|
||
|
||
| 值 | 用途 | 長度 | 儲存 |
|
||
|----|------|------|------|
|
||
| `code_verifier` | PKCE,token 換取證明 | 32 byte → 43 字元 base64url | server pending session |
|
||
| `state` | CSRF 防護 | 32 byte → 43 字元 base64url | server pending session |
|
||
| `nonce` | OIDC ID token replay 防護 | 32 byte → 43 字元 base64url | server pending session,後續驗 id_token |
|
||
|
||
三個都用 `crypto/rand` 產生,**不重複使用**。
|
||
|
||
### 6.3 完整呼叫範例
|
||
|
||
```go
|
||
// /api/auth/login
|
||
pkce, _ := oidc.GeneratePKCE()
|
||
state, _ := oidc.GenerateState()
|
||
nonce, _ := oidc.GenerateNonce()
|
||
sessions.PutPending(&PendingSession{
|
||
State: state,
|
||
Nonce: nonce,
|
||
CodeVerifier: pkce.Verifier,
|
||
})
|
||
|
||
authURL := fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+email+profile&state=%s&code_challenge=%s&code_challenge_method=S256&nonce=%s",
|
||
discovery.AuthorizationEndpoint,
|
||
url.QueryEscape(clientID),
|
||
url.QueryEscape(redirectURL),
|
||
url.QueryEscape(state),
|
||
url.QueryEscape(pkce.Challenge),
|
||
url.QueryEscape(nonce))
|
||
```
|
||
|
||
---
|
||
|
||
## 7. id_token 驗證
|
||
|
||
### 7.1 Discovery + JWKS
|
||
|
||
```
|
||
visionA-backend 啟動時:
|
||
1. fetch ${VISIONA_OIDC_ISSUER_URL}/.well-known/openid-configuration
|
||
→ 取出 jwks_uri、authorization_endpoint、token_endpoint
|
||
→ 快取 1 小時
|
||
2. fetch jwks_uri
|
||
→ 取出公鑰(可能多把,每把有 kid)
|
||
→ 快取 1 小時
|
||
|
||
每次驗 id_token:
|
||
1. parse header 取 kid
|
||
2. 從 cache 找對應公鑰
|
||
3. cache miss → 重新 fetch JWKS(Phase 0.6 簡化:直接 refresh,不做 thundering herd 防護)
|
||
```
|
||
|
||
### 7.2 必驗欄位
|
||
|
||
| Claim | 驗證 |
|
||
|-------|------|
|
||
| `iss` | 必須 == `VISIONA_OIDC_ISSUER_URL` |
|
||
| `aud` | 必須包含 `VISIONA_OIDC_CLIENT_ID` |
|
||
| `exp` | 必須 > now |
|
||
| `iat` | 必須 ≤ now(容忍 60s clock skew) |
|
||
| `nonce` | 必須 == pending.Nonce |
|
||
| 簽章 | 用 JWKS 對應 kid 的公鑰驗 RS256 |
|
||
|
||
### 7.3 Claim Mapping
|
||
|
||
```go
|
||
UserContext{
|
||
UserID: claims.Subject, // OIDC sub
|
||
Email: claims.Email,
|
||
}
|
||
Session{
|
||
UserID: claims.Subject,
|
||
Email: claims.Email,
|
||
Name: claims.Name, // OIDC profile scope
|
||
}
|
||
```
|
||
|
||
> **Member Center 健檢驗證**:id_token 已含 `email` + `name`,不需呼叫 `/oauth/userinfo`(健檢報告也指出該端點目前未實作)。
|
||
|
||
---
|
||
|
||
## 8. UserContext 改造
|
||
|
||
### 8.1 既有 struct(保持不變)
|
||
|
||
```go
|
||
type UserContext struct {
|
||
UserID string
|
||
Email string
|
||
Roles []string
|
||
OrgID string
|
||
}
|
||
```
|
||
|
||
### 8.2 雛形 → OIDC 對應
|
||
|
||
| 欄位 | 雛形(StaticAuth) | OIDC |
|
||
|------|-------------------|------|
|
||
| UserID | 固定 `"demo-user"` | OIDC `sub`(Member Center 的 user UUID) |
|
||
| Email | 固定 `"demo@visiona.cloud"` | OIDC `email` |
|
||
| Roles | `["admin"]` | 雛形先空 `[]`(Member Center 雛形未提供 role claim) |
|
||
| OrgID | `""` | 雛形先空(visionA 自己就是 tenant,無需 OrgID) |
|
||
|
||
### 8.3 影響範圍
|
||
|
||
| 檔案 / 位置 | 影響 |
|
||
|------------|------|
|
||
| `auth.FromContext(c)` | API 不變 |
|
||
| handler 用 `userCtx.UserID` 拿 ID | API 不變,但拿到的值從 `"demo-user"` 變成 OIDC sub(UUID 字串)|
|
||
| `handlers/pairing.go` 產 Pairing Token 時 `user_id = userCtx.UserID` | 自動跟著變,邏輯無需改 |
|
||
| 任何測試硬寫 `"demo-user"` | 需改成測試 OIDC server 的 user sub |
|
||
|
||
---
|
||
|
||
## 9. Pairing 流程確認 user binding 仍正確
|
||
|
||
### 9.1 既有實作(B5)
|
||
|
||
```go
|
||
// POST /api/pairing/token
|
||
func (h *PairingHandler) CreateToken(c *gin.Context) {
|
||
uc := auth.FromContext(c)
|
||
plain, info, err := h.pairingStore.Create(c.Request.Context(), uc.UserID, 15*time.Minute)
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 9.2 改 OIDC 後
|
||
|
||
完全不動。`uc.UserID` 從 `"demo-user"` 變成 OIDC sub(例:`6d7c1b2e-3f44-4a55-b8a1-1234567890ab`),handler 邏輯不變。
|
||
|
||
### 9.3 測試確認項目
|
||
|
||
OB5 任務必須補測試覆蓋:
|
||
- 兩個不同 OIDC user 各自 Pair 一台 Agent
|
||
- 確認 `pairingStore.Validate(token)` 回的 `UserID` 是「產 token 那個 user」
|
||
- 確認 user A 看不到 user B 的 device list(如果 device repository 有 user 隔離)
|
||
|
||
> 雛形 `InMemoryDeviceRepository` 是否有 user 隔離?— 需檢查既有 B5 實作。如果沒有,補上 `WHERE owner_user_id = ?` 邏輯(雛形 in-memory 就 filter)。
|
||
> 列為 OB5 的子任務。
|
||
|
||
### 9.4 Agent 端不動
|
||
|
||
Agent 拿到 Session Token 後完全不知道 user 是誰、用哪種 Auth。Pairing Token / Session Token 是 user binding 的單一來源,Member Center 接入完全不影響 agent。
|
||
|
||
---
|
||
|
||
## 10. Frontend 改造
|
||
|
||
### 10.1 `/login` 頁
|
||
|
||
**改造前**(Phase 0):email + password 表單,submit 打 `POST /api/auth/login`
|
||
|
||
**改造後**(Phase 0.6):
|
||
```tsx
|
||
// src/app/login/page.tsx
|
||
export default function LoginPage() {
|
||
const t = useTranslations('login');
|
||
return (
|
||
<div className="...">
|
||
<h1>{t('title')}</h1>
|
||
<p>{t('description')}</p>
|
||
<Button asChild size="lg">
|
||
<a href="/api/auth/login">{t('signInWithInnovedus')}</a>
|
||
</Button>
|
||
<p className="mt-4 text-sm text-muted-foreground">
|
||
{t('newAccountHint')}{' '}
|
||
<a href={memberCenterSignupURL} target="_blank">{t('signUpAtMemberCenter')}</a>
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**關鍵點**:
|
||
- 用 `<a href>` 不是 `<button onClick>`,因為要做完整 page navigation 才能讓 cookie 流程生效
|
||
- 不用 fetch、不用 React Router — 純 browser redirect
|
||
- `memberCenterSignupURL` 從環境變數讀(`NEXT_PUBLIC_MEMBER_CENTER_SIGNUP_URL`)
|
||
|
||
### 10.2 Callback 頁(**不需要**)
|
||
|
||
OIDC callback 是 backend 的事,frontend 完全不知道 callback 存在:
|
||
- MC redirect → backend `/api/auth/callback` → backend redirect → frontend `/`(或 `return_to`)
|
||
- frontend 看到的就是「正常進到 `/`」
|
||
|
||
### 10.3 `/account` 頁
|
||
|
||
```tsx
|
||
// src/app/account/page.tsx
|
||
const { data: me } = useSWR('/api/auth/me', apiFetch);
|
||
|
||
return (
|
||
<div>
|
||
<h1>{me.name}</h1>
|
||
<p>{me.email}</p>
|
||
<p className="text-sm text-muted-foreground">{t('userId')}: {me.user_id}</p>
|
||
<Button onClick={handleLogout}>{t('signOut')}</Button>
|
||
<p className="text-sm">
|
||
{t('manageAccountAtMemberCenter')}{' '}
|
||
<a href={memberCenterAccountURL} target="_blank">{memberCenterAccountURL}</a>
|
||
</p>
|
||
</div>
|
||
);
|
||
```
|
||
|
||
### 10.4 Header user info
|
||
|
||
```tsx
|
||
// src/components/layout/header.tsx
|
||
const { data: me } = useSWR('/api/auth/me', apiFetch, {
|
||
revalidateOnFocus: false,
|
||
shouldRetryOnError: false, // 401 就讓 middleware 處理
|
||
});
|
||
|
||
// 顯示 me.name / me.email
|
||
```
|
||
|
||
### 10.5 `/register` 頁
|
||
|
||
**移除註冊表單**,改成「請至 Member Center 註冊」說明頁,附連結。
|
||
|
||
或者:`/register` 直接 redirect 到 `memberCenterSignupURL`。
|
||
|
||
> 由 Frontend Agent 在 OF3 任務時決定哪個方案;Architect 建議「說明頁 + 連結」較友善(避免突然跳出 visionA 域名)。
|
||
|
||
### 10.6 登出
|
||
|
||
```ts
|
||
// auth-store action
|
||
async function logout() {
|
||
await apiFetch('/api/auth/logout', { method: 'POST' });
|
||
// 清 store 的 user 資料
|
||
set({ user: null });
|
||
// 不需 router.push,使用者下次發 API 會收到 401,AuthGuard 自動跳 /login
|
||
router.push('/login');
|
||
}
|
||
```
|
||
|
||
### 10.7 Auth Guard
|
||
|
||
既有 AuthGuard(route protection)邏輯不變:
|
||
- 試打 `/api/auth/me`,401 → `/login`
|
||
- 200 → render
|
||
|
||
### 10.8 移除前端 token 管理
|
||
|
||
Phase 0 雛形 frontend 把 token 存 localStorage(`security.md` §14.1 已標為 Phase 1 必還的安全債)。
|
||
|
||
**Phase 0.6 直接修掉**:
|
||
- 移除 `auth-store` 的 `token` 欄位
|
||
- 移除 `localStorage.setItem('visionA.auth.token', ...)`
|
||
- 移除 API client 的 `Authorization: Bearer ...` header(改靠 cookie,`credentials: 'include'`)
|
||
- WS 連線 token 走 cookie(瀏覽器同 domain WS 會自動帶 cookie;不需 querystring token)
|
||
|
||
> 這同時解掉 `security.md` §14.1、§14.2、§14.3 三個安全債 — 用 OIDC 接入順便清。
|
||
|
||
---
|
||
|
||
## 11. Member Center 端設定
|
||
|
||
### 11.1 開發者手動 setup(dev)
|
||
|
||
```bash
|
||
# 1. 起 Member Center
|
||
cd ~/member_center
|
||
dotnet run --project src/MemberCenter.Api
|
||
# → http://localhost:5050
|
||
|
||
# 2. 透過 admin API 建 tenant
|
||
curl -X POST http://localhost:5050/admin/tenants \
|
||
-H "Authorization: Bearer <admin-token>" \
|
||
-d '{"slug":"visionA","name":"visionA Cloud"}'
|
||
|
||
# 3. 透過 admin API 建 OAuth client
|
||
curl -X POST http://localhost:5050/admin/oauth-clients \
|
||
-H "Authorization: Bearer <admin-token>" \
|
||
-d '{
|
||
"tenant_slug": "visionA",
|
||
"usage": "webhook_outbound", # 見 §11.2 的 limitation 說明
|
||
"redirect_uris": [
|
||
"http://localhost:3721/api/auth/callback"
|
||
]
|
||
}'
|
||
# → response: { client_id: "visionA_xxx", client_secret: "xxx" }
|
||
|
||
# 4. seed demo user(讓現有 demo 流程能繼續用)
|
||
curl -X POST http://localhost:5050/admin/users \
|
||
-H "Authorization: Bearer <admin-token>" \
|
||
-d '{
|
||
"tenant_slug": "visionA",
|
||
"email": "demo@visionA.local",
|
||
"password": "demo123",
|
||
"name": "Demo User"
|
||
}'
|
||
|
||
# 5. 把 client_id + client_secret 寫進 visionA-backend 的 .env
|
||
echo "VISIONA_OIDC_CLIENT_ID=visionA_xxx" >> visionA-backend/.env
|
||
echo "VISIONA_OIDC_CLIENT_SECRET=xxx" >> visionA-backend/.env
|
||
```
|
||
|
||
> 上述步驟由 OD1 任務的 docker-compose 自動化(見 §12)。
|
||
|
||
### 11.2 OAuth Client `usage` 欄位 limitation
|
||
|
||
**健檢發現**:Member Center 的 OAuth client 註冊機制有以下 usage 類型:
|
||
- `tenant_api`:產品線後端呼叫 MC API 用,**不需要 redirect_uris**
|
||
- `webhook_outbound`:MC 通知產品線用,**有 redirect_uris**
|
||
|
||
但 OAuth Authorization Code flow **必須有 redirect_uris**,而 visionA 是「使用者瀏覽器 redirect 給 MC、登入後 redirect 回 visionA-backend」這種「web app」場景,理論上應該有 `usage=web_app`,但 Member Center 目前沒有。
|
||
|
||
**Phase 0.6 處理(採方案 C)**:
|
||
- 雛形 visionA 暫用 `usage=webhook_outbound`(命名語意不對,但能設 redirect_uris)
|
||
- 在 Member Center 專案開 issue:「請新增 `usage=web_app` 類型,語意是 OAuth Authorization Code redirect flow 的 client」
|
||
- 待 MC 加完後,visionA 改用 `usage=web_app`,無需動程式碼(只是改 admin API 註冊時的欄位值)
|
||
- 在 ADR-010 記錄這個 limitation 與後續 follow-up
|
||
|
||
**為什麼不採方案 A(visionA 自己選 webhook_outbound 不開 issue)**:
|
||
- 之後 Member Center 真的有 webhook_outbound 場景時會撞名
|
||
- 內部產品就應該主動推改善
|
||
|
||
**為什麼不採方案 B(修 Member Center 程式碼)**:
|
||
- 不在 Phase 0.6 範圍
|
||
- Member Center 是另一條線、另一個團隊節奏
|
||
|
||
### 11.3 Production 設定
|
||
|
||
Production 時:
|
||
- `redirect_uris` 改為 `["https://app.visiona.cloud/api/auth/callback"]`
|
||
- `client_secret` 改放 AWS Secrets Manager / Vault,**不能在 env 明文**
|
||
- Member Center prod URL:待 Innovedus DevOps 決定(暫定 `https://members.innovedus.com`)
|
||
|
||
---
|
||
|
||
## 12. docker-compose dev 環境
|
||
|
||
### 12.1 檔案位置
|
||
|
||
```
|
||
visionA/
|
||
├── docker-compose.dev.yml ⬅ 新增(repo 根目錄)
|
||
├── visionA-backend/
|
||
│ ├── docker/
|
||
│ │ ├── Dockerfile.api-server (既有 B6)
|
||
│ │ ├── Dockerfile.remote-proxy (既有 B6)
|
||
│ │ └── docker-compose.yml (既有,純 backend dev)
|
||
│ └── ...
|
||
└── docker/
|
||
└── member-center-seed.sql ⬅ 新增(OD1 產出)
|
||
```
|
||
|
||
### 12.2 `docker-compose.dev.yml`
|
||
|
||
```yaml
|
||
services:
|
||
postgres:
|
||
image: postgres:15-alpine
|
||
environment:
|
||
POSTGRES_DB: membercenter
|
||
POSTGRES_USER: mc
|
||
POSTGRES_PASSWORD: mcpass
|
||
ports:
|
||
- "5432:5432"
|
||
volumes:
|
||
- mc-data:/var/lib/postgresql/data
|
||
- ./docker/member-center-seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U mc -d membercenter"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 10
|
||
|
||
member-center:
|
||
# 雛形:直接從本地 ~/member_center 路徑 build
|
||
# 未來:從 Innovedus container registry pull
|
||
build:
|
||
context: ${MEMBER_CENTER_PATH:-../member_center}
|
||
dockerfile: Dockerfile
|
||
environment:
|
||
ConnectionStrings__Default: "Host=postgres;Database=membercenter;Username=mc;Password=mcpass"
|
||
ASPNETCORE_URLS: "http://+:5050"
|
||
ports:
|
||
- "5050:5050"
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
|
||
api-server:
|
||
build:
|
||
context: ./visionA-backend
|
||
dockerfile: docker/Dockerfile.api-server
|
||
environment:
|
||
VISIONA_API_PORT: 3721
|
||
VISIONA_AUTH_MODE: oidc
|
||
VISIONA_OIDC_ISSUER_URL: "http://member-center:5050"
|
||
VISIONA_OIDC_CLIENT_ID: ${VISIONA_OIDC_CLIENT_ID}
|
||
VISIONA_OIDC_CLIENT_SECRET: ${VISIONA_OIDC_CLIENT_SECRET}
|
||
VISIONA_OIDC_REDIRECT_URL: "http://localhost:3721/api/auth/callback"
|
||
VISIONA_FRONTEND_URL: "http://localhost:3000"
|
||
VISIONA_SESSION_SECRET: ${VISIONA_SESSION_SECRET}
|
||
VISIONA_PROXY_INTERNAL_URL: "http://remote-proxy:3801"
|
||
ports:
|
||
- "3721:3721"
|
||
depends_on:
|
||
member-center:
|
||
condition: service_started
|
||
|
||
remote-proxy:
|
||
build:
|
||
context: ./visionA-backend
|
||
dockerfile: docker/Dockerfile.remote-proxy
|
||
environment:
|
||
VISIONA_TUNNEL_PORT: 3800
|
||
VISIONA_PROXY_INTERNAL_PORT: 3801
|
||
VISIONA_PAIRING_TOKEN: ${VISIONA_PAIRING_TOKEN}
|
||
VISIONA_STATIC_USER_ID: ${VISIONA_STATIC_USER_ID:-} # OIDC 接後可留空
|
||
ports:
|
||
- "3800:3800"
|
||
- "3801:3801"
|
||
|
||
volumes:
|
||
mc-data:
|
||
```
|
||
|
||
### 12.3 `member-center-seed.sql`
|
||
|
||
```sql
|
||
-- 自動建 tenant + oauth client + demo user
|
||
-- 注意:實際 schema 要確認 Member Center 的真實 DDL
|
||
-- 此處僅示意,實作時 DevOps Agent 需先讀 MC 的 DB schema 對齊
|
||
|
||
INSERT INTO tenants (slug, name, created_at)
|
||
VALUES ('visionA', 'visionA Cloud', NOW())
|
||
ON CONFLICT (slug) DO NOTHING;
|
||
|
||
INSERT INTO oauth_clients (
|
||
tenant_id, client_id, client_secret_hash,
|
||
usage, redirect_uris, created_at
|
||
) VALUES (
|
||
(SELECT id FROM tenants WHERE slug = 'visionA'),
|
||
'visionA_dev_client',
|
||
-- bcrypt hash of 'visionA_dev_secret_change_in_prod'
|
||
'$2a$12$...',
|
||
'webhook_outbound', -- 見 §11.2 limitation
|
||
ARRAY['http://localhost:3721/api/auth/callback'],
|
||
NOW()
|
||
) ON CONFLICT (client_id) DO NOTHING;
|
||
|
||
INSERT INTO users (tenant_id, email, password_hash, name, email_verified, created_at)
|
||
VALUES (
|
||
(SELECT id FROM tenants WHERE slug = 'visionA'),
|
||
'demo@visionA.local',
|
||
-- bcrypt hash of 'demo123'
|
||
'$2a$12$...',
|
||
'Demo User',
|
||
true,
|
||
NOW()
|
||
) ON CONFLICT (tenant_id, email) DO NOTHING;
|
||
```
|
||
|
||
> 上述 SQL 是示意;DevOps Agent OD1 任務需先確認 Member Center 的真實 schema。
|
||
|
||
### 12.4 Makefile
|
||
|
||
```makefile
|
||
# visionA/Makefile(repo 根目錄;如不存在則新增)
|
||
|
||
.PHONY: dev-with-mc
|
||
dev-with-mc:
|
||
@if [ ! -f .env.dev ]; then \
|
||
echo "請先建立 .env.dev(參考 .env.dev.example)"; \
|
||
exit 1; \
|
||
fi
|
||
docker compose -f docker-compose.dev.yml --env-file .env.dev up --build
|
||
|
||
.PHONY: dev-with-mc-down
|
||
dev-with-mc-down:
|
||
docker compose -f docker-compose.dev.yml down
|
||
|
||
.PHONY: dev-frontend
|
||
dev-frontend:
|
||
cd visionA-frontend && pnpm dev
|
||
```
|
||
|
||
開發者 workflow:
|
||
```bash
|
||
# 一鍵起 backend + MC + postgres
|
||
make dev-with-mc
|
||
|
||
# 另開一個 terminal 跑 frontend
|
||
make dev-frontend
|
||
# → http://localhost:3000
|
||
```
|
||
|
||
### 12.5 `.env.dev.example`
|
||
|
||
```bash
|
||
# 開發者複製為 .env.dev 後填值
|
||
|
||
# Member Center seed 用的 client(與 seed.sql 對應)
|
||
VISIONA_OIDC_CLIENT_ID=visionA_dev_client
|
||
VISIONA_OIDC_CLIENT_SECRET=visionA_dev_secret_change_in_prod
|
||
|
||
# Cookie 簽章(請改隨機值)
|
||
VISIONA_SESSION_SECRET=please-change-me-32-bytes-random
|
||
|
||
# Pairing token(雛形仍用 static)
|
||
VISIONA_PAIRING_TOKEN=vAc_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||
```
|
||
|
||
---
|
||
|
||
## 13. 環境變數新增
|
||
|
||
### 13.1 visionA-backend 新增
|
||
|
||
| 變數 | 預設 | 必填 | 說明 |
|
||
|------|------|------|------|
|
||
| `VISIONA_AUTH_MODE` | `static` | — | `static` / `oidc`;切換 `StaticAuthService` vs `OIDCAuthService`(OB5 起實際只支援 oidc) |
|
||
| `VISIONA_OIDC_ISSUER_URL` | — | ✅ | dev: `http://localhost:5050`;stage: `https://stage-9527.innovedus.com:7850/`(含結尾斜線);prod: 待 IT 決定 |
|
||
| `VISIONA_OIDC_CLIENT_ID` | — | ✅ | OAuth client ID(可 public 可 confidential,由 IdP 註冊決定) |
|
||
| `VISIONA_OIDC_CLIENT_SECRET` | — | **選填**(A1 改造後)| 為空時走 **public PKCE-only mode**;非空時走 confidential mode(ADR-013)|
|
||
| `VISIONA_OIDC_REDIRECT_URL` | — | ✅ | dev: `http://localhost:3721/api/auth/callback`;stage: `https://stage-9527.innovedus.com:9527/api/auth/callback`;prod: `https://api.visiona.cloud/api/auth/callback` |
|
||
| `VISIONA_OIDC_SCOPES` | `openid email profile` | — | 空格分隔 |
|
||
| ~~`VISIONA_OIDC_SERVICE_CLIENT_ID`~~ | — | **Phase 0.8b v0.4 再次廢棄**(v1.x 廢棄;v2.0 規劃啟用;v0.4 / ADR-016 撤回 v2.0 啟用)| ~~client_credentials grant 用的 confidential client ID — FAA 線~~。visionA 端不再有 visionA → MC server-to-server 路徑;download 改走 [ADR-016](./adr/adr-016-download-via-converter.md) converter 中轉 |
|
||
| ~~`VISIONA_OIDC_SERVICE_CLIENT_SECRET`~~ | — | **Phase 0.8b v0.4 再次廢棄**(同上)| ~~對應 client_credentials grant 的 secret~~。**舊洩漏值** `RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=` 仍作廢;v0.4 後 visionA 端不再持有 service client secret |
|
||
| ~~`VISIONA_OIDC_TENANT_ID`~~ | — | **Phase 0.8b v0.4 再次廢棄**(同上)| ~~MC service client 註冊時對應的 tenant~~。visionA 端不再需要 tenant_id claim(FAA 端的 tenant 驗由 converter 自己管,與 visionA 無關)|
|
||
| `VISIONA_CONVERTER_API_KEY` | — | **Phase 0.8b v1.0 新增;v2.0 / v0.4 維持** | visionA → converter 服務間認證 pre-shared API key(64 字元 hex);**v0.4 起也用於 download 路徑**(converter `GET /api/v1/jobs/{id}/result`)。詳見 ADR-015 §1、ADR-016 §1、`conversion.md` §3.1 |
|
||
| ~~`VISIONA_FAA_API_KEY`~~ | — | **Phase 0.8b v2.0 撤回**(v1.0 加 / v2.0 移除 / v0.4 維持移除)| — |
|
||
| ~~`VISIONA_FAA_BASE_URL`~~ | — | **Phase 0.8b v0.4 再次廢棄**(既有 / v2.0 維持 / v0.4 移除)| ~~visionA → FAA 的 base URL~~。visionA 端不再直接打 FAA |
|
||
| `VISIONA_FRONTEND_URL` | — | ✅ | dev: `http://localhost:3000`;stage: `https://stage-9527.innovedus.com:9527`;prod: `https://app.visiona.cloud` |
|
||
| `VISIONA_SESSION_SECRET` | — | ✅ | 至少 32 byte 隨機字串,HMAC cookie 簽章。產法:`openssl rand -hex 32` |
|
||
| `VISIONA_SESSION_COOKIE_NAME` | `visiona_session` | — | — |
|
||
| `VISIONA_SESSION_COOKIE_DOMAIN` | (空)| — | prod: `.visiona.cloud`(同 origin 部署可留空)|
|
||
| `VISIONA_SESSION_COOKIE_SECURE` | `false` | — | stage / prod 必設 `true`(HTTPS)|
|
||
| `VISIONA_SESSION_COOKIE_SAMESITE` | `Lax` | — | OIDC redirect 必須 `Lax` |
|
||
| `VISIONA_SESSION_ABSOLUTE_TTL` | `168h` | — | 7 天 |
|
||
| `VISIONA_SESSION_IDLE_TTL` | `24h` | — | — |
|
||
|
||
#### 13.1.1 三環境 client mode 範例(A1 改造後 / ADR-013)
|
||
|
||
**dev 環境(confidential client)—— docker-compose seed `demo@visionA.local`:**
|
||
|
||
```bash
|
||
# .env.dev
|
||
VISIONA_OIDC_ISSUER_URL=http://localhost:5050
|
||
VISIONA_OIDC_CLIENT_ID=visionA_dev_client
|
||
VISIONA_OIDC_CLIENT_SECRET=visionA_dev_secret_change_in_prod # ← 有值,走 confidential
|
||
VISIONA_OIDC_REDIRECT_URL=http://localhost:3721/api/auth/callback
|
||
VISIONA_FRONTEND_URL=http://localhost:3000
|
||
VISIONA_SESSION_SECRET=please-change-me-32-bytes-random
|
||
VISIONA_SESSION_COOKIE_SECURE=false
|
||
# Service client 不設(Phase 0.7 不接 MC API)
|
||
```
|
||
|
||
**stage 環境(public PKCE-only client + Phase 0.8b v0.4 單線 server-to-server 認證設計)—— Innovedus stage MC 配給的真實 client:**
|
||
|
||
```bash
|
||
# .env.stage
|
||
# === user login(OIDC,未變)===
|
||
VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/
|
||
VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e
|
||
# VISIONA_OIDC_CLIENT_SECRET 不設 ← 空值,走 public PKCE-only mode(ADR-013)
|
||
VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callback
|
||
VISIONA_FRONTEND_URL=https://stage-9527.innovedus.com:9527
|
||
VISIONA_SESSION_SECRET=<openssl rand -hex 32 產的值,stage host 持有,不進 git>
|
||
VISIONA_SESSION_COOKIE_SECURE=true
|
||
|
||
# === Phase 0.8b v0.4 — visionA 端唯一一條 server-to-server 鏈:visionA → converter(API key)===
|
||
# 同一把 key 用於 init / poll / promote / GET result endpoint
|
||
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
|
||
VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值,與 converter 端 CONVERTER_API_KEY 對齊>
|
||
|
||
# === Phase 0.8b v0.4 撤回(v2.0 規劃要設、v0.4 / ADR-016 撤回)===
|
||
# visionA 端不再需要 MC service client(visionA → MC server-to-server 路徑完全移除)
|
||
# VISIONA_OIDC_SERVICE_CLIENT_ID=...
|
||
# VISIONA_OIDC_SERVICE_CLIENT_SECRET=...
|
||
# VISIONA_OIDC_TENANT_ID=...
|
||
# visionA 端不再直接打 FAA(download 走 converter 中轉)
|
||
# VISIONA_FAA_BASE_URL=...
|
||
# VISIONA_FAA_API_KEY=...(v1.0 加 / v2.0 撤回 / v0.4 維持撤回)
|
||
```
|
||
|
||
> ⚠️ **secret 處理規則(v0.4)**:
|
||
>
|
||
> - visionA 端不再持有任何 MC service client secret(撤回 v2.0 規劃)
|
||
> - v1.x 在對話中外洩的舊 secret `RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=`(舊 client `23605e14...` 的)仍作廢、不會被 visionA 重新引用
|
||
> - v2.0 提供的 `4242ba63099d4f318dd3f143d27ef4c5` service client + 對應 secret 在 v0.4 不再被 visionA 使用;若 MC team / 其他產品線需要可獨立持有,與 visionA 無關
|
||
|
||
**prod 環境(依 IT 配置):**
|
||
|
||
prod MC client 是 public 還是 confidential 由 Innovedus IT 在註冊 OAuth client 時決定。visionA-backend 兩者都支援,env 設 / 不設 `VISIONA_OIDC_CLIENT_SECRET` 即可,**不需重 build**(同一份 binary)。
|
||
|
||
#### 13.1.2 啟動時 client mode 偵測
|
||
|
||
api-server 啟動時應 log 一行(**不可 log secret 本身**):
|
||
|
||
```
|
||
[INFO] OIDC client mode: public (PKCE-only)
|
||
# 或
|
||
[INFO] OIDC client mode: confidential (PKCE + client_secret)
|
||
```
|
||
|
||
判斷依據:`OIDCConfig.ClientSecret == ""`。
|
||
這條 log 是排查「為什麼 token exchange 401」的第一步 — IdP 註冊的 client 類型必須與 visionA-backend 啟動時的 mode 對齊(兩端錯配會 401 unauthorized client)。
|
||
|
||
#### 13.1.3 Phase 0.8b v0.4 — Server-to-server 認證單線設計(visionA 端只剩 visionA → converter)
|
||
|
||
> v1.x「Phase 0.8b 全面廢棄 Service Client 概念」→ v2.0 部分撤回(FAA 線復活)→ **v0.4 / ADR-016 再次撤回 v2.0**(FAA 線完全移除)。
|
||
>
|
||
> 詳見 [ADR-016](./adr/adr-016-download-via-converter.md)、`conversion.md` §3、ADR-015 v2.1。
|
||
>
|
||
> **v0.4 摘要**(visionA 端只剩一條 server-to-server 線):
|
||
>
|
||
> **visionA → converter(與 user login 解耦)**:
|
||
> - **不透過 MC**:不走 `POST /oauth/token` 換 service token + JWKS 驗 + scope 驗的鏈路
|
||
> - **改用 pre-shared API key**:`VISIONA_CONVERTER_API_KEY`(64-hex)
|
||
> - **header 格式**:`Authorization: Bearer <key>`
|
||
> - **converter 端 middleware**:constant-time compare env 字串,不驗 JWKS / scope / tenant
|
||
> - **endpoints**:`POST /api/v1/jobs`(init)、`GET /api/v1/jobs/:id`(poll)、`POST /api/v1/jobs/:id/promote`(promote)、**`GET /api/v1/jobs/:id/result`(v0.4 新增;download)**
|
||
>
|
||
> **visionA → FAA / MC(v0.4 整段撤回)**:
|
||
> - v1.x 改 API key 已撤回(v2.0);v2.0 規劃要回到 MC service token + delegated download token 路徑 → **v0.4 整段再次撤回**
|
||
> - **原因(致命發現 2026-05-16)**:對 MC source 全 grep 驗證後確認 MC 沒有 `POST /file-access/download-tokens` endpoint、也沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint—— v2.0 規劃的路徑是 fictional、從未實際 e2e 跑通
|
||
> - **v0.4 處理**:visionA 端不再有任何 visionA → MC / visionA → FAA server-to-server 路徑;download 改走 converter 中轉(converter 自己用 OAuth client_credentials + `files:upload.write` scope 推 FAA、後續 download 從 converter MinIO stream 回 visionA,converter → FAA 鏈路 Phase 1 已上線、與 visionA 無關)
|
||
> - **MC 不再是 visionA 的 server-to-server 依賴**;MC 仍是 visionA user login 的 OIDC IdP(公開 PKCE client、本文件 §1-§12),但兩條線完全獨立
|
||
>
|
||
> **為什麼 v0.4 撤回 v2.0**:v2.0 規劃時 architect agent 沒實際讀 MC source、沿用 ADR-014 §2 的 assume;對 source 驗證後發現 ADR-014 §2 從 2026-05-02 寫定起就是 broken design(MC 沒有對應 endpoint),整條鏈從未 e2e 跑通。v0.4 改採 ADR-016(converter 加新 endpoint),visionA + converter 都是 jimchen 自己維護的 repo、可單方控制,不必動 MC / FAA / warrenchen。
|
||
>
|
||
> - **user login**:仍是 OIDC(PKCE / cookie session / JWKS)— 本文件 §1-§12 全部適用
|
||
> - **server-to-server visionA → converter**:不是 OIDC、不屬於本文件範圍 — 看 `conversion.md` §3.1 與 ADR-015 §1
|
||
> - **server-to-server visionA → FAA**:**不存在**(v0.4 撤回 v2.0 規劃;不再使用本文件 §13.1 列出的 OIDC ServiceClient* / TenantID 三個 env)
|
||
>
|
||
> **本小節保留的用途(v0.4 修訂)**:說明 v1.x 廢棄 → v2.0 復活 → v0.4 再次廢棄的完整時序,並標清「OIDC ServiceClient* 三個 env 在 v0.4 後維持廢棄」,避免讀者誤啟用。
|
||
|
||
### 13.2 visionA-frontend 新增
|
||
|
||
| 變數 | 預設 | 說明 |
|
||
|------|------|------|
|
||
| `NEXT_PUBLIC_MEMBER_CENTER_SIGNUP_URL` | dev: `http://localhost:5050/signup` | 註冊頁連結 |
|
||
| `NEXT_PUBLIC_MEMBER_CENTER_ACCOUNT_URL` | dev: `http://localhost:5050/account` | 帳號管理連結 |
|
||
|
||
### 13.3 移除/廢棄的環境變數
|
||
|
||
| 變數 | 處理 |
|
||
|------|------|
|
||
| `VISIONA_STATIC_USER_ID` | 仍保留(給 `static` mode;測試環境可能還用) |
|
||
| `VISIONA_AUTH_STATIC_TOKEN`(如有)| 移除 |
|
||
|
||
### 13.4 Config struct 更新
|
||
|
||
```go
|
||
// internal/config/config.go
|
||
type Config struct {
|
||
// ...
|
||
Auth struct {
|
||
Mode string `env:"VISIONA_AUTH_MODE" default:"static"`
|
||
|
||
// static mode(既有)
|
||
StaticUserID string `env:"VISIONA_STATIC_USER_ID" default:"demo-user"`
|
||
|
||
// OIDC mode(新增)
|
||
OIDCIssuerURL string `env:"VISIONA_OIDC_ISSUER_URL"`
|
||
OIDCClientID string `env:"VISIONA_OIDC_CLIENT_ID"`
|
||
OIDCClientSecret string `env:"VISIONA_OIDC_CLIENT_SECRET"`
|
||
OIDCRedirectURL string `env:"VISIONA_OIDC_REDIRECT_URL"`
|
||
OIDCScopes string `env:"VISIONA_OIDC_SCOPES" default:"openid email profile"`
|
||
}
|
||
Session struct { // 注意:與既有 tunnel session 配置不同 namespace
|
||
Secret string `env:"VISIONA_SESSION_SECRET"`
|
||
CookieName string `env:"VISIONA_SESSION_COOKIE_NAME" default:"visiona_session"`
|
||
CookieDomain string `env:"VISIONA_SESSION_COOKIE_DOMAIN"`
|
||
CookieSecure bool `env:"VISIONA_SESSION_COOKIE_SECURE" default:"false"`
|
||
CookieSameSite string `env:"VISIONA_SESSION_COOKIE_SAMESITE" default:"Lax"`
|
||
AbsoluteTTL time.Duration `env:"VISIONA_SESSION_ABSOLUTE_TTL" default:"168h"`
|
||
IdleTTL time.Duration `env:"VISIONA_SESSION_IDLE_TTL" default:"24h"`
|
||
}
|
||
Frontend struct {
|
||
URL string `env:"VISIONA_FRONTEND_URL"`
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 14. 安全考量
|
||
|
||
### 14.1 攻擊面 vs 防護
|
||
|
||
| 攻擊 | 防護 |
|
||
|------|------|
|
||
| **CSRF(攻擊者誘騙登入到自己的帳號)** | `state` parameter(pending session 驗)+ `SameSite=Lax` cookie |
|
||
| **id_token replay**(攔截再用)| `nonce` claim(pending session 驗)+ `exp` |
|
||
| **Authorization code 攔截 + 自己換 token** | PKCE(攔截方無 verifier)+ confidential client(攔截方無 secret) |
|
||
| **Token 偷竊(XSS 讀 localStorage)** | BFF — token 在 backend,不暴露給 browser;cookie HttpOnly |
|
||
| **Cookie 偷竊(網路嗅探)** | `Secure` flag(HTTPS only)+ HSTS(prod) |
|
||
| **Cookie 竄改** | HMAC-SHA256 簽章(`SignCookie`) |
|
||
| **Session fixation**(攻擊者預設定 session id)| 每次 login 都產新 session id(不接受 client 提供)|
|
||
| **重定向至惡意站**(open redirect)| `return_to` 必須以 `/` 開頭、不能以 `//` 開頭、白名單檢查 |
|
||
|
||
### 14.2 HTTPS
|
||
|
||
| 環境 | 要求 |
|
||
|------|------|
|
||
| dev | HTTP OK(所有 `Secure=false`、`http://localhost`)|
|
||
| staging / prod | **強制 HTTPS**(`Secure=true`、HSTS)|
|
||
|
||
prod TLS 終止點:ALB / Caddy / nginx,由 DevOps 決定(記在 `infra.md` TODO)。
|
||
|
||
### 14.3 Token 不暴露給 frontend
|
||
|
||
| 項目 | 雛形(StaticAuth) | OIDC 後 |
|
||
|------|-------------------|---------|
|
||
| Access token 在哪 | localStorage(**安全債** §14.1)| **session store(server)**,frontend 不接觸 |
|
||
| Refresh token | (無)| 雛形不用,Phase 1 做 |
|
||
| ID token | (無)| session store;驗完即可丟,雛形保留以備未來用 |
|
||
| WS 認證 | querystring `?token=` | cookie(瀏覽器自動帶)|
|
||
|
||
### 14.4 Secret 管理
|
||
|
||
| Secret | dev | prod |
|
||
|--------|-----|------|
|
||
| `VISIONA_OIDC_CLIENT_SECRET` | `.env.dev`(gitignore)| AWS Secrets Manager / Vault |
|
||
| `VISIONA_SESSION_SECRET` | `.env.dev` | 同上,**啟動時注入**,定期 rotation |
|
||
|
||
`.gitignore` 必含 `.env*` `!.env.example` `!.env.dev.example`。
|
||
|
||
### 14.5 Logging
|
||
|
||
- **不能 log**:access_token、id_token、cookie 完整值、session_id 完整值
|
||
- **可以 log**:user_id、email、session_id 前 8 字元(debug 用)
|
||
- 認證失敗:log IP + reason,方便偵測攻擊
|
||
|
||
---
|
||
|
||
## 15. 取代 StaticAuth 的影響範圍
|
||
|
||
### 15.1 程式碼影響
|
||
|
||
| 檔案 | 影響 | 處理 |
|
||
|------|------|------|
|
||
| `internal/auth/static.go` | 移除 `StaticAuthService` | 刪檔 |
|
||
| `internal/auth/static_provider.go` | 移除 `StaticAuthProvider` | 刪檔 |
|
||
| `cmd/api-server/main.go` | 改 wire-up:從 `static.NewAuthService()` 改成根據 `VISIONA_AUTH_MODE` 切換 | 修改 |
|
||
| `internal/api/middleware/auth.go` | 從 Bearer header 改 cookie | 修改(核心邏輯都搬到 `OIDCAuthService.Authenticate`) |
|
||
| `internal/api/handlers/auth.go` | 大改造:login/callback/logout/me | 改寫 |
|
||
| `cmd/api-server/main.go` 路由註冊 | 確認 `/api/auth/*` 全部接到新 handler | 修改 |
|
||
| `b5_integration_test.go` | 用 `StaticAuthProvider` mock 過 | 改用 fake OIDC server(見 §15.2) |
|
||
| `internal/api/handlers/*_test.go` | 任何 mock auth 的測試 | 改用 mock cookie + mock usersession |
|
||
|
||
### 15.2 測試策略
|
||
|
||
**需要 mock OIDC 的測試**:用 `httptest.Server` 起一個 fake OIDC provider:
|
||
|
||
```go
|
||
// internal/oidc/fake_test.go
|
||
func NewFakeOIDCServer(t *testing.T) *httptest.Server {
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/.well-known/openid-configuration", func(w, r) {
|
||
json.NewEncoder(w).Encode(DiscoveryDoc{
|
||
Issuer: server.URL,
|
||
AuthorizationEndpoint: server.URL + "/oauth/authorize",
|
||
TokenEndpoint: server.URL + "/oauth/token",
|
||
JWKSURI: server.URL + "/jwks",
|
||
})
|
||
})
|
||
mux.HandleFunc("/oauth/authorize", func(w, r) {
|
||
// 直接 redirect 回 redirect_uri,帶假 code
|
||
})
|
||
mux.HandleFunc("/oauth/token", func(w, r) {
|
||
// 回假 access_token + id_token(用 test 的私鑰簽)
|
||
})
|
||
mux.HandleFunc("/jwks", func(w, r) {
|
||
// 回 test 公鑰
|
||
})
|
||
return httptest.NewServer(mux)
|
||
}
|
||
```
|
||
|
||
OT1 任務負責建這個 fake server + 完整 integration test。
|
||
|
||
### 15.3 雛形 demo-user 兼容性
|
||
|
||
dev docker-compose seed 一個 `demo@visionA.local` / `demo123` 帳號:
|
||
- 開發者 `make dev-with-mc` 起服 → 開 `http://localhost:3000` → 點「用 Innovedus 登入」→ 進 MC 登入頁 → 用 `demo@visionA.local` / `demo123` 登入 → 回 visionA
|
||
- 過去 `StaticAuth` 時代的 demo 流程「使用者一進來就有 demo-user」**不再存在**
|
||
- 但 dev 體驗只多一步登入,可接受
|
||
|
||
### 15.4 已 deploy 的雛形怎麼辦
|
||
|
||
雛形目前只跑在開發者本機,沒有 prod。Phase 0.6 切換時:
|
||
- 開發者 `git pull` → 補新 env vars → `make dev-with-mc` → 直接生效
|
||
- 無遷移成本
|
||
|
||
---
|
||
|
||
## 16. ADR-010 摘要
|
||
|
||
完整 ADR 見 [`adr/adr-010-oidc-bff.md`](adr/adr-010-oidc-bff.md)。
|
||
|
||
**Decision**:採 OAuth Authorization Code + PKCE + BFF Pattern + Innovedus Member Center。
|
||
|
||
**Alternatives considered**:
|
||
- SPA + PKCE(Member Center 不支援 public client)
|
||
- Auth0 / Cognito / Clerk(vendor lock-in、與「跨 Innovedus 產品線 SSO」目標衝突)
|
||
- 自刻 OAuth + JWT(重複造輪子)
|
||
- 繼續用 StaticAuthProvider(Phase 0.6 必須升級)
|
||
|
||
**Consequences**:
|
||
- ✅ 跨 Innovedus 產品 SSO
|
||
- ✅ Token 不暴露 frontend,安全性提升
|
||
- ✅ 順便清三個前端安全債(localStorage token、refresh、WS querystring token)
|
||
- ⚠️ Backend 需要管理 cookie session store
|
||
- ⚠️ Member Center 必須上線才能 dev / staging 測試
|
||
- ⚠️ Member Center 雛形 OAuth client 註冊有 `usage` limitation,暫用 `webhook_outbound` 並開 issue
|
||
|
||
---
|
||
|
||
## 17. ADR-005 處理
|
||
|
||
### 17.1 評估
|
||
|
||
ADR-005「雛形階段不接真實 DB 與 Auth」的 Auth 部分被推翻(DB 部分仍有效)。
|
||
|
||
### 17.2 決議:直接更新 ADR-005 + 新增 ADR-011
|
||
|
||
**採雙管齊下方案**:
|
||
|
||
1. **更新 ADR-005**:在「狀態」改為 `Accepted (Auth 部分由 ADR-011 取代,DB 部分仍有效)`,並新增「Update 2026-04-26」段落說明 Auth 已升級到 OIDC,引用 ADR-010 + ADR-011。
|
||
2. **新增 ADR-011**:「雛形 Auth 升級接 Member Center」,明確記錄推翻 ADR-005 的決策時間點與動機。
|
||
|
||
**不單獨改 ADR-005 的理由**:
|
||
- ADR 是歷史紀錄,原始決策內容應保留
|
||
- 用 ADR-011 顯式標註「supersedes ADR-005 (Auth section)」更符合 ADR 慣例
|
||
- 未來新成員看到 ADR-005 還能理解「為什麼當初這樣決定」
|
||
|
||
**不只新增 ADR-011 不改 ADR-005 的理由**:
|
||
- ADR-005 中明確寫「雛形階段不接真實 Auth」,現在實際做了,不註記會讓讀者困惑
|
||
- 加一行 Update + 連結到 ADR-011 即可
|
||
|
||
### 17.3 ADR-011 大綱
|
||
|
||
```
|
||
# ADR-011: 雛形 Auth 升級接 Member Center(推翻 ADR-005 Auth 部分)
|
||
|
||
## 狀態:Accepted — 2026-04-26
|
||
## Supersedes: ADR-005(僅 Auth 部分;DB 部分維持)
|
||
|
||
## 背景
|
||
- ADR-005 當初決定雛形不接 Auth,理由是不阻擋 tunnel / pairing / forward 端對端驗證
|
||
- 雛形已交付(Phase 0 + Phase 0.5 全綠)
|
||
- 同期 Innovedus Member Center 已能用
|
||
- 進到 Phase 0.6 — 真實使用者是 Phase 1 的前置條件
|
||
|
||
## 決策
|
||
雛形 Phase 0.6 接 Member Center OIDC(取代 StaticAuthProvider / StaticAuthService)。
|
||
|
||
## 影響
|
||
- ADR-005「不接 Auth」部分作廢
|
||
- ADR-005「不接 DB」部分仍有效(user metadata 在 Member Center,雛形 visionA-backend 仍不需 DB)
|
||
- AuthProvider / AuthService interface 不變(OIDC 實作直接套用)
|
||
|
||
## 引用
|
||
- ADR-005(被推翻部分)
|
||
- ADR-010(OIDC BFF Pattern 詳細決策)
|
||
- oidc-tdd.md(實作細節)
|
||
```
|
||
|
||
實際 ADR-011 由 OB1 任務開頭時建立。
|
||
|
||
---
|
||
|
||
## 18. 開發任務拆分
|
||
|
||
依角色拆分為四組:OB(OIDC Backend)、OF(OIDC Frontend)、OD(OIDC DevOps)、OT(OIDC Testing)。
|
||
|
||
### 18.1 任務清單
|
||
|
||
| # | 任務 | 描述 | 大小 | 依賴 | 預估人日 |
|
||
|---|------|------|------|------|---------|
|
||
| **OB1** | `internal/oidc/` package | Discovery / PKCE / JWKS / id_token verify;含 unit test | M | — | 1.5 |
|
||
| **OB2** | `internal/usersession/` package | Store interface + InMemoryStore + cookie sign/verify + 單元測試 | M | — | 1.0 |
|
||
| **OB3** | `OIDCAuthService` + `OIDCAuthProvider` | `internal/auth/oidc_*.go`;改 middleware;wire-up `cmd/api-server/main.go` | M | OB1, OB2 | 1.0 |
|
||
| **OB4** | `/api/auth/login` `/callback` `/logout` `/me` handlers | 完整 redirect flow handler | L | OB1, OB2, OB3 | 1.5 |
|
||
| **OB5** | 移除 `StaticAuthProvider` + 補測 user 隔離 | 刪檔;確認 Pairing 與 Device repository 仍以 user 隔離;改 `b5_integration_test.go` | M | OB4, OT1 | 1.0 |
|
||
| **OB6** | ADR-011 + 更新 ADR-005 | 寫 ADR-011;ADR-005 加 Update 段 | S | OB4 完成驗證後 | 0.5 |
|
||
| | **Backend 小計** | | | | **6.5** |
|
||
| **OF1** | `/login` 頁改造 | 移除 email/password 表單,改成「用 Innovedus 帳號登入」按鈕 | S | — | 0.5 |
|
||
| **OF2** | API client 改造 + auth-store 重構 | 移除 localStorage token、移除 Bearer header、改 cookie;`/api/auth/me` 接到 Header 與 `/account` | M | OB4 | 1.0 |
|
||
| **OF3** | `/register` 移除 / 改說明頁;`/account` 改造 | 連結到 Member Center;`/account` 顯示 user info + 登出 | S | OF2 | 0.5 |
|
||
| **OF4** | i18n 字典補(中英) | 新增 `signInWithInnovedus`、`signUpAtMemberCenter` 等 key | S | OF1, OF3 | 0.3 |
|
||
| | **Frontend 小計** | | | | **2.3** |
|
||
| **OD1** | docker-compose.dev.yml + Member Center seed | 起 postgres + member-center + visionA-backend;seed tenant + oauth client + demo user;驗證 `make dev-with-mc` 一鍵起 | L | OB4 | 1.5 |
|
||
| **OD2** | `.env.dev.example` + Makefile target | 完整環境變數範本與 `make dev-with-mc` / `make dev-with-mc-down` | S | OD1 | 0.3 |
|
||
| | **DevOps 小計** | | | | **1.8** |
|
||
| **OT1** | Fake OIDC server + integration tests | `internal/oidc/fake_test.go`;端對端測試 login → callback → /me;含 PKCE / state / nonce 驗證 | M | OB1 | 1.0 |
|
||
| **OT2** | E2E:visionA + 真 Member Center 跑通 | `make dev-with-mc` + Playwright 跑「點登入 → 輸帳密 → 進 dashboard」 | M | OD1, OF3 | 1.0 |
|
||
| | **Testing 小計** | | | | **2.0** |
|
||
| | **總計** | | | | **12.6 人日** |
|
||
|
||
### 18.2 依賴順序與平行化
|
||
|
||
```
|
||
OB1 ─┬─► OB3 ─► OB4 ─┬─► OB5 ─► OB6
|
||
│ │
|
||
└─► OT1 ────────┘
|
||
OB2 ─► OB3
|
||
├─► OD1 ──► OD2
|
||
│ │
|
||
└─► OF2 ──► OF3 ──► OF4
|
||
│
|
||
└─► OT2 (要等 OD1 + OF3)
|
||
OF1 (與 OF2 平行)
|
||
```
|
||
|
||
**平行化建議**:
|
||
- 第一波(並行):OB1 + OB2 + OF1
|
||
- 第二波:OB3 + OT1
|
||
- 第三波:OB4
|
||
- 第四波(並行):OD1 + OF2 + OB6
|
||
- 第五波:OF3 + OD2 + OB5
|
||
- 第六波:OF4 + OT2
|
||
|
||
### 18.3 上線順序
|
||
|
||
每個任務完成 → Reviewer 審查 → 通過進下一個(與 Phase 0 雛形流程一致)。
|
||
**OB4 完成時做一次 demo**:開發者 `make dev-with-mc` 跑通 login flow → 給使用者看 → 使用者確認後再進 OB5(移除 StaticAuth)。
|
||
|
||
---
|
||
|
||
## 版本記錄
|
||
|
||
| 日期 | 版本 | 變更 |
|
||
|------|------|------|
|
||
| 2026-04-26 | 0.1 | Architect Agent 初稿(Phase 0.6 OIDC 接入 TDD 增補) |
|
||
| 2026-05-11 | 0.2 | **Phase 0.8b** 對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md):(1) Metadata 區補 Phase 0.8b 範圍說明(user login 不變、server-to-server 改 API key);(2) §13.1 環境變數表把 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` 兩 row 標廢棄、新增 `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` 兩 row;(3) §13.1.1 stage env 範例移除 service client 區、新增 API key 區;(4) 新增 §13.1.3 說明 server-to-server 與 user login OIDC 已脫鉤,引導讀者去 `conversion.md` §3 與 ADR-015。本文件其他章節(§1-§12、§14-§17)關於 user login 部分全部不變。 |
|
||
| 2026-05-16 | 0.3 | **對應 ADR-015 v2.0 範圍縮限**:撤回 v0.2 「server-to-server 全部改 API key、service client env 全部廢棄」決定。FAA 線回到 ADR-014 §2 原設計(MC service token + delegated download token),需要 MC OIDC service client;converter 線維持 v0.2 的 API key 路線。主要變更:(1) Metadata 區更新 v2.0 範圍說明 — 區分 converter 線(API key)與 FAA 線(仍 OAuth);(2) §13.1 環境變數表 — `VISIONA_OIDC_SERVICE_CLIENT_ID` / `VISIONA_OIDC_SERVICE_CLIENT_SECRET` / `VISIONA_OIDC_TENANT_ID` 三 row 從廢棄改回「v2.0 重新啟用」、補 stage 對應值(`4242ba63...` / `732270c0-...`),`VISIONA_FAA_API_KEY` row 標撤回,`VISIONA_FAA_BASE_URL` row 補 stage 值;(3) §13.1.1 stage env 範例加回 service client + tenant_id(用 placeholder)、保留 converter API key、撤回 `VISIONA_FAA_API_KEY`、補 secret 處理規則注意事項;(4) §13.1.3 改為「v2.0 雙線設計」說明 — converter 線解耦 / FAA 線未解耦、引導讀者看 `conversion.md` §3.1 vs §3.2 / ADR-015 v2.0 §1 vs §2 / ADR-014 §2。本文件 user login 章節(§1-§12、§14-§17)全部不變。 |
|
||
| 2026-05-16 | 0.4 | **對應 [ADR-016](./adr/adr-016-download-via-converter.md)**:再次撤回 v0.3「server-to-server FAA 線回到 MC service token + delegated download token」全部規劃。原因:對 MC source 全 grep 驗證後確認 MC **沒有** `POST /file-access/download-tokens` endpoint、也沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint—— ADR-014 §2 / ADR-015 v2.0 §2 的 delegated token 鏈從 2026-05-02 起即為 broken design、從未 e2e 跑通。**v0.4 新設計**:visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉;visionA 端 server-to-server 鏈路收斂為單條 visionA → converter(API key)。主要變更:(1) Metadata 區更新 v0.4 範圍說明 — visionA → FAA / MC 整段撤回;(2) 上位文件改為 ADR-015 v2.1 + ADR-016;(3) §13.1 環境變數表 — `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID` 三 row 從 v0.3「重新啟用」改回「再次廢棄」、`VISIONA_FAA_BASE_URL` row 從「使用中」改為「再次廢棄」、`VISIONA_CONVERTER_API_KEY` row 補「v0.4 起也用於 download 路徑」說明;(4) §13.1.1 stage env 範例移除 service client + tenant_id + FAA URL(v0.3 加回的全部撤回)、加註「visionA 端不再持有 MC service client secret」、secret 處理規則整段改寫;(5) §13.1.3 改寫為「v0.4 單線設計」說明 — visionA → FAA / MC 全部撤回,加完整時序(v1.x 廢棄 → v2.0 復活 → v0.4 再次廢棄)、引導讀者看 conversion.md §3 / ADR-016 / ADR-015 v2.1;(6) 加完整撤回理由與「為什麼 v0.4 撤回 v2.0」說明。本文件 user login 章節(§1-§12、§14-§17)全部不變、Phase 0.6 OIDC 接 MC 的設計完全不受影響。 |
|