Files
libretechandClaude Opus 4.7 03bc16571d 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>
2026-04-28 22:19:09 +02:00

92 lines
2.7 KiB
Go

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
}