| 1 | package 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 | |
| 22 | import ( |
| 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. |
| 38 | const returnCookieAge int64 = 600 |
| 39 | const returnCookieLimit = 4096 |
| 40 | |
| 41 | type 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. |
| 49 | func (a *app) returnCookieName() string { |
| 50 | if a.secure() { |
| 51 | return "__Host-calls_return" |
| 52 | } |
| 53 | return "calls_return" |
| 54 | } |
| 55 | |
| 56 | func (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. |
| 61 | func (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 | |
| 70 | func (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. |
| 76 | func 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 | |
| 84 | func safeReturnPath(p string) bool { |
| 85 | return handoff.SafePath(p) && !strings.HasPrefix(p, "/j/") |
| 86 | } |
| 87 | |
| 88 | func (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 | |
| 116 | func (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. |
| 126 | func (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. |
| 176 | func (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. |
| 194 | func 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). |
| 202 | type toSignedOut struct{ http.ResponseWriter } |
| 203 | |
| 204 | func (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 | |