jim800121chen dab13ed984 docs(autoflow): ADR-016 — visionA download 改走 converter GetResult,撤回 FAA delegated token 鏈
致命發現(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>
2026-05-16 12:30:46 +08:00

1873 lines
80 KiB
Markdown
Raw Permalink 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.

# OIDC 接入 TDD — visionA Cloud × Innovedus Member Center
## Metadata
- **作者**Architect Agent
- **狀態**Phase 0.8b v0.4 修訂converter 線改 API keyFAA 線**完全撤回**改走 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 loginbrowser → visionA backend**:完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token本文件 §1-§12、§14-§17 全部仍有效。
> - **server-to-servervisionA backend → converter**v1.0 改用 pre-shared API key 取代 OAuth `client_credentials` grantv2.0 / v0.4 維持。詳見 ADR-015 §1。
> - **server-to-servervisionA 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、不必綁第三方 vendorClerk / 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 Patternbackend 持 token、frontend 用 cookie | Refresh token rotationMember Center 暫無 refresh |
| id_token 驗簽JWKS | Member Center webhookuser 刪除 / 停用通知) |
| 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` cookieHttpOnly
- 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 }
// 驗 expirationjwt-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 hexcookie 帶這個)
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 // absolute7 天)
IdleExpireAt time.Time // idle24 小時,每次存取更新)
}
type PendingSession struct {
ID string // 隨機 32 byte hexpending 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` 實作同 interfacePhase 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.ClientProvider 主要是 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 反查(雛形先回 ErrNotImplementedhandler 會用 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
}
// 驗 stateCSRF 防護)
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 拿完整 infomiddleware 只放 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.4middleware 不變。
**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` | 防 CSRFOIDC redirect 從 MC 回 callback 是 GETLax 允許 |
| Domain | `.visiona.cloud`prod/ 不設dev | 前端與 backend 同 domain 才共享 |
| Path | `/` | 全站 |
| Max-Age | `604800`7 天) | 與 session.ExpiresAt 一致 |
### 5.2 Session 生命週期
| 事件 | 行為 |
|------|------|
| Create | `ExpiresAt = now + 7d``IdleExpireAt = now + 24h` |
| Getmiddleware 每次)| 檢查 `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 → base64url43 字元) |
| `code_challenge_method` | `plain``S256` | **強制 `S256`** |
| `code_challenge` | base64url(SHA256(verifier)) | — |
### 6.2 三個隨機值
| 值 | 用途 | 長度 | 儲存 |
|----|------|------|------|
| `code_verifier` | PKCEtoken 換取證明 | 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 JWKSPhase 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 subUUID 字串)|
| `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 0email + 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 會收到 401AuthGuard 自動跳 /login
router.push('/login');
}
```
### 10.7 Auth Guard
既有 AuthGuardroute 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 開發者手動 setupdev
```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
**為什麼不採方案 AvisionA 自己選 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/Makefilerepo 根目錄;如不存在則新增)
.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 modeADR-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 claimFAA 端的 tenant 驗由 converter 自己管,與 visionA 無關)|
| `VISIONA_CONVERTER_API_KEY` | — | **Phase 0.8b v1.0 新增v2.0 / v0.4 維持** | visionA → converter 服務間認證 pre-shared API key64 字元 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 loginOIDC未變===
VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/
VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e
# VISIONA_OIDC_CLIENT_SECRET 不設 ← 空值,走 public PKCE-only modeADR-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 → converterAPI 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 clientvisionA → MC server-to-server 路徑完全移除)
# VISIONA_OIDC_SERVICE_CLIENT_ID=...
# VISIONA_OIDC_SERVICE_CLIENT_SECRET=...
# VISIONA_OIDC_TENANT_ID=...
# visionA 端不再直接打 FAAdownload 走 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 / MCv0.4 整段撤回)**
> - v1.x 改 API key 已撤回v2.0v2.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 回 visionAconverter → 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 designMC 沒有對應 endpoint整條鏈從未 e2e 跑通。v0.4 改採 ADR-016converter 加新 endpointvisionA + converter 都是 jimchen 自己維護的 repo、可單方控制不必動 MC / FAA / warrenchen。
>
> - **user login**:仍是 OIDCPKCE / 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` parameterpending session 驗)+ `SameSite=Lax` cookie |
| **id_token replay**(攔截再用)| `nonce` claimpending session 驗)+ `exp` |
| **Authorization code 攔截 + 自己換 token** | PKCE攔截方無 verifier+ confidential client攔截方無 secret |
| **Token 偷竊XSS 讀 localStorage** | BFF — token 在 backend不暴露給 browsercookie HttpOnly |
| **Cookie 偷竊(網路嗅探)** | `Secure` flagHTTPS only+ HSTSprod |
| **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 storeserver**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 + PKCEMember Center 不支援 public client
- Auth0 / Cognito / Clerkvendor lock-in、與「跨 Innovedus 產品線 SSO」目標衝突
- 自刻 OAuth + JWT重複造輪子
- 繼續用 StaticAuthProviderPhase 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-010OIDC BFF Pattern 詳細決策)
- oidc-tdd.md實作細節
```
實際 ADR-011 由 OB1 任務開頭時建立。
---
## 18. 開發任務拆分
依角色拆分為四組OBOIDC Backend、OFOIDC Frontend、ODOIDC DevOps、OTOIDC 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`;改 middlewarewire-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-011ADR-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-backendseed 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** | E2EvisionA + 真 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 clientconverter 線維持 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 → converterAPI 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 URLv0.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 的設計完全不受影響。 |