package api import ( "context" "errors" "net/http" "time" "github.com/gin-gonic/gin" "visiona-backend/internal/auth" "visiona-backend/internal/session" ) // pairingTokenTTL 是新發 pairing token 的存活時間。 // // 對齊 security.md §1.3 的「短期一次性 token」設計:15 分鐘足夠完成 // 「使用者啟動 local-tool → 點配對按鈕 → token 進來」的流程。 const pairingTokenTTL = 15 * time.Minute // registerPairingRoutes 註冊 /api/pairing/* 的 routes。 // // MVP 全集合(B4 + B5): // - POST /api/pairing/token → 建立 + 回傳 pairing token // - GET /api/pairing/status → 回傳當前 user 的 tunnel 連線狀態 // - GET /api/pairing/tokens → 列當前 user 的所有 token(B5) // - DELETE /api/pairing/tokens/:token → 撤銷指定 token(B5) func registerPairingRoutes(g *gin.RouterGroup, deps Deps) { g.POST("/pairing/token", pairingCreateTokenHandler(deps)) g.GET("/pairing/status", pairingStatusHandler(deps)) g.GET("/pairing/tokens", pairingListTokensHandler(deps)) g.DELETE("/pairing/tokens/:token", pairingRevokeTokenHandler(deps)) } // PairingTokenResponse 是 POST /api/pairing/token 的 data payload。 // // 對齊 api-spec.md §2: // // { "token": "vAc_...", "expires_at": "..." } type PairingTokenResponse struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` } // pairingCreateTokenHandler 建立一個新的 pairing token。 // // 流程: // 1. 從 UserContext 取出 userID(OIDC sub,由 AuthMiddleware 注入) // 2. PairingStore.Create 產生一個合法格式的 vAc_ token // 3. 回傳 token plaintext 給前端「只此一次」顯示 // // 失敗回應: // - PairingStore 未注入 → 501 NOT_IMPLEMENTED // - Create 內部錯誤 → 500 INTERNAL_ERROR // // 安全提醒:plaintext 只回給合法登入的使用者,不寫進 server log。 func pairingCreateTokenHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.PairingStore == nil { WriteNotImplemented(c, "pairing store not configured") return } // Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) // 移除 inline demo-user fallback:強制要求 AuthMiddleware 已注入合法 UserContext。 uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() plaintext, info, err := deps.PairingStore.Create(ctx, userID, pairingTokenTTL) if err != nil { logOrDefault(deps.Logger).Error("pairing: create token failed", "error", err, "user_id", userID, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to create pairing token", nil) return } // 取 ExpiresAt — 雛形必定非 nil(pairingTokenTTL > 0),保險檢查 var expires time.Time if info != nil && info.ExpiresAt != nil { expires = *info.ExpiresAt } // 故意不 log plaintext(只 log token prefix 與 expires) logOrDefault(deps.Logger).Info("pairing: token created", "user_id", userID, "token_prefix", tokenPrefix(plaintext), "expires_at", expires, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, PairingTokenResponse{ Token: plaintext, ExpiresAt: expires, }) } } // PairingStatusResponse 是 GET /api/pairing/status 的 data payload。 // // 對齊 api-spec.md §2: // // { // "connected": true, // "connected_at": "...", // "last_seen_at": "...", // "device_id": "dev-xxx", // "agent_version": "..." // } type PairingStatusResponse struct { Connected bool `json:"connected"` ConnectedAt *time.Time `json:"connected_at,omitempty"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` DeviceID string `json:"device_id,omitempty"` AgentVersion string `json:"agent_version,omitempty"` } // pairingStatusHandler 回報當前 user 的 tunnel 連線狀態。 // // 雛形實作:直接 List 所有 sessions(單 user 場景),找第一個。 // 多 user 階段(B5)會改成「按 user_id 過濾」並考慮多 device 場景。 func pairingStatusHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { resp := PairingStatusResponse{Connected: false} if deps.SessionStore == nil { WriteSuccess(c, http.StatusOK, resp) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() summaries, err := deps.SessionStore.List(ctx) if err != nil { // 跟 system/health 一樣,list 失敗不致命,回 connected=false logOrDefault(deps.Logger).Warn("pairing/status: list sessions failed", "error", err, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, resp) return } // Phase 0.7 security audit M2:寬鬆比對暫保留待人工介入修復。 // 詳細理由見 proxy.go pickActiveSessionToken 註解:relay 端 LocalHandle.Summary // 不帶 UserID,strict equality 會讓所有 e2e proxy 鏈路全斷。 // 此處仍要求 UserContext 非空(C1 加固),但 s.UserID 暫接受空字串。 uc, ok := UserContextFrom(c) var picked *session.Summary if ok && uc != nil && uc.UserID != "" { for _, s := range summaries { // 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserID。 if s.UserID == "" || s.UserID == uc.UserID { picked = s break } } } if picked == nil { WriteSuccess(c, http.StatusOK, resp) return } resp.Connected = true ca := picked.ConnectedAt ls := picked.LastHeartbeat resp.ConnectedAt = &ca resp.LastSeenAt = &ls resp.DeviceID = picked.DeviceID // AgentVersion 雛形未從 tunnel 讀回;B5 會在 tunnel handshake 時收集 WriteSuccess(c, http.StatusOK, resp) } } // tokenPrefix 截前 8 字元用於 log,避免完整 token 進日誌。 // // 在 pairing.go 中重複實作(不複用 relay package 的同名函式)以避免跨層循環依賴; // 行為與 relay.tokenPrefix 一致。 func tokenPrefix(t string) string { if len(t) <= 8 { return t } return t[:8] } // ========================================================================== // B5 新增:List / Revoke tokens // ========================================================================== // PairingTokenListItem 是 GET /api/pairing/tokens 回應中的單筆 token。 // // **注意**:不回 Plaintext — 那只能在建立時給一次。 type PairingTokenListItem struct { TokenPrefix string `json:"token_prefix"` // 前 12 字元,例:`vAc_7f3c8e2a` Kind string `json:"kind"` DeviceID string `json:"device_id,omitempty"` CreatedAt time.Time `json:"created_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` UsedAt *time.Time `json:"used_at,omitempty"` RevokedAt *time.Time `json:"revoked_at,omitempty"` } // pairingListTokensHandler 實作 GET /api/pairing/tokens。 // // 回當前 user 的所有 token(含已使用 / 撤銷 / 過期),供 UI 顯示。 // **絕對不回 Plaintext** — 前端已在建立時保留那份 plaintext(只此一次)。 func pairingListTokensHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.PairingStore == nil { WriteNotImplemented(c, "pairing store not configured") return } // Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } userID := uc.UserID ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() tokens, err := deps.PairingStore.List(ctx, userID) if err != nil { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "list pairing tokens failed: "+err.Error(), nil) return } out := make([]PairingTokenListItem, 0, len(tokens)) for _, t := range tokens { item := PairingTokenListItem{ TokenPrefix: tokenPrefix12(t.Plaintext), Kind: string(t.Kind), DeviceID: t.DeviceID, CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt, UsedAt: t.UsedAt, RevokedAt: t.RevokedAt, } out = append(out, item) } WriteSuccess(c, http.StatusOK, out) } } // pairingRevokeTokenHandler 實作 DELETE /api/pairing/tokens/:token。 // // 雛形:直接收 plaintext token 作為 path param(path 可被日誌記錄 — 雛形容忍; // Phase 1 會改為回「token id」而非 plaintext,路徑不洩漏原文)。 // // Revoke 成功回 204 No Content;不存在回 404 + NOT_FOUND。 func pairingRevokeTokenHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.PairingStore == nil { WriteNotImplemented(c, "pairing store not configured") return } token := c.Param("token") if token == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "token param required", nil) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() if err := deps.PairingStore.Revoke(ctx, token); err != nil { if errors.Is(err, auth.ErrInvalidToken) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "token not found", nil) return } WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "revoke token failed: "+err.Error(), nil) return } logOrDefault(deps.Logger).Info("pairing: token revoked", "token_prefix", tokenPrefix(token), "request_id", RequestIDFrom(c)) c.Status(http.StatusNoContent) } } // tokenPrefix12 截前 12 字元(`vAc_` + 8 hex),用於 list 顯示。 func tokenPrefix12(t string) string { if len(t) <= 12 { return t } return t[:12] } // ========================================================================== // AB11 新增:POST /api/pairing/exchange(public — 不走 AuthMiddleware) // ========================================================================== // // 行為對齊 visiona-agent-tdd.md §4.3 + security.md §1.2: // 1. agent 送 Pairing Token 過來 // 2. 雲端驗證(存在 / 未過期 / 未使用 / 未撤銷) // 3. 產生 Session Token(vAs_ + 64 hex,90 天 TTL) // 4. 把 Pairing Token 標為 used(一次性,無法再交換) // 5. 回 { session_token, account, relay_url, expires_at } // // 雛形取捨: // - remote-proxy 端目前只做 token 格式驗證(relay/server.go isAcceptableToken), // **不會**實際查 SessionTokenStore。這是對齊 TDD「選項 A」的雛形設計; // Phase 1 要新增 remote-proxy → api-server 的 `/internal/session-token/:token` 驗證。 // - Rate limit / token rotation / 真實 DB 都留給 Phase 1。 // defaultRelayPublicURL 是 relay_url 的雛形 placeholder,當 Deps.RelayPublicURL // 未設定時用此值(讓 agent 至少能收到一個格式正確的 URL,實機請透過 // VISIONA_RELAY_PUBLIC_URL 覆寫)。 const defaultRelayPublicURL = "wss://relay.visionA.cloud" // registerPairingPublicRoutes 註冊**不需要 auth**的 pairing endpoints。 // // 目前只有 /api/pairing/exchange — agent 拿 Pairing Token 換 Session Token 時 // 本身還沒有登入身份,故不能套 AuthMiddleware。 // 呼叫方:NewRouter() 在 engine 層級直接註冊(不加 apiGroup)。 func registerPairingPublicRoutes(r gin.IRouter, deps Deps) { r.POST("/api/pairing/exchange", pairingExchangeHandler(deps)) } // PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。 type PairingExchangeRequest struct { PairingToken string `json:"pairing_token" binding:"required"` } // PairingExchangeResponse 是 POST /api/pairing/exchange 成功時的 data payload。 // // 欄位對齊 visiona-agent-tdd.md §7.1: // // { // "session_token": "vAs_...", // "account": "demo@visionA.local", // "relay_url": "wss://relay.visionA.cloud", // "expires_at": "2026-07-21T00:00:00Z" // } type PairingExchangeResponse struct { SessionToken string `json:"session_token"` Account string `json:"account"` RelayURL string `json:"relay_url"` ExpiresAt time.Time `json:"expires_at"` } // Pairing exchange 專用錯誤碼(對齊 TDD §7.1 四種 case)。 // // 注意:這些 code 走 ErrorBody.Error.Code 欄位,不是 envelope top-level code。 // 刻意與 security.md §1.2 的語意一致;前端 agent 可以 switch 對應 UI 文案。 const ( ErrCodeInvalidPairingToken = "INVALID_PAIRING_TOKEN" ErrCodePairingTokenExpired = "PAIRING_TOKEN_EXPIRED" ErrCodePairingTokenUsed = "PAIRING_TOKEN_USED" ErrCodePairingTokenRevoked = "PAIRING_TOKEN_REVOKED" ) // pairingExchangeHandler 實作 Pairing → Session Token 交換。 // // 失敗回應: // - 400 VALIDATION_FAILED — body 缺 pairing_token // - 401 INVALID_PAIRING_TOKEN — 格式錯 / 不存在 // - 401 PAIRING_TOKEN_EXPIRED — 過期 // - 401 PAIRING_TOKEN_USED — 已交換過 // - 401 PAIRING_TOKEN_REVOKED — 已撤銷 // - 500 INTERNAL_ERROR — Session Token 產生失敗 // - 501 NOT_IMPLEMENTED — store 未注入 // // 安全提醒:回應內**絕對不包含** Pairing Token 原文;log 只印 prefix。 func pairingExchangeHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { // 雛形:store 任一缺失 → 501(避免 nil pointer) if deps.PairingStore == nil || deps.SessionTokenStore == nil { WriteNotImplemented(c, "pairing exchange store not configured") return } // Parse body var req PairingExchangeRequest if err := c.ShouldBindJSON(&req); err != nil { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "pairing_token is required", nil) return } // 格式驗證 — 不合格直接 401 INVALID_PAIRING_TOKEN(避免把格式錯誤漏進 store) if !auth.IsValidPairingToken(req.PairingToken) { logOrDefault(deps.Logger).Warn("pairing exchange: invalid token format", "token_prefix", tokenPrefix(req.PairingToken), "request_id", RequestIDFrom(c)) WriteError(c, http.StatusUnauthorized, ErrCodeInvalidPairingToken, "pairing token format invalid", nil) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() // Validate — store 會判斷存在 / 未過期 / 未使用 / 未撤銷 info, err := deps.PairingStore.Validate(ctx, req.PairingToken) if err != nil { code, msg := mapPairingExchangeError(err) logOrDefault(deps.Logger).Warn("pairing exchange: validate failed", "code", code, "token_prefix", tokenPrefix(req.PairingToken), "request_id", RequestIDFrom(c)) WriteError(c, http.StatusUnauthorized, code, msg, nil) return } // Generate Session Token plaintext, sessionInfo, err := deps.SessionTokenStore.Create( ctx, info.UserID, info.DeviceID, // Pairing Token 雛形還沒綁 device_id,為空沒關係 info.TokenHash, auth.SessionTokenTTL, ) if err != nil { logOrDefault(deps.Logger).Error("pairing exchange: create session token failed", "error", err, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to create session token", nil) return } // Mark pairing token as used。 // // Phase 0.7 security fix M3 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) // MarkUsed 失敗代表「pairing token 一次性」保證可能被破壞 — 同一個 token // 可能再被 exchange 一次。改為 abort:撤銷剛產生的 session token、回 500, // 而不是 silent log warn 繼續往前。 // // 注意:deviceID 沿用 info.DeviceID(可能為空)。雛形 MarkUsed 對空字串 // 是安全的(它只是覆寫欄位)。 if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, info.DeviceID); err != nil { // 嘗試 revoke 剛產生的 session token;revoke 自身失敗不再 retry,只 log。 revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext) logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted", "error", err, "revoke_err", revokeErr, "token_prefix", tokenPrefix(req.PairingToken), "session_token_prefix", tokenPrefix(plaintext), "device_id", info.DeviceID, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "pairing token mark-used failed; aborted", nil) return } // 組 response relayURL := deps.RelayPublicURL if relayURL == "" { relayURL = defaultRelayPublicURL } var expires time.Time if sessionInfo.ExpiresAt != nil { expires = *sessionInfo.ExpiresAt } else { expires = time.Now().UTC().Add(auth.SessionTokenTTL) } // 雛形 account — OIDC sub 通常是 UUID 不適合給人看,這裡用 userID + 固定 suffix // 當 placeholder。Phase 1 接 DB 後可改回 session.Email(OIDC 已帶)。 account := info.UserID + "@visionA.local" // 故意只 log token prefix,避免完整 session token 進日誌 logOrDefault(deps.Logger).Info("pairing exchange: success", "user_id", info.UserID, "device_id", info.DeviceID, "session_token_prefix", tokenPrefix(plaintext), "pairing_token_prefix", tokenPrefix(req.PairingToken), "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, PairingExchangeResponse{ SessionToken: plaintext, Account: account, RelayURL: relayURL, ExpiresAt: expires, }) } } // mapPairingExchangeError 把 PairingStore.Validate 回傳的 sentinel error 轉成 // 對應的 pairing exchange error code + 對使用者可見的訊息。 // // 未匹配時 fallback 到 INVALID_PAIRING_TOKEN(避免洩漏內部錯誤細節)。 func mapPairingExchangeError(err error) (code, message string) { switch { case errors.Is(err, auth.ErrTokenExpired): return ErrCodePairingTokenExpired, "pairing token expired" case errors.Is(err, auth.ErrTokenUsed): return ErrCodePairingTokenUsed, "pairing token already used" case errors.Is(err, auth.ErrTokenRevoked): return ErrCodePairingTokenRevoked, "pairing token revoked" case errors.Is(err, auth.ErrInvalidToken): return ErrCodeInvalidPairingToken, "pairing token invalid" default: return ErrCodeInvalidPairingToken, "pairing token invalid" } }