Raw
  1package idp
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"html/template"
  9	"maps"
 10	"net/http"
 11	"net/url"
 12	"slices"
 13	"strconv"
 14	"strings"
 15
 16	transporthttp "tug/internal/transport/http"
 17	"tug/internal/transport/web/text"
 18)
 19
 20const DiscoveryPath = "/.well-known/openid-configuration"
 21
 22var (
 23	getPaths = map[string]bool{
 24		DiscoveryPath:          true,
 25		"/keys":                true,
 26		"/auth":                true,
 27		"/auth/" + ConnectorID: true,
 28		"/callback":            true,
 29		"/userinfo":            true,
 30		"/static/main.css":     true,
 31		"/theme/styles.css":    true,
 32		"/theme/logo.png":      true,
 33		"/theme/favicon.png":   true,
 34	}
 35	postPaths = map[string]bool{
 36		"/token":    true,
 37		"/userinfo": true,
 38	}
 39	noStorePaths = map[string]bool{
 40		"/auth":                true,
 41		"/auth/" + ConnectorID: true,
 42		"/callback":            true,
 43	}
 44	hiddenDiscoveryKeys = []string{"introspection_endpoint", "device_authorization_endpoint"}
 45)
 46
 47type handler struct {
 48	dex       http.Handler
 49	discovery []byte
 50	public    map[string]bool
 51	page      transporthttp.ErrorPage
 52}
 53
 54func newHandler(dex http.Handler, public map[string]bool, page transporthttp.ErrorPage) (*handler, error) {
 55	doc, err := filteredDiscovery(dex)
 56	if err != nil {
 57		return nil, err
 58	}
 59	return &handler{dex: dex, discovery: doc, public: public, page: page}, nil
 60}
 61
 62func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 63	p := r.URL.Path
 64	if !(r.Method == http.MethodGet && getPaths[p]) && !(r.Method == http.MethodPost && postPaths[p]) {
 65		w.Header().Set("Cache-Control", "no-store")
 66		h.page(w, r, http.StatusNotFound)
 67		return
 68	}
 69	front := p == "/callback"
 70	if noStorePaths[p] {
 71		w.Header().Set("Cache-Control", "no-store")
 72	}
 73	switch {
 74	case p == DiscoveryPath:
 75		w.Header().Set("Content-Type", "application/json")
 76		w.Header().Set("Content-Length", strconv.Itoa(len(h.discovery)))
 77		w.Write(h.discovery)
 78		return
 79	case p == "/auth/"+ConnectorID || p == "/auth":
 80		q, err := transporthttp.Query(r)
 81		if err != nil {
 82			h.page(w, r, http.StatusBadRequest)
 83			return
 84		}
 85		if p == "/auth" && q.Get("req") != "" {
 86			front = true
 87			break
 88		}
 89		fresh, status := h.fresh(r, q)
 90		if status != 0 {
 91			h.page(w, r, status)
 92			return
 93		}
 94		r = fresh.WithContext(context.WithValue(fresh.Context(), hintContext{}, q.Get("id_token_hint")))
 95	}
 96	if front && frontOn() {
 97		h.front(w, r, p)
 98		return
 99	}
