package auth import ( "context" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "kjol/security" "github.com/google/uuid" ) // ---- fakes ----------------------------------------------------------------- type fakeStore struct { mu sync.Mutex sessions map[string]Session inserted []Session limits []int // limit passed to each EnforceSessionLimit call revoked []string } func newFakeStore() *fakeStore { return &fakeStore{sessions: map[string]Session{}} } func (s *fakeStore) FetchByKey(_ context.Context, key string) (Session, error) { s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[key] if !ok { return Session{}, ErrSessionNotFound } return session, nil } func (s *fakeStore) Insert(_ context.Context, session Session) error { s.mu.Lock() defer s.mu.Unlock() s.inserted = append(s.inserted, session) // exactly as the engine handed it over stored := session stored.ID = uuid.New() // stand in for the database default s.sessions[session.Key] = stored return nil } func (s *fakeStore) Revoke(_ context.Context, key string) error { s.mu.Lock() defer s.mu.Unlock() session, ok := s.sessions[key] if !ok { return ErrSessionNotFound } session.Revoked = true s.sessions[key] = session s.revoked = append(s.revoked, key) return nil } func (s *fakeStore) EnforceSessionLimit(_ context.Context, _ uuid.UUID, limit int) error { s.mu.Lock() defer s.mu.Unlock() s.limits = append(s.limits, limit) return nil } // put installs a session directly, bypassing login. func (s *fakeStore) put(session Session) { s.mu.Lock() defer s.mu.Unlock() s.sessions[session.Key] = session } type fakeDirectory struct { mu sync.Mutex users map[string]User outcomes []bool // one per RecordLoginResult call } func (d *fakeDirectory) FindByIdentifier(_ context.Context, identifier string) (User, error) { d.mu.Lock() defer d.mu.Unlock() u, ok := d.users[identifier] if !ok { return User{}, ErrUserNotFound } return u, nil } func (d *fakeDirectory) RecordLoginResult(_ context.Context, _ User, success bool) error { d.mu.Lock() defer d.mu.Unlock() d.outcomes = append(d.outcomes, success) return nil } // fakeResolver hands back a fixed permission set, cloned per call so the engine // can't mutate the fixture out from under a later test. type fakeResolver struct{ perms map[string]bool } func (r fakeResolver) Resolve(_ context.Context, _ Session) map[string]bool { out := make(map[string]bool, len(r.perms)) for k, v := range r.perms { out[k] = v } return out } // ---- harness --------------------------------------------------------------- const ( testPassword = "correct-horse" testUsername = "mta" ) type harness struct { auth *Authenticator str *fakeStore dir *fakeDirectory user User } func newHarness(t *testing.T, mutate ...func(*Config)) *harness { t.Helper() hash, err := security.HashPassword(testPassword) if err != nil { t.Fatalf("hash password: %v", err) } orgID := uuid.New() user := User{ ID: uuid.New(), PasswordHash: hash, Timezone: "UTC", OrgID: &orgID, } cfg := Config{ CookieName: "test_identity", LoginPath: "/auth/login", LogoutPath: "/auth/logout", DefaultPath: "/app/dashboard", Redirect: true, SessionTTL: 24 * time.Hour, KeyBytes: 32, MaxActiveSessions: 5, MaxLoginAttempts: 25, SuperPermission: "*", } for _, m := range mutate { m(&cfg) } str := newFakeStore() dir := &fakeDirectory{users: map[string]User{testUsername: user}} return &harness{ auth: New(cfg, str, dir, fakeResolver{perms: map[string]bool{"read": true}}), str: str, dir: dir, user: user, } } // okHandler records that the request made it through the middleware. func okHandler(served *bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { *served = true w.WriteHeader(http.StatusOK) } } // ---- login ----------------------------------------------------------------- func TestLoginSuccess(t *testing.T) { h := newHarness(t) principal, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{ IPAddr: "10.0.0.1", UserAgent: "test-agent", }) if err != nil { t.Fatalf("Login: %v", err) } if !principal.Authenticated { t.Error("principal is not authenticated") } if key == "" || principal.Session.Key != key { t.Errorf("session key not returned: key=%q session.Key=%q", key, principal.Session.Key) } if principal.Session.UserID != h.user.ID { t.Errorf("UserID = %v, want %v", principal.Session.UserID, h.user.ID) } if principal.Session.OrgID == nil || *principal.Session.OrgID != *h.user.OrgID { t.Error("session did not inherit the user's org") } if principal.Session.IPAddr != "10.0.0.1" || principal.Session.UserAgent != "test-agent" { t.Error("session did not record the request metadata") } if !principal.Permissions["read"] { t.Error("permissions were not resolved onto the principal") } if got, want := principal.Session.Expiration, time.Now().Add(24*time.Hour); got.Sub(want).Abs() > time.Minute { t.Errorf("Expiration = %v, want ~%v", got, want) } if len(h.str.inserted) != 1 { t.Fatalf("inserted %d sessions, want 1", len(h.str.inserted)) } // The store (or its database default) owns ID assignment: cdrateline's // InsertIdentity omits the id column entirely, so an engine-invented ID // would be silently dropped and disagree with the stored row. if h.str.inserted[0].ID != uuid.Nil { t.Errorf("engine assigned session ID %v; that is the store's job", h.str.inserted[0].ID) } if len(h.dir.outcomes) != 1 || !h.dir.outcomes[0] { t.Errorf("login outcomes = %v, want [true]", h.dir.outcomes) } if len(h.str.limits) != 1 || h.str.limits[0] != 5 { t.Errorf("session limits enforced = %v, want [5]", h.str.limits) } } func TestLoginRejectsBadPasswordAndRecordsFailure(t *testing.T) { h := newHarness(t) _, _, err := h.auth.Login(t.Context(), testUsername, "wrong", SessionMeta{}) if err != ErrInvalidCredentials { t.Fatalf("err = %v, want ErrInvalidCredentials", err) } if len(h.str.inserted) != 0 { t.Error("a session was created for a bad password") } if len(h.dir.outcomes) != 1 || h.dir.outcomes[0] { t.Errorf("login outcomes = %v, want [false]", h.dir.outcomes) } } func TestLoginRejectsUnknownUserIndistinguishably(t *testing.T) { h := newHarness(t) _, _, err := h.auth.Login(t.Context(), "nobody", testPassword, SessionMeta{}) if err != ErrInvalidCredentials { t.Fatalf("err = %v, want ErrInvalidCredentials", err) } // Nothing to record against: the user does not exist. if len(h.dir.outcomes) != 0 { t.Errorf("recorded a login outcome for a nonexistent user: %v", h.dir.outcomes) } } func TestLoginLocksOutAfterMaxAttempts(t *testing.T) { h := newHarness(t, func(c *Config) { c.MaxLoginAttempts = 3 }) locked := h.user locked.FailedLoginAttempts = 4 // past the cap h.dir.users[testUsername] = locked // The correct password must not open a locked-out account. _, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) if err != ErrInvalidCredentials { t.Fatalf("err = %v, want ErrInvalidCredentials for a locked-out account", err) } if len(h.str.inserted) != 0 { t.Error("a locked-out account was issued a session") } } func TestLoginLockoutDisabledWhenMaxAttemptsUnset(t *testing.T) { h := newHarness(t, func(c *Config) { c.MaxLoginAttempts = 0 }) noisy := h.user noisy.FailedLoginAttempts = 999 h.dir.users[testUsername] = noisy if _, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}); err != nil { t.Fatalf("Login: %v, want success when lockout is disabled", err) } } func TestLoginSkipsSessionLimitWhenDisabled(t *testing.T) { h := newHarness(t, func(c *Config) { c.MaxActiveSessions = 0 }) if _, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}); err != nil { t.Fatalf("Login: %v", err) } if len(h.str.limits) != 0 { t.Errorf("enforced a session limit though the cap is disabled: %v", h.str.limits) } } func TestLogoutRevokesSession(t *testing.T) { h := newHarness(t) _, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) if err != nil { t.Fatalf("Login: %v", err) } if err := h.auth.Logout(t.Context(), key); err != nil { t.Fatalf("Logout: %v", err) } session, _ := h.str.FetchByKey(t.Context(), key) if !session.Revoked { t.Error("session was not revoked") } // Revoking an unknown or already-revoked session is a no-op, not an error. if err := h.auth.Logout(t.Context(), "no-such-key"); err != nil { t.Errorf("Logout of unknown key: %v, want nil", err) } } // ---- LoadContext ----------------------------------------------------------- // login logs in and returns the raw session key. func (h *harness) login(t *testing.T) string { t.Helper() _, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) if err != nil { t.Fatalf("Login: %v", err) } return key } func TestLoadContextAuthenticatesViaCookie(t *testing.T) { h := newHarness(t) key := h.login(t) var got Principal handler := h.auth.LoadContext(func(w http.ResponseWriter, r *http.Request) { got = PrincipalFrom(r) }, true) r := httptest.NewRequest(http.MethodGet, "/app/dashboard", nil) r.AddCookie(&http.Cookie{Name: "test_identity", Value: key}) w := httptest.NewRecorder() handler(w, r) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if !got.Authenticated || got.Session.UserID != h.user.ID { t.Error("cookie session did not authenticate") } if !got.Permissions["read"] { t.Error("permissions were not attached to the request") } } func TestLoadContextAuthenticatesViaBearerToken(t *testing.T) { h := newHarness(t) key := h.login(t) var got Principal handler := h.auth.LoadContext(func(w http.ResponseWriter, r *http.Request) { got = PrincipalFrom(r) }, true) r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) r.Header.Set("Authorization", "Bearer "+key) w := httptest.NewRecorder() handler(w, r) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if !got.Authenticated { t.Error("bearer token did not authenticate") } } func TestLoadContextOptionalAuthServesAnonymously(t *testing.T) { h := newHarness(t) var got Principal served := false handler := h.auth.LoadContext(func(w http.ResponseWriter, r *http.Request) { served, got = true, PrincipalFrom(r) }, false) w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/", nil)) if !served { t.Fatal("optional-auth route did not serve an anonymous request") } if got.Authenticated || got.Permissions != nil { t.Error("anonymous request did not get the zero Principal") } } func TestLoadContextRedirectsAnonymousBrowserToLogin(t *testing.T) { h := newHarness(t) served := false handler := h.auth.LoadContext(okHandler(&served), true) w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/app/secret?x=1", nil)) if served { t.Fatal("an anonymous request reached a protected handler") } if w.Code != http.StatusFound { t.Fatalf("status = %d, want 302", w.Code) } if got, want := w.Header().Get("Location"), "/auth/login?redirect=%2Fapp%2Fsecret%3Fx%3D1"; got != want { t.Errorf("Location = %q, want %q", got, want) } } func TestLoadContextAnswersAPIWithJSON401(t *testing.T) { h := newHarness(t) served := false handler := h.auth.LoadContext(okHandler(&served), true) w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/api/user/profile", nil)) if served { t.Fatal("an anonymous request reached a protected API handler") } if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", w.Code) } if ct := w.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } if !strings.Contains(w.Body.String(), "valid authentication required") { t.Errorf("body = %q", w.Body.String()) } } func TestLoadContextRejectsExpiredSession(t *testing.T) { h := newHarness(t) h.str.put(Session{ Key: "expired-key", UserID: h.user.ID, Created: time.Now().Add(-48 * time.Hour), Expiration: time.Now().Add(-time.Hour), }) t.Run("api gets a JSON 401", func(t *testing.T) { served := false handler := h.auth.LoadContext(okHandler(&served), true) r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) r.AddCookie(&http.Cookie{Name: "test_identity", Value: "expired-key"}) w := httptest.NewRecorder() handler(w, r) if served || w.Code != http.StatusUnauthorized { t.Fatalf("served=%v status=%d, want false/401", served, w.Code) } if !strings.Contains(w.Body.String(), "session expired") { t.Errorf("body = %q, want it to name the expiry", w.Body.String()) } }) t.Run("browser is redirected and has its dead cookie cleared", func(t *testing.T) { served := false handler := h.auth.LoadContext(okHandler(&served), true) r := httptest.NewRequest(http.MethodGet, "/app/dashboard", nil) r.AddCookie(&http.Cookie{Name: "test_identity", Value: "expired-key"}) w := httptest.NewRecorder() handler(w, r) if served || w.Code != http.StatusFound { t.Fatalf("served=%v status=%d, want false/302", served, w.Code) } cookie := w.Result().Cookies()[0] if cookie.Name != "test_identity" || cookie.MaxAge != -1 { t.Errorf("expired session did not clear the cookie: %+v", cookie) } }) } func TestLoadContextRejectsRevokedSession(t *testing.T) { h := newHarness(t) h.str.put(Session{ Key: "revoked-key", UserID: h.user.ID, Expiration: time.Now().Add(time.Hour), Revoked: true, }) served := false handler := h.auth.LoadContext(okHandler(&served), true) r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) r.AddCookie(&http.Cookie{Name: "test_identity", Value: "revoked-key"}) w := httptest.NewRecorder() handler(w, r) if served { t.Fatal("a revoked session authenticated a request") } if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } func TestLoadContextSendsAuthenticatedUserAwayFromLoginPage(t *testing.T) { h := newHarness(t) key := h.login(t) served := false handler := h.auth.LoadContext(okHandler(&served), false) r := httptest.NewRequest(http.MethodGet, "/auth/login", nil) r.AddCookie(&http.Cookie{Name: "test_identity", Value: key}) w := httptest.NewRecorder() handler(w, r) if w.Code != http.StatusFound || w.Header().Get("Location") != "/app/dashboard" { t.Errorf("status=%d Location=%q, want 302 to /app/dashboard", w.Code, w.Header().Get("Location")) } } func TestLoadContextServesLoginPageToAnonymousUser(t *testing.T) { h := newHarness(t) served := false handler := h.auth.LoadContext(okHandler(&served), true) w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) if !served { t.Fatal("the login page must be reachable while signed out, or login is impossible") } } // A bearer-token request must not disable redirects for the browser requests // that follow it. The middleware previously kept `redirect` in the enclosing // closure and cleared it on seeing a bearer token, so one API call permanently // converted this route's redirect-to-login into a bare 401 for every user. func TestLoadContextBearerRequestDoesNotLeakIntoLaterRequests(t *testing.T) { h := newHarness(t) served := false handler := h.auth.LoadContext(okHandler(&served), true) // An anonymous API call carrying a bearer token: answered with JSON, no redirect. r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) r.Header.Set("Authorization", "Bearer bogus") handler(httptest.NewRecorder(), r) // A browser hitting a page on the same route must still be redirected. w := httptest.NewRecorder() handler(w, httptest.NewRequest(http.MethodGet, "/app/secret", nil)) if w.Code != http.StatusFound { t.Fatalf("status = %d, want 302: a prior bearer request leaked into this one", w.Code) } } // ---- authorization --------------------------------------------------------- func principalWith(perms ...string) Principal { p := Principal{Authenticated: true, Permissions: map[string]bool{}} for _, perm := range perms { p.Permissions[perm] = true } return p } func TestAuthorized(t *testing.T) { a := newHarness(t).auth tests := []struct { name string held Principal need []string allow bool }{ {"holds the permission", principalWith("read"), []string{"read"}, true}, {"missing the permission", principalWith("read"), []string{"write"}, false}, {"holds all of several", principalWith("read", "write"), []string{"read", "write"}, true}, {"holds only some of several", principalWith("read"), []string{"read", "write"}, false}, {"super permission satisfies anything", principalWith("*"), []string{"write"}, true}, {"no requirements", principalWith(), nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := a.Authorized(tt.held, tt.need...); got != tt.allow { t.Errorf("Authorized = %v, want %v", got, tt.allow) } }) } } func TestAuthorizedExactIgnoresSuperPermission(t *testing.T) { a := newHarness(t).auth super := principalWith("*") if !a.Authorized(super, "write") { t.Error("Authorized: the super permission should satisfy any requirement") } if a.AuthorizedExact(super, "write") { t.Error("AuthorizedExact: the super permission must not bypass an exact check") } if !a.AuthorizedExact(principalWith("write"), "write") { t.Error("AuthorizedExact: a genuinely held permission should pass") } } func TestAuthorizedAny(t *testing.T) { a := newHarness(t).auth if !a.AuthorizedAny(principalWith("write"), "read", "write") { t.Error("AuthorizedAny: holding one of the listed permissions should pass") } if a.AuthorizedAny(principalWith("delete"), "read", "write") { t.Error("AuthorizedAny: holding none of them should fail") } if !a.AuthorizedAny(principalWith("*"), "read") { t.Error("AuthorizedAny: the super permission should satisfy it") } if a.AuthorizedAny(principalWith("read")) { t.Error("AuthorizedAny: an empty list grants nothing") } } // requireCase drives a Require-family middleware over a request whose principal // holds perms, and reports the status and whether the handler ran. func requireCase(t *testing.T, mw func(http.HandlerFunc) http.HandlerFunc, path string, perms ...string) (int, bool) { t.Helper() served := false r := withPrincipal(httptest.NewRequest(http.MethodGet, path, nil), principalWith(perms...)) w := httptest.NewRecorder() mw(okHandler(&served))(w, r) return w.Code, served } func TestRequireGatesHandler(t *testing.T) { a := newHarness(t).auth t.Run("allows a caller holding the permission", func(t *testing.T) { mw := func(h http.HandlerFunc) http.HandlerFunc { return a.Require(h, "read") } if code, served := requireCase(t, mw, "/api/x", "read"); !served || code != http.StatusOK { t.Errorf("status=%d served=%v, want 200/true", code, served) } }) t.Run("denies a caller missing it, in JSON on an API route", func(t *testing.T) { mw := func(h http.HandlerFunc) http.HandlerFunc { return a.Require(h, "write") } code, served := requireCase(t, mw, "/api/x", "read") if served || code != http.StatusForbidden { t.Errorf("status=%d served=%v, want 403/false", code, served) } }) t.Run("denies a browser caller with a bare 403", func(t *testing.T) { mw := func(h http.HandlerFunc) http.HandlerFunc { return a.Require(h, "write") } if code, served := requireCase(t, mw, "/app/x", "read"); served || code != http.StatusForbidden { t.Errorf("status=%d served=%v, want 403/false", code, served) } }) t.Run("denies an anonymous caller", func(t *testing.T) { served := false w := httptest.NewRecorder() a.Require(okHandler(&served), "read")(w, httptest.NewRequest(http.MethodGet, "/api/x", nil)) if served || w.Code != http.StatusForbidden { t.Errorf("status=%d served=%v, want 403/false", w.Code, served) } }) } func TestRequireExactAndRequireAnyMiddleware(t *testing.T) { a := newHarness(t).auth exact := func(h http.HandlerFunc) http.HandlerFunc { return a.RequireExact(h, "write") } if code, served := requireCase(t, exact, "/api/x", "*"); served || code != http.StatusForbidden { t.Errorf("RequireExact: status=%d served=%v, want 403/false for a super-permission holder", code, served) } any := func(h http.HandlerFunc) http.HandlerFunc { return a.RequireAny(h, "read", "write") } if code, served := requireCase(t, any, "/api/x", "write"); !served || code != http.StatusOK { t.Errorf("RequireAny: status=%d served=%v, want 200/true", code, served) } if code, served := requireCase(t, any, "/api/x", "delete"); served || code != http.StatusForbidden { t.Errorf("RequireAny: status=%d served=%v, want 403/false", code, served) } } // ---- implications ---------------------------------------------------------- func TestImplicationsGrantTransitively(t *testing.T) { h := newHarness(t, func(c *Config) { c.Implications = map[string][]string{ "manage_events": {"view_events", "view_seasons"}, } }) h.auth.resolver = fakeResolver{perms: map[string]bool{"manage_events": true}} _, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) if err != nil { t.Fatalf("Login: %v", err) } session, _ := h.str.FetchByKey(t.Context(), key) perms := h.auth.resolvePermissions(t.Context(), session) for _, want := range []string{"view_events", "view_seasons"} { if !perms[want] { t.Errorf("%q was not implied by manage_events", want) } } } func TestImplicationsRespectExplicitDeny(t *testing.T) { h := newHarness(t, func(c *Config) { c.Implications = map[string][]string{"manage_events": {"view_events"}} }) // An explicit deny — the permission is present and false, not merely absent. h.auth.resolver = fakeResolver{perms: map[string]bool{ "manage_events": true, "view_events": false, }} perms := h.auth.resolvePermissions(t.Context(), Session{}) if perms["view_events"] { t.Error("an implication overrode an explicit deny") } } func TestImplicationsIgnoreUnheldSource(t *testing.T) { h := newHarness(t, func(c *Config) { c.Implications = map[string][]string{"manage_events": {"view_events"}} }) h.auth.resolver = fakeResolver{perms: map[string]bool{"read": true}} if perms := h.auth.resolvePermissions(t.Context(), Session{}); perms["view_events"] { t.Error("an implication fired though its source permission is not held") } } // ---- api keys -------------------------------------------------------------- type fakeAPIKeys struct { mu sync.Mutex keys map[string]APIKey touched []uuid.UUID } func (k *fakeAPIKeys) FindAPIKeyByHash(_ context.Context, hash string) (APIKey, error) { k.mu.Lock() defer k.mu.Unlock() key, ok := k.keys[hash] if !ok { return APIKey{}, ErrAPIKeyNotFound } return key, nil } func (k *fakeAPIKeys) TouchAPIKey(_ context.Context, id uuid.UUID) { k.mu.Lock() defer k.mu.Unlock() k.touched = append(k.touched, id) } func TestLoadAPIKey(t *testing.T) { const raw = "sk_live_secret" orgID := uuid.New() newAuth := func(key APIKey) (*Authenticator, *fakeAPIKeys) { keys := &fakeAPIKeys{keys: map[string]APIKey{HashAPIKey(raw): key}} h := newHarness(t, func(c *Config) { c.APIKeys = keys }) return h.auth, keys } t.Run("a valid key authenticates and scopes the request to its org", func(t *testing.T) { a, keys := newAuth(APIKey{ID: uuid.New(), OrgID: orgID}) var got Principal r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) r.Header.Set("Authorization", "Bearer "+raw) w := httptest.NewRecorder() a.LoadAPIKey(func(w http.ResponseWriter, r *http.Request) { got = PrincipalFrom(r) })(w, r) if !got.Authenticated || !got.IsAPIKey { t.Fatalf("principal = %+v, want an authenticated API-key principal", got) } if got.Session.OrgID == nil || *got.Session.OrgID != orgID { t.Error("principal was not scoped to the key's org") } if got.Session.UserID != uuid.Nil { t.Error("an API key must not impersonate a user") } // TouchAPIKey runs on its own goroutine; give it a moment. deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { keys.mu.Lock() n := len(keys.touched) keys.mu.Unlock() if n == 1 { return } time.Sleep(5 * time.Millisecond) } t.Error("the key's last-used was never recorded") }) t.Run("a revoked key is rejected", func(t *testing.T) { a, _ := newAuth(APIKey{ID: uuid.New(), OrgID: orgID, Revoked: true}) served := false r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) r.Header.Set("Authorization", "Bearer "+raw) w := httptest.NewRecorder() a.LoadAPIKey(okHandler(&served))(w, r) if served || w.Code != http.StatusUnauthorized { t.Errorf("status=%d served=%v, want 401/false", w.Code, served) } }) t.Run("an unknown key is rejected", func(t *testing.T) { a, _ := newAuth(APIKey{ID: uuid.New(), OrgID: orgID}) served := false r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) r.Header.Set("Authorization", "Bearer not-a-key") w := httptest.NewRecorder() a.LoadAPIKey(okHandler(&served))(w, r) if served || w.Code != http.StatusUnauthorized { t.Errorf("status=%d served=%v, want 401/false", w.Code, served) } }) t.Run("a missing header is rejected", func(t *testing.T) { a, _ := newAuth(APIKey{ID: uuid.New(), OrgID: orgID}) served := false w := httptest.NewRecorder() a.LoadAPIKey(okHandler(&served))(w, httptest.NewRequest(http.MethodGet, "/api/external/data", nil)) if served || w.Code != http.StatusUnauthorized { t.Errorf("status=%d served=%v, want 401/false", w.Code, served) } }) t.Run("the route is closed when no API-key directory is configured", func(t *testing.T) { a := newHarness(t).auth // no Config.APIKeys served := false r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) r.Header.Set("Authorization", "Bearer "+raw) w := httptest.NewRecorder() a.LoadAPIKey(okHandler(&served))(w, r) if served || w.Code != http.StatusUnauthorized { t.Errorf("status=%d served=%v, want 401/false", w.Code, served) } }) } func TestHashAPIKeyIsStableAndHidesTheKey(t *testing.T) { hash := HashAPIKey("sk_live_secret") if hash != HashAPIKey("sk_live_secret") { t.Error("HashAPIKey is not deterministic") } if hash == HashAPIKey("sk_live_secret2") { t.Error("distinct keys collided") } if strings.Contains(hash, "sk_live_secret") { t.Error("the digest leaks the raw key") } if len(hash) != 64 { t.Errorf("len = %d, want 64 hex chars of SHA-256", len(hash)) } } // ---- cookies --------------------------------------------------------------- func TestSetSessionCookie(t *testing.T) { h := newHarness(t) principal, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) if err != nil { t.Fatalf("Login: %v", err) } t.Run("without remember-me it dies with the browser", func(t *testing.T) { w := httptest.NewRecorder() if err := h.auth.SetSessionCookie(w, httptest.NewRequest(http.MethodPost, "/auth/login", nil), principal, false); err != nil { t.Fatalf("SetSessionCookie: %v", err) } cookie := w.Result().Cookies()[0] if cookie.Value != principal.Session.Key { t.Error("the cookie does not carry the session key") } if !cookie.HttpOnly { t.Error("the session cookie must be HttpOnly") } if !cookie.Expires.IsZero() { t.Error("without remember-me the cookie must be a session cookie") } }) t.Run("with remember-me it carries the session's expiry", func(t *testing.T) { w := httptest.NewRecorder() if err := h.auth.SetSessionCookie(w, httptest.NewRequest(http.MethodPost, "/auth/login", nil), principal, true); err != nil { t.Fatalf("SetSessionCookie: %v", err) } cookie := w.Result().Cookies()[0] if !cookie.Expires.Equal(principal.Session.Expiration.Truncate(time.Second)) && cookie.Expires.Sub(principal.Session.Expiration).Abs() > time.Second { t.Errorf("Expires = %v, want the session expiration %v", cookie.Expires, principal.Session.Expiration) } }) t.Run("it fails rather than overflow the domain's cookie budget", func(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/auth/login", nil) r.AddCookie(&http.Cookie{Name: "bloat", Value: strings.Repeat("x", maxCookieBytes)}) if err := h.auth.SetSessionCookie(httptest.NewRecorder(), r, principal, true); err == nil { t.Error("SetSessionCookie: nil error, want a failure when the budget is blown") } }) } // ---- password policy ------------------------------------------------------- func TestCheckPassword(t *testing.T) { a := newHarness(t, func(c *Config) { c.Password = PasswordPolicy{ MinLength: 8, RequiredUppercase: 1, RequiredLowercase: 1, RequiredNumbers: 1, RequiredSymbols: 1, } }).auth tests := []struct { name string password string ok bool }{ {"satisfies every rule", "Passw0rd!", true}, {"blank", "", false}, {"whitespace only", " ", false}, {"too short", "Pw0!", false}, {"no uppercase", "passw0rd!", false}, {"no lowercase", "PASSW0RD!", false}, {"no number", "Password!", false}, {"no symbol", "Passw0rdd", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := a.CheckPassword(tt.password) if tt.ok && err != nil { t.Errorf("CheckPassword(%q) = %v, want nil", tt.password, err) } if !tt.ok && err == nil { t.Errorf("CheckPassword(%q) = nil, want an error", tt.password) } }) } } func TestZeroPasswordPolicyAcceptsAnyNonBlankPassword(t *testing.T) { if err := CheckPasswordPolicy(PasswordPolicy{}, "a"); err != nil { t.Errorf("CheckPasswordPolicy = %v, want nil under the zero policy", err) } if err := CheckPasswordPolicy(PasswordPolicy{}, ""); err == nil { t.Error("CheckPasswordPolicy: a blank password must always be rejected") } } func TestGeneratePasswordResetToken(t *testing.T) { first, err := GeneratePasswordResetToken() if err != nil { t.Fatalf("GeneratePasswordResetToken: %v", err) } if len(first) != 64 { t.Errorf("len = %d, want 64 hex chars of 32 random bytes", len(first)) } second, err := GeneratePasswordResetToken() if err != nil { t.Fatalf("GeneratePasswordResetToken: %v", err) } if first == second { t.Error("two reset tokens came back identical") } } // ---- context --------------------------------------------------------------- func TestPrincipalFromRequestWithoutMiddleware(t *testing.T) { p := PrincipalFrom(httptest.NewRequest(http.MethodGet, "/", nil)) if p.Authenticated { t.Error("a request that never passed through the middleware came back authenticated") } }