Simplify xworkmate account sync contract

This commit is contained in:
Haitao Pan 2026-04-13 19:28:25 +08:00
parent c9f92bf3cb
commit e80c047a26
4 changed files with 195 additions and 415 deletions

View File

@ -40,8 +40,6 @@ 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"
@ -84,10 +82,6 @@ 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
@ -317,9 +311,6 @@ 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 {
@ -385,13 +376,11 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected.GET("/session", h.session)
authProtected.DELETE("/session", h.deleteSession)
authProtected.GET("/xworkmate/profile", h.getXWorkmateProfile)
authProtected.GET("/xworkmate/profile/sync", h.getXWorkmateProfileSync)
authProtected.PUT("/xworkmate/profile", h.updateXWorkmateProfile)
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)
@ -448,7 +437,6 @@ 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")

View File

@ -618,6 +618,63 @@ func (h *handler) getXWorkmateProfile(c *gin.Context) {
c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, profile, tokenConfigured))
}
func (h *handler) getXWorkmateProfileSync(c *gin.Context) {
user, ok := h.currentAuthenticatedUser(c)
if !ok {
return
}
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
if err != nil {
if errors.Is(err, store.ErrTenantMembershipNotFound) {
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
return
}
if errors.Is(err, store.ErrTenantNotFound) {
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
return
}
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
return
}
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
return
}
bridgeServerURL := ""
if profile != nil {
bridgeServerURL = strings.TrimSpace(profile.BridgeServerURL)
}
if bridgeServerURL == "" {
respondError(c, http.StatusConflict, "bridge_server_url_unavailable", "bridge server url is unavailable")
return
}
if h.xworkmateVaultService == nil {
respondError(c, http.StatusConflict, "bridge_auth_token_unavailable", "bridge auth token is unavailable")
return
}
locator, ok := findStoredXWorkmateSecretLocator(profile, store.XWorkmateSecretLocatorTargetBridgeAuthToken)
if !ok {
respondError(c, http.StatusConflict, "bridge_auth_token_unavailable", "bridge auth token is unavailable")
return
}
bridgeAuthToken, err := h.xworkmateVaultService.ReadSecret(c.Request.Context(), locator)
if err != nil || strings.TrimSpace(bridgeAuthToken) == "" {
respondError(c, http.StatusConflict, "bridge_auth_token_unavailable", "bridge auth token is unavailable")
return
}
c.JSON(http.StatusOK, gin.H{
"BRIDGE_SERVER_URL": bridgeServerURL,
"BRIDGE_AUTH_TOKEN": strings.TrimSpace(bridgeAuthToken),
})
}
func (h *handler) updateXWorkmateProfile(c *gin.Context) {
user, ok := h.currentAuthenticatedUser(c)
if !ok {

View File

@ -1,319 +0,0 @@
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"`
BridgeServerURL string `json:"BRIDGE_SERVER_URL"`
AuthMode string `json:"authMode"`
BridgeAuthToken string `json:"BRIDGE_AUTH_TOKEN"`
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.XWorkmateSecretLocatorTargetBridgeAuthToken)
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
}
bridgeServerURL := strings.TrimSpace(profile.BridgeServerURL)
if bridgeServerURL == "" {
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,
BridgeServerURL: bridgeServerURL,
AuthMode: "shared-token",
BridgeAuthToken: gatewayToken,
ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339),
Scopes: append([]string(nil), ticket.Scopes...),
})
}

View File