100	h.dex.ServeHTTP(w, r)
101}
102
103var (
104	frontOn  = func() bool { return true }
105	frontTap = func(path string, status int, header http.Header, body []byte) {}
106)
107
108var interruptions = []struct {
109	path    string
110	status  int
111	message string
112}{
113	{"/callback", http.StatusBadRequest, "Requested resource does not exist."},
114	{"/callback", http.StatusInternalServerError, "Login error."},
115	{"/callback", http.StatusInternalServerError, "Authentication failed. Please contact your administrator or try again later."},
116	{"/auth", http.StatusInternalServerError, "Internal server error."},
117}
118
119var interruptedPage = template.Must(template.New("interrupted").Parse(`<!doctype html>
120<html lang="en">
121<head>
122<meta charset="utf-8">
123<meta name="viewport" content="width=device-width, initial-scale=1">
124<title>{{.Title}}</title>
125<link href="theme/styles.css" rel="stylesheet">
126<link rel="icon" href="theme/favicon.png">
127</head>
128<body class="theme-body">
129<div class="dex-container">
130<div class="theme-panel">
131<h1 class="theme-heading">{{.Title}}</h1>
132<p>{{.Body}}</p>
133</div>
134</div>
135</body>
136</html>
137`))
138
139var interruptedPages = map[int][]byte{
140	http.StatusBadRequest:          renderInterrupted(text.ErrorBadRequestTitle),
141	http.StatusInternalServerError: renderInterrupted(text.ErrorInternalTitle),
142}
143
144func renderInterrupted(title text.Key) []byte {
145	heading, _ := text.Lookup(title)
146	body, _ := text.Lookup(text.LoginInterrupted)
147	var buf bytes.Buffer
148	if err := interruptedPage.Execute(&buf, struct{ Title, Body string }{heading, body}); err != nil {
149		panic(err)
150	}
151	return buf.Bytes()
152}
153
154func (h *handler) front(w http.ResponseWriter, r *http.Request, p string) {
155	c := &capture{header: w.Header().Clone()}
156	h.dex.ServeHTTP(c, r)
157	if c.status == 0 {
158		c.status = http.StatusOK
159	}
160	frontTap(p, c.status, c.header, c.body.Bytes())
161	if interrupted(p, c.status, c.body.Bytes()) {
162		w.Header().Set("Content-Type", "text/html; charset=utf-8")
163		w.WriteHeader(c.status)
164		w.Write(interruptedPages[c.status])
165		return
166	}
167	dst := w.Header()
168	clear(dst)
169	maps.Copy(dst, c.header)
170	w.WriteHeader(c.status)
171	w.Write(c.body.Bytes())
172}
173
174func interrupted(p string, status int, body []byte) bool {
175	for _, i := range interruptions {
176		if i.path == p && i.status == status && bytes.Contains(body, []byte(i.message)) {
177			return true
178		}
179	}
180	return false
181}
182
183func (h *handler) fresh(r *http.Request, q url.Values) (*http.Request, int) {
184	if q.Get("nonce") == "" {
185		return r, http.StatusBadRequest
186	}
187	if h.public[q.Get("client_id")] && (q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256") {
188		return r, http.StatusBadRequest
189	}
190	changed := false
191	if prompts, ok := q["prompt"]; ok {
192		var kept []string
193		for _, v := range prompts {
194			for _, p := range strings.Fields(v) {
195				if p == "consent" && !slices.Contains(strings.Fields(v), "none") {
196					changed = true
197					continue
198				}
199				kept = append(kept, p)
200			}
201		}
202		if changed {
203			if len(kept) == 0 {
204				q.Del("prompt")
205			} else {
206				q.Set("prompt", strings.Join(kept, " "))
207			}
208		}
209	}
210	if q.Get("approval_prompt") == "force" {
211		q.Del("approval_prompt")
212		changed = true
213	}
214	if !changed {
215		return r, 0
216	}
217	stripped := r.Clone(r.Context())
218	stripped.URL.RawQuery = q.Encode()
219	stripped.RequestURI = stripped.URL.RequestURI()
220	return stripped, 0
221}
222
223type capture struct {
224	header http.Header
225	status int
226	body   bytes.Buffer
227}
228
229func (c *capture) Header() http.Header { return c.header }
230
231func (c *capture) WriteHeader(status int) {
232	if c.status == 0 {
233		c.status = status
234	}
235}
236
237func (c *capture) Write(b []byte) (int, error) {
238	if c.status == 0 {
239		c.status = http.StatusOK
240	}
241	return c.body.Write(b)
242}
243
244func filteredDiscovery(dex http.Handler) ([]byte, error) {
245	c := &capture{header: http.Header{}}
246	dex.ServeHTTP(c, &http.Request{Method: http.MethodGet, URL: &url.URL{Path: DiscoveryPath}, Header: http.Header{}, Host: "localhost"})
247	if c.status != http.StatusOK {
248		return nil, fmt.Errorf("idp: dex discovery answered %d", c.status)
249	}
250	var doc map[string]json.RawMessage
251	if err := json.Unmarshal(c.body.Bytes(), &doc); err != nil {
252		return nil, fmt.Errorf("idp: decode dex discovery: %w", err)
253	}
254	for _, k := range hiddenDiscoveryKeys {
255		delete(doc, k)
256	}
257	out, err := json.MarshalIndent(doc, "", "  ")
258	if err != nil {
259		return nil, fmt.Errorf("idp: encode discovery: %w", err)
260	}
261	return out, nil
262}