oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "math"
8 "net/http"
9 "net/url"
10 "time"
11
12 "amadan.net/oficina/home/handoff"
13 "amadan.net/rastrillo/rastrillo"
14 "amadan.net/rastrillo/rastrillo/sessions"
15)
16
17const localLinkNavigationCookie = "__Host-oficina-docs-local-link-navigation"
18const localLinkNavigationLifetime = int64(600)
19const localLinkNavigationLimit = 1024
20const localLinkCompletePath = "/auth/local/complete"
21
22type localLinkMarker struct {
23 Version int `json:"v"`
24 Action string `json:"action"`
25 Issued int64 `json:"issued"`
26 Expires int64 `json:"expires"`
27}
28
29type localLinkNavigation struct {
30 sealer handoff.Sealer
31 sess *sessions.Sessions
32 origin, host string
33 now func() time.Time
34}
35
36// newLocalLinkNavigation takes the already validated suite origin and the same
37// session store used by local auth. The marker conveys navigation only.
38func newLocalLinkNavigation(sealer handoff.Sealer, sess *sessions.Sessions, origin string) *localLinkNavigation {
39 u, err := url.Parse(origin)
40 if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || sealer == nil || sess == nil {
41 return nil
42 }
43 return &localLinkNavigation{sealer: sealer, sess: sess, origin: origin, host: u.Host, now: time.Now}
44}
45
46func localNavigationCookie(w http.ResponseWriter, name, value string, age int) {
47 http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: age})
48}
49
50func (n *localLinkNavigation) purpose() string { return "docs.local-link-navigation.v1:" + n.origin }
51
52func (n *localLinkNavigation) guard(w http.ResponseWriter, r *http.Request, path string) bool {
53 w.Header().Set("Cache-Control", "no-store")
54 if r.Method != http.MethodGet {
55 w.Header().Set("Allow", http.MethodGet)
56 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusMethodNotAllowed)
57 return false
58 }
59 if r.Host != n.host || r.URL.Path != path || r.URL.RawQuery != "" {
60 http.Redirect(w, r, "/", http.StatusSeeOther)
61 return false
62 }
63 return true
64}
65
66// Begin abandons browser pointers to an old link. Rotation and freshness also
67// invalidate its binding; clearing cookies does not consume database rows.
68// A cross-site top-level GET may abandon a flow, but cannot create a link.
69func (n *localLinkNavigation) Begin(w http.ResponseWriter, r *http.Request) {
70 localNavigationCookie(w, localLinkNavigationCookie, "", -1)
71 if !n.guard(w, r, "/account/home") {
72 return
73 }
74 localNavigationCookie(w, "__Host-docs-home-init", "", -1)
75 localNavigationCookie(w, "__Host-docs-home-proof", "", -1)
76 now := n.now().Unix()
77 if now <= 0 || now > math.MaxInt64-localLinkNavigationLifetime {
78 http.Redirect(w, r, "/", http.StatusSeeOther)
79 return
80 }
81 payload, _ := json.Marshal(localLinkMarker{Version: 1, Action: "link-start", Issued: now, Expires: now + localLinkNavigationLifetime})
82 sealed, err := n.sealer.Seal(n.purpose(), payload)
83 if err != nil {
84 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
85 return
86 }
87 encoded := base64.RawURLEncoding.EncodeToString(sealed)
88 if len(encoded) > localLinkNavigationLimit {
89 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
90 return
91 }
92 localNavigationCookie(w, localLinkNavigationCookie, encoded, int(localLinkNavigationLifetime))
93 http.Redirect(w, r, "/signin/local", http.StatusSeeOther)
94}
95
96func (n *localLinkNavigation) marker(r *http.Request, now int64) bool {
97 value := ""
98 count := 0
99 for _, c := range r.Cookies() {
100 if c.Name == localLinkNavigationCookie {
101 count++
102 value = c.Value
103 }
104 }
105 if count != 1 || value == "" || len(value) > localLinkNavigationLimit {
106 return false
107 }
108 sealed, err := base64.RawURLEncoding.Strict().DecodeString(value)
109 if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value {
110 return false
111 }
112 payload, err := n.sealer.Open(n.purpose(), sealed)
113 if err != nil || len(payload) > localLinkNavigationLimit {
114 return false
115 }
116 var marker localLinkMarker
117 if json.Unmarshal(payload, &marker) != nil {
118 return false
119 }
120 // Only this helper writes the payload. Exact re-encoding excludes duplicate
121 // and unknown fields, alternate numbers, and any trailing JSON value.
122 canonical, _ := json.Marshal(marker)
123 if !bytes.Equal(payload, canonical) {
124 return false
125 }
126 return marker.Version == 1 && marker.Action == "link-start" && marker.Issued > 0 && marker.Issued <= now && now < marker.Expires && marker.Expires > marker.Issued && marker.Expires-marker.Issued <= localLinkNavigationLifetime
127}
128
129// Complete reads the newly presented session after local verification. It
130// cannot initiate or confirm linking, and never treats a Home mint as local
131// proof. A copied marker can at most repeat navigation to the same page.
132func (n *localLinkNavigation) Complete(w http.ResponseWriter, r *http.Request) {
133 localNavigationCookie(w, localLinkNavigationCookie, "", -1)
134 if !n.guard(w, r, localLinkCompletePath) {
135 return
136 }
137 now := n.now()
138 // The page the person was sent to sign in from (spec §9.4), or the
139 // list. The Home link-start marker still wins: that flow resumes at
140 // /account/home. The remembered page is left in place, as it always
141 // was: LinkStart reads it too, so connecting accounts right after
142 // signing in still returns to the same document.
143 target := n.returnPath(r)
144 if n.marker(r, now.Unix()) {
145 count := 0
146 for _, c := range r.Cookies() {
147 if c.Name == n.sess.CookieName() {
148 count++
149 }
150 }
151 if count == 1 {
152 if session, ok := n.sess.From(r); ok {
153 if _, fresh := freshLocalAuthentication(session, now); fresh {
154 target = "/account/home"
155 }
156 }
157 }
158 }
159 http.Redirect(w, r, target, http.StatusSeeOther)
160}
161