@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@ -155,11 +154,11 @@ func TestBuildXWorkmateTokenConfiguredUsesSecretLocators(t *testing.T) {
}
}
func TestXWorkmateBridgeBootstrapTicketLifecycle(t *testing.T) {
func TestGetXWorkmateProfileSyncReturnsManagedBridgeCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
vaultService := newMemoryXWorkmateVaultService()
router, user, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService)
router, _, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService)
profileBody, err := json.Marshal(map[string]any{
"profile": map[string]any{
@ -198,95 +197,150 @@ func TestXWorkmateBridgeBootstrapTicketLifecycle(t *testing.T) {
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)
req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected profile sync success, got %d: %s", rec.Code, rec.Body.String())
}
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())
var payload struct {
BridgeServerURL string `json:"BRIDGE_SERVER_URL"`
BridgeAuthToken string `json:"BRIDGE_AUTH_TOKEN"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode profile sync response: %v", err)
}
if payload.BridgeServerURL != "wss://openclaw.example.com" {
t.Fatalf("expected bridge server url, got %#v", payload)
}
if payload.BridgeAuthToken != "shared-token-value" {
t.Fatalf("expected bridge auth token, got %#v", payload)
}
}
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.BridgeAuthToken != "shared-token-value" {
t.Fatalf("expected returned bridge auth token, got %#v", consumed)
}
if consumed.BridgeServerURL != "wss://openclaw.example.com" {
t.Fatalf("expected returned bridge server url, got %#v", consumed)
}
func TestGetXWorkmateProfileSyncConflictsWhenManagedBridgeContractMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
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())
}
t.Run("missing bridge server url", func(t *testing.T) {
vaultService := newMemoryXWorkmateVaultService()
router, _, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService)
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)
}
profileBody, err := json.Marshal(map[string]any{
"profile": map[string]any{
"secretLocators": []map[string]any{
{
"id": "locator-openclaw",
"provider": "vault",
"secretPath": "kv/openclaw",
"secretKey": "token",
"target": store.XWorkmateSecretLocatorTargetBridgeAuthToken,
"required": true,
},
},
},
})
if err != nil {
t.Fatalf("marshal profile: %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())
}
putReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody))
putReq.Header.Set("Content-Type", "application/json")
putReq.Header.Set("Authorization", "Bearer "+token)
putReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
putRec := httptest.NewRecorder()
router.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("expected profile update success, got %d: %s", putRec.Code, putRec.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 err := vaultService.WriteSecret(context.Background(), store.XWorkmateSecretLocator{
Provider: "vault",
SecretPath: "kv/openclaw",
SecretKey: "token",
Target: store.XWorkmateSecretLocatorTargetBridgeAuthToken,
}, "shared-token-value"); err != nil {
t.Fatalf("write secret: %v", err)
}
if user.ID == "" {
t.Fatalf("expected created user id")
req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected profile sync conflict, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("missing bridge auth token", func(t *testing.T) {
vaultService := newMemoryXWorkmateVaultService()
router, _, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService)
profileBody, err := json.Marshal(map[string]any{
"profile": map[string]any{
"BRIDGE_SERVER_URL": "wss://openclaw.example.com",
},
})
if err != nil {
t.Fatalf("marshal profile: %v", err)
}
putReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody))
putReq.Header.Set("Content-Type", "application/json")
putReq.Header.Set("Authorization", "Bearer "+token)
putReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
putRec := httptest.NewRecorder()
router.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("expected profile update success, got %d: %s", putRec.Code, putRec.Body.String())
}
req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected profile sync conflict, got %d: %s", rec.Code, rec.Body.String())
}
})
}
func TestXWorkmateBridgeBootstrapRoutesRemoved(t *testing.T) {
gin.SetMode(gin.TestMode)
router, _, token := newXWorkmateTestHarness(t)
requests := []*http.Request{
httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`))),
httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/bridge/bootstrap/SHORTCODE", nil),
httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap/ticket-id/revoke", nil),
httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(`{}`))),
}
for _, req := range requests {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected route removal 404 for %s %s, got %d: %s", req.Method, req.URL.Path, rec.Code, rec.Body.String())
}
}
}
func TestGetXWorkmateProfileSyncRequiresSession(t *testing.T) {
gin.SetMode(gin.TestMode)
router, _, _ := newXWorkmateTestHarness(t)
req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected profile sync unauthorized without session, got %d: %s", rec.Code, rec.Body.String())
}
}