package calls // Back to where you were (shared chrome spec §9.4). Before this a // signed-out visit to /settings, a meeting's holding screen or any other // guarded page went to a bare /signin, and after the emailed link the // person landed on the meeting list: rastrillo/auth's RequireSession // drops the path, and its Verify always lands on a fixed SignedInPath. // // So the path rides a cookie instead of the link. GET /signin?return_to= // remembers it, sealed and short-lived, and /continue (SignedInPath) // goes there once a pending meeting join — which takes precedence, being // the reason the person signed in at all — has had its turn. With Home // configured, connect's own Signin remembers return_to for the Home // exchange; this cookie serves the local link there (/signin/local). // // This is a port of Slides' cookie (b19fbda), which Astra reviewed on // oficina/slides #14: sealed with a key of its own, bound to this origin // by the purpose, with an issued and an expiry time checked on read, one // cookie only, strict base64, and handoff.SafePath on the way in and the // way out, so it can never name another site or loop into /signin. import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/json" "math" "net/http" "net/url" "strings" "amadan.net/oficina/home/handoff" "amadan.net/oficina/home/handoff/seal" ) // returnCookieAge is how long a remembered page lasts: a link opened // after ten minutes, or in another browser, lands on the meeting list. const returnCookieAge int64 = 600 const returnCookieLimit = 4096 type returnIntent struct { Path string `json:"path"` Issued int64 `json:"issued"` Expires int64 `json:"expires"` } // returnCookieName is host-only, and __Host- prefixed wherever the origin // is https. func (a *app) returnCookieName() string { if a.secure() { return "__Host-calls_return" } return "calls_return" } func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://") } // returnSealer keeps the cookie independent of every other sealed value: // its key is a domain-bound hash of the instance key. A changed key // invalidates old return cookies, which is safe. func (a *app) returnSealer() *seal.Sealer { key := sha256.Sum256([]byte("oficina/calls return v1\x00" + a.cfg.instanceKey)) s, err := seal.New(key[:], "oficina/calls") if err != nil { return nil } return s } func (a *app) returnPurpose() string { return "calls.return.v1:" + a.cfg.Origin } // safeReturn is the request's return_to, when there is exactly one and // it is a same-origin path (handoff.SafePath: no scheme, no host, no // "//", no control characters, not /signin or /auth/*) and not an // invitation, whose path is the capability itself. func safeReturn(q url.Values) (string, bool) { values, ok := q["return_to"] if !ok || len(values) != 1 || !safeReturnPath(values[0]) { return "", false } return values[0], true } func safeReturnPath(p string) bool { return handoff.SafePath(p) && !strings.HasPrefix(p, "/j/") } func (a *app) rememberReturn(w http.ResponseWriter, path string) { s := a.returnSealer() if !safeReturnPath(path) || s == nil { return } now := a.store.now().Unix() if now <= 0 || now > math.MaxInt64-returnCookieAge { return } raw, err := json.Marshal(returnIntent{Path: path, Issued: now, Expires: now + returnCookieAge}) if err != nil { return } sealed, err := s.Seal(a.returnPurpose(), raw) if err != nil { return } value := base64.RawURLEncoding.EncodeToString(sealed) if len(value) > returnCookieLimit { return } http.SetCookie(w, &http.Cookie{ Name: a.returnCookieName(), Value: value, Path: "/", MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(), SameSite: http.SameSiteLaxMode, }) } func (a *app) forgetReturn(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: a.returnCookieName(), Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.secure(), SameSite: http.SameSiteLaxMode, }) } // rememberedReturn reads the remembered page back, validated again: the // cookie is a request header like any other. func (a *app) rememberedReturn(r *http.Request) (string, bool) { s := a.returnSealer() if s == nil { return "", false } count := 0 for _, c := range r.Cookies() { if c.Name == a.returnCookieName() { count++ } } if count != 1 { return "", false } c, err := r.Cookie(a.returnCookieName()) if err != nil || c.Value == "" || len(c.Value) > returnCookieLimit { return "", false } sealed, err := base64.RawURLEncoding.Strict().DecodeString(c.Value) if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != c.Value { return "", false } raw, err := s.Open(a.returnPurpose(), sealed) if err != nil || len(raw) > returnCookieLimit { return "", false } var intent returnIntent if json.Unmarshal(raw, &intent) != nil { return "", false } canonical, _ := json.Marshal(intent) now := a.store.now().Unix() if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now || intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || !safeReturnPath(intent.Path) { return "", false } return intent.Path, true } // requireSignedInPage is rastrillo/auth's RequireSession with return_to // for a page: a signed-out GET or HEAD that asked for HTML is answered // 303 /signin?return_to=. Everything else — a POST, and the // room's stream, signal, ICE and info fetches, which never ask for HTML — // goes to the guard and gets exactly today's answer, which is what // room.js's readRefusal reads a sign-out from (spec §9.4). // // With Home configured every signed-out GET carries return_to, as // homeReturnTo on main (bf4e24d, #22) already did: a fetch redirected to // /signin?return_to=… has still gone somewhere else, which is what // readRefusal reads, so the room's answer is unchanged there too. func (a *app) requireSignedInPage(next http.Handler) http.Handler { guarded := a.auth.RequireSession(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if (r.Method == http.MethodGet || r.Method == http.MethodHead) && (wantsHTML(r) || a.home != nil) { if _, ok := a.auth.SessionFrom(r); !ok { w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) return } } guarded.ServeHTTP(w, r) }) } // signedOutAfter wraps rastrillo/auth's Signout, which ends the session // and redirects to the sign-in form: a page that, with no word about // what had just happened, read like an error. It goes to /signed-out, // which says so. func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { signout(&toSignedOut{ResponseWriter: w}, r) } } // toSignedOut rewrites Signout's Location, at the last moment a header is // still settable (sameAnswer's technique). type toSignedOut struct{ http.ResponseWriter } func (t *toSignedOut) WriteHeader(status int) { if t.Header().Get("Location") == signinPath { t.Header().Set("Location", "/signed-out") } t.ResponseWriter.WriteHeader(status) }