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}