Files
kjol/go/auth/auth_test.go
2026-07-14 16:49:56 -04:00

1015 lines
30 KiB
Go

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