Also publishes the jobs topic on agent revoke and updates the agent snippet to mount stacks read-write at the same path.
257 lines
8.2 KiB
Go
257 lines
8.2 KiB
Go
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)
|
|
}
|