diff --git a/go.mod b/go.mod index db55330..8ac9846 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,13 @@ go 1.26.3 require ( github.com/coder/websocket v1.8.15 + github.com/coreos/go-oidc/v3 v3.21.0 github.com/docker/docker v28.5.2+incompatible github.com/jackc/pgx/v5 v5.10.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.36.0 ) require ( @@ -23,6 +25,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index a1d0591..f933059 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -29,6 +31,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -119,6 +123,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= diff --git a/internal/master/webauth/auth.go b/internal/master/webauth/auth.go new file mode 100644 index 0000000..0d51d63 --- /dev/null +++ b/internal/master/webauth/auth.go @@ -0,0 +1,184 @@ +// Package webauth logs people into the master UI through OIDC (Authentik), +// keeps their sessions, and protects state-changing requests with a CSRF +// token. +package webauth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "log/slog" + "net/http" + "slices" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/shcizo/package-updater/internal/master/model" +) + +const ( + sessionCookieName = "pu_session" + flowCookieName = "pu_oidc_flow" + sessionTTL = 12 * time.Hour +) + +// Store keeps sessions. *store.Store satisfies it. +type Store interface { + CreateSession(ctx context.Context, s model.Session) error + GetSession(ctx context.Context, id string, now time.Time) (model.Session, error) + DeleteSession(ctx context.Context, id string) error +} + +// Auth holds the login handlers and the session middleware. +type Auth struct { + provider Provider + store Store + allowedGroup string + secure bool + log *slog.Logger + now func() time.Time +} + +// New returns an Auth. Only members of allowedGroup may log in. secureCookies +// should be true whenever the UI is served over HTTPS. +func New(p Provider, s Store, allowedGroup string, secureCookies bool, log *slog.Logger) *Auth { + return &Auth{provider: p, store: s, allowedGroup: allowedGroup, secure: secureCookies, log: log, now: time.Now} +} + +// Login starts the OIDC flow. State and PKCE verifier travel in a short-lived +// cookie scoped to /auth/; tampering with it can only break the user's own +// login. +func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { + state := randomString() + verifier := oauth2.GenerateVerifier() + http.SetCookie(w, &http.Cookie{ + Name: flowCookieName, Value: state + "." + verifier, Path: "/auth/", + MaxAge: 600, HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, a.provider.AuthCodeURL(state, verifier), http.StatusFound) +} + +// Callback finishes the OIDC flow, checks group membership and creates the +// session. +func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) { + flow, err := r.Cookie(flowCookieName) + if err != nil { + http.Error(w, "login flow expired, start again", http.StatusBadRequest) + return + } + state, verifier, ok := strings.Cut(flow.Value, ".") + if !ok || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("state")), []byte(state)) != 1 { + http.Error(w, "invalid login state", http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{Name: flowCookieName, Path: "/auth/", MaxAge: -1}) + + claims, err := a.provider.Exchange(r.Context(), r.URL.Query().Get("code"), verifier) + if err != nil { + a.log.Warn("login_failed", "error", err) + http.Error(w, "login failed", http.StatusUnauthorized) + return + } + if !slices.Contains(claims.Groups, a.allowedGroup) { + a.log.Warn("login_forbidden", "subject", claims.Subject, "required_group", a.allowedGroup) + http.Error(w, "forbidden: you are not a member of the group allowed to use package-updater", http.StatusForbidden) + return + } + + sess := model.Session{ + ID: randomString(), Subject: claims.Subject, Name: claims.Name, Email: claims.Email, + CSRFToken: randomString(), ExpiresAt: a.now().Add(sessionTTL), + } + if err := a.store.CreateSession(r.Context(), sess); err != nil { + a.log.Error("create_session_failed", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, Value: sess.ID, Path: "/", Expires: sess.ExpiresAt, + HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode, + }) + a.log.Info("login", "subject", claims.Subject, "name", claims.Name) + http.Redirect(w, r, "/", http.StatusFound) +} + +// Logout ends the session. Mount behind Require so it is CSRF-protected. +func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) { + if s, ok := SessionFrom(r.Context()); ok { + if err := a.store.DeleteSession(r.Context(), s.ID); err != nil { + a.log.Error("delete_session_failed", "error", err) + } + } + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Path: "/", MaxAge: -1}) + _, _ = w.Write([]byte("Logged out.")) +} + +// Require lets a request through only with a valid session, and for +// anything but GET/HEAD only with the session's CSRF token in the +// X-CSRF-Token header or the csrf_token form field. htmx requests without a +// session get 401 + HX-Redirect so the whole page navigates to the login, +// instead of the login page being swapped into a fragment. Event-stream +// (SSE) requests without a session get a plain 401: a redirect would make +// the browser's EventSource retry /auth/login repeatedly and overwrite the +// login flow cookie of a login in progress in another tab. +func (a *Auth) Require(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, err := a.session(r) + if err != nil && !errors.Is(err, model.ErrNotFound) { + a.log.Error("session_lookup_failed", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err != nil { + switch { + case r.Header.Get("HX-Request") == "true": + w.Header().Set("HX-Redirect", "/auth/login") + w.WriteHeader(http.StatusUnauthorized) + case strings.Contains(r.Header.Get("Accept"), "text/event-stream"): + w.WriteHeader(http.StatusUnauthorized) + case r.Method == http.MethodGet: + http.Redirect(w, r, "/auth/login", http.StatusFound) + default: + http.Error(w, "unauthorized", http.StatusUnauthorized) + } + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + token := r.Header.Get("X-CSRF-Token") + if token == "" { + token = r.FormValue("csrf_token") + } + if subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) != 1 { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess))) + }) +} + +func (a *Auth) session(r *http.Request) (model.Session, error) { + c, err := r.Cookie(sessionCookieName) + if err != nil { + return model.Session{}, model.ErrNotFound + } + return a.store.GetSession(r.Context(), c.Value, a.now()) +} + +type sessionKey struct{} + +// SessionFrom returns the session Require stored in ctx. +func SessionFrom(ctx context.Context) (model.Session, bool) { + s, ok := ctx.Value(sessionKey{}).(model.Session) + return s, ok +} + +func randomString() string { + var b [32]byte + _, _ = rand.Read(b[:]) + return base64.RawURLEncoding.EncodeToString(b[:]) +} diff --git a/internal/master/webauth/auth_test.go b/internal/master/webauth/auth_test.go new file mode 100644 index 0000000..9204284 --- /dev/null +++ b/internal/master/webauth/auth_test.go @@ -0,0 +1,227 @@ +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) + 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_StateMismatchRejected(t *testing.T) { + a := newAuth(&fakeProvider{}, newFakeStore()) + flow, _ := startLogin(t, a) + rec := callback(a, flow, "forged") + 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{}}, + {"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) +} diff --git a/internal/master/webauth/provider.go b/internal/master/webauth/provider.go new file mode 100644 index 0000000..442b675 --- /dev/null +++ b/internal/master/webauth/provider.go @@ -0,0 +1,85 @@ +package webauth + +import ( + "context" + "errors" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Claims is what the master needs from the identity provider. +type Claims struct { + Subject string + Name string + Email string + Groups []string +} + +// Provider performs the OIDC authorization code flow with PKCE. Declared as +// an interface so handlers can be tested without a real identity provider. +type Provider interface { + AuthCodeURL(state, verifier string) string + Exchange(ctx context.Context, code, verifier string) (Claims, error) +} + +// OIDCConfig configures the real provider. +type OIDCConfig struct { + Issuer string // e.g. https://auth.example.com/application/o/package-updater/ + ClientID string + ClientSecret string + RedirectURL string // PUBLIC_URL + /auth/callback +} + +type oidcProvider struct { + oauth *oauth2.Config + verifier *oidc.IDTokenVerifier +} + +// NewOIDCProvider discovers the issuer's endpoints. It makes a network call, +// so the master fails at startup if Authentik is unreachable. +func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (Provider, error) { + p, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, fmt.Errorf("oidc discovery: %w", err) + } + return &oidcProvider{ + oauth: &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: p.Endpoint(), + RedirectURL: cfg.RedirectURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + }, nil +} + +func (p *oidcProvider) AuthCodeURL(state, verifier string) string { + return p.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) +} + +func (p *oidcProvider) Exchange(ctx context.Context, code, verifier string) (Claims, error) { + tok, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + return Claims{}, fmt.Errorf("exchange code: %w", err) + } + raw, ok := tok.Extra("id_token").(string) + if !ok { + return Claims{}, errors.New("token response has no id_token") + } + idt, err := p.verifier.Verify(ctx, raw) + if err != nil { + return Claims{}, fmt.Errorf("verify id_token: %w", err) + } + var c struct { + Name string `json:"name"` + Email string `json:"email"` + Groups []string `json:"groups"` + } + if err := idt.Claims(&c); err != nil { + return Claims{}, fmt.Errorf("read claims: %w", err) + } + return Claims{Subject: idt.Subject, Name: c.Name, Email: c.Email, Groups: c.Groups}, nil +}