新增 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>
74 KiB
OIDC 接入 TDD — visionA Cloud × Innovedus Member Center
Metadata
- 作者:Architect Agent
- 狀態:Phase 0.8b 修訂(service client / server-to-server 改 API key;user login 部分不變)
- 最後更新:2026-05-11
- 文件角色:Phase 0.6 把 visionA-backend 的
StaticAuthProvider替換為 OIDC 接 Innovedus Member Center - 上位文件:
TDD.md、security.md、adr/adr-005-no-db-auth-in-prototype.md、adr/adr-010-oidc-bff.md、adr/adr-013-public-client.md、adr/adr-015-server-to-server-api-key.md(Phase 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 login(browser → visionA backend):完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token,本文件 §1-§12、§14-§17 全部仍有效。
- server-to-server(visionA backend → converter / FAA):Phase 0.8b 改用 pre-shared API key 取代原本的 OAuth
client_credentialsgrant。詳見 ADR-015;本文件 §13.1 「Service Client 預留欄位」段落隨之更新(改為標示廢棄、不再啟用)。
索引
- 為什麼接 Member Center
- 整體架構圖
- BFF Flow 詳細時序圖
- Backend 模組設計
- Session 設計(cookie)
- PKCE 實作細節
- id_token 驗證
- UserContext 改造
- Pairing 流程確認 user binding 仍正確
- Frontend 改造
- Member Center 端設定
- docker-compose dev 環境
- 環境變數新增
- 安全考量
- 取代 StaticAuth 的影響範圍
- ADR-010 摘要 — OIDC 接入策略
- ADR-005 處理(更新還是新 ADR-011)
- 開發任務拆分
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_sessioncookie(HttpOnly) - backend 是 OIDC confidential client(持有 client_secret)
- 這就是為什麼必須是 BFF 而不是 SPA + PKCE:Member Center 不支援 public client
3. BFF Flow 詳細時序圖
3.1 首次登入流程
Browser visionA-backend Member Center
│ │ │
│ 1. GET /login │ │
├─────────────────────────►│ (frontend 路由,純 SSR) │
│ │ │
│ 2. 點「用 Innovedus 登入」│ │
│ window.location = │ │
│ '/api/auth/login' │ │
│ │ │
│ 3. GET /api/auth/login │ │
├─────────────────────────►│ │
│ │ - 產 PKCE (verifier+challenge)
│ │ - 產 state (CSRF) │
│ │ - 產 nonce (replay) │
│ │ - 產 pending_session_id │
│ │ - usersession.PutPending( │
│ │ pending_session_id, │
│ │ {verifier,state,nonce,│
│ │ return_to}) │
│ │ - Set-Cookie: │
│ │ visiona_pending_sid=… │
│ │ (HttpOnly, 10min TTL) │
│ │ │
│ 4. 302 to MC /oauth/authorize?
│ response_type=code
│ &client_id=visionA
│ &redirect_uri=http://localhost:3721/api/auth/callback
│ &scope=openid email profile
│ &state=<state>
│ &code_challenge=<challenge>
│ &code_challenge_method=S256
│ &nonce=<nonce>
│◄─────────────────────────┤ │
│ │
│ 5. 跟隨 302 │ │
├─────────────────────────────────────────────────────►│
│ │ │
│ 6. MC 顯示登入頁 │ │
│◄─────────────────────────────────────────────────────┤
│ │ │
│ 7. 輸入帳密、submit │ │
├─────────────────────────────────────────────────────►│
│ │ │
│ 8. 302 to │ │
│ redirect_uri?code=xxx&state=<state> │
│◄─────────────────────────────────────────────────────┤
│ │ │
│ 9. GET /api/auth/callback?code=xxx&state=<state> │
│ Cookie: visiona_pending_sid=… │
├─────────────────────────►│ │
│ │ - usersession.PopPending( │
│ │ pending_session_id) │
│ │ - 驗 state == saved.state │
│ │ - 取出 saved.verifier │
│ │ │
│ │ 10. POST /oauth/token │
│ │ grant_type=authorization_code
│ │ code=xxx │
│ │ redirect_uri=… │
│ │ code_verifier=<verifier>│
│ │ client_id=visionA │
│ │ client_secret=<secret> │
│ ├──────────────────────────►│
│ │ │
│ │ 11. 200 OK │
│ │ { access_token, id_token,│
│ │ token_type, expires_in}│
│ │◄──────────────────────────┤
│ │ │
│ │ - 取 JWKS (cached) │
│ │ - 驗 id_token (sig+iss+aud│
│ │ +exp+nonce) │
│ │ - 解 claims: │
│ │ sub, email, name │
│ │ - usersession.Create( │
│ │ {user_id=sub, │
│ │ email, name, │
│ │ access_token, │
│ │ id_token, │
│ │ expires_at}) │
│ │ → session_id │
│ │ - Set-Cookie: │
│ │ visiona_session=<sid> │
│ │ HttpOnly Secure │
│ │ SameSite=Lax │
│ │ Max-Age=86400 │
│ │ - Clear-Cookie: │
│ │ visiona_pending_sid │
│ │ │
│ 12. 302 to │ │
│ {VISIONA_FRONTEND_URL}{return_to} │
│◄─────────────────────────┤ │
│ │ │
│ 13. GET / │ │
│ Cookie: visiona_session=<sid> │
├─────────────────────────►│ │
│ │ - usersession.Get(sid) │
│ │ - inject UserContext │
│ │ - middleware 注入 c.MustGet│
│ 14. 200 OK + HTML │ │
│◄─────────────────────────┤ │
3.2 後續 API 呼叫
Browser visionA-backend
│ │
│ GET /api/devices │
│ Cookie: visiona_session │
├─────────────────────────►│
│ │ AuthMiddleware:
│ │ 1. cookie -> session_id
│ │ 2. usersession.Get(sid)
│ │ 3. 若 expired → 401
│ │ 4. UserContext 注入 gin.Context
│ │ Handler 用 auth.FromContext(c)
│ │
│ 200 OK │
│◄─────────────────────────┤
3.3 登出
Browser visionA-backend
│ │
│ POST /api/auth/logout │
│ Cookie: visiona_session │
├─────────────────────────►│
│ │ - usersession.Delete(sid)
│ │ - Clear-Cookie: visiona_session
│ │ (Phase 0.6 不做 RP-initiated
│ │ logout 到 Member Center)
│ 204 No Content │
│◄─────────────────────────┤
│ │
│ 前端 auth-store.clear() │
│ window.location='/login' │
4. Backend 模組設計
4.1 模組總覽
visionA-backend/
└── internal/
├── oidc/ ⬅ 新增
│ ├── client.go # OIDC client 封裝
│ ├── discovery.go # /.well-known/openid-configuration 抓取 + 快取
│ ├── pkce.go # code_verifier + code_challenge 產生
│ ├── jwks.go # JWKS 抓取 + 快取 + 驗簽
│ └── claims.go # id_token claims struct
│
├── usersession/ ⬅ 新增(避免與 internal/session 衝突)
│ ├── store.go # SessionStore interface
│ ├── inmemory.go # InMemoryStore 實作
│ ├── types.go # Session struct + PendingSession
│ └── cookie.go # cookie 編解碼 + 簽章
│
├── auth/ ⬅ 修改
│ ├── service.go # AuthService interface(既有)
│ ├── provider.go # AuthProvider interface(既有)
│ ├── static.go # StaticAuthService(既有,**移除**)
│ ├── static_provider.go # StaticAuthProvider(既有,**移除**)
│ ├── oidc_service.go ⬅ 新增 OIDCAuthService
│ ├── oidc_provider.go ⬅ 新增 OIDCAuthProvider
│ └── pairing.go # 不變(Pairing token 是另一條線)
│
├── api/
│ ├── middleware/
│ │ └── auth.go ⬅ 修改:從 cookie 取 session(不再從 Bearer header)
│ └── handlers/
│ └── auth.go ⬅ 大改造(見 §4.5)
│
├── session/ # 既有 tunnel session 管理(**不動**)
└── ...
命名衝突澄清:
internal/session/= tunnel session(remote-proxy 持有的 yamux session,與 local agent 一對一)— 已存在,不動internal/usersession/= HTTP user session(browser cookie 對應的使用者 session) — 新增- 兩者完全不同概念,分開放兩個 package 避免混淆
4.2 internal/oidc/
// 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 }
// 驗 expiration(jwt-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 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 分鐘
}
// 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 實作同 interface(Phase 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.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 處理: 既有
AuthProviderinterface 是「username + password」風格,跟 OIDC redirect flow 不完全契合。 Phase 0.6 的處理:
Login/Register回ErrUseOIDCRedirect/ErrUseMemberCenterSignup- 真正的 login/callback 流程在
handlers/auth.go直接呼叫oidc.Client,不走AuthProvider.LoginAuthProvider主要功能變成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
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
// 既有 middleware 從 Authorization: Bearer header 拿 token
// 新版:從 cookie 取 sid → usersession 取 → UserContext 注入
func RequireAuth(authSvc auth.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
uc, err := authSvc.Authenticate(c.Request.Context(), c.Request)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized", "detail": err.Error()})
return
}
c.Set("user", uc)
c.Next()
}
}
具體邏輯都在 OIDCAuthService.Authenticate(§4.4),middleware 不變。
handler 程式碼完全不用改 — 還是用 auth.FromContext(c) 拿 UserContext。
5. Session 設計(cookie)
5.1 Cookie 屬性
| 屬性 | 值 | 理由 |
|---|---|---|
| Name | visiona_session |
與 backend 其他 cookie 區分 |
| Value | <session_id>.<hmac> |
32 byte hex + HMAC-SHA256 簽章 |
| HttpOnly | true |
防 XSS 讀取 |
| Secure | true(prod)/ false(dev http) |
強制 HTTPS |
| SameSite | Lax |
防 CSRF;OIDC redirect 從 MC 回 callback 是 GET,Lax 允許 |
| Domain | .visiona.cloud(prod)/ 不設(dev) |
前端與 backend 同 domain 才共享 |
| Path | / |
全站 |
| Max-Age | 604800(7 天) |
與 session.ExpiresAt 一致 |
5.2 Session 生命週期
| 事件 | 行為 |
|---|---|
| Create | ExpiresAt = now + 7d、IdleExpireAt = now + 24h |
| Get(middleware 每次) | 檢查 now < ExpiresAt && now < IdleExpireAt,否則刪 + 401 |
| Touch | LastSeenAt = now、IdleExpireAt = now + 24h(absolute 不變) |
| Delete | 從 store 移除 |
| CleanupExpired | background goroutine 每 60s 跑,刪所有過期 session |
雛形 in-memory 重啟即消失:可接受。Phase 1 換 Redis / DB。
5.3 Pending session(登入中暫存)
獨立 cookie(visiona_pending_sid),10 分鐘 TTL,callback 完成或過期即刪。
這是為了:
- PKCE verifier 必須在 token exchange 前存在 server side(不能放 cookie 裡,會被 JS 讀走)
- state 防 CSRF
- nonce 防 replay
- return_to 記住使用者原本想去哪
5.4 Cookie 簽章
// 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 完整呼叫範例
// /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
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 已含
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 sub(Member Center 的 user UUID) |
固定 "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)
// 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):
// 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 會收到 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)
# 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_uriswebhook_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
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/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:
# 一鍵起 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 OIDCAuthService(OB5 起實際只支援 oidc) |
VISIONA_OIDC_ISSUER_URL |
— | ✅ | dev: http://localhost:5050;stage: https://stage-9527.innovedus.com:7850/(含結尾斜線);prod: 待 IT 決定 |
VISIONA_OIDC_CLIENT_ID |
— | ✅ | OAuth client ID(可 public 可 confidential,由 IdP 註冊決定) |
VISIONA_OIDC_CLIENT_SECRET |
— | 選填(A1 改造後) | 為空時走 public PKCE-only mode;非空時走 confidential mode(ADR-013) |
VISIONA_OIDC_REDIRECT_URL |
— | ✅ | dev: http://localhost:3721/api/auth/callback;stage: https://stage-9527.innovedus.com:9527/api/auth/callback;prod: https://api.visiona.cloud/api/auth/callback |
VISIONA_OIDC_SCOPES |
openid email profile |
— | 空格分隔 |
VISIONA_OIDC_SERVICE_CLIENT_ID |
— | Phase 0.8b 廢棄 | .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 key(64 字元 hex),詳見 ADR-015 與 conversion.md §3 |
VISIONA_FAA_API_KEY |
— | Phase 0.8b 新增 | visionA → FAA 服務間認證 pre-shared API key(64 字元 hex),詳見 ADR-015 與 conversion.md §3 |
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:
# .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 login(OIDC,未變)===
VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/
VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e
# VISIONA_OIDC_CLIENT_SECRET 不設 ← 空值,走 public PKCE-only mode(ADR-013)
VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callback
VISIONA_FRONTEND_URL=https://stage-9527.innovedus.com:9527
VISIONA_SESSION_SECRET=<openssl rand -hex 32 產的值,stage host 持有,不進 git>
VISIONA_SESSION_COOKIE_SECURE=true
# === Phase 0.8b 服務間認證(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-015 與
conversion.md§3。摘要:
- 不再透過 MC:visionA → converter / FAA 不再走
POST /oauth/token換 service token + JWKS 驗 + scope 驗 的鏈路- 改用 pre-shared API key:每個下游各自獨立的 64-hex API key(
VISIONA_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:仍是 OIDC(PKCE / 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 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:
// 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 + 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 註冊有
usagelimitation,暫用webhook_outbound並開 issue
17. ADR-005 處理
17.1 評估
ADR-005「雛形階段不接真實 DB 與 Auth」的 Auth 部分被推翻(DB 部分仍有效)。
17.2 決議:直接更新 ADR-005 + 新增 ADR-011
採雙管齊下方案:
- 更新 ADR-005:在「狀態」改為
Accepted (Auth 部分由 ADR-011 取代,DB 部分仍有效),並新增「Update 2026-04-26」段落說明 Auth 已升級到 OIDC,引用 ADR-010 + ADR-011。 - 新增 ADR-011:「雛形 Auth 升級接 Member Center」,明確記錄推翻 ADR-005 的決策時間點與動機。
不單獨改 ADR-005 的理由:
- ADR 是歷史紀錄,原始決策內容應保留
- 用 ADR-011 顯式標註「supersedes ADR-005 (Auth section)」更符合 ADR 慣例
- 未來新成員看到 ADR-005 還能理解「為什麼當初這樣決定」
不只新增 ADR-011 不改 ADR-005 的理由:
- ADR-005 中明確寫「雛形階段不接真實 Auth」,現在實際做了,不註記會讓讀者困惑
- 加一行 Update + 連結到 ADR-011 即可
17.3 ADR-011 大綱
# ADR-011: 雛形 Auth 升級接 Member Center(推翻 ADR-005 Auth 部分)
## 狀態:Accepted — 2026-04-26
## Supersedes: ADR-005(僅 Auth 部分;DB 部分維持)
## 背景
- ADR-005 當初決定雛形不接 Auth,理由是不阻擋 tunnel / pairing / forward 端對端驗證
- 雛形已交付(Phase 0 + Phase 0.5 全綠)
- 同期 Innovedus Member Center 已能用
- 進到 Phase 0.6 — 真實使用者是 Phase 1 的前置條件
## 決策
雛形 Phase 0.6 接 Member Center OIDC(取代 StaticAuthProvider / StaticAuthService)。
## 影響
- ADR-005「不接 Auth」部分作廢
- ADR-005「不接 DB」部分仍有效(user metadata 在 Member Center,雛形 visionA-backend 仍不需 DB)
- AuthProvider / AuthService interface 不變(OIDC 實作直接套用)
## 引用
- ADR-005(被推翻部分)
- ADR-010(OIDC BFF Pattern 詳細決策)
- oidc-tdd.md(實作細節)
實際 ADR-011 由 OB1 任務開頭時建立。
18. 開發任務拆分
依角色拆分為四組:OB(OIDC Backend)、OF(OIDC Frontend)、OD(OIDC DevOps)、OT(OIDC Testing)。
18.1 任務清單
| # | 任務 | 描述 | 大小 | 依賴 | 預估人日 |
|---|---|---|---|---|---|
| OB1 | internal/oidc/ package |
Discovery / PKCE / JWKS / id_token verify;含 unit test | M | — | 1.5 |
| OB2 | internal/usersession/ package |
Store interface + InMemoryStore + cookie sign/verify + 單元測試 | M | — | 1.0 |
| OB3 | OIDCAuthService + OIDCAuthProvider |
internal/auth/oidc_*.go;改 middleware;wire-up cmd/api-server/main.go |
M | OB1, OB2 | 1.0 |
| OB4 | /api/auth/login /callback /logout /me handlers |
完整 redirect flow handler | L | OB1, OB2, OB3 | 1.5 |
| OB5 | 移除 StaticAuthProvider + 補測 user 隔離 |
刪檔;確認 Pairing 與 Device repository 仍以 user 隔離;改 b5_integration_test.go |
M | OB4, OT1 | 1.0 |
| OB6 | ADR-011 + 更新 ADR-005 | 寫 ADR-011;ADR-005 加 Update 段 | S | OB4 完成驗證後 | 0.5 |
| Backend 小計 | 6.5 | ||||
| OF1 | /login 頁改造 |
移除 email/password 表單,改成「用 Innovedus 帳號登入」按鈕 | S | — | 0.5 |
| OF2 | API client 改造 + auth-store 重構 | 移除 localStorage token、移除 Bearer header、改 cookie;/api/auth/me 接到 Header 與 /account |
M | OB4 | 1.0 |
| OF3 | /register 移除 / 改說明頁;/account 改造 |
連結到 Member Center;/account 顯示 user info + 登出 |
S | OF2 | 0.5 |
| OF4 | i18n 字典補(中英) | 新增 signInWithInnovedus、signUpAtMemberCenter 等 key |
S | OF1, OF3 | 0.3 |
| Frontend 小計 | 2.3 | ||||
| OD1 | docker-compose.dev.yml + Member Center seed | 起 postgres + member-center + visionA-backend;seed tenant + oauth client + demo user;驗證 make dev-with-mc 一鍵起 |
L | OB4 | 1.5 |
| OD2 | .env.dev.example + Makefile target |
完整環境變數範本與 make dev-with-mc / make dev-with-mc-down |
S | OD1 | 0.3 |
| DevOps 小計 | 1.8 | ||||
| OT1 | Fake OIDC server + integration tests | internal/oidc/fake_test.go;端對端測試 login → callback → /me;含 PKCE / state / nonce 驗證 |
M | OB1 | 1.0 |
| OT2 | E2E:visionA + 真 Member Center 跑通 | make dev-with-mc + Playwright 跑「點登入 → 輸帳密 → 進 dashboard」 |
M | OD1, OF3 | 1.0 |
| Testing 小計 | 2.0 | ||||
| 總計 | 12.6 人日 |
18.2 依賴順序與平行化
OB1 ─┬─► OB3 ─► OB4 ─┬─► OB5 ─► OB6
│ │
└─► OT1 ────────┘
OB2 ─► OB3
├─► OD1 ──► OD2
│ │
└─► OF2 ──► OF3 ──► OF4
│
└─► OT2 (要等 OD1 + OF3)
OF1 (與 OF2 平行)
平行化建議:
- 第一波(並行):OB1 + OB2 + OF1
- 第二波:OB3 + OT1
- 第三波:OB4
- 第四波(並行):OD1 + OF2 + OB6
- 第五波:OF3 + OD2 + OB5
- 第六波:OF4 + OT2
18.3 上線順序
每個任務完成 → Reviewer 審查 → 通過進下一個(與 Phase 0 雛形流程一致)。
OB4 完成時做一次 demo:開發者 make dev-with-mc 跑通 login flow → 給使用者看 → 使用者確認後再進 OB5(移除 StaticAuth)。
版本記錄
| 日期 | 版本 | 變更 |
|---|---|---|
| 2026-04-26 | 0.1 | Architect Agent 初稿(Phase 0.6 OIDC 接入 TDD 增補) |
| 2026-05-11 | 0.2 | Phase 0.8b 對應 ADR-015:(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 部分全部不變。 |