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}