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>
193 lines
5.5 KiB
Go
193 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|