package idp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"maps"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
transporthttp "tug/internal/transport/http"
"tug/internal/transport/web/text"
)
const DiscoveryPath = "/.well-known/openid-configuration"
var (
getPaths = map[string]bool{
DiscoveryPath: true,
"/keys": true,
"/auth": true,
"/auth/" + ConnectorID: true,
"/callback": true,
"/userinfo": true,
"/static/main.css": true,
"/theme/styles.css": true,
"/theme/logo.png": true,
"/theme/favicon.png": true,
}
postPaths = map[string]bool{
"/token": true,
"/userinfo": true,
}
noStorePaths = map[string]bool{
"/auth": true,
"/auth/" + ConnectorID: true,
"/callback": true,
}
hiddenDiscoveryKeys = []string{"introspection_endpoint", "device_authorization_endpoint"}
)
type handler struct {
dex http.Handler
discovery []byte
public map[string]bool
page transporthttp.ErrorPage
}
func newHandler(dex http.Handler, public map[string]bool, page transporthttp.ErrorPage) (*handler, error) {
doc, err := filteredDiscovery(dex)
if err != nil {
return nil, err
}
return &handler{dex: dex, discovery: doc, public: public, page: page}, nil
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if !(r.Method == http.MethodGet && getPaths[p]) && !(r.Method == http.MethodPost && postPaths[p]) {
w.Header().Set("Cache-Control", "no-store")
h.page(w, r, http.StatusNotFound)
return
}
front := p == "/callback"
if noStorePaths[p] {
w.Header().Set("Cache-Control", "no-store")
}
switch {
case p == DiscoveryPath:
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(h.discovery)))
w.Write(h.discovery)
return
case p == "/auth/"+ConnectorID || p == "/auth":
q, err := transporthttp.Query(r)
if err != nil {
h.page(w, r, http.StatusBadRequest)
return
}
if p == "/auth" && q.Get("req") != "" {
front = true
break
}
fresh, status := h.fresh(r, q)
if status != 0 {
h.page(w, r, status)
return
}
r = fresh.WithContext(context.WithValue(fresh.Context(), hintContext{}, q.Get("id_token_hint")))
}
if front && frontOn() {
h.front(w, r, p)
return
}
h.dex.ServeHTTP(w, r)
}
var (
frontOn = func() bool { return true }
frontTap = func(path string, status int, header http.Header, body []byte) {}
)
var interruptions = []struct {
path string
status int
message string
}{
{"/callback", http.StatusBadRequest, "Requested resource does not exist."},
{"/callback", http.StatusInternalServerError, "Login error."},
{"/callback", http.StatusInternalServerError, "Authentication failed. Please contact your administrator or try again later."},
{"/auth", http.StatusInternalServerError, "Internal server error."},
}
var interruptedPage = template.Must(template.New("interrupted").Parse(`
{{.Title}}
`))
var interruptedPages = map[int][]byte{
http.StatusBadRequest: renderInterrupted(text.ErrorBadRequestTitle),
http.StatusInternalServerError: renderInterrupted(text.ErrorInternalTitle),
}
func renderInterrupted(title text.Key) []byte {
heading, _ := text.Lookup(title)
body, _ := text.Lookup(text.LoginInterrupted)
var buf bytes.Buffer
if err := interruptedPage.Execute(&buf, struct{ Title, Body string }{heading, body}); err != nil {
panic(err)
}
return buf.Bytes()
}
func (h *handler) front(w http.ResponseWriter, r *http.Request, p string) {
c := &capture{header: w.Header().Clone()}
h.dex.ServeHTTP(c, r)
if c.status == 0 {
c.status = http.StatusOK
}
frontTap(p, c.status, c.header, c.body.Bytes())
if interrupted(p, c.status, c.body.Bytes()) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(c.status)
w.Write(interruptedPages[c.status])
return
}
dst := w.Header()
clear(dst)
maps.Copy(dst, c.header)
w.WriteHeader(c.status)
w.Write(c.body.Bytes())
}
func interrupted(p string, status int, body []byte) bool {
for _, i := range interruptions {
if i.path == p && i.status == status && bytes.Contains(body, []byte(i.message)) {
return true
}
}
return false
}
func (h *handler) fresh(r *http.Request, q url.Values) (*http.Request, int) {
if q.Get("nonce") == "" {
return r, http.StatusBadRequest
}
if h.public[q.Get("client_id")] && (q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256") {
return r, http.StatusBadRequest
}
changed := false
if prompts, ok := q["prompt"]; ok {
var kept []string
for _, v := range prompts {
for _, p := range strings.Fields(v) {
if p == "consent" && !slices.Contains(strings.Fields(v), "none") {
changed = true
continue
}
kept = append(kept, p)
}
}
if changed {
if len(kept) == 0 {
q.Del("prompt")
} else {
q.Set("prompt", strings.Join(kept, " "))
}
}
}
if q.Get("approval_prompt") == "force" {
q.Del("approval_prompt")
changed = true
}
if !changed {
return r, 0
}
stripped := r.Clone(r.Context())
stripped.URL.RawQuery = q.Encode()
stripped.RequestURI = stripped.URL.RequestURI()
return stripped, 0
}
type capture struct {
header http.Header
status int
body bytes.Buffer
}
func (c *capture) Header() http.Header { return c.header }
func (c *capture) WriteHeader(status int) {
if c.status == 0 {
c.status = status
}
}
func (c *capture) Write(b []byte) (int, error) {
if c.status == 0 {
c.status = http.StatusOK
}
return c.body.Write(b)
}
func filteredDiscovery(dex http.Handler) ([]byte, error) {
c := &capture{header: http.Header{}}
dex.ServeHTTP(c, &http.Request{Method: http.MethodGet, URL: &url.URL{Path: DiscoveryPath}, Header: http.Header{}, Host: "localhost"})
if c.status != http.StatusOK {
return nil, fmt.Errorf("idp: dex discovery answered %d", c.status)
}
var doc map[string]json.RawMessage
if err := json.Unmarshal(c.body.Bytes(), &doc); err != nil {
return nil, fmt.Errorf("idp: decode dex discovery: %w", err)
}
for _, k := range hiddenDiscoveryKeys {
delete(doc, k)
}
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, fmt.Errorf("idp: encode discovery: %w", err)
}
return out, nil
}