oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3// Back to where you were (shared chrome spec §9.4). Before this a
4// signed-out visit to /settings, a meeting's holding screen or any other
5// guarded page went to a bare /signin, and after the emailed link the
6// person landed on the meeting list: rastrillo/auth's RequireSession
7// drops the path, and its Verify always lands on a fixed SignedInPath.
8//
9// So the path rides a cookie instead of the link. GET /signin?return_to=
10// remembers it, sealed and short-lived, and /continue (SignedInPath)
11// goes there once a pending meeting join — which takes precedence, being
12// the reason the person signed in at all — has had its turn. With Home
13// configured, connect's own Signin remembers return_to for the Home
14// exchange; this cookie serves the local link there (/signin/local).
15//
16// This is a port of Slides' cookie (b19fbda), which Astra reviewed on
17// oficina/slides #14: sealed with a key of its own, bound to this origin
18// by the purpose, with an issued and an expiry time checked on read, one
19// cookie only, strict base64, and handoff.SafePath on the way in and the
20// way out, so it can never name another site or loop into /signin.
21
22import (
23 "bytes"
24 "crypto/sha256"
25 "encoding/base64"
26 "encoding/json"
27 "math"
28 "net/http"
29 "net/url"
30 "strings"
31
32 "amadan.net/oficina/home/handoff"
33 "amadan.net/oficina/home/handoff/seal"
34)
35
36// returnCookieAge is how long a remembered page lasts: a link opened
37// after ten minutes, or in another browser, lands on the meeting list.
38const returnCookieAge int64 = 600
39const returnCookieLimit = 4096
40
41type returnIntent struct {
42 Path string `json:"path"`
43 Issued int64 `json:"issued"`
44 Expires int64 `json:"expires"`
45}
46
47// returnCookieName is host-only, and __Host- prefixed wherever the origin
48// is https.
49func (a *app) returnCookieName() string {
50 if a.secure() {
51 return "__Host-calls_return"
52 }
53 return "calls_return"
54}
55
56func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://") }
57
58// returnSealer keeps the cookie independent of every other sealed value:
59// its key is a domain-bound hash of the instance key. A changed key
60// invalidates old return cookies, which is safe.
61func (a *app) returnSealer() *seal.Sealer {
62 key := sha256.Sum256([]byte("oficina/calls return v1\x00" + a.cfg.instanceKey))
63 s, err := seal.New(key[:], "oficina/calls")
64 if err != nil {
65 return nil
66 }
67 return s
68}
69
70func (a *app) returnPurpose() string { return "calls.return.v1:" + a.cfg.Origin }
71
72// safeReturn is the request's return_to, when there is exactly one and
73// it is a same-origin path (handoff.SafePath: no scheme, no host, no
74// "//", no control characters, not /signin or /auth/*) and not an
75// invitation, whose path is the capability itself.
76func safeReturn(q url.Values) (string, bool) {
77 values, ok := q["return_to"]
78 if !ok || len(values) != 1 || !safeReturnPath(values[0]) {
79 return "", false
80 }
81 return values[0], true
82}
83
84func safeReturnPath(p string) bool {
85 return handoff.SafePath(p) && !strings.HasPrefix(p, "/j/")
86}
87
88func (a *app) rememberReturn(w http.ResponseWriter, path string) {
89 s := a.returnSealer()
90 if !safeReturnPath(path) || s == nil {
91 return
92 }
93 now := a.store.now().Unix()
94 if now <= 0 || now > math.MaxInt64-returnCookieAge {
95 return
96 }
97 raw, err := json.Marshal(returnIntent{Path: path, Issued: now, Expires: now + returnCookieAge})
98 if err != nil {
99 return
100 }
101 sealed, err := s.Seal(a.returnPurpose(), raw)
102 if err != nil {
103 return
104 }
105 value := base64.RawURLEncoding.EncodeToString(sealed)
106 if len(value) > returnCookieLimit {
107 return
108 }
109 http.SetCookie(w, &http.Cookie{
110 Name: a.returnCookieName(), Value: value, Path: "/",
111 MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(),
112 SameSite: http.SameSiteLaxMode,
113 })
114}
115
116func (a *app) forgetReturn(w http.ResponseWriter) {
117 http.SetCookie(w, &http.Cookie{
118 Name: a.returnCookieName(), Value: "", Path: "/",
119 MaxAge: -1, HttpOnly: true, Secure: a.secure(),
120 SameSite: http.SameSiteLaxMode,
121 })
122}
123
124// rememberedReturn reads the remembered page back, validated again: the
125// cookie is a request header like any other.
126func (a *app) rememberedReturn(r *http.Request) (string, bool) {
127 s := a.returnSealer()
128 if s == nil {
129 return "", false
130 }
131 count := 0
132 for _, c := range r.Cookies() {
133 if c.Name == a.returnCookieName() {
134 count++
135 }
136 }
137 if count != 1 {
138 return "", false
139 }
140 c, err := r.Cookie(a.returnCookieName())
141 if err != nil || c.Value == "" || len(c.Value) > returnCookieLimit {
142 return "", false
143 }
144 sealed, err := base64.RawURLEncoding.Strict().DecodeString(c.Value)
145 if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != c.Value {
146 return "", false
147 }
148 raw, err := s.Open(a.returnPurpose(), sealed)
149 if err != nil || len(raw) > returnCookieLimit {
150 return "", false
151 }
152 var intent returnIntent
153 if json.Unmarshal(raw, &intent) != nil {
154 return "", false
155 }
156 canonical, _ := json.Marshal(intent)
157 now := a.store.now().Unix()
158 if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now ||
159 intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || !safeReturnPath(intent.Path) {
160 return "", false
161 }
162 return intent.Path, true
163}
164
165// requireSignedInPage is rastrillo/auth's RequireSession with return_to
166// for a page: a signed-out GET or HEAD that asked for HTML is answered
167// 303 /signin?return_to=<the page>. Everything else — a POST, and the
168// room's stream, signal, ICE and info fetches, which never ask for HTML —
169// goes to the guard and gets exactly today's answer, which is what
170// room.js's readRefusal reads a sign-out from (spec §9.4).
171//
172// With Home configured every signed-out GET carries return_to, as
173// homeReturnTo on main (bf4e24d, #22) already did: a fetch redirected to
174// /signin?return_to=… has still gone somewhere else, which is what
175// readRefusal reads, so the room's answer is unchanged there too.
176func (a *app) requireSignedInPage(next http.Handler) http.Handler {
177 guarded := a.auth.RequireSession(next)
178 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
179 if (r.Method == http.MethodGet || r.Method == http.MethodHead) && (wantsHTML(r) || a.home != nil) {
180 if _, ok := a.auth.SessionFrom(r); !ok {
181 w.Header().Set("Cache-Control", "no-store")
182 http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
183 return
184 }
185 }
186 guarded.ServeHTTP(w, r)
187 })
188}
189
190// signedOutAfter wraps rastrillo/auth's Signout, which ends the session
191// and redirects to the sign-in form: a page that, with no word about
192// what had just happened, read like an error. It goes to /signed-out,
193// which says so.
194func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc {
195 return func(w http.ResponseWriter, r *http.Request) {
196 signout(&toSignedOut{ResponseWriter: w}, r)
197 }
198}
199
200// toSignedOut rewrites Signout's Location, at the last moment a header is
201// still settable (sameAnswer's technique).
202type toSignedOut struct{ http.ResponseWriter }
203
204func (t *toSignedOut) WriteHeader(status int) {
205 if t.Header().Get("Location") == signinPath {
206 t.Header().Set("Location", "/signed-out")
207 }
208 t.ResponseWriter.WriteHeader(status)
209}
210