Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .devharness/operations.db
Binary file not shown.
9 changes: 9 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"mcpServers": {
"devharness": {
"command": "/Users/baai/codebase/DevHarness/devharness",
"args": ["serve"],
"env": {}
}
}
}
11 changes: 10 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"

"github.com/wbavon/devharness-testbed/handlers"
"github.com/wbavon/devharness-testbed/middleware"
)

func main() {
Expand All @@ -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)
}
}
Expand Down
121 changes: 121 additions & 0 deletions middleware/auth.go
Original file line number Diff line number Diff line change
@@ -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"
)
155 changes: 155 additions & 0 deletions middleware/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading