jim800121chen b9c228df4f docs(autoflow): Phase 0.8b ADR-015 + TDD 修訂 — server-to-server 改 API key
新增 ADR-015:visionA → converter / FAA 從 OAuth client_credentials 改 pre-shared API key
- §1-§9 決策、4 個替代方案、後果分析、合規性
- §3.5 reference middleware snippet(Go converter + C# FAA 兩種寫法)+ 部署檢查清單
- 部分 supersede ADR-014 §5/§6/§7(service token / scope / MC retry rows)
- 觸發背景:Phase 0.8 stage e2e 撞 4 個 blocker,1:1 internal trust 用 OAuth client_credentials 過度設計

3 份 TDD 配合修訂:
- conversion.md:重寫 §3 服務間認證、§4.1 download 退回 server-side stream proxy、刪 §2.4 mc_token_client、§5.3 補 cancel 鏈、§10.3 改 pre-shared key 保護
- api-conversion.md:error code idp_unavailable → converter_auth_failed/faa_auth_failed;download response 從 302 redirect 改 200 + Content-Disposition: attachment + NEF stream
- oidc-tdd.md:標廢棄 service client env 兩 row、新增 API key env 兩 row、§13.1.3 user login 與 server-to-server 脫鉤說明、v0.2 changelog

未動:source code(步驟 2 由 backend agent 處理;範圍含 mc_token_client 刪除、TenantID 移除、API key 改造,含 test files)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:39:45 +08:00

74 KiB
Raw Blame History

OIDC 接入 TDD — visionA Cloud × Innovedus Member Center

Metadata

  • 作者Architect Agent
  • 狀態Phase 0.8b 修訂service client / server-to-server 改 API keyuser login 部分不變)
  • 最後更新2026-05-11
  • 文件角色Phase 0.6 把 visionA-backend 的 StaticAuthProvider 替換為 OIDC 接 Innovedus Member Center
  • 上位文件TDD.mdsecurity.mdadr/adr-005-no-db-auth-in-prototype.mdadr/adr-010-oidc-bff.mdadr/adr-013-public-client.mdadr/adr-015-server-to-server-api-key.mdPhase 0.8b 服務間認證部分 supersede ADR-014 §5/§6 service token 段落user login 部分不受影響)
  • 下位文件adr/adr-010-oidc-bff.md(本文件 §16
  • 讀者Backend / Frontend / DevOps / Testing Agents

Phase 0.8b 範圍說明(重要)

  • 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 / FAAPhase 0.8b 改用 pre-shared API key 取代原本的 OAuth client_credentials grant。詳見 ADR-015本文件 §13.1 「Service Client 預留欄位」段落隨之更新(改為標示廢棄、不再啟用)。

索引

  1. 為什麼接 Member Center
  2. 整體架構圖
  3. BFF Flow 詳細時序圖
  4. Backend 模組設計
  5. Session 設計cookie
  6. PKCE 實作細節
  7. id_token 驗證
  8. UserContext 改造
  9. Pairing 流程確認 user binding 仍正確
  10. Frontend 改造
  11. Member Center 端設定
  12. docker-compose dev 環境
  13. 環境變數新增
  14. 安全考量
  15. 取代 StaticAuth 的影響範圍
  16. ADR-010 摘要 — OIDC 接入策略
  17. ADR-005 處理(更新還是新 ADR-011
  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 取代 StaticPairingStorePairing 走另一條線,本次不動)
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 tokenaccess_token / id_token / refresh_token 都在 backend
  • visionA-frontend 只看到一個 visiona_session cookieHttpOnly
  • backend 是 OIDC confidential client(持有 client_secret
  • 這就是為什麼必須是 BFF 而不是 SPA + PKCEMember 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 sessionremote-proxy 持有的 yamux session與 local agent 一對一)— 已存在,不動
  • internal/usersession/ = HTTP user sessionbrowser cookie 對應的使用者 session — 新增
  • 兩者完全不同概念,分開放兩個 package 避免混淆

4.2 internal/oidc/

// 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)
// 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
}
// 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 */ }
// 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
}
// 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/

