package forwardauth import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "net/url" "regexp" "slices" "strings" "tug/internal/service/session" transporthttp "tug/internal/transport/http" "tug/internal/transport/oidcrp" "tug/internal/transport/web" ) const ( LoginPath = "/.sso/login" CallbackPath = "/.sso/callback" LogoutPath = web.AppLogoutPath ) const ( outcomeAllowed = "allowed" outcomeUnauthenticated = "unauthenticated" outcomeForbidden = "forbidden" outcomeUnavailable = "unavailable" ) var Outcomes = []string{outcomeAllowed, outcomeUnauthenticated, outcomeForbidden, outcomeUnavailable} var groupName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) type RelyingParty interface { SessionSecret(r *http.Request) ([]byte, bool) Begin(w http.ResponseWriter, r *http.Request, returnPath string) Callback(w http.ResponseWriter, r *http.Request) } type Resolver interface { Resolve(ctx context.Context, secret []byte, host string) (session.Principal, error) } type Config struct { RP RelyingParty Sessions Resolver Kit *web.Kit Guard web.Guard Ender web.SessionEnder Observe func(outcome string) } type ForwardAuth struct { rp RelyingParty sessions Resolver kit *web.Kit logout http.Handler observe func(string) } var ( _ RelyingParty = (*oidcrp.RP)(nil) _ Resolver = (*session.Service)(nil) ) func New(cfg Config) (*ForwardAuth, error) { if cfg.RP == nil || cfg.Sessions == nil || cfg.Kit == nil || cfg.Guard == nil || cfg.Ender == nil { return nil, errors.New("forwardauth: the relying party, the sessions, the kit, the guard and the ender are required") } return &ForwardAuth{ rp: cfg.RP, sessions: cfg.Sessions, kit: cfg.Kit, logout: cfg.Kit.AppHost(cfg.Guard, cfg.Kit.AppLogoutRoutes(cfg.Ender)...), observe: observeOrDiscard(cfg.Observe), }, nil } func (f *ForwardAuth) AuthZ() http.Handler { return http.HandlerFunc(f.authz) } func (f *ForwardAuth) SSO() http.Handler { return http.HandlerFunc(f.sso) } func (f *ForwardAuth) principal(r *http.Request) (session.Principal, error) { secret, ok := f.rp.SessionSecret(r) if !ok { return session.Principal{}, session.ErrNoSession } return f.sessions.Resolve(r.Context(), secret, transporthttp.CanonicalHost(r.Host)) } func (f *ForwardAuth) authz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") p, err := f.principal(r) switch { case errors.Is(err, session.ErrNoSession): f.observe(outcomeUnauthenticated) f.unauthenticated(w, r) return case err != nil: slog.Warn("authz: the session store failed", "err", err) f.observe(outcomeUnavailable) if wantsJSON(r) { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "unavailable"}) return } if errors.Is(err, session.ErrUnavailable) { w.Header().Set("Retry-After", "1") f.kit.ErrorPage(w, transporthttp.MarkBusy(r), http.StatusServiceUnavailable) return } f.kit.FailurePage(w, r, http.StatusServiceUnavailable) return } if !allowed(r.URL.RawQuery, p.Groups) { f.observe(outcomeForbidden) if wantsJSON(r) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) return } f.kit.RenderAppForbidden(w, r, web.Ident(p.Username)) return } f.observe(outcomeAllowed) h := w.Header() h.Set("Remote-User", p.Username) h.Set("Remote-Groups", strings.Join(p.Groups, ",")) if p.DisplayName != "" { h.Set("Remote-Name", p.DisplayName) } w.WriteHeader(http.StatusOK) } func allowed(rawQuery string, groups []string) bool { q, err := url.ParseQuery(rawQuery) if err != nil { return false } values, present := q["require"] if !present { return true } for _, v := range values { for _, g := range strings.Split(v, ",") { if groupName.MatchString(g) && slices.Contains(groups, g) { return true } } } return false } func usableReturn(p string) bool { return oidcrp.ValidReturn(p) == nil } func loginURL(r *http.Request) string { rd := r.Header.Get("X-Forwarded-Uri") if !usableReturn(rd) { rd = "/" } return "https://" + r.Host + LoginPath + "?rd=" + url.QueryEscape(rd) } func (f *ForwardAuth) unauthenticated(w http.ResponseWriter, r *http.Request) { login := loginURL(r) if wantsJSON(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthenticated", "login_url": login}) return } w.Header().Set("Location", login) w.WriteHeader(http.StatusFound) } func wantsJSON(r *http.Request) bool { return strings.Contains(strings.Join(r.Header.Values("Accept"), ","), "application/json") || r.Header.Get("X-Requested-With") == "XMLHttpRequest" } func writeJSON(w http.ResponseWriter, status int, v map[string]string) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) enc.Encode(v) w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) w.Write(buf.Bytes()) } func (f *ForwardAuth) sso(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case LoginPath: f.login(w, r) case CallbackPath: f.rp.Callback(w, r) case LogoutPath: f.logout.ServeHTTP(w, r) default: f.kit.ErrorPage(w, r, http.StatusNotFound) } } func (f *ForwardAuth) login(w http.ResponseWriter, r *http.Request) { q, err := url.ParseQuery(r.URL.RawQuery) rd := "/" if v, present := q["rd"]; present { rd = v[0] } if err != nil || !usableReturn(rd) { w.Header().Set("Cache-Control", "no-store") f.kit.ErrorPage(w, r, http.StatusBadRequest) return } if _, err := f.principal(r); err == nil { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Location", "https://"+r.Host+rd) w.WriteHeader(http.StatusSeeOther) return } f.rp.Begin(w, r, rd) } func observeOrDiscard(observe func(string)) func(string) { if observe == nil { return func(string) {} } return observe }