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}