// 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 分鐘
}
// 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)  // 取出後立刻刪
}
// 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 秒呼叫
// 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

// 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
}
// 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 / RegisterErrUseOIDCRedirect / 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(雛形可移除)+ AuthProviderRedirectBasedOIDC更乾淨。Phase 0.6 不動,避免改動範圍擴大。

4.5 internal/api/handlers/auth.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

// 既有 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

屬性 理由
Name visiona_session 與 backend 其他 cookie 區分
Value <session_id>.<hmac> 32 byte hex + HMAC-SHA256 簽章
HttpOnly true 防 XSS 讀取
Secure trueprod/ falsedev http 強制 HTTPS
SameSite Lax 防 CSRFOIDC redirect 從 MC 回 callback 是 GETLax 允許
Domain .visiona.cloudprod/ 不設dev 前端與 backend 同 domain 才共享
Path / 全站
Max-Age 6048007 天) 與 session.ExpiresAt 一致

5.2 Session 生命週期

事件 行為
Create ExpiresAt = now + 7dIdleExpireAt = now + 24h
Getmiddleware 每次) 檢查 now < ExpiresAt && now < IdleExpireAt,否則刪 + 401
Touch LastSeenAt = nowIdleExpireAt = now + 24habsolute 不變)
Delete 從 store 移除
CleanupExpired background goroutine 每 60s 跑,刪所有過期 session

雛形 in-memory 重啟即消失可接受。Phase 1 換 Redis / DB。

5.3 Pending session登入中暫存

獨立 cookievisiona_pending_sid10 分鐘 TTLcallback 完成或過期即刪。 這是為了:

  • PKCE verifier 必須在 token exchange 前存在 server side不能放 cookie 裡,會被 JS 讀走)
  • state 防 CSRF
  • nonce 防 replay
  • return_to 記住使用者原本想去哪
// 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 plainS256 強制 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 完整呼叫範例

// /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

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保持不變

type UserContext struct {
    UserID string
    Email  string
    Roles  []string
    OrgID  string
}

8.2 雛形 → OIDC 對應

欄位 雛形StaticAuth OIDC
UserID 固定 "demo-user" OIDC subMember 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

// 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 sub6d7c1b2e-3f44-4a55-b8a1-1234567890abhandler 邏輯不變。

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

// 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

// 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

// 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 登出

// 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/me401 → /login
  • 200 → render

10.8 移除前端 token 管理

Phase 0 雛形 frontend 把 token 存 localStoragesecurity.md §14.1 已標為 Phase 1 必還的安全債)。

Phase 0.6 直接修掉

  • 移除 auth-storetoken 欄位
  • 移除 localStorage.setItem('visionA.auth.token', ...)
  • 移除 API client 的 Authorization: Bearer ... header改靠 cookiecredentials: 'include'
  • WS 連線 token 走 cookie瀏覽器同 domain WS 會自動帶 cookie不需 querystring token

這同時解掉 security.md §14.1、§14.2、§14.3 三個安全債 — 用 OIDC 接入順便清。


11. Member Center 端設定

11.1 開發者手動 setupdev

# 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_outboundMC 通知產品線用,有 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

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

-- 自動建 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

# 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

# 一鍵起 backend + MC + postgres
make dev-with-mc

# 另開一個 terminal 跑 frontend
make dev-frontend
# → http://localhost:3000

12.5 .env.dev.example

# 開發者複製為 .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 OIDCAuthServiceOB5 起實際只支援 oidc
VISIONA_OIDC_ISSUER_URL dev: http://localhost:5050stage: 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/callbackstage: https://stage-9527.innovedus.com:9527/api/auth/callbackprod: https://api.visiona.cloud/api/auth/callback
VISIONA_OIDC_SCOPES openid email profile 空格分隔
VISIONA_OIDC_SERVICE_CLIENT_ID Phase 0.8b 廢棄 client_credentials grant 用的 confidential client ID。Phase 0.8 短暫啟用後Phase 0.8b 改用 API keyADR-015取代env 從 .env.stage.example 移除
VISIONA_OIDC_SERVICE_CLIENT_SECRET Phase 0.8b 廢棄 同上廢棄stage 上已洩漏的 secret 直接作廢、不 rotate
VISIONA_CONVERTER_API_KEY Phase 0.8b 新增 visionA → converter 服務間認證 pre-shared API key64 字元 hex詳見 ADR-015 與 conversion.md §3
VISIONA_FAA_API_KEY Phase 0.8b 新增 visionA → FAA 服務間認證 pre-shared API key64 字元 hex詳見 ADR-015 與 conversion.md §3
VISIONA_FRONTEND_URL dev: http://localhost:3000stage: https://stage-9527.innovedus.com:9527prod: 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 必設 trueHTTPS
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

