Add bridge bootstrap ticket flow
This commit is contained in:
parent
f6cae1d8e7
commit
fdbef2ab29
13
api/api.go
13
api/api.go
@ -39,6 +39,8 @@ const defaultPasswordResetTTL = 30 * time.Minute
|
||||
const maxMFAVerificationAttempts = 5
|
||||
const defaultMFALockoutDuration = 5 * time.Minute
|
||||
const defaultOAuthExchangeCodeTTL = 5 * time.Minute
|
||||
const defaultBridgeBootstrapTTL = 5 * time.Minute
|
||||
const defaultBridgeBootstrapTarget = "https://xworkmate-bridge.svc.plus"
|
||||
|
||||
const sessionCookieName = "xc_session"
|
||||
|
||||
@ -74,6 +76,10 @@ type handler struct {
|
||||
oauthExchangeCodes map[string]oauthExchangeCode
|
||||
oauthExchangeMu sync.RWMutex
|
||||
oauthExchangeTTL time.Duration
|
||||
bridgeBootstrapTickets map[string]bridgeBootstrapTicket
|
||||
bridgeBootstrapByCode map[string]string
|
||||
bridgeBootstrapMu sync.RWMutex
|
||||
bridgeBootstrapTTL time.Duration
|
||||
metricsProvider service.UserMetricsProvider
|
||||
agentStatusReader agentStatusReader
|
||||
tokenService *auth.TokenService
|
||||
@ -303,6 +309,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
|
||||
passwordResets: make(map[string]passwordReset),
|
||||
oauthExchangeCodes: make(map[string]oauthExchangeCode),
|
||||
oauthExchangeTTL: defaultOAuthExchangeCodeTTL,
|
||||
bridgeBootstrapTickets: make(map[string]bridgeBootstrapTicket),
|
||||
bridgeBootstrapByCode: make(map[string]string),
|
||||
bridgeBootstrapTTL: defaultBridgeBootstrapTTL,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
@ -361,6 +370,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
|
||||
authProtected.GET("/xworkmate/secrets", h.getXWorkmateSecrets)
|
||||
authProtected.PUT("/xworkmate/secrets/:target", h.putXWorkmateSecret)
|
||||
authProtected.DELETE("/xworkmate/secrets/:target", h.deleteXWorkmateSecret)
|
||||
authProtected.POST("/xworkmate/bridge/bootstrap", h.createXWorkmateBridgeBootstrapTicket)
|
||||
authProtected.GET("/xworkmate/bridge/bootstrap/:shortCode", h.lookupXWorkmateBridgeBootstrapTicket)
|
||||
authProtected.POST("/xworkmate/bridge/bootstrap/:ticketId/revoke", h.revokeXWorkmateBridgeBootstrapTicket)
|
||||
|
||||
authProtected.POST("/mfa/totp/provision", h.provisionTOTP)
|
||||
authProtected.POST("/mfa/totp/verify", h.verifyTOTP)
|
||||
@ -417,6 +429,7 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
|
||||
internalGroup.GET("/network/identities", h.internalNetworkIdentities)
|
||||
internalGroup.GET("/policy/:accountUUID", h.internalAccountPolicy)
|
||||
internalGroup.POST("/nodes/heartbeat", h.internalNodeHeartbeat)
|
||||
internalGroup.POST("/xworkmate/bridge/bootstrap/consume", h.internalConsumeXWorkmateBridgeBootstrapTicket)
|
||||
|
||||
// Public /api routes for admin/management (expected by frontend at /api/admin/...)
|
||||
apiGroup := r.Group("/api")
|
||||
|
||||
319
api/xworkmate_bridge_bootstrap.go
Normal file
319
api/xworkmate_bridge_bootstrap.go
Normal file
@ -0,0 +1,319 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"account/internal/auth"
|
||||
"account/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
bridgeBootstrapScheme = "xworkmate-bridge-bootstrap"
|
||||
bridgeBootstrapScopeA = "connect"
|
||||
bridgeBootstrapScopeB = "pairing.bootstrap"
|
||||
bridgeBootstrapShortLen = 8
|
||||
)
|
||||
|
||||
type bridgeBootstrapTicket struct {
|
||||
TicketID string
|
||||
ShortCode string
|
||||
TenantID string
|
||||
UserID string
|
||||
ProfileScope string
|
||||
TargetBridge string
|
||||
Scopes []string
|
||||
ExpiresAt time.Time
|
||||
OneTime bool
|
||||
IssuedAt time.Time
|
||||
ConsumedAt time.Time
|
||||
RevokedAt time.Time
|
||||
}
|
||||
|
||||
type bridgeBootstrapIssueResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ShortCode string `json:"shortCode"`
|
||||
Bridge string `json:"bridge"`
|
||||
Scheme string `json:"scheme"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Scopes []string `json:"scopes"`
|
||||
OneTime bool `json:"oneTime"`
|
||||
QRPayload string `json:"qrPayload"`
|
||||
}
|
||||
|
||||
type bridgeBootstrapConsumeResponse struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
TargetBridge string `json:"targetBridge"`
|
||||
OpenclawURL string `json:"openclawUrl"`
|
||||
AuthMode string `json:"authMode"`
|
||||
ExchangeToken string `json:"exchangeToken"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func sanitizeBridgeTarget(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return defaultBridgeBootstrapTarget
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return defaultBridgeBootstrapTarget
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
|
||||
func newBridgeBootstrapQRCode(ticketID, bridge string) string {
|
||||
payload, _ := json.Marshal(gin.H{
|
||||
"scheme": bridgeBootstrapScheme,
|
||||
"ticket": ticketID,
|
||||
"bridge": bridge,
|
||||
})
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func newBridgeBootstrapShortCode() string {
|
||||
for {
|
||||
raw := make([]byte, 6)
|
||||
_, _ = rand.Read(raw)
|
||||
code := strings.ToUpper(
|
||||
strings.TrimRight(base64.RawURLEncoding.EncodeToString(raw), "="),
|
||||
)
|
||||
code = strings.NewReplacer("-", "A", "_", "B").Replace(code)
|
||||
if len(code) >= bridgeBootstrapShortLen {
|
||||
return code[:bridgeBootstrapShortLen]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) storeBridgeBootstrapTicket(ticket bridgeBootstrapTicket) {
|
||||
h.bridgeBootstrapMu.Lock()
|
||||
defer h.bridgeBootstrapMu.Unlock()
|
||||
h.bridgeBootstrapTickets[ticket.TicketID] = ticket
|
||||
h.bridgeBootstrapByCode[strings.ToUpper(ticket.ShortCode)] = ticket.TicketID
|
||||
}
|
||||
|
||||
func (h *handler) loadBridgeBootstrapTicketByID(ticketID string) (bridgeBootstrapTicket, bool) {
|
||||
h.bridgeBootstrapMu.RLock()
|
||||
defer h.bridgeBootstrapMu.RUnlock()
|
||||
ticket, ok := h.bridgeBootstrapTickets[strings.TrimSpace(ticketID)]
|
||||
return ticket, ok
|
||||
}
|
||||
|
||||
func (h *handler) loadBridgeBootstrapTicketByCode(shortCode string) (bridgeBootstrapTicket, bool) {
|
||||
h.bridgeBootstrapMu.RLock()
|
||||
defer h.bridgeBootstrapMu.RUnlock()
|
||||
ticketID, ok := h.bridgeBootstrapByCode[strings.ToUpper(strings.TrimSpace(shortCode))]
|
||||
if !ok {
|
||||
return bridgeBootstrapTicket{}, false
|
||||
}
|
||||
ticket, ok := h.bridgeBootstrapTickets[ticketID]
|
||||
return ticket, ok
|
||||
}
|
||||
|
||||
func (h *handler) updateBridgeBootstrapTicket(ticket bridgeBootstrapTicket) {
|
||||
h.bridgeBootstrapMu.Lock()
|
||||
defer h.bridgeBootstrapMu.Unlock()
|
||||
h.bridgeBootstrapTickets[ticket.TicketID] = ticket
|
||||
if ticket.ShortCode != "" {
|
||||
h.bridgeBootstrapByCode[strings.ToUpper(ticket.ShortCode)] = ticket.TicketID
|
||||
}
|
||||
}
|
||||
|
||||
func bridgeBootstrapExpired(ticket bridgeBootstrapTicket, now time.Time) bool {
|
||||
return ticket.ExpiresAt.IsZero() || now.After(ticket.ExpiresAt)
|
||||
}
|
||||
|
||||
func requireBridgeBootstrapAccess(c *gin.Context, h *handler) (*store.User, *xworkmateAccessContext, bool) {
|
||||
user, ok := h.currentAuthenticatedUser(c)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusForbidden, "tenant_access_denied", "failed to resolve tenant access")
|
||||
return nil, nil, false
|
||||
}
|
||||
return user, access, true
|
||||
}
|
||||
|
||||
func (h *handler) createXWorkmateBridgeBootstrapTicket(c *gin.Context) {
|
||||
user, access, ok := requireBridgeBootstrapAccess(c, h)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !auth.IsMFAVerified(c) {
|
||||
respondError(c, http.StatusForbidden, "mfa_required", "mfa verification required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TargetBridge string `json:"targetBridge"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
now := time.Now().UTC()
|
||||
ticket := bridgeBootstrapTicket{
|
||||
TicketID: generateRandomState(),
|
||||
ShortCode: newBridgeBootstrapShortCode(),
|
||||
TenantID: strings.TrimSpace(access.Tenant.ID),
|
||||
UserID: strings.TrimSpace(user.ID),
|
||||
ProfileScope: access.ProfileScope,
|
||||
TargetBridge: sanitizeBridgeTarget(req.TargetBridge),
|
||||
Scopes: []string{bridgeBootstrapScopeA, bridgeBootstrapScopeB},
|
||||
ExpiresAt: now.Add(h.bridgeBootstrapTTL),
|
||||
OneTime: true,
|
||||
IssuedAt: now,
|
||||
}
|
||||
h.storeBridgeBootstrapTicket(ticket)
|
||||
|
||||
c.JSON(http.StatusOK, bridgeBootstrapIssueResponse{
|
||||
Ticket: ticket.TicketID,
|
||||
ShortCode: ticket.ShortCode,
|
||||
Bridge: ticket.TargetBridge,
|
||||
Scheme: bridgeBootstrapScheme,
|
||||
ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339),
|
||||
Scopes: append([]string(nil), ticket.Scopes...),
|
||||
OneTime: ticket.OneTime,
|
||||
QRPayload: newBridgeBootstrapQRCode(ticket.TicketID, ticket.TargetBridge),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) lookupXWorkmateBridgeBootstrapTicket(c *gin.Context) {
|
||||
user, access, ok := requireBridgeBootstrapAccess(c, h)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ticket, found := h.loadBridgeBootstrapTicketByCode(c.Param("shortCode"))
|
||||
if !found || ticket.UserID != strings.TrimSpace(user.ID) || ticket.TenantID != strings.TrimSpace(access.Tenant.ID) {
|
||||
respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found")
|
||||
return
|
||||
}
|
||||
if bridgeBootstrapExpired(ticket, time.Now().UTC()) {
|
||||
respondError(c, http.StatusGone, "bridge_bootstrap_expired", "bridge bootstrap ticket expired")
|
||||
return
|
||||
}
|
||||
if !ticket.RevokedAt.IsZero() {
|
||||
respondError(c, http.StatusGone, "bridge_bootstrap_revoked", "bridge bootstrap ticket revoked")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, bridgeBootstrapIssueResponse{
|
||||
Ticket: ticket.TicketID,
|
||||
ShortCode: ticket.ShortCode,
|
||||
Bridge: ticket.TargetBridge,
|
||||
Scheme: bridgeBootstrapScheme,
|
||||
ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339),
|
||||
Scopes: append([]string(nil), ticket.Scopes...),
|
||||
OneTime: ticket.OneTime,
|
||||
QRPayload: newBridgeBootstrapQRCode(ticket.TicketID, ticket.TargetBridge),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) revokeXWorkmateBridgeBootstrapTicket(c *gin.Context) {
|
||||
user, access, ok := requireBridgeBootstrapAccess(c, h)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ticket, found := h.loadBridgeBootstrapTicketByID(c.Param("ticketId"))
|
||||
if !found || ticket.UserID != strings.TrimSpace(user.ID) || ticket.TenantID != strings.TrimSpace(access.Tenant.ID) {
|
||||
respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found")
|
||||
return
|
||||
}
|
||||
ticket.RevokedAt = time.Now().UTC()
|
||||
h.updateBridgeBootstrapTicket(ticket)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "ticketId": ticket.TicketID, "revokedAt": ticket.RevokedAt.Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
func (h *handler) internalConsumeXWorkmateBridgeBootstrapTicket(c *gin.Context) {
|
||||
if !h.ensureXWorkmateVaultService(c) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Ticket string `json:"ticket"`
|
||||
Bridge string `json:"bridge"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "invalid_request", "invalid bridge bootstrap consume request")
|
||||
return
|
||||
}
|
||||
ticket, found := h.loadBridgeBootstrapTicketByID(req.Ticket)
|
||||
if !found {
|
||||
respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if bridgeBootstrapExpired(ticket, now) {
|
||||
respondError(c, http.StatusGone, "bridge_bootstrap_expired", "bridge bootstrap ticket expired")
|
||||
return
|
||||
}
|
||||
if !ticket.RevokedAt.IsZero() {
|
||||
respondError(c, http.StatusGone, "bridge_bootstrap_revoked", "bridge bootstrap ticket revoked")
|
||||
return
|
||||
}
|
||||
if ticket.OneTime && !ticket.ConsumedAt.IsZero() {
|
||||
respondError(c, http.StatusConflict, "bridge_bootstrap_consumed", "bridge bootstrap ticket already consumed")
|
||||
return
|
||||
}
|
||||
if sanitizeBridgeTarget(req.Bridge) != ticket.TargetBridge {
|
||||
respondError(c, http.StatusForbidden, "bridge_bootstrap_target_mismatch", "bridge bootstrap target mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
profile, err := h.loadXWorkmateProfile(
|
||||
ctx,
|
||||
&xworkmateAccessContext{
|
||||
ProfileScope: ticket.ProfileScope,
|
||||
Tenant: &store.Tenant{
|
||||
ID: ticket.TenantID,
|
||||
},
|
||||
},
|
||||
&store.User{
|
||||
ID: ticket.UserID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "xworkmate_profile_unavailable", "failed to load xworkmate profile")
|
||||
return
|
||||
}
|
||||
if profile == nil {
|
||||
respondError(c, http.StatusNotFound, "xworkmate_profile_not_found", "xworkmate profile not found")
|
||||
return
|
||||
}
|
||||
locator, ok := findStoredXWorkmateSecretLocator(profile, store.XWorkmateSecretLocatorTargetOpenclawGatewayToken)
|
||||
if !ok {
|
||||
respondError(c, http.StatusConflict, "gateway_token_not_configured", "gateway token is not configured")
|
||||
return
|
||||
}
|
||||
gatewayToken, err := h.xworkmateVaultService.ReadSecret(ctx, locator)
|
||||
if err != nil || strings.TrimSpace(gatewayToken) == "" {
|
||||
respondError(c, http.StatusConflict, "gateway_token_unavailable", "gateway token is unavailable")
|
||||
return
|
||||
}
|
||||
openclawURL := strings.TrimSpace(profile.OpenclawURL)
|
||||
if openclawURL == "" {
|
||||
respondError(c, http.StatusConflict, "gateway_endpoint_not_configured", "gateway endpoint is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
ticket.ConsumedAt = now
|
||||
h.updateBridgeBootstrapTicket(ticket)
|
||||
|
||||
c.JSON(http.StatusOK, bridgeBootstrapConsumeResponse{
|
||||
TicketID: ticket.TicketID,
|
||||
TargetBridge: ticket.TargetBridge,
|
||||
OpenclawURL: openclawURL,
|
||||
AuthMode: "shared-token",
|
||||
ExchangeToken: gatewayToken,
|
||||
ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339),
|
||||
Scopes: append([]string(nil), ticket.Scopes...),
|
||||
})
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@ -154,6 +155,141 @@ func TestBuildXWorkmateTokenConfiguredUsesSecretLocators(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXWorkmateBridgeBootstrapTicketLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
vaultService := newMemoryXWorkmateVaultService()
|
||||
router, user, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService)
|
||||
|
||||
profileBody, err := json.Marshal(map[string]any{
|
||||
"profile": map[string]any{
|
||||
"openclawUrl": "wss://openclaw.example.com",
|
||||
"secretLocators": []map[string]any{
|
||||
{
|
||||
"id": "locator-openclaw",
|
||||
"provider": "vault",
|
||||
"secretPath": "kv/openclaw",
|
||||
"secretKey": "token",
|
||||
"target": store.XWorkmateSecretLocatorTargetOpenclawGatewayToken,
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal profile: %v", err)
|
||||
}
|
||||
putProfileReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody))
|
||||
putProfileReq.Header.Set("Content-Type", "application/json")
|
||||
putProfileReq.Header.Set("Authorization", "Bearer "+token)
|
||||
putProfileReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
|
||||
putProfileRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(putProfileRec, putProfileReq)
|
||||
if putProfileRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected profile update success, got %d: %s", putProfileRec.Code, putProfileRec.Body.String())
|
||||
}
|
||||
|
||||
if err := vaultService.WriteSecret(context.Background(), store.XWorkmateSecretLocator{
|
||||
Provider: "vault",
|
||||
SecretPath: "kv/openclaw",
|
||||
SecretKey: "token",
|
||||
Target: store.XWorkmateSecretLocatorTargetOpenclawGatewayToken,
|
||||
}, "shared-token-value"); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`)))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+token)
|
||||
createReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected bootstrap create success, got %d: %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created bridgeBootstrapIssueResponse
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode bootstrap create response: %v", err)
|
||||
}
|
||||
if created.Ticket == "" || created.ShortCode == "" {
|
||||
t.Fatalf("expected bootstrap ticket and short code, got %#v", created)
|
||||
}
|
||||
|
||||
lookupReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/bridge/bootstrap/"+created.ShortCode, nil)
|
||||
lookupReq.Header.Set("Authorization", "Bearer "+token)
|
||||
lookupReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
|
||||
lookupRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(lookupRec, lookupReq)
|
||||
if lookupRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected bootstrap lookup success, got %d: %s", lookupRec.Code, lookupRec.Body.String())
|
||||
}
|
||||
|
||||
consumeReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, created.Ticket, created.Bridge))))
|
||||
consumeReq.Header.Set("Content-Type", "application/json")
|
||||
consumeReq.Header.Set("X-Service-Token", "internal-test-token")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "internal-test-token")
|
||||
consumeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(consumeRec, consumeReq)
|
||||
if consumeRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected bootstrap consume success, got %d: %s", consumeRec.Code, consumeRec.Body.String())
|
||||
}
|
||||
var consumed bridgeBootstrapConsumeResponse
|
||||
if err := json.Unmarshal(consumeRec.Body.Bytes(), &consumed); err != nil {
|
||||
t.Fatalf("decode bootstrap consume response: %v", err)
|
||||
}
|
||||
if consumed.ExchangeToken != "shared-token-value" {
|
||||
t.Fatalf("expected returned exchange token, got %#v", consumed)
|
||||
}
|
||||
if consumed.OpenclawURL != "wss://openclaw.example.com" {
|
||||
t.Fatalf("expected returned openclaw url, got %#v", consumed)
|
||||
}
|
||||
|
||||
replayReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, created.Ticket, created.Bridge))))
|
||||
replayReq.Header.Set("Content-Type", "application/json")
|
||||
replayReq.Header.Set("X-Service-Token", "internal-test-token")
|
||||
replayRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(replayRec, replayReq)
|
||||
if replayRec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected bootstrap replay conflict, got %d: %s", replayRec.Code, replayRec.Body.String())
|
||||
}
|
||||
|
||||
revokeCreateReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`)))
|
||||
revokeCreateReq.Header.Set("Content-Type", "application/json")
|
||||
revokeCreateReq.Header.Set("Authorization", "Bearer "+token)
|
||||
revokeCreateReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
|
||||
revokeCreateRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(revokeCreateRec, revokeCreateReq)
|
||||
if revokeCreateRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected second bootstrap create success, got %d: %s", revokeCreateRec.Code, revokeCreateRec.Body.String())
|
||||
}
|
||||
var revokeCreated bridgeBootstrapIssueResponse
|
||||
if err := json.Unmarshal(revokeCreateRec.Body.Bytes(), &revokeCreated); err != nil {
|
||||
t.Fatalf("decode revoke bootstrap create response: %v", err)
|
||||
}
|
||||
|
||||
revokeReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap/"+revokeCreated.Ticket+"/revoke", nil)
|
||||
revokeReq.Header.Set("Authorization", "Bearer "+token)
|
||||
revokeReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
|
||||
revokeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(revokeRec, revokeReq)
|
||||
if revokeRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected bootstrap revoke success, got %d: %s", revokeRec.Code, revokeRec.Body.String())
|
||||
}
|
||||
|
||||
revokedConsumeReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, revokeCreated.Ticket, revokeCreated.Bridge))))
|
||||
revokedConsumeReq.Header.Set("Content-Type", "application/json")
|
||||
revokedConsumeReq.Header.Set("X-Service-Token", "internal-test-token")
|
||||
revokedConsumeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(revokedConsumeRec, revokedConsumeReq)
|
||||
if revokedConsumeRec.Code != http.StatusGone {
|
||||
t.Fatalf("expected revoked bootstrap consume gone, got %d: %s", revokedConsumeRec.Code, revokedConsumeRec.Body.String())
|
||||
}
|
||||
|
||||
if user.ID == "" {
|
||||
t.Fatalf("expected created user id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAndGetXWorkmateProfileRoundTripsSecretLocators(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@ -421,6 +557,12 @@ func (f *flakyXWorkmateVaultService) DeleteSecret(ctx context.Context, locator s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *flakyXWorkmateVaultService) ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error) {
|
||||
_ = ctx
|
||||
_ = locator
|
||||
return "", errors.New("vault unavailable")
|
||||
}
|
||||
|
||||
func (f *flakyXWorkmateVaultService) HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error) {
|
||||
_ = ctx
|
||||
_ = locator
|
||||
|
||||
@ -21,6 +21,7 @@ type xworkmateVaultService interface {
|
||||
WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error
|
||||
DeleteSecret(ctx context.Context, locator store.XWorkmateSecretLocator) error
|
||||
HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error)
|
||||
ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error)
|
||||
}
|
||||
|
||||
type XWorkmateVaultConfig struct {
|
||||
@ -174,6 +175,27 @@ func (s *memoryXWorkmateVaultService) HasSecret(ctx context.Context, locator sto
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *memoryXWorkmateVaultService) ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error) {
|
||||
_ = ctx
|
||||
store.NormalizeXWorkmateSecretLocator(&locator)
|
||||
if locator.SecretPath == "" || locator.SecretKey == "" {
|
||||
return "", fmt.Errorf("vault locator is incomplete")
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
secretMap := s.store[locator.SecretPath]
|
||||
if secretMap == nil {
|
||||
return "", fmt.Errorf("secret not found")
|
||||
}
|
||||
value, ok := secretMap[locator.SecretKey]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("secret not found")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *httpXWorkmateVaultService) WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error {
|
||||
store.NormalizeXWorkmateSecretLocator(&locator)
|
||||
if locator.SecretPath == "" || locator.SecretKey == "" {
|
||||
@ -256,6 +278,22 @@ func (s *httpXWorkmateVaultService) HasSecret(ctx context.Context, locator store
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *httpXWorkmateVaultService) ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error) {
|
||||
store.NormalizeXWorkmateSecretLocator(&locator)
|
||||
if locator.SecretPath == "" || locator.SecretKey == "" {
|
||||
return "", fmt.Errorf("vault locator is incomplete")
|
||||
}
|
||||
data, err := s.readSecretMap(ctx, locator.SecretPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value, ok := data[locator.SecretKey]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("secret not found")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *httpXWorkmateVaultService) readSecretMap(ctx context.Context, secretPath string) (map[string]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.dataURL(secretPath), nil)
|
||||
if err != nil {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user