Raw
 1package web
 2
 3import (
 4	"bytes"
 5	"crypto/rand"
 6	"encoding/binary"
 7	"log/slog"
 8	"net/http"
 9	"net/url"
10	"strings"
11	"time"
12
13	transporthttp "tug/internal/transport/http"
14	"tug/internal/transport/web/text"
15)
16
17const (
18	ContinuePath    = "/login/continue"
19	contCookie      = "__Host-authsvc_cont"
20	contTTL         = 60 * time.Second
21	contNonceLength = 16
22)
23
24func (k *Kit) continuationTarget(r *http.Request, target string) bool {
25	u, err := url.Parse(target)
26	if err != nil || u.Host == "" || k.origins.allows(target) != nil {
27		return false
28	}
29	return !strings.EqualFold(u.Hostname(), transporthttp.CanonicalHost(r.Host))
30}
31
32func (k *Kit) Continue(w http.ResponseWriter, r *http.Request, target string) {
33	if !k.continuationTarget(r, target) {
34		slog.Error("continuation refused: target is not an allowed cross-origin destination")
35		k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
36		return
37	}
38	nonce := make([]byte, contNonceLength)
39	rand.Read(nonce)
40	payload := append(nonce, binary.BigEndian.AppendUint64(nil, uint64(k.now().Add(contTTL).Unix()))...)
41	payload = append(payload, target...)
42	setCookie(w, contCookie, seal(k.keys.cont, payload), int(contTTL/time.Second))
43	http.Redirect(w, r, ContinuePath, http.StatusSeeOther)
44}
45
46type continueData struct {
47	Target string
48}
49
50func (k *Kit) consume(w http.ResponseWriter, r *http.Request) {
51	c, err := r.Cookie(contCookie)
52	if err != nil {
53		k.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.ContinuationInvalid)
54		return
55	}
56	setCookie(w, contCookie, "", -1)
57	payload, err := unseal(k.keys.cont, c.Value)
58	now := k.now()
59	if err != nil || len(payload) < contNonceLength+8 {
60		k.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.ContinuationInvalid)
61		return
62	}
63	nonce := payload[:contNonceLength]
64	expiry := time.Unix(int64(binary.BigEndian.Uint64(payload[contNonceLength:contNonceLength+8])), 0)
65	target := string(bytes.Clone(payload[contNonceLength+8:]))
66	if now.After(expiry) || !k.continuationTarget(r, target) || !k.spent.Spend(string(nonce), expiry, now) {
67		k.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.ContinuationInvalid)
68		return
69	}
70	k.render(w, r, http.StatusOK, "continue", k.newPage(w, r, continueData{Target: target}))
71}
72
73func (k *Kit) ContinueRoute() Route {
74	return Route{Method: http.MethodGet, Path: ContinuePath, Access: Public, Handler: http.HandlerFunc(k.consume)}
75}