Support review bridge auth token

This commit is contained in:
Haitao Pan 2026-05-30 10:34:51 +08:00
parent a383cf98d8
commit d321364681
5 changed files with 58 additions and 14 deletions

View File

@ -40,15 +40,16 @@
环境变量: 环境变量:
- `BRIDGE_AUTH_TOKEN` - `BRIDGE_AUTH_TOKEN`
- `BRIDGE_REVIEW_AUTH_TOKEN`可选Apple review / beta 工测专用临时 token。清空该环境变量并重启/reload bridge 即可单独关停,不影响主 token。
- `ACP_ALLOWED_ORIGINS` - `ACP_ALLOWED_ORIGINS`
规则: 规则:
- `/acp``/acp/rpc` 都做 origin allowlist 校验 - `/acp``/acp/rpc` 都做 origin allowlist 校验
- 空 `Origin` 默认允许 - 空 `Origin` 默认允许
- `/api/ping`、`/acp`、`/acp/rpc` 在 `BRIDGE_AUTH_TOKEN` 非空时都要求 bearer header - `/api/ping`、`/acp`、`/acp/rpc` 在任一 bridge token 非空时都要求 bearer header
- `BRIDGE_AUTH_TOKEN` 为空时默认放行 - `BRIDGE_AUTH_TOKEN` `BRIDGE_REVIEW_AUTH_TOKEN`为空时默认放行
- `BRIDGE_AUTH_TOKEN` 非空时,接受裸 token 或 `Bearer <token>` - token 非空时,接受裸 token 或 `Bearer <token>`
- `xworkmate-app` 生产 Origin 固定为 `https://xworkmate.svc.plus` - `xworkmate-app` 生产 Origin 固定为 `https://xworkmate.svc.plus`
## 3.1 Lightweight Distributed Task Forwarding ## 3.1 Lightweight Distributed Task Forwarding

View File

@ -47,8 +47,11 @@ func NewServer() *Server {
sessions: make(map[string]*session), sessions: make(map[string]*session),
config: config, config: config,
allowedOrigins: shared.ParseAllowedOrigins(shared.EnvOrDefault("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*")), allowedOrigins: shared.ParseAllowedOrigins(shared.EnvOrDefault("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*")),
authService: service.NewStaticTokenAuthService(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", "")), authService: service.NewStaticTokenAuthService(
openClawGate: newOpenClawGatewayAdmissionGate(config), shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""),
shared.EnvOrDefault("BRIDGE_REVIEW_AUTH_TOKEN", ""),
),
openClawGate: newOpenClawGatewayAdmissionGate(config),
taskForwarder: newDistributedTaskForwarder(distributedTaskForwarderConfig{ taskForwarder: newDistributedTaskForwarder(distributedTaskForwarderConfig{
Endpoint: resolveDistributedTaskForwardEndpoint(config), Endpoint: resolveDistributedTaskForwardEndpoint(config),
Token: resolveDistributedTaskForwardToken(config), Token: resolveDistributedTaskForwardToken(config),

View File

@ -899,6 +899,27 @@ func TestHandleRPCCapabilitiesRequiresBearerAuthorizationWhenBridgeAuthTokenConf
} }
} }
func TestHandleRPCAllowsReviewBearerAuthorizationWhenConfigured(t *testing.T) {
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "review-bridge-test-token")
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
server := NewServer()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"http://127.0.0.1/acp/rpc",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"acp.capabilities"}`),
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer review-bridge-test-token")
server.HandleRPC(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200 for configured review token, got %d", recorder.Code)
}
}
func TestHandleRPCRejectsUnknownOrigin(t *testing.T) { func TestHandleRPCRejectsUnknownOrigin(t *testing.T) {
t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus") t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus")
t.Setenv("BRIDGE_AUTH_TOKEN", "") t.Setenv("BRIDGE_AUTH_TOKEN", "")

View File

@ -3,36 +3,42 @@ package service
import "strings" import "strings"
type StaticTokenAuthService struct { type StaticTokenAuthService struct {
expectedToken string expectedTokens map[string]struct{}
} }
func NewStaticTokenAuthService(expectedToken string) *StaticTokenAuthService { func NewStaticTokenAuthService(expectedToken string, extraTokens ...string) *StaticTokenAuthService {
return &StaticTokenAuthService{ tokens := map[string]struct{}{}
expectedToken: strings.TrimSpace(expectedToken), for _, token := range append([]string{expectedToken}, extraTokens...) {
trimmed := strings.TrimSpace(token)
if trimmed != "" {
tokens[trimmed] = struct{}{}
}
} }
return &StaticTokenAuthService{expectedTokens: tokens}
} }
func (s *StaticTokenAuthService) ValidateToken(token string) bool { func (s *StaticTokenAuthService) ValidateToken(token string) bool {
token = strings.TrimSpace(token) token = strings.TrimSpace(token)
if s.expectedToken == "" { if len(s.expectedTokens) == 0 {
return true return true
} }
return token == s.expectedToken _, ok := s.expectedTokens[token]
return ok
} }
func (s *StaticTokenAuthService) ValidateAuthorizationHeader(header string) bool { func (s *StaticTokenAuthService) ValidateAuthorizationHeader(header string) bool {
header = strings.TrimSpace(header) header = strings.TrimSpace(header)
if s.expectedToken == "" { if len(s.expectedTokens) == 0 {
return true return true
} }
if header == "" { if header == "" {
return false return false
} }
if header == s.expectedToken { if s.ValidateToken(header) {
return true return true
} }
if !strings.HasPrefix(strings.ToLower(header), "bearer ") { if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
return false return false
} }
return strings.TrimSpace(header[len("Bearer "):]) == s.expectedToken return s.ValidateToken(strings.TrimSpace(header[len("Bearer "):]))
} }

View File

@ -34,3 +34,16 @@ func TestStaticTokenAuthServiceValidateAuthorizationHeaderStrictWhenSet(t *testi
t.Fatal("expected non-bearer header to be rejected") t.Fatal("expected non-bearer header to be rejected")
} }
} }
func TestStaticTokenAuthServiceValidateAuthorizationHeaderAcceptsReviewToken(t *testing.T) {
svc := NewStaticTokenAuthService("production-secret", "review-secret")
if !svc.ValidateAuthorizationHeader("Bearer production-secret") {
t.Fatal("expected production bearer header to be accepted")
}
if !svc.ValidateAuthorizationHeader("Bearer review-secret") {
t.Fatal("expected review bearer header to be accepted")
}
if svc.ValidateAuthorizationHeader("Bearer disabled-review-secret") {
t.Fatal("expected unconfigured review bearer header to be rejected")
}
}