Files
pages/internal/auth/middleware_test.go
2026-08-15 07:13:00 +00:00

408 lines
12 KiB
Go

package auth
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/store"
)
// okHandler records that the request got past the middleware.
func okHandler(reached *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if reached != nil {
*reached = true
}
w.WriteHeader(http.StatusNoContent)
})
}
func errorCode(t *testing.T, body []byte) api.Code {
t.Helper()
var env api.ErrorEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("response is not an error envelope: %v (%s)", err, body)
}
if env.Error.Code == "" {
t.Fatalf("envelope has no error code: %s", body)
}
return env.Error.Code
}
func newMiddleware(t *testing.T, db *store.DB, logTo *bytes.Buffer) *Middleware {
t.Helper()
var h slog.Handler = slog.NewTextHandler(logTo, &slog.HandlerOptions{Level: slog.LevelDebug})
log := slog.New(h)
return &Middleware{
V: NewVerifier(db, log, DefaultCacheTTL),
Limiter: NewLimiter(5, time.Minute, 128),
Trusted: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
Log: log,
}
}
func TestAuthenticateAcceptsValidToken(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
var gotID *Identity
h := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := IdentityFrom(r.Context())
if !ok {
t.Error("no identity in context behind Authenticate")
}
gotID = id
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204: %s", rec.Code, rec.Body)
}
if gotID == nil || gotID.KeyID != keyID {
t.Errorf("identity = %+v, want key %s", gotID, keyID)
}
}
func TestAuthenticateRejects(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
_, secret, err := Parse(token)
if err != nil {
t.Fatal(err)
}
unknown, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
header string
}{
{"no header", ""},
{"empty bearer", "Bearer "},
{"wrong scheme", "Basic " + token},
{"token without scheme", token},
{"malformed token", "Bearer not-a-token"},
{"unknown key", "Bearer " + unknown},
{"truncated secret", "Bearer " + Prefix + "_" + keyID + "_" + secret[:42]},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached := false
h := m.Authenticate(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if reached {
t.Error("request reached the handler")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401: %s", rec.Code, rec.Body)
}
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeUnauthorized {
t.Errorf("code = %q, want %q", got, api.CodeUnauthorized)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
t.Errorf("WWW-Authenticate = %q", got)
}
// The client must not learn which check failed.
body := rec.Body.String()
for _, leak := range []string{"revoked", "expired", "unknown key", "secret mismatch"} {
if strings.Contains(strings.ToLower(body), leak) {
t.Errorf("response distinguishes the failure reason (%q): %s", leak, body)
}
}
})
}
}
// The scheme is case-insensitive per RFC 7235, and some CI clients send "bearer".
func TestAuthenticateAcceptsAnyCaseScheme(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
for _, scheme := range []string{"Bearer", "bearer", "BEARER", "BeArEr"} {
reached := false
h := m.Authenticate(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", scheme+" "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !reached {
t.Errorf("scheme %q rejected: %d %s", scheme, rec.Code, rec.Body)
}
}
}
// A token in the query string ends up in proxy logs and browser history, so it
// must never be accepted as a credential.
func TestTokenInQueryStringIsNotAccepted(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
reached := false
h := m.Authenticate(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami?token="+token+"&access_token="+token, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if reached {
t.Fatal("a query-string token authenticated the request")
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
// The load-bearing one: nothing this middleware logs may contain the secret.
func TestAuthLogsNeverContainCredentials(t *testing.T) {
db := testStore(t)
var logBuf bytes.Buffer
m := newMiddleware(t, db, &logBuf)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
_, secret, err := Parse(token)
if err != nil {
t.Fatal(err)
}
handler := httpx.Chain(
m.Authenticate(okHandler(nil)),
httpx.WithRequestID(m.Trusted),
httpx.AccessLog(m.Log, m.Trusted),
httpx.Recover(m.Log),
)
// A successful request, a wrong-secret request, and a garbage request: the
// three paths that each touch the token.
for _, hdr := range []string{
"Bearer " + token,
"Bearer " + Prefix + "_" + keyID + "_" + strings.Repeat("z", 43),
"Bearer " + token + "trailing",
} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
req.Header.Set("Authorization", hdr)
req.RemoteAddr = "10.1.2.3:5555"
handler.ServeHTTP(httptest.NewRecorder(), req)
}
out := logBuf.String()
if out == "" {
t.Fatal("nothing was logged; the test would pass vacuously")
}
for _, forbidden := range []string{secret, token, "Bearer", "Authorization"} {
if strings.Contains(out, forbidden) {
t.Errorf("log contains %q:\n%s", forbidden, out)
}
}
// The public half is supposed to be there — otherwise an operator cannot
// tell which key made a request.
if !strings.Contains(out, keyID) {
t.Errorf("log does not record the key id:\n%s", out)
}
}
func TestAuthenticateRateLimitsFailures(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
m.Limiter = NewLimiter(3, time.Minute, 32)
bad, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
good, _ := mintInto(t, db, store.ScopeAdmin, nil)
send := func(token, remote string) *httptest.ResponseRecorder {
h := m.Authenticate(okHandler(nil))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = remote
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
if got := send(bad, "10.1.2.3:5555").Code; got != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i, got)
}
}
rec := send(bad, "10.1.2.3:5555")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429: %s", rec.Code, rec.Body)
}
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeRateLimited {
t.Errorf("code = %q, want %q", got, api.CodeRateLimited)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("429 without a Retry-After header")
}
// A different address is unaffected...
if got := send(bad, "10.9.9.9:5555").Code; got != http.StatusUnauthorized {
t.Errorf("unrelated client got %d, want 401", got)
}
// ...and a client that had never failed can still authenticate.
if got := send(good, "10.8.8.8:5555").Code; got != http.StatusNoContent {
t.Errorf("valid token from a clean client got %d, want 204", got)
}
}
func TestRequireAdmin(t *testing.T) {
db := testStore(t)
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
p := store.DefaultProject("demo")
if err := db.CreateProject(context.Background(), p); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
identity *Identity
wantStatus int
}{
{"admin", &Identity{KeyID: "a", Scope: store.ScopeAdmin}, http.StatusNoContent},
{"project", &Identity{KeyID: "b", Scope: store.ScopeProject, ProjectID: &p.ID}, http.StatusForbidden},
{"none", nil, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached := false
h := RequireAdmin(log)(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
if tc.identity != nil {
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
}
if reached != (tc.wantStatus == http.StatusNoContent) {
t.Errorf("handler reached = %v", reached)
}
})
}
}
func TestRequireProject(t *testing.T) {
db := testStore(t)
ctx := context.Background()
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
mine := store.DefaultProject("mine")
theirs := store.DefaultProject("theirs")
if err := db.CreateProject(ctx, mine); err != nil {
t.Fatal(err)
}
if err := db.CreateProject(ctx, theirs); err != nil {
t.Fatal(err)
}
resolver := ResolverFunc(func(ctx context.Context, name string) (int64, error) {
p, err := db.ProjectByName(ctx, name)
if err != nil {
return 0, err
}
return p.ID, nil
})
admin := &Identity{KeyID: "admin00000000000", Scope: store.ScopeAdmin}
owner := &Identity{KeyID: "owner00000000000", Scope: store.ScopeProject, ProjectID: &mine.ID}
cases := []struct {
name string
identity *Identity
project string
wantStatus int
}{
{"owner on own project", owner, "mine", http.StatusNoContent},
{"owner on another project", owner, "theirs", http.StatusForbidden},
{"admin on any project", admin, "theirs", http.StatusNoContent},
{"admin on unknown project", admin, "ghost", http.StatusNotFound},
// An unknown name must look exactly like someone else's project to a
// project-scoped key, or the API becomes a project-name oracle.
{"owner on unknown project", owner, "ghost", http.StatusForbidden},
{"no identity", nil, "mine", http.StatusUnauthorized},
{"missing name", owner, "", http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached := false
mux := http.NewServeMux()
mux.Handle("GET /api/v1/projects/{name}", RequireProject("name", resolver, log)(okHandler(&reached)))
// The "missing name" case cannot be produced through the mux, so it
// exercises the handler directly.
var h http.Handler = mux
target := "/api/v1/projects/" + tc.project
if tc.project == "" {
h = RequireProject("name", resolver, log)(okHandler(&reached))
target = "/api/v1/projects/"
}
req := httptest.NewRequest(http.MethodGet, target, nil)
if tc.identity != nil {
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
}
if reached != (tc.wantStatus == http.StatusNoContent) {
t.Errorf("handler reached = %v", reached)
}
})
}
}
// Ownership is decided on row ids. A project key must not gain access to a
// project just because a name resolves to it.
func TestOwnsComparesIDsNotNames(t *testing.T) {
one := int64(1)
two := int64(2)
cases := []struct {
name string
id *Identity
ask int64
want bool
}{
{"admin owns anything", &Identity{Scope: store.ScopeAdmin}, 42, true},
{"project owns itself", &Identity{Scope: store.ScopeProject, ProjectID: &one}, 1, true},
{"project does not own another", &Identity{Scope: store.ScopeProject, ProjectID: &two}, 1, false},
{"project key without a project owns nothing", &Identity{Scope: store.ScopeProject}, 1, false},
{"nil identity owns nothing", nil, 1, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.id.Owns(tc.ask); got != tc.want {
t.Errorf("Owns(%d) = %v, want %v", tc.ask, got, tc.want)
}
})
}
}