# OIDC 接入 TDD — visionA Cloud × Innovedus Member Center ## Metadata - **作者**:Architect Agent - **狀態**:Draft(Phase 0.6 增補;待使用者確認後進入 OB1 開發) - **最後更新**:2026-04-26 - **文件角色**: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-010-oidc-bff.md`(本文件 §16) - **讀者**:Backend / Frontend / DevOps / Testing Agents --- ## 索引 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= │ &code_challenge= │ &code_challenge_method=S256 │ &nonce= │◄─────────────────────────┤ │ │ │ │ 5. 跟隨 302 │ │ ├─────────────────────────────────────────────────────►│ │ │ │ │ 6. MC 顯示登入頁 │ │ │◄─────────────────────────────────────────────────────┤ │ │ │ │ 7. 輸入帳密、submit │ │ ├─────────────────────────────────────────────────────►│ │ │ │ │ 8. 302 to │ │ │ redirect_uri?code=xxx&state= │ │◄─────────────────────────────────────────────────────┤ │ │ │ │ 9. GET /api/auth/callback?code=xxx&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=│ │ │ client_id=visionA │ │ │ client_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= │ │ │ 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= │ ├─────────────────────────►│ │ │ │ - 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: . 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 | `.` | 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 (

{t('title')}

{t('description')}

{t('newAccountHint')}{' '} {t('signUpAtMemberCenter')}

); } ``` **關鍵點**: - 用 `` 不是 `

{t('manageAccountAtMemberCenter')}{' '} {memberCenterAccountURL}

); ``` ### 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 " \ -d '{"slug":"visionA","name":"visionA Cloud"}' # 3. 透過 admin API 建 OAuth client curl -X POST http://localhost:5050/admin/oauth-clients \ -H "Authorization: Bearer " \ -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 " \ -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` | — | **選填(預留)** | client_credentials grant 用的 confidential client ID;Phase 0.7 不啟用,Phase 1 接 MC API 時用(ADR-013)| | `VISIONA_OIDC_SERVICE_CLIENT_SECRET` | — | **選填(預留)** | 同上對應的 secret;**禁止 commit** | | `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)—— Innovedus stage MC 配給的真實 client:** ```bash # .env.stage 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= VISIONA_SESSION_COOKIE_SECURE=true # Service client 預留欄位但不啟用: # VISIONA_OIDC_SERVICE_CLIENT_ID= # VISIONA_OIDC_SERVICE_CLIENT_SECRET= ``` **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.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 增補) |