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) } }) } }