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 <jwt> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.librete.ch/public/librenotes/internal/auth"
|
||||
)
|
||||
|
||||
// AuthMiddleware validates the Authorization: Bearer <jwt> 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user