package webauth import ( "context" "errors" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/master/model" ) type fakeProvider struct { claims Claims err error gotVer string } func (p *fakeProvider) AuthCodeURL(state, _ string) string { return "https://auth.example/authorize?state=" + url.QueryEscape(state) } func (p *fakeProvider) Exchange(_ context.Context, _ string, verifier string) (Claims, error) { p.gotVer = verifier return p.claims, p.err } type fakeStore struct { mu sync.Mutex sessions map[string]model.Session } func newFakeStore() *fakeStore { return &fakeStore{sessions: map[string]model.Session{}} } func (f *fakeStore) CreateSession(_ context.Context, s model.Session) error { f.mu.Lock() defer f.mu.Unlock() f.sessions[s.ID] = s return nil } func (f *fakeStore) GetSession(_ context.Context, id string, now time.Time) (model.Session, error) { f.mu.Lock() defer f.mu.Unlock() s, ok := f.sessions[id] if !ok || !s.ExpiresAt.After(now) { return model.Session{}, model.ErrNotFound } return s, nil } func (f *fakeStore) DeleteSession(_ context.Context, id string) error { f.mu.Lock() defer f.mu.Unlock() delete(f.sessions, id) return nil } func newAuth(p *fakeProvider, s *fakeStore) *Auth { return New(p, s, "updater-admins", true, slog.New(slog.NewTextHandler(io.Discard, nil))) } // startLogin runs /auth/login and returns the flow cookie and the state. func startLogin(t *testing.T, a *Auth) (*http.Cookie, string) { t.Helper() rec := httptest.NewRecorder() a.Login(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) require.Equal(t, http.StatusFound, rec.Code) loc, err := url.Parse(rec.Header().Get("Location")) require.NoError(t, err) cookies := rec.Result().Cookies() require.Len(t, cookies, 1) return cookies[0], loc.Query().Get("state") } func callback(a *Auth, flow *http.Cookie, state string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+url.QueryEscape(state), nil) if flow != nil { req.AddCookie(flow) } rec := httptest.NewRecorder() a.Callback(rec, req) return rec } func TestLogin_SetsFlowCookieAndRedirects(t *testing.T) { a := newAuth(&fakeProvider{}, newFakeStore()) flow, state := startLogin(t, a) assert.True(t, flow.HttpOnly) assert.True(t, flow.Secure) assert.NotEmpty(t, state) } func TestCallback_InvalidStateRejected(t *testing.T) { a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"updater-admins"}}}, newFakeStore()) flow, _ := startLogin(t, a) emptyStateFlow := &http.Cookie{Name: flowCookieName, Value: ".verifier"} cases := []struct { name string flow *http.Cookie state string }{ {"state mismatch", flow, "forged"}, {"empty state in cookie and query", emptyStateFlow, ""}, {"missing flow cookie", nil, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { rec := callback(a, c.flow, c.state) assert.Equal(t, http.StatusBadRequest, rec.Code) }) } } func TestCallback_UserOutsideGroupForbidden(t *testing.T) { store := newFakeStore() a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"family"}}}, store) flow, state := startLogin(t, a) rec := callback(a, flow, state) assert.Equal(t, http.StatusForbidden, rec.Code) assert.Empty(t, store.sessions) } func TestCallback_ExchangeFailure(t *testing.T) { a := newAuth(&fakeProvider{err: errors.New("bad code")}, newFakeStore()) flow, state := startLogin(t, a) assert.Equal(t, http.StatusUnauthorized, callback(a, flow, state).Code) } func TestCallback_CreatesSession(t *testing.T) { store := newFakeStore() p := &fakeProvider{claims: Claims{Subject: "u1", Name: "Samuel", Groups: []string{"updater-admins"}}} a := newAuth(p, store) flow, state := startLogin(t, a) rec := callback(a, flow, state) require.Equal(t, http.StatusFound, rec.Code) assert.Equal(t, "/", rec.Header().Get("Location")) assert.NotEmpty(t, p.gotVer, "the PKCE verifier from the flow cookie must reach the exchange") var sessionCookie *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == sessionCookieName { sessionCookie = c } } require.NotNil(t, sessionCookie) assert.True(t, sessionCookie.HttpOnly) assert.Equal(t, http.SameSiteLaxMode, sessionCookie.SameSite) s, ok := store.sessions[sessionCookie.Value] require.True(t, ok) assert.Equal(t, "Samuel", s.Name) assert.NotEmpty(t, s.CSRFToken) } func protectedRequest(method, target string, sessionID string) *http.Request { req := httptest.NewRequest(method, target, nil) if sessionID != "" { req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionID}) } return req } func TestRequire(t *testing.T) { store := newFakeStore() store.sessions["live"] = model.Session{ID: "live", Name: "Samuel", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)} store.sessions["old"] = model.Session{ID: "old", CSRFToken: "tok", ExpiresAt: time.Now().Add(-time.Hour)} a := newAuth(&fakeProvider{}, store) var gotName string next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s, _ := SessionFrom(r.Context()) gotName = s.Name w.WriteHeader(http.StatusNoContent) }) h := a.Require(next) cases := []struct { name string req *http.Request wantCode int wantHeader [2]string }{ {"no session GET redirects to login", protectedRequest(http.MethodGet, "/", ""), http.StatusFound, [2]string{"Location", "/auth/login"}}, {"expired session on htmx fragment", func() *http.Request { r := protectedRequest(http.MethodGet, "/fragments/services", "old") r.Header.Set("HX-Request", "true") return r }(), http.StatusUnauthorized, [2]string{"HX-Redirect", "/auth/login"}}, {"event-stream without session gets plain 401", func() *http.Request { r := protectedRequest(http.MethodGet, "/events", "") r.Header.Set("Accept", "text/event-stream") return r }(), http.StatusUnauthorized, [2]string{"Location", ""}}, {"no session POST", protectedRequest(http.MethodPost, "/agents", ""), http.StatusUnauthorized, [2]string{}}, {"POST without CSRF", protectedRequest(http.MethodPost, "/agents", "live"), http.StatusForbidden, [2]string{}}, {"POST with CSRF header", func() *http.Request { r := protectedRequest(http.MethodPost, "/agents", "live") r.Header.Set("X-CSRF-Token", "tok") return r }(), http.StatusNoContent, [2]string{}}, {"POST with CSRF form field", func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/agents", strings.NewReader("csrf_token=tok")) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"}) return r }(), http.StatusNoContent, [2]string{}}, {"POST with CSRF only in query string", func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/agents?csrf_token=tok", strings.NewReader("name=x")) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"}) return r }(), http.StatusForbidden, [2]string{}}, {"GET with session", protectedRequest(http.MethodGet, "/", "live"), http.StatusNoContent, [2]string{}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, c.req) assert.Equal(t, c.wantCode, rec.Code) if c.wantHeader[0] != "" { assert.Equal(t, c.wantHeader[1], rec.Header().Get(c.wantHeader[0])) } }) } assert.Equal(t, "Samuel", gotName) } func TestLogout_DeletesSession(t *testing.T) { store := newFakeStore() store.sessions["live"] = model.Session{ID: "live", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)} a := newAuth(&fakeProvider{}, store) req := protectedRequest(http.MethodPost, "/auth/logout", "live") req.Header.Set("X-CSRF-Token", "tok") rec := httptest.NewRecorder() a.Require(http.HandlerFunc(a.Logout)).ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.Empty(t, store.sessions) } func TestWithSession_RoundTrips(t *testing.T) { ctx := WithSession(context.Background(), model.Session{Subject: "alice"}) sess, ok := SessionFrom(ctx) assert.True(t, ok) assert.Equal(t, "alice", sess.Subject) }