From 03bc16571d9f1a6d6e928be416f328988a71067c Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 22:19:09 +0200 Subject: [PATCH] Add tenant-aware HTTP middleware and router internal/httpapi/ provides: - Tenant{UserID, Email} carried on context.Context, with WithTenant / TenantFrom helpers and ErrNoTenant for the programming-error case (route reached without middleware). - AuthMiddleware verifies an Authorization: Bearer on every request via auth.Signer.Verify (which already enforces HS256 and rejects alg=none). On failure: 401, with the underlying reason logged server-side but not exposed to the client. - RequireTenantOwnership(ownerID) compares the request's tenant against the resource owner; returns 403 on mismatch. Handlers that touch tenant-owned resources call this guard. - Server.Routes() mounts /auth/* unauthenticated and wraps /api/* with the middleware. /api/whoami is included as the canonical example of a tenant-scoped endpoint. Tests cover: valid JWT pass-through, missing/empty Authorization, wrong scheme, malformed JWT, tampered signature, JWT signed with a different secret (cross-tenant key confusion), and the 200/403 matrix for RequireTenantOwnership. Closes #11. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/httpapi/context.go | 39 ++++++ internal/httpapi/middleware.go | 91 +++++++++++++ internal/httpapi/middleware_test.go | 192 ++++++++++++++++++++++++++++ internal/httpapi/router.go | 50 ++++++++ 4 files changed, 372 insertions(+) create mode 100644 internal/httpapi/context.go create mode 100644 internal/httpapi/middleware.go create mode 100644 internal/httpapi/middleware_test.go create mode 100644 internal/httpapi/router.go diff --git a/internal/httpapi/context.go b/internal/httpapi/context.go new file mode 100644 index 0000000..4134a35 --- /dev/null +++ b/internal/httpapi/context.go @@ -0,0 +1,39 @@ +// Package httpapi provides the HTTP-facing layer for the multi-tenant +// backend: auth middleware, tenant context propagation, and route +// wiring for the auth and note endpoints. +package httpapi + +import ( + "context" + "errors" +) + +// Tenant carries the per-request tenant identity extracted from a +// validated JWT. It is the only thing handlers need to know about +// "who is this request for". +type Tenant struct { + UserID string + Email string +} + +type ctxKey struct{} + +// ErrNoTenant indicates that handler code expected a tenant on the +// request context but found none. This is always a programming error +// (the route was reached without going through AuthMiddleware). +var ErrNoTenant = errors.New("httpapi: no tenant in context") + +// WithTenant returns a derived context carrying t. +func WithTenant(ctx context.Context, t Tenant) context.Context { + return context.WithValue(ctx, ctxKey{}, t) +} + +// TenantFrom retrieves the tenant from ctx. Panics are avoided by +// returning ErrNoTenant when the value is missing. +func TenantFrom(ctx context.Context) (Tenant, error) { + v, ok := ctx.Value(ctxKey{}).(Tenant) + if !ok { + return Tenant{}, ErrNoTenant + } + return v, nil +} diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go new file mode 100644 index 0000000..e09465d --- /dev/null +++ b/internal/httpapi/middleware.go @@ -0,0 +1,91 @@ +package httpapi + +import ( + "log" + "net/http" + "strings" + + "git.librete.ch/public/librenotes/internal/auth" +) + +// AuthMiddleware validates the Authorization: Bearer header on +// every request. On success the verified Tenant is attached to the +// request context so downstream handlers can scope their work. On any +// failure (missing header, wrong scheme, invalid/expired/forged JWT) +// the request is rejected with 401 — the failure reason is logged +// server-side but not surfaced to the client to avoid hinting at +// validation internals. +func AuthMiddleware(signer *auth.Signer, logger *log.Logger) func(http.Handler) http.Handler { + if logger == nil { + logger = log.Default() + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok, err := bearerToken(r.Header.Get("Authorization")) + if err != nil { + logger.Printf("auth: %v from %s", err, r.RemoteAddr) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + claims, err := signer.Verify(tok) + if err != nil { + logger.Printf("auth: jwt verify failed for %s: %v", r.RemoteAddr, err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + ctx := WithTenant(r.Context(), Tenant{ + UserID: claims.UserID, + Email: claims.Email, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func bearerToken(header string) (string, error) { + const prefix = "Bearer " + if header == "" { + return "", errMissingHeader + } + if !strings.HasPrefix(header, prefix) { + return "", errBadScheme + } + tok := strings.TrimSpace(header[len(prefix):]) + if tok == "" { + return "", errEmptyToken + } + return tok, nil +} + +// Sentinel errors for log diagnostics. Not exported; clients always +// see "unauthorized". +var ( + errMissingHeader = strErr("missing Authorization header") + errBadScheme = strErr("expected Bearer scheme") + errEmptyToken = strErr("empty bearer token") +) + +type strErr string + +func (e strErr) Error() string { return string(e) } + +// RequireTenantOwnership compares the tenant on the request with the +// owner of the resource. Returns true if access is allowed; otherwise +// writes 403 to w and returns false. +// +// Handlers that mutate or read tenant-owned resources should call this +// before serving the response. The middleware ensures a Tenant is on +// the context; the handler's job is to ensure the *resource* belongs +// to that tenant. +func RequireTenantOwnership(w http.ResponseWriter, r *http.Request, ownerID string) bool { + t, err := TenantFrom(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return false + } + if t.UserID != ownerID { + http.Error(w, "forbidden", http.StatusForbidden) + return false + } + return true +} diff --git a/internal/httpapi/middleware_test.go b/internal/httpapi/middleware_test.go new file mode 100644 index 0000000..2aff051 --- /dev/null +++ b/internal/httpapi/middleware_test.go @@ -0,0 +1,192 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.librete.ch/public/librenotes/internal/auth" +) + +func newSigner() *auth.Signer { + return auth.NewSigner([]byte("test-secret-32-bytes-of-keymaterial!!")) +} + +func quietLogger() *log.Logger { + return log.New(&bytes.Buffer{}, "", 0) +} + +// passthrough handler: writes the tenant info from context. +func passthrough(t *testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant, err := TenantFrom(r.Context()) + if err != nil { + t.Errorf("no tenant in context: %v", err) + http.Error(w, "no tenant", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(tenant) + }) +} + +func TestAuthMiddleware_Valid(t *testing.T) { + signer := newSigner() + tok, _ := signer.Issue("u-1", "u@example.com") + mw := AuthMiddleware(signer, quietLogger()) + h := mw(passthrough(t)) + + req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("got %d body=%s", rec.Code, rec.Body) + } + var got Tenant + _ = json.NewDecoder(rec.Body).Decode(&got) + if got.UserID != "u-1" || got.Email != "u@example.com" { + t.Errorf("tenant mismatch: %+v", got) + } +} + +func TestAuthMiddleware_MissingHeader(t *testing.T) { + mw := AuthMiddleware(newSigner(), quietLogger()) + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("handler should not run") + })) + req := httptest.NewRequest(http.MethodGet, "/api/x", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d", rec.Code) + } +} + +func TestAuthMiddleware_BadScheme(t *testing.T) { + mw := AuthMiddleware(newSigner(), quietLogger()) + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + for _, hdr := range []string{"Basic abc", "Bearer", " ", "Token xyz"} { + req := httptest.NewRequest(http.MethodGet, "/api/x", nil) + req.Header.Set("Authorization", hdr) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("hdr %q: got %d", hdr, rec.Code) + } + } +} + +func TestAuthMiddleware_InvalidJWT(t *testing.T) { + mw := AuthMiddleware(newSigner(), quietLogger()) + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("handler should not run") + })) + req := httptest.NewRequest(http.MethodGet, "/api/x", nil) + req.Header.Set("Authorization", "Bearer not.a.jwt") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d", rec.Code) + } +} + +func TestAuthMiddleware_TamperedJWT(t *testing.T) { + signer := newSigner() + tok, _ := signer.Issue("u-1", "u@example.com") + tampered := tok[:len(tok)-2] + "XX" + mw := AuthMiddleware(signer, quietLogger()) + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("handler should not run") + })) + req := httptest.NewRequest(http.MethodGet, "/api/x", nil) + req.Header.Set("Authorization", "Bearer "+tampered) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d", rec.Code) + } +} + +func TestAuthMiddleware_DifferentSecretRejects(t *testing.T) { + a := newSigner() + b := auth.NewSigner([]byte("different-32-bytes-of-keymaterial!!!!")) + tok, _ := a.Issue("u-1", "u@example.com") + mw := AuthMiddleware(b, quietLogger()) + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("handler should not run") + })) + req := httptest.NewRequest(http.MethodGet, "/api/x", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d", rec.Code) + } +} + +func TestRequireTenantOwnership(t *testing.T) { + mw := AuthMiddleware(newSigner(), quietLogger()) + signer := newSigner() + tok, _ := signer.Issue("alice", "a@x") + mw = AuthMiddleware(signer, quietLogger()) + + cases := []struct { + name, owner string + want int + }{ + {"self", "alice", http.StatusOK}, + {"other", "bob", http.StatusForbidden}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !RequireTenantOwnership(w, r, c.owner) { + return + } + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/api/notes/"+c.owner, nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != c.want { + t.Errorf("got %d want %d", rec.Code, c.want) + } + }) + } +} + +func TestRouterWiring(t *testing.T) { + signer := newSigner() + srv := &Server{ + Auth: auth.Handlers{Service: nil}, // not exercised here + Signer: signer, + Logger: quietLogger(), + } + mux := srv.Routes() + + // /api/whoami requires auth. + req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("unauth /api/whoami got %d", rec.Code) + } + + // With JWT. + tok, _ := signer.Issue("u-9", "x@y") + req = httptest.NewRequest(http.MethodGet, "/api/whoami", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("auth /api/whoami got %d body=%s", rec.Code, rec.Body) + } + if !strings.Contains(rec.Body.String(), `"user_id":"u-9"`) { + t.Errorf("body missing user_id: %s", rec.Body) + } +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go new file mode 100644 index 0000000..b2d8d01 --- /dev/null +++ b/internal/httpapi/router.go @@ -0,0 +1,50 @@ +package httpapi + +import ( + "encoding/json" + "log" + "net/http" + + "git.librete.ch/public/librenotes/internal/auth" +) + +// Server wires routes for the multi-tenant backend. The auth endpoints +// live under /auth/* and are unauthenticated. Everything under /api/* +// is wrapped by AuthMiddleware and receives a Tenant on the context. +type Server struct { + Auth auth.Handlers + Signer *auth.Signer + Logger *log.Logger +} + +// Routes returns an http.Handler with all routes mounted. +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/auth/login", s.Auth.HandleLogin) + mux.HandleFunc("/auth/verify", s.Auth.HandleVerify) + + protected := http.NewServeMux() + protected.HandleFunc("/api/whoami", s.handleWhoami) + + mw := AuthMiddleware(s.Signer, s.Logger) + mux.Handle("/api/", mw(protected)) + + return mux +} + +// handleWhoami returns the verified tenant identity. Useful for +// frontend session-bootstrapping and as the canonical example of a +// tenant-scoped handler. +func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) { + t, err := TenantFrom(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "user_id": t.UserID, + "email": t.Email, + }) +}