package calls import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "html/template" "io" "net/http" "net/url" "strings" "sync" "time" "amadan.net/oficina/calls/protocol" "amadan.net/rastrillo/rastrillo/sessions" ) // Native authorization transfers an existing browser identity into an ordinary // framework session. Requests expire on restart or after five minutes. Only the // app knows the verifier; browser URLs never contain session credentials. type nativeAuthorization struct { mu sync.Mutex pending map[string]*nativeRequest offers map[string]*nativeOffer sessions *sessions.Sessions } type nativeRequest struct { Challenge string Target string Expires time.Time Cookie string } func (a *app) wireNativeAuth() error { s, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger}) if err != nil { return err } a.nativeAuth = &nativeAuthorization{pending: make(map[string]*nativeRequest), offers: make(map[string]*nativeOffer), sessions: s} return nil } func nativeRandom() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic(err) } return base64.RawURLEncoding.EncodeToString(b) } func nativeToken(s string) bool { b, e := base64.RawURLEncoding.DecodeString(s) return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s } // nativeTarget is where the app may ask the browser to land, and the // set is closed on purpose: a target is written into a pending request // by an unauthenticated caller and later redirected to with the // person's own session, so anything open here is an open redirect with // an ambient credential behind it. // // Two shapes are one meeting — /m/{sqid} and /j/{token} — and the third // is "/", the meeting list. "/" is how the app signs in to an instance // rather than to a meeting: the person who has no link yet, because the // meeting they are about to be in does not exist until they start it. // An app that does hold a link still names it, because landing on the // link is what consumes an invitation (spec §3.2) and "/" consumes // nothing. func nativeTarget(s string) bool { if s == "/" { return true } if !strings.HasPrefix(s, "/") { return false } u, e := url.Parse(s) if e != nil || u.IsAbs() || u.Host != "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" { return false } p := strings.Split(strings.TrimPrefix(s, "/"), "/") if len(p) != 2 || (p[0] != "m" && p[0] != "j") || len(p[1]) == 0 || len(p[1]) > 128 { return false } return strings.IndexFunc(p[1], func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') }) < 0 } func (n *nativeAuthorization) find(id string) *nativeRequest { for k, v := range n.pending { if !time.Now().Before(v.Expires) { delete(n.pending, k) } } return n.pending[id] } func nativeHeaders(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Referrer-Policy", "no-referrer") } // These two endpoints accept no ambient authority and never enable CORS. func (a *app) nativeRequest(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) if r.Header.Get("Content-Type") != "application/json" { http.Error(w, "expected JSON", 415) return } var input struct { Challenge string `json:"challenge"` Target string `json:"target"` // Offer is the web's "Join in the app" (appOffer): a request // made for it is approved in the browser the person is already // signed in to, rather than in whichever one the app opens. Offer string `json:"offer"` } if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Challenge) || !nativeTarget(input.Target) || (input.Offer != "" && !nativeToken(input.Offer)) { http.Error(w, "invalid request", 400) return } n := a.nativeAuth n.mu.Lock() defer n.mu.Unlock() n.find("") if len(n.pending) >= 256 { http.Error(w, "try again later", 429) return } var offer *nativeOffer if input.Offer != "" { // One request per offer, for the meeting it was made for. A // second claim — the app asked twice, or something else on the // device answered the oficina-calls:// link first — is refused, // and the person's browser shows the code of the first, which // the app that lost cannot show. offer = n.offer(input.Offer) if offer == nil || offer.Request != "" || offer.Target != input.Target { http.Error(w, "this link has expired", http.StatusGone) return } } id := nativeRandom() n.pending[id] = &nativeRequest{Challenge: input.Challenge, Target: input.Target, Expires: time.Now().Add(5 * time.Minute)} w.Header().Set("Content-Type", "application/json") if offer != nil { offer.Request = id json.NewEncoder(w).Encode(map[string]any{"id": id, "offer": true}) return } json.NewEncoder(w).Encode(map[string]string{"id": id}) } func (a *app) nativeExchange(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) if r.Header.Get("Content-Type") != "application/json" { http.Error(w, "expected JSON", 415) return } var input struct { ID string `json:"id"` Verifier string `json:"verifier"` } if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Verifier) { http.Error(w, "invalid request", 400) return } hash := sha256.Sum256([]byte(input.Verifier)) n := a.nativeAuth n.mu.Lock() defer n.mu.Unlock() p := n.find(input.ID) if p == nil || p.Challenge != base64.RawURLEncoding.EncodeToString(hash[:]) { http.Error(w, "request expired", 404) return } if p.Cookie == "" { w.WriteHeader(http.StatusAccepted) return } delete(n.pending, input.ID) // one attempt, including a revoked source session check := r.Clone(r.Context()) check.Header = make(http.Header) check.AddCookie(&http.Cookie{Name: n.sessions.CookieName(), Value: p.Cookie}) session, ok := n.sessions.From(check) if !ok { http.Error(w, "sign in again", 401) return } if _, err := a.personForSession(r.Context(), session); err != nil { http.Error(w, "sign in again", 401) return } token, err := n.sessions.Mint(session) if err != nil { http.Error(w, "cannot sign in", 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"name": n.sessions.CookieName(), "value": token, "target": p.Target}) } func (a *app) nativeStart(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) n := a.nativeAuth n.mu.Lock() p := n.find(r.URL.Query().Get("id")) target := "" if p != nil { target = p.Target } n.mu.Unlock() if target == "" { http.NotFound(w, r) return } a.nativePending(w, r.URL.Query().Get("id"), 300) if strings.HasPrefix(target, "/m/") { target += "/join" } http.Redirect(w, r, target, http.StatusSeeOther) } func (a *app) nativePendingCookie() string { if strings.HasPrefix(a.cfg.Origin, "https:") { return "__Host-calls_native" } return "calls_native" } func (a *app) nativePending(w http.ResponseWriter, id string, age int) { http.SetCookie(w, &http.Cookie{Name: a.nativePendingCookie(), Value: id, Path: "/", HttpOnly: true, Secure: strings.HasPrefix(a.cfg.Origin, "https:"), SameSite: http.SameSiteLaxMode, MaxAge: age}) } // Run only after the normal sign-in continuation and invitation consumption. func (a *app) nativeResume(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if r.Method == "GET" && (path == "/" || path == "/welcome" || strings.HasPrefix(path, "/m/") && strings.HasSuffix(path, "/join")) { if c, e := r.Cookie(a.nativePendingCookie()); e == nil && nativeToken(c.Value) { if _, ok := a.nativeAuth.sessions.From(r); ok { a.nativePending(w, "", -1) http.Redirect(w, r, "/native/authorize?id="+c.Value, http.StatusSeeOther) return } } } next.ServeHTTP(w, r) }) } func (a *app) nativeAuthorize(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) id := r.URL.Query().Get("id") n := a.nativeAuth n.mu.Lock() p := n.find(id) valid := p != nil && p.Cookie == "" cancelHref := "/" if valid { cancelHref = p.Target if strings.HasPrefix(cancelHref, "/m/") { cancelHref += "/join" } } n.mu.Unlock() if !valid { http.NotFound(w, r) return } person, _ := currentPerson(r) title := "Sign in to the Calls app" if person.Anonymous { title = "Continue to the Calls app" } // The hand-off pages keep no-referrer (nativeHeaders) and so show no // account menu: an empty corner, as the sign-in pages have (spec §9.5, // "not a chrome page"). render(w, "native", a.page(r, frame{parts: []string{title}, signinPage: true}, map[string]any{"CancelHref": cancelHref, "Guest": person.Anonymous, "Title": title, "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)})) } func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) c, e := r.Cookie(a.nativeAuth.sessions.CookieName()) if e != nil { http.Error(w, "sign in again", 401) return } n := a.nativeAuth n.mu.Lock() p := n.find(r.URL.Query().Get("id")) if p == nil || p.Cookie != "" { n.mu.Unlock() http.NotFound(w, r) return } p.Cookie = c.Value n.mu.Unlock() http.Redirect(w, r, "/native/complete", http.StatusSeeOther) } func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) person, _ := currentPerson(r) title := "Calls is signed in" if person.Anonymous { title = "Calls is ready" } render(w, "native", a.page(r, frame{parts: []string{title}, signinPage: true}, map[string]any{"Title": title, "Done": true})) } // nativeStartMeeting is POST /native/meetings/start: the app's half of // the list's one-click button. The web answers that button with the // link-once page, which is a screen and not something a Swift app can // read; this answers the one fact the app needs — the meeting's sqid — // and leaves the link to the share flow the room already has. // // It is the same store.createMeeting every other meeting goes through, // under the same requireMember as POST /meetings/start, so nothing // about who may start a meeting is decided twice. // // Ambient authority, unlike /native/request and /native/exchange: this // runs on the session the app is holding. That is what csrf.Protect // above is for — callscore sends Origin on every POST — and the JSON // content type is the second lock, because an HTML form cannot send // one and a cross-origin fetch that tries is stopped at the preflight. func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) if r.Header.Get("Content-Type") != "application/json" { http.Error(w, "expected JSON", http.StatusUnsupportedMediaType) return } // The app's zone, when it sends one, so the title is the time where // the starter is (callTitle). An app from before 0.4.7 sends "{}". var input struct { Zone string `json:"zone"` } _ = json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&input) p, _ := currentPerson(r) title := callTitle(a.store.now(), callZone(input.Zone), a.cfg.zone) m, _, err := a.store.createCall(r.Context(), title, actorFor(p)) if err != nil { a.logger.Error("calls: native start meeting", "err", err) http.Error(w, "cannot start a meeting", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") // The title is the server's, not the app's: callTitle writes it in // the zone the app named, or the instance's. json.NewEncoder(w).Encode(map[string]string{"sqid": m.Sqid, "title": title.Text}) } // The web's "Join in the app" (2026-10-02). A person signed in on the // web — a member, a guest who signed in, a guest who only gave a name — // asks for the same meeting in the app. The app holds a session of its // own, or none, so the meeting's /m/ address alone means nothing to it: // it needs this person's standing, and the only safe way to give it that // is the handoff above, approved in a browser where this person is // signed in. The app opens the *default* browser for that, which need // not be the one they are in; a guest there has no way to sign in again. // // So the browser starts it instead. GET /m/{sqid}/app makes an offer // bound to this session and this meeting and shows a page that opens // oficina-calls://join?url=…&offer=…. The app claims the offer with an // ordinary /native/request carrying its challenge; this page, waiting, // then turns into /native/authorize for that request, in this browser, // with the code the app is showing. Approving there is what gives the // app a session — the same verifier exchange, the same one use, the same // five minutes, the same standing as the browser's session, and a guest // still only in the meeting their grant names. // // What the offer is not: a credential. It is in a URL handed to another // app, so on its own it buys nothing — a claim gets a request id, and // only the browser that made the offer can see which request claimed it // and approve it. type nativeOffer struct { Target string Owner [32]byte Expires time.Time Request string } // offer finds a live offer, sweeping expired ones. Under n.mu. func (n *nativeAuthorization) offer(id string) *nativeOffer { for k, v := range n.offers { if !time.Now().Before(v.Expires) { delete(n.offers, k) } } return n.offers[id] } // appOffer is GET /m/{sqid}/app, the holding screen's "Join in the app" // for a signed-in browser. Authorized like the holding screen itself. func (a *app) appOffer(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) m, _, _, ok := a.roomAccess(w, r) if !ok { return } c, err := r.Cookie(a.nativeAuth.sessions.CookieName()) if err != nil { a.ErrorPage(w, r, http.StatusNotFound, "") return } owner := sha256.Sum256([]byte(c.Value)) n := a.nativeAuth n.mu.Lock() n.offer("") // One offer per browser session: asking again replaces it, so a // person pressing the button twice cannot fill the table. for k, v := range n.offers { if v.Owner == owner { delete(n.offers, k) } } if len(n.offers) >= 256 { n.mu.Unlock() a.ErrorPage(w, r, http.StatusTooManyRequests, "") return } id := nativeRandom() n.offers[id] = &nativeOffer{Target: "/m/" + m.Sqid, Owner: owner, Expires: time.Now().Add(5 * time.Minute)} n.mu.Unlock() http.Redirect(w, r, "/native/offer?o="+id, http.StatusSeeOther) } // offerPage is GET /native/offer?o=…: the page that waits for the app. // Before the app has claimed the offer it says to open the app and // offers the link; once it has, it is /native/authorize for that // request. room.js is not here: app-offer.js asks again with Accept: // application/json and follows "next", and with no script the page // refreshes itself (native.html). func (a *app) offerPage(w http.ResponseWriter, r *http.Request) { nativeHeaders(w) id := r.URL.Query().Get("o") wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json") var owner [32]byte if c, err := r.Cookie(a.nativeAuth.sessions.CookieName()); err == nil { owner = sha256.Sum256([]byte(c.Value)) } n := a.nativeAuth n.mu.Lock() var offer nativeOffer found := false if nativeToken(id) { if o := n.offer(id); o != nil && o.Owner == owner { offer, found = *o, true } } n.mu.Unlock() if !found { if wantJSON { streamJSON(w, http.StatusNotFound, map[string]any{"expired": true}) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) render(w, "native", a.page(r, frame{parts: []string{"Join in the app"}, signinPage: true}, map[string]any{"Title": "This link to the app has expired", "Expired": true})) return } if offer.Request != "" { next := "/native/authorize?id=" + offer.Request if wantJSON { streamJSON(w, http.StatusOK, map[string]any{"next": next}) return } http.Redirect(w, r, next, http.StatusSeeOther) return } if wantJSON { streamJSON(w, http.StatusOK, map[string]any{"waiting": true}) return } render(w, "native", a.page(r, frame{parts: []string{"Join in the app"}, signinPage: true}, map[string]any{ "Title": "Join in the Calls app", "Waiting": true, "AppURL": nativeOfferURL(a.cfg.Origin+offer.Target, id), "Poll": "/native/offer?o=" + id, "BackHref": offer.Target + "/join", })) } // nativeOfferURL is the link that opens the app with an offer. func nativeOfferURL(meeting, offer string) template.URL { return template.URL("oficina-calls://join?url=" + url.QueryEscape(meeting) + "&offer=" + url.QueryEscape(offer)) }