Add API key authentication for proxy endpoints.
Support multiple API keys from config, env, and CLI, enforce auth on non-public endpoints, and pass keys through remote deploy verification. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -126,7 +126,7 @@ func NewServer(addr string, svc *service.Service) *Server {
|
||||
|
||||
s.http = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.withRecorder(withCORS(mux)),
|
||||
Handler: s.withRecorder(withCORS(s.withAuth(mux))),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
return s
|
||||
@@ -1686,6 +1686,65 @@ func writeOpenAIError(w http.ResponseWriter, status int, kind string, message st
|
||||
})
|
||||
}
|
||||
|
||||
func isPublicPath(path string) bool {
|
||||
switch path {
|
||||
case "/", "/health", "/runtime/status", "/v1/runtime/status":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAnthropicPath(path string) bool {
|
||||
switch path {
|
||||
case "/v1/messages", "/v1/messages/count_tokens":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func extractAPIKey(r *http.Request) string {
|
||||
if value := strings.TrimSpace(r.Header.Get("x-api-key")); value != "" {
|
||||
return value
|
||||
}
|
||||
auth := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if auth == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isPublicPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
keys := s.svc.APIKeys()
|
||||
if len(keys) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
provided := extractAPIKey(r)
|
||||
for _, key := range keys {
|
||||
if provided == key {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if isAnthropicPath(r.URL.Path) {
|
||||
writeAnthropicError(w, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
|
||||
return
|
||||
}
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
|
||||
})
|
||||
}
|
||||
|
||||
func streamingHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
@@ -81,6 +81,83 @@ func TestCapabilitiesAdvertiseAgentCompatibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAllowsPublicHealthWithoutAPIKey(t *testing.T) {
|
||||
server := NewServer("", service.New(service.Config{
|
||||
Model: "Qwen3-Coder",
|
||||
Timeout: time.Second,
|
||||
APIKeys: []string{"key-1", "key-2"},
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
server.http.Handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsProtectedEndpointWithoutAPIKey(t *testing.T) {
|
||||
server := NewServer("", service.New(service.Config{
|
||||
Model: "Qwen3-Coder",
|
||||
Timeout: time.Second,
|
||||
APIKeys: []string{"key-1", "key-2"},
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
server.http.Handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "authentication_error") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAcceptsBearerAndXAPIKey(t *testing.T) {
|
||||
server := NewServer("", service.New(service.Config{
|
||||
Model: "Qwen3-Coder",
|
||||
Timeout: time.Second,
|
||||
APIKeys: []string{"key-1", "key-2"},
|
||||
}))
|
||||
|
||||
bearerReq := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||
bearerReq.Header.Set("Authorization", "Bearer key-2")
|
||||
bearerRec := httptest.NewRecorder()
|
||||
server.http.Handler.ServeHTTP(bearerRec, bearerReq)
|
||||
if bearerRec.Code != http.StatusOK {
|
||||
t.Fatalf("bearer status = %d body = %s", bearerRec.Code, bearerRec.Body.String())
|
||||
}
|
||||
|
||||
xReq := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
|
||||
xReq.Header.Set("x-api-key", "key-1")
|
||||
xRec := httptest.NewRecorder()
|
||||
server.http.Handler.ServeHTTP(xRec, xReq)
|
||||
if xRec.Code != http.StatusOK {
|
||||
t.Fatalf("x-api-key status = %d body = %s", xRec.Code, xRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicAuthErrorShape(t *testing.T) {
|
||||
server := NewServer("", service.New(service.Config{
|
||||
Model: "Qwen3-Coder",
|
||||
Timeout: time.Second,
|
||||
APIKeys: []string{"key-1"},
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"kmodel","messages":[{"role":"user","content":"hello"}],"max_tokens":16}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
server.http.Handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"type":"error"`) {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestNormalizeOpenAIRequestRejectsMissingUserAndAssistantMessages(t *testing.T) {
|
||||
req := openAIChatRequest{
|
||||
Model: "test-model",
|
||||
|
||||
@@ -53,6 +53,7 @@ type Config struct {
|
||||
ShellType string
|
||||
SessionMode SessionMode
|
||||
Timeout time.Duration
|
||||
APIKeys []string
|
||||
RemoteFallbackEnabled bool
|
||||
RemoteFallbackModels []string
|
||||
LingmaBootstrapEnabled bool
|
||||
@@ -185,6 +186,7 @@ func New(cfg Config) *Service {
|
||||
cfg.Mode = "agent"
|
||||
}
|
||||
cfg.Model = strings.TrimSpace(cfg.Model)
|
||||
cfg.APIKeys = cleanStringSlice(cfg.APIKeys)
|
||||
if strings.TrimSpace(cfg.ShellType) == "" {
|
||||
cfg.ShellType = lingmaipc.DefaultShellType()
|
||||
}
|
||||
@@ -247,6 +249,26 @@ func (s *Service) DefaultModel() string {
|
||||
return strings.TrimSpace(s.cfg.Model)
|
||||
}
|
||||
|
||||
func (s *Service) APIKeys() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.cfg.APIKeys...)
|
||||
}
|
||||
|
||||
func cleanStringSlice(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
item := strings.TrimSpace(value)
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) PrepareRuntime() error {
|
||||
s.mu.Lock()
|
||||
cfg := s.cfg
|
||||
|
||||
Reference in New Issue
Block a user