// 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 || state == "" || 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.PostFormValue("csrf_token") // body only: a token in the URL leaks via logs and Referer } if subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) != 1 { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } } next.ServeHTTP(w, r.WithContext(WithSession(r.Context(), 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{} // WithSession returns ctx carrying sess, as Require does. func WithSession(ctx context.Context, sess model.Session) context.Context { return context.WithValue(ctx, sessionKey{}, sess) } // 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[:]) }