From 840f717b66fdbe8584138f9c56022d86aebc147b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:03:36 +0000 Subject: [PATCH] Require API key auth on /v2/check and /v2/authorize The edge agent served /v2/check and /v2/authorize behind only logging middleware, so any actor with network access to the agent could query the cached authorization graph without credentials. ServerConfig.ApiKey was populated but never enforced on inbound requests. Add authMiddleware that validates the Authorization header against the configured API key (constant-time compare), matching the cloud API's "Authorization: ApiKey " scheme, and reject unauthenticated requests with 401 before the handler runs. Fail closed when no key is configured. /health stays unauthenticated for readiness probes. Add HTTP-level regression tests covering unauthenticated, wrong-key, and valid-key requests. --- errors.go | 17 +++++++ middleware.go | 21 +++++++++ server.go | 11 +++-- server_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 server_test.go diff --git a/errors.go b/errors.go index e7fcf6c..c6ea6fd 100644 --- a/errors.go +++ b/errors.go @@ -22,6 +22,7 @@ import ( const ( ErrorCacheNotReady = "cache_not_ready" + ErrorUnauthorized = "unauthorized" ) // CacheNotReady type @@ -39,3 +40,19 @@ func NewCacheNotReady() *CacheNotReady { ), } } + +// Unauthorized type +type Unauthorized struct { + *service.GenericError +} + +func NewUnauthorized() *Unauthorized { + return &Unauthorized{ + GenericError: service.NewGenericError( + "Unauthorized", + ErrorUnauthorized, + http.StatusUnauthorized, + "Unauthorized", + ), + } +} diff --git a/middleware.go b/middleware.go index 5e23d18..35a7298 100644 --- a/middleware.go +++ b/middleware.go @@ -15,11 +15,14 @@ package edge import ( + "crypto/subtle" "fmt" "log" "net" "net/http" "time" + + "github.com/warrant-dev/warrant/pkg/service" ) type loggingResponseWriter struct { @@ -36,6 +39,24 @@ func (rw *loggingResponseWriter) WriteHeader(code int) { rw.ResponseWriter.WriteHeader(code) } +// authMiddleware enforces that inbound requests present the configured API key +// via an "Authorization: ApiKey " header, matching the WorkOS cloud API. +// Requests without a valid credential are rejected with 401 before any handler +// runs. If no API key is configured, the agent fails closed and rejects all +// requests rather than serving the authorization graph anonymously. +func authMiddleware(apiKey string, next http.Handler) http.Handler { + expected := fmt.Sprintf("ApiKey %s", apiKey) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + provided := r.Header.Get("Authorization") + if apiKey == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { + service.SendErrorResponse(w, NewUnauthorized()) + return + } + + next.ServeHTTP(w, r) + }) +} + func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { startTime := time.Now() diff --git a/server.go b/server.go index 8d9ee75..a26976c 100644 --- a/server.go +++ b/server.go @@ -147,12 +147,15 @@ func (server *Server) check(w http.ResponseWriter, r *http.Request) { }) } -func (server *Server) Run() error { +func (server *Server) Handler() http.Handler { mux := http.NewServeMux() mux.Handle("/health", loggingMiddleware(http.HandlerFunc(server.health))) - mux.Handle(fmt.Sprintf("/%s/authorize", ApiVersion), loggingMiddleware(http.HandlerFunc(server.check))) - mux.Handle(fmt.Sprintf("/%s/check", ApiVersion), loggingMiddleware(http.HandlerFunc(server.check))) + mux.Handle(fmt.Sprintf("/%s/authorize", ApiVersion), loggingMiddleware(authMiddleware(server.config.ApiKey, http.HandlerFunc(server.check)))) + mux.Handle(fmt.Sprintf("/%s/check", ApiVersion), loggingMiddleware(authMiddleware(server.config.ApiKey, http.HandlerFunc(server.check)))) + return mux +} +func (server *Server) Run() error { log.Printf("Edge agent ready to serve authz requests on port %d", server.config.Port) - return http.ListenAndServe(fmt.Sprintf(":%d", server.config.Port), mux) + return http.ListenAndServe(fmt.Sprintf(":%d", server.config.Port), server.Handler()) } diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..317636b --- /dev/null +++ b/server_test.go @@ -0,0 +1,124 @@ +// Copyright 2024 WorkOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package edge + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testApiKey = "sk_test_edge_agent_key" + +// warrant present in the seeded cache +const seededWarrant = "role:admin#member@user:alice@example.com" + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + repo := NewMemoryRepository() + if err := repo.Set(seededWarrant, 1); err != nil { + t.Fatalf("failed to seed repository: %v", err) + } + server, err := NewServer(ServerConfig{ + ApiKey: testApiKey, + Port: 3000, + Repository: repo, + }) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + return httptest.NewServer(server.Handler()) +} + +func postCheck(t *testing.T, baseURL, authHeader, body string) int { + t.Helper() + req, err := http.NewRequest(http.MethodPost, baseURL+"/v2/check", strings.NewReader(body)) + if err != nil { + t.Fatalf("failed to build request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + return resp.StatusCode +} + +// A request for the seeded warrant that an attacker-observable oracle would +// answer "Authorized" (HTTP 200) if it reached the handler. +const checkBody = `{"warrants":[{"objectType":"role","objectId":"admin","relation":"member","subject":{"objectType":"user","objectId":"alice@example.com"}}]}` + +func TestCheckRejectsUnauthenticatedRequest(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + if status := postCheck(t, ts.URL, "", checkBody); status != http.StatusUnauthorized { + t.Fatalf("unauthenticated /v2/check: expected 401, got %d", status) + } +} + +func TestCheckRejectsWrongApiKey(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + if status := postCheck(t, ts.URL, "ApiKey wrong", checkBody); status != http.StatusUnauthorized { + t.Fatalf("wrong-key /v2/check: expected 401, got %d", status) + } +} + +func TestCheckAllowsValidApiKey(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + status := postCheck(t, ts.URL, "ApiKey "+testApiKey, checkBody) + if status != http.StatusOK { + t.Fatalf("authenticated /v2/check: expected 200, got %d", status) + } +} + +func TestAuthorizeRejectsUnauthenticatedRequest(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v2/authorize", strings.NewReader(checkBody)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated /v2/authorize: expected 401, got %d", resp.StatusCode) + } +} + +func TestHealthRemainsUnauthenticated(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/health") + if err != nil { + t.Fatalf("health request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("/health: expected 200, got %d", resp.StatusCode) + } +}