Simplify xworkmate account sync contract
This commit is contained in:
parent
c9f92bf3cb
commit
e80c047a26
14
api/api.go
14
api/api.go
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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...),
|
||||
})
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user