Raw
  1package forwardauth
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"errors"
  8	"log/slog"
  9	"net/http"
 10	"net/url"
 11	"regexp"
 12	"slices"
 13	"strings"
 14
 15	"tug/internal/service/session"
 16	transporthttp "tug/internal/transport/http"
 17	"tug/internal/transport/oidcrp"
 18	"tug/internal/transport/web"
 19)
 20
 21const (
 22	LoginPath    = "/.sso/login"
 23	CallbackPath = "/.sso/callback"
 24	LogoutPath   = web.AppLogoutPath
 25)
 26
 27const (
 28	outcomeAllowed         = "allowed"
 29	outcomeUnauthenticated = "unauthenticated"
 30	outcomeForbidden       = "forbidden"
 31	outcomeUnavailable     = "unavailable"
 32)
 33
 34var Outcomes = []string{outcomeAllowed, outcomeUnauthenticated, outcomeForbidden, outcomeUnavailable}
 35
 36var groupName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
 37
 38type RelyingParty interface {
 39	SessionSecret(r *http.Request) ([]byte, bool)
 40	Begin(w http.ResponseWriter, r *http.Request, returnPath string)
 41	Callback(w http.ResponseWriter, r *http.Request)
 42}
 43
 44type Resolver interface {
 45	Resolve(ctx context.Context, secret []byte, host string) (session.Principal, error)
 46}
 47
 48type Config struct {
 49	RP       RelyingParty
 50	Sessions Resolver
 51	Kit      *web.Kit
 52	Guard    web.Guard
 53	Ender    web.SessionEnder
 54	Observe  func(outcome string)
 55}
 56
 57type ForwardAuth struct {
 58	rp       RelyingParty
 59	sessions Resolver
 60	kit      *web.Kit
 61	logout   http.Handler
 62	observe  func(string)
 63}
 64
 65var (
 66	_ RelyingParty = (*oidcrp.RP)(nil)
 67	_ Resolver     = (*session.Service)(nil)
 68)
 69
 70func New(cfg Config) (*ForwardAuth, error) {
 71	if cfg.RP == nil || cfg.Sessions == nil || cfg.Kit == nil || cfg.Guard == nil || cfg.Ender == nil {
 72		return nil, errors.New("forwardauth: the relying party, the sessions, the kit, the guard and the ender are required")
 73	}
 74	return &ForwardAuth{
 75		rp:       cfg.RP,
 76		sessions: cfg.Sessions,
 77		kit:      cfg.Kit,
 78		logout:   cfg.Kit.AppHost(cfg.Guard, cfg.Kit.AppLogoutRoutes(cfg.Ender)...),
 79		observe:  observeOrDiscard(cfg.Observe),
 80	}, nil
 81}
 82
 83func (f *ForwardAuth) AuthZ() http.Handler {
 84	return http.HandlerFunc(f.authz)
 85}
 86
 87func (f *ForwardAuth) SSO() http.Handler {
 88	return http.HandlerFunc(f.sso)
 89}
 90
 91func (f *ForwardAuth) principal(r *http.Request) (session.Principal, error) {
 92	secret, ok := f.rp.SessionSecret(r)
 93	if !ok {
 94		return session.Principal{}, session.ErrNoSession
 95	}
 96	return f.sessions.Resolve(r.Context(), secret, transporthttp.CanonicalHost(r.Host))
 97}
 98
 99func (f *ForwardAuth) authz(w http.ResponseWriter, r *http.Request) {
100	w.Header().Set("Cache-Control", "no-store")
101	p, err := f.principal(r)
102	switch {
103	case errors.Is(err, session.ErrNoSession):
104		f.observe(outcomeUnauthenticated)
105		f.unauthenticated(w, r)
106		return
107	case err != nil:
108		slog.Warn("authz: the session store failed", "err", err)
109		f.observe(outcomeUnavailable)
110		if wantsJSON(r) {
111			writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "unavailable"})
112			return
113		}
114		if errors.Is(err, session.ErrUnavailable) {
115			w.Header().Set("Retry-After", "1")
116			f.kit.ErrorPage(w, transporthttp.MarkBusy(r), http.StatusServiceUnavailable)
117			return
118		}
119		f.kit.FailurePage(w, r, http.StatusServiceUnavailable)
120		return
121	}
122	if !allowed(r.URL.RawQuery, p.Groups) {
123		f.observe(outcomeForbidden)
124		if wantsJSON(r) {
125			writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
126			return
127		}
128		f.kit.RenderAppForbidden(w, r, web.Ident(p.Username))
129		return
130	}
131	f.observe(outcomeAllowed)
132	h := w.Header()
133	h.Set("Remote-User", p.Username)
134	h.Set("Remote-Groups", strings.Join(p.Groups, ","))
135	if p.DisplayName != "" {
136		h.Set("Remote-Name", p.DisplayName)
137	}
138	w.WriteHeader(http.StatusOK)
139}
140
141func allowed(rawQuery string, groups []string) bool {
142	q, err := url.ParseQuery(rawQuery)
143	if err != nil {
144		return false
145	}
146	values, present := q["require"]
147	if !present {
148		return true
149	}
150	for _, v := range values {
151		for _, g := range strings.Split(v, ",") {
152			if groupName.MatchString(g) && slices.Contains(groups, g) {
153				return true
154			}
155		}
156	}
157	return false
158}
159
160func usableReturn(p string) bool {
161	return oidcrp.ValidReturn(p) == nil
162}
163
164func loginURL(r *http.Request) string {
165	rd := r.Header.Get("X-Forwarded-Uri")
166	if !usableReturn(rd) {
167		rd = "/"
168	}
169	return "https://" + r.Host + LoginPath + "?rd=" + url.QueryEscape(rd)
170}
171
172func (f *ForwardAuth) unauthenticated(w http.ResponseWriter, r *http.Request) {
173	login := loginURL(r)
174	if wantsJSON(r) {
175		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthenticated", "login_url": login})
176		return
177	}
178	w.Header().Set("Location", login)
179	w.WriteHeader(http.StatusFound)
180}
181
182func wantsJSON(r *http.Request) bool {
183	return strings.Contains(strings.Join(r.Header.Values("Accept"), ","), "application/json") || r.Header.Get("X-Requested-With") == "XMLHttpRequest"
184}
185
186func writeJSON(w http.ResponseWriter, status int, v map[string]string) {
187	var buf bytes.Buffer
188	enc := json.NewEncoder(&buf)
189	enc.SetEscapeHTML(false)
190	enc.Encode(v)
191	w.Header().Set("Content-Type", "application/json")
192	w.WriteHeader(status)
193	w.Write(buf.Bytes())
194}
195
196func (f *ForwardAuth) sso(w http.ResponseWriter, r *http.Request) {
197	switch r.URL.Path {
198	case LoginPath:
199		f.login(w, r)
200	case CallbackPath:
201		f.rp.Callback(w, r)
202	case LogoutPath:
203		f.logout.ServeHTTP(w, r)
204	default:
205		f.kit.ErrorPage(w, r, http.StatusNotFound)
206	}
207}
208
209func (f *ForwardAuth) login(w http.ResponseWriter, r *http.Request) {
210	q, err := url.ParseQuery(r.URL.RawQuery)
211	rd := "/"
212	if v, present := q["rd"]; present {
213		rd = v[0]
214	}
215	if err != nil || !usableReturn(rd) {
216		w.Header().Set("Cache-Control", "no-store")
217		f.kit.ErrorPage(w, r, http.StatusBadRequest)
218		return
219	}
220	if _, err := f.principal(r); err == nil {
221		w.Header().Set("Cache-Control", "no-store")
222		w.Header().Set("Location", "https://"+r.Host+rd)
223		w.WriteHeader(http.StatusSeeOther)
224		return
225	}
226	f.rp.Begin(w, r, rd)
227}
228
229func observeOrDiscard(observe func(string)) func(string) {
230	if observe == nil {
231		return func(string) {}
232	}
233	return observe
234}