# .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 API key 服務間認證)—— Innovedus stage MC 配給的真實 client

# .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 服務間認證API key取代 OAuth service token===
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值,與 converter 端 CONVERTER_API_KEY 對齊>
VISIONA_FAA_API_KEY=<openssl rand -hex 32 產的值,與 FAA 端 FAA_API_KEY 對齊>

# === Phase 0.8b 移除(不再使用)===
# VISIONA_OIDC_SERVICE_CLIENT_ID=...已廢棄ADR-015
# VISIONA_OIDC_SERVICE_CLIENT_SECRET=...已廢棄stage 上已洩漏的值直接作廢、不 rotate
# VISIONA_OIDC_TENANT_ID=...conversion 不再依賴;其他模組未發現使用)

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 — Server-to-server 認證從 MC OIDC 解耦

Phase 0.6-0.8 階段保留的「Service Client」概念在 Phase 0.8b 全面廢棄。詳見 ADR-015conversion.md §3。

摘要:

  • 不再透過 MCvisionA → converter / FAA 不再走 POST /oauth/token 換 service token + JWKS 驗 + scope 驗 的鏈路
  • 改用 pre-shared API key:每個下游各自獨立的 64-hex API keyVISIONA_CONVERTER_API_KEY / VISIONA_FAA_API_KEY
  • header 格式不變:仍是 Authorization: Bearer <key>,只是 token 來源從「MC 簽的 JWT」變成「visionA env 內的 pre-shared secret」
  • converter / FAA 端 middleware 同步改寫constant-time compare env 字串,不再驗 JWKS / scope / tenant

為什麼把這個段落放在 OIDC TDD:原本 ADR-014 §5 把「service client / client_credentials grant」與「user login OIDC」放同一條 OIDC 整合線Phase 0.8b 後這兩條線完全脫鉤:

  • user login仍是 OIDCPKCE / cookie session / JWKS — 本文件 §1-§12 全部適用
  • server-to-server不再是 OIDC、不再屬於本文件範圍 — 看 conversion.md §3 與 ADR-015

此小節保留作為「OIDC 路徑 → API key 路徑」的指引,避免讀者讀到本文件 §13.1 看到舊 service client env 還以為要啟用。

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 更新

// 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=falsehttp://localhost
staging / prod 強制 HTTPSSecure=true、HSTS

prod TLS 終止點ALB / Caddy / nginx由 DevOps 決定(記在 infra.md TODO

14.3 Token 不暴露給 frontend

項目 雛形StaticAuth OIDC 後
Access token 在哪 localStorage安全債 §14.1 session storeserverfrontend 不接觸
Refresh token (無) 雛形不用Phase 1 做
ID token (無) session store驗完即可丟雛形保留以備未來用
WS 認證 querystring ?token= cookie瀏覽器自動帶

14.4 Secret 管理

Secret dev prod
VISIONA_OIDC_CLIENT_SECRET .env.devgitignore AWS Secrets Manager / Vault
VISIONA_SESSION_SECRET .env.dev 同上,啟動時注入,定期 rotation

.gitignore 必含 .env* !.env.example !.env.dev.example

14.5 Logging

  • 不能 logaccess_token、id_token、cookie 完整值、session_id 完整值
  • 可以 loguser_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-upstatic.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

// 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

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 字典補(中英) 新增 signInWithInnovedussignUpAtMemberCenter 等 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(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 部分全部不變。