diff --git a/.devharness/operations.db b/.devharness/operations.db new file mode 100644 index 0000000..e9f76f5 Binary files /dev/null and b/.devharness/operations.db differ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..46f91b9 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "devharness": { + "command": "/Users/baai/codebase/DevHarness/devharness", + "args": ["serve"], + "env": {} + } + } +} diff --git a/main.go b/main.go index 679a8b8..8382c64 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "os" "github.com/wbavon/devharness-testbed/handlers" + "github.com/wbavon/devharness-testbed/middleware" ) func main() { @@ -18,13 +19,21 @@ func main() { mux.HandleFunc("PUT /api/items/{id}", handlers.UpdateItem) mux.HandleFunc("DELETE /api/items/{id}", handlers.DeleteItem) + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("JWT_SECRET environment variable is required") + } + + authMiddleware := middleware.Auth(jwtSecret, []string{"/health"}) + handler := authMiddleware(mux) + port := os.Getenv("PORT") if port == "" { port = "8080" } log.Printf("Starting server on :%s", port) - if err := http.ListenAndServe(":"+port, mux); err != nil { + if err := http.ListenAndServe(":"+port, handler); err != nil { log.Fatalf("Server failed: %v", err) } } diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..b912c9e --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "time" +) + +// Auth returns middleware that validates JWT Bearer tokens from the +// Authorization header. Requests to paths listed in skipPaths bypass +// authentication. The token is validated using the provided secret with +// HMAC-SHA256 (HS256). +func Auth(secret string, skipPaths []string) func(http.Handler) http.Handler { + skip := make(map[string]bool, len(skipPaths)) + for _, p := range skipPaths { + skip[p] = true + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if skip[r.URL.Path] { + next.ServeHTTP(w, r) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + http.Error(w, `{"error":"invalid authorization header format"}`, http.StatusUnauthorized) + return + } + + token := parts[1] + if err := validateJWT(token, secret); err != nil { + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// jwtHeader is the expected header for HS256 tokens. +type jwtHeader struct { + Alg string `json:"alg"` + Typ string `json:"typ"` +} + +// jwtPayload contains the standard claims we validate. +type jwtPayload struct { + Exp int64 `json:"exp,omitempty"` + Sub string `json:"sub,omitempty"` +} + +func validateJWT(token, secret string) error { + segments := strings.SplitN(token, ".", 3) + if len(segments) != 3 { + return errInvalidToken + } + + // Verify header + headerBytes, err := base64.RawURLEncoding.DecodeString(segments[0]) + if err != nil { + return errInvalidToken + } + var h jwtHeader + if err := json.Unmarshal(headerBytes, &h); err != nil { + return errInvalidToken + } + if h.Alg != "HS256" { + return errInvalidToken + } + + // Verify signature + signingInput := segments[0] + "." + segments[1] + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + expectedSig := mac.Sum(nil) + + actualSig, err := base64.RawURLEncoding.DecodeString(segments[2]) + if err != nil { + return errInvalidToken + } + if !hmac.Equal(expectedSig, actualSig) { + return errInvalidToken + } + + // Check expiry + payloadBytes, err := base64.RawURLEncoding.DecodeString(segments[1]) + if err != nil { + return errInvalidToken + } + var p jwtPayload + if err := json.Unmarshal(payloadBytes, &p); err != nil { + return errInvalidToken + } + if p.Exp > 0 && time.Now().Unix() > p.Exp { + return errTokenExpired + } + + return nil +} + +type tokenError string + +func (e tokenError) Error() string { return string(e) } + +const ( + errInvalidToken tokenError = "invalid token" + errTokenExpired tokenError = "token expired" +) diff --git a/middleware/auth_test.go b/middleware/auth_test.go new file mode 100644 index 0000000..ad082b2 --- /dev/null +++ b/middleware/auth_test.go @@ -0,0 +1,155 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// buildJWT creates a signed HS256 JWT for testing purposes. +func buildJWT(header, payload map[string]any, secret string) string { + h, _ := json.Marshal(header) + p, _ := json.Marshal(payload) + + seg0 := base64.RawURLEncoding.EncodeToString(h) + seg1 := base64.RawURLEncoding.EncodeToString(p) + + signingInput := seg0 + "." + seg1 + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return seg0 + "." + seg1 + "." + sig +} + +func validToken(secret string) string { + return buildJWT( + map[string]any{"alg": "HS256", "typ": "JWT"}, + map[string]any{"sub": "user1", "exp": time.Now().Add(time.Hour).Unix()}, + secret, + ) +} + +func expiredToken(secret string) string { + return buildJWT( + map[string]any{"alg": "HS256", "typ": "JWT"}, + map[string]any{"sub": "user1", "exp": time.Now().Add(-time.Hour).Unix()}, + secret, + ) +} + +var okHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +}) + +const testSecret = "test-secret-key" + +func TestAuth_ValidToken(t *testing.T) { + mw := Auth(testSecret, nil) + handler := mw(okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/items", nil) + req.Header.Set("Authorization", "Bearer "+validToken(testSecret)) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestAuth_MissingHeader(t *testing.T) { + mw := Auth(testSecret, nil) + handler := mw(okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/items", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAuth_InvalidFormat(t *testing.T) { + mw := Auth(testSecret, nil) + handler := mw(okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/items", nil) + req.Header.Set("Authorization", "Basic abc123") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAuth_InvalidSignature(t *testing.T) { + mw := Auth(testSecret, nil) + handler := mw(okHandler) + + token := validToken("wrong-secret") + req := httptest.NewRequest(http.MethodGet, "/api/items", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAuth_ExpiredToken(t *testing.T) { + mw := Auth(testSecret, nil) + handler := mw(okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/items", nil) + req.Header.Set("Authorization", "Bearer "+expiredToken(testSecret)) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAuth_SkipPaths(t *testing.T) { + mw := Auth(testSecret, []string{"/health"}) + handler := mw(okHandler) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 for skipped path, got %d", w.Code) + } +} + +func TestAuth_MalformedToken(t *testing.T) { + mw := Auth(testSecret, nil) + handler := mw(okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/items", nil) + req.Header.Set("Authorization", "Bearer not.a.valid.jwt") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +}