| 1 | package calls |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "crypto/sha256" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "html/template" |
| 9 | "io" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "time" |
| 15 | |
| 16 | "amadan.net/oficina/calls/protocol" |
| 17 | "amadan.net/rastrillo/rastrillo/sessions" |
| 18 | ) |
| 19 | |
| 20 | // Native authorization transfers an existing browser identity into an ordinary |
| 21 | // framework session. Requests expire on restart or after five minutes. Only the |
| 22 | // app knows the verifier; browser URLs never contain session credentials. |
| 23 | type nativeAuthorization struct { |
| 24 | mu sync.Mutex |
| 25 | pending map[string]*nativeRequest |
| 26 | offers map[string]*nativeOffer |
| 27 | sessions *sessions.Sessions |
| 28 | } |
| 29 | type nativeRequest struct { |
| 30 | Challenge string |
| 31 | Target string |
| 32 | Expires time.Time |
| 33 | Cookie string |
| 34 | } |
| 35 | |
| 36 | func (a *app) wireNativeAuth() error { |
| 37 | s, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger}) |
| 38 | if err != nil { |
| 39 | return err |
| 40 | } |
| 41 | a.nativeAuth = &nativeAuthorization{pending: make(map[string]*nativeRequest), offers: make(map[string]*nativeOffer), sessions: s} |
| 42 | return nil |
| 43 | } |
| 44 | func nativeRandom() string { |
| 45 | b := make([]byte, 32) |
| 46 | if _, err := rand.Read(b); err != nil { |
| 47 | panic(err) |
| 48 | } |
| 49 | return base64.RawURLEncoding.EncodeToString(b) |
| 50 | } |
| 51 | func nativeToken(s string) bool { |
| 52 | b, e := base64.RawURLEncoding.DecodeString(s) |
| 53 | return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s |
| 54 | } |
| 55 | |
| 56 | // nativeTarget is where the app may ask the browser to land, and the |
| 57 | // set is closed on purpose: a target is written into a pending request |
| 58 | // by an unauthenticated caller and later redirected to with the |
| 59 | // person's own session, so anything open here is an open redirect with |
| 60 | // an ambient credential behind it. |
| 61 | // |
| 62 | // Two shapes are one meeting — /m/{sqid} and /j/{token} — and the third |
| 63 | // is "/", the meeting list. "/" is how the app signs in to an instance |
| 64 | // rather than to a meeting: the person who has no link yet, because the |
| 65 | // meeting they are about to be in does not exist until they start it. |
| 66 | // An app that does hold a link still names it, because landing on the |
| 67 | // link is what consumes an invitation (spec §3.2) and "/" consumes |
| 68 | // nothing. |
| 69 | func nativeTarget(s string) bool { |
| 70 | if s == "/" { |
| 71 | return true |
| 72 | } |
| 73 | if !strings.HasPrefix(s, "/") { |
| 74 | return false |
| 75 | } |
| 76 | u, e := url.Parse(s) |
| 77 | if e != nil || u.IsAbs() || u.Host != "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" { |
| 78 | return false |
| 79 | } |
| 80 | p := strings.Split(strings.TrimPrefix(s, "/"), "/") |
| 81 | if len(p) != 2 || (p[0] != "m" && p[0] != "j") || len(p[1]) == 0 || len(p[1]) > 128 { |
| 82 | return false |
| 83 | } |
| 84 | return strings.IndexFunc(p[1], func(r rune) bool { |
| 85 | return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') |
| 86 | }) < 0 |
| 87 | } |
| 88 | func (n *nativeAuthorization) find(id string) *nativeRequest { |
| 89 | for k, v := range n.pending { |
| 90 | if !time.Now().Before(v.Expires) { |
| 91 | delete(n.pending, k) |
| 92 | } |
| 93 | } |
| 94 | return n.pending[id] |
| 95 | } |
| 96 | func nativeHeaders(w http.ResponseWriter) { |
| 97 | w.Header().Set("Cache-Control", "no-store") |
| 98 | w.Header().Set("Referrer-Policy", "no-referrer") |
| 99 | } |
| 100 | |
| 101 | // These two endpoints accept no ambient authority and never enable CORS. |
| 102 | func (a *app) nativeRequest(w http.ResponseWriter, r *http.Request) { |
| 103 | nativeHeaders(w) |
| 104 | if r.Header.Get("Content-Type") != "application/json" { |
| 105 | http.Error(w, "expected JSON", 415) |
| 106 | return |
| 107 | } |
| 108 | var input struct { |
| 109 | Challenge string `json:"challenge"` |
| 110 | Target string `json:"target"` |
| 111 | // Offer is the web's "Join in the app" (appOffer): a request |
| 112 | // made for it is approved in the browser the person is already |
| 113 | // signed in to, rather than in whichever one the app opens. |
| 114 | Offer string `json:"offer"` |
| 115 | } |
| 116 | if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Challenge) || !nativeTarget(input.Target) || (input.Offer != "" && !nativeToken(input.Offer)) { |
| 117 | http.Error(w, "invalid request", 400) |
| 118 | return |
| 119 | } |
| 120 | n := a.nativeAuth |
| 121 | n.mu.Lock() |
| 122 | defer n.mu.Unlock() |
| 123 | n.find("") |
| 124 | if len(n.pending) >= 256 { |
| 125 | http.Error(w, "try again later", 429) |
| 126 | return |
| 127 | } |
| 128 | var offer *nativeOffer |
| 129 | if input.Offer != "" { |
| 130 | // One request per offer, for the meeting it was made for. A |
| 131 | // second claim — the app asked twice, or something else on the |
| 132 | // device answered the oficina-calls:// link first — is refused, |
| 133 | // and the person's browser shows the code of the first, which |
| 134 | // the app that lost cannot show. |
| 135 | offer = n.offer(input.Offer) |
| 136 | if offer == nil || offer.Request != "" || offer.Target != input.Target { |
| 137 | http.Error(w, "this link has expired", http.StatusGone) |
| 138 | return |
| 139 | } |
| 140 | } |
| 141 | id := nativeRandom() |
| 142 | n.pending[id] = &nativeRequest{Challenge: input.Challenge, Target: input.Target, Expires: time.Now().Add(5 * time.Minute)} |
| 143 | w.Header().Set("Content-Type", "application/json") |
| 144 | if offer != nil { |
| 145 | offer.Request = id |
| 146 | json.NewEncoder(w).Encode(map[string]any{"id": id, "offer": true}) |
| 147 | return |
| 148 | } |
| 149 | json.NewEncoder(w).Encode(map[string]string{"id": id}) |
| 150 | } |
| 151 | func (a *app) nativeExchange(w http.ResponseWriter, r *http.Request) { |
| 152 | nativeHeaders(w) |
| 153 | if r.Header.Get("Content-Type") != "application/json" { |
| 154 | http.Error(w, "expected JSON", 415) |
| 155 | return |
| 156 | } |
| 157 | var input struct { |
| 158 | ID string `json:"id"` |
| 159 | Verifier string `json:"verifier"` |
| 160 | } |
| 161 | if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || !nativeToken(input.Verifier) { |
| 162 | http.Error(w, "invalid request", 400) |
| 163 | return |
| 164 | } |
| 165 | hash := sha256.Sum256([]byte(input.Verifier)) |
| 166 | n := a.nativeAuth |
| 167 | n.mu.Lock() |
| 168 | defer n.mu.Unlock() |
| 169 | p := n.find(input.ID) |
| 170 | if p == nil || p.Challenge != base64.RawURLEncoding.EncodeToString(hash[:]) { |
| 171 | http.Error(w, "request expired", 404) |
| 172 | return |
| 173 | } |
| 174 | if p.Cookie == "" { |
| 175 | w.WriteHeader(http.StatusAccepted) |
| 176 | return |
| 177 | } |
| 178 | delete(n.pending, input.ID) // one attempt, including a revoked source session |
| 179 | check := r.Clone(r.Context()) |
| 180 | check.Header = make(http.Header) |
| 181 | check.AddCookie(&http.Cookie{Name: n.sessions.CookieName(), Value: p.Cookie}) |
| 182 | session, ok := n.sessions.From(check) |
| 183 | if !ok { |
| 184 | http.Error(w, "sign in again", 401) |
| 185 | return |
| 186 | } |
| 187 | if _, err := a.personForSession(r.Context(), session); err != nil { |
| 188 | http.Error(w, "sign in again", 401) |
| 189 | return |
| 190 | } |
| 191 | token, err := n.sessions.Mint(session) |
| 192 | if err != nil { |
| 193 | http.Error(w, "cannot sign in", 500) |
| 194 | return |
| 195 | } |
| 196 | w.Header().Set("Content-Type", "application/json") |
| 197 | json.NewEncoder(w).Encode(map[string]string{"name": n.sessions.CookieName(), "value": token, "target": p.Target}) |
| 198 | } |
| 199 | func (a *app) nativeStart(w http.ResponseWriter, r *http.Request) { |
| 200 | nativeHeaders(w) |
| 201 | n := a.nativeAuth |
| 202 | n.mu.Lock() |
| 203 | p := n.find(r.URL.Query().Get("id")) |
| 204 | target := "" |
| 205 | if p != nil { |
| 206 | target = p.Target |
| 207 | } |
| 208 | n.mu.Unlock() |
| 209 | if target == "" { |
| 210 | http.NotFound(w, r) |
| 211 | return |
| 212 | } |
| 213 | a.nativePending(w, r.URL.Query().Get("id"), 300) |
| 214 | if strings.HasPrefix(target, "/m/") { |
| 215 | target += "/join" |
| 216 | } |
| 217 | http.Redirect(w, r, target, http.StatusSeeOther) |
| 218 | } |
| 219 | func (a *app) nativePendingCookie() string { |
| 220 | if strings.HasPrefix(a.cfg.Origin, "https:") { |
| 221 | return "__Host-calls_native" |
| 222 | } |
| 223 | return "calls_native" |
| 224 | } |
| 225 | func (a *app) nativePending(w http.ResponseWriter, id string, age int) { |
| 226 | 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}) |
| 227 | } |
| 228 | |
| 229 | // Run only after the normal sign-in continuation and invitation consumption. |
| 230 | func (a *app) nativeResume(next http.Handler) http.Handler { |
| 231 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 232 | path := r.URL.Path |
| 233 | if r.Method == "GET" && (path == "/" || path == "/welcome" || strings.HasPrefix(path, "/m/") && strings.HasSuffix(path, "/join")) { |
| 234 | if c, e := r.Cookie(a.nativePendingCookie()); e == nil && nativeToken(c.Value) { |
| 235 | if _, ok := a.nativeAuth.sessions.From(r); ok { |
| 236 | a.nativePending(w, "", -1) |
| 237 | http.Redirect(w, r, "/native/authorize?id="+c.Value, http.StatusSeeOther) |
| 238 | return |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | next.ServeHTTP(w, r) |
| 243 | }) |
| 244 | } |
| 245 | func (a *app) nativeAuthorize(w http.ResponseWriter, r *http.Request) { |
| 246 | nativeHeaders(w) |
| 247 | id := r.URL.Query().Get("id") |
| 248 | n := a.nativeAuth |
| 249 | n.mu.Lock() |
| 250 | p := n.find(id) |
| 251 | valid := p != nil && p.Cookie == "" |
| 252 | cancelHref := "/" |
| 253 | if valid { |
| 254 | cancelHref = p.Target |
| 255 | if strings.HasPrefix(cancelHref, "/m/") { |
| 256 | cancelHref += "/join" |
| 257 | } |
| 258 | } |
| 259 | n.mu.Unlock() |
| 260 | if !valid { |
| 261 | http.NotFound(w, r) |
| 262 | return |
| 263 | } |
| 264 | person, _ := currentPerson(r) |
| 265 | title := "Sign in to the Calls app" |
| 266 | if person.Anonymous { |
| 267 | title = "Continue to the Calls app" |
| 268 | } |
| 269 | // The hand-off pages keep no-referrer (nativeHeaders) and so show no |
| 270 | // account menu: an empty corner, as the sign-in pages have (spec §9.5, |
| 271 | // "not a chrome page"). |
| 272 | 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)})) |
| 273 | } |
| 274 | func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) { |
| 275 | nativeHeaders(w) |
| 276 | c, e := r.Cookie(a.nativeAuth.sessions.CookieName()) |
| 277 | if e != nil { |
| 278 | http.Error(w, "sign in again", 401) |
| 279 | return |
| 280 | } |
| 281 | n := a.nativeAuth |
| 282 | n.mu.Lock() |
| 283 | p := n.find(r.URL.Query().Get("id")) |
| 284 | if p == nil || p.Cookie != "" { |
| 285 | n.mu.Unlock() |
| 286 | http.NotFound(w, r) |
| 287 | return |
| 288 | } |
| 289 | p.Cookie = c.Value |
| 290 | n.mu.Unlock() |
| 291 | http.Redirect(w, r, "/native/complete", http.StatusSeeOther) |
| 292 | } |
| 293 | |
| 294 | func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) { |
| 295 | nativeHeaders(w) |
| 296 | person, _ := currentPerson(r) |
| 297 | title := "Calls is signed in" |
| 298 | if person.Anonymous { |
| 299 | title = "Calls is ready" |
| 300 | } |
| 301 | render(w, "native", a.page(r, frame{parts: []string{title}, signinPage: true}, map[string]any{"Title": title, "Done": true})) |
| 302 | } |
| 303 | |
| 304 | // nativeStartMeeting is POST /native/meetings/start: the app's half of |
| 305 | // the list's one-click button. The web answers that button with the |
| 306 | // link-once page, which is a screen and not something a Swift app can |
| 307 | // read; this answers the one fact the app needs — the meeting's sqid — |
| 308 | // and leaves the link to the share flow the room already has. |
| 309 | // |
| 310 | // It is the same store.createMeeting every other meeting goes through, |
| 311 | // under the same requireMember as POST /meetings/start, so nothing |
| 312 | // about who may start a meeting is decided twice. |
| 313 | // |
| 314 | // Ambient authority, unlike /native/request and /native/exchange: this |
| 315 | // runs on the session the app is holding. That is what csrf.Protect |
| 316 | // above is for — callscore sends Origin on every POST — and the JSON |
| 317 | // content type is the second lock, because an HTML form cannot send |
| 318 | // one and a cross-origin fetch that tries is stopped at the preflight. |
| 319 | func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) { |
| 320 | nativeHeaders(w) |
| 321 | if r.Header.Get("Content-Type") != "application/json" { |
| 322 | http.Error(w, "expected JSON", http.StatusUnsupportedMediaType) |
| 323 | return |
| 324 | } |
| 325 | // The app's zone, when it sends one, so the title is the time where |
| 326 | // the starter is (callTitle). An app from before 0.4.7 sends "{}". |
| 327 | var input struct { |
| 328 | Zone string `json:"zone"` |
| 329 | } |
| 330 | _ = json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&input) |
| 331 | p, _ := currentPerson(r) |
| 332 | title := callTitle(a.store.now(), callZone(input.Zone), a.cfg.zone) |
| 333 | m, _, err := a.store.createCall(r.Context(), title, actorFor(p)) |
| 334 | if err != nil { |
| 335 | a.logger.Error("calls: native start meeting", "err", err) |
| 336 | http.Error(w, "cannot start a meeting", http.StatusInternalServerError) |
| 337 | return |
| 338 | } |
| 339 | w.Header().Set("Content-Type", "application/json") |
| 340 | // The title is the server's, not the app's: callTitle writes it in |
| 341 | // the zone the app named, or the instance's. |
| 342 | json.NewEncoder(w).Encode(map[string]string{"sqid": m.Sqid, "title": title.Text}) |
| 343 | } |
| 344 | |
| 345 | // The web's "Join in the app" (2026-10-02). A person signed in on the |
| 346 | // web — a member, a guest who signed in, a guest who only gave a name — |
| 347 | // asks for the same meeting in the app. The app holds a session of its |
| 348 | // own, or none, so the meeting's /m/ address alone means nothing to it: |
| 349 | // it needs this person's standing, and the only safe way to give it that |
| 350 | // is the handoff above, approved in a browser where this person is |
| 351 | // signed in. The app opens the *default* browser for that, which need |
| 352 | // not be the one they are in; a guest there has no way to sign in again. |
| 353 | // |
| 354 | // So the browser starts it instead. GET /m/{sqid}/app makes an offer |
| 355 | // bound to this session and this meeting and shows a page that opens |
| 356 | // oficina-calls://join?url=…&offer=…. The app claims the offer with an |
| 357 | // ordinary /native/request carrying its challenge; this page, waiting, |
| 358 | // then turns into /native/authorize for that request, in this browser, |
| 359 | // with the code the app is showing. Approving there is what gives the |
| 360 | // app a session — the same verifier exchange, the same one use, the same |
| 361 | // five minutes, the same standing as the browser's session, and a guest |
| 362 | // still only in the meeting their grant names. |
| 363 | // |
| 364 | // What the offer is not: a credential. It is in a URL handed to another |
| 365 | // app, so on its own it buys nothing — a claim gets a request id, and |
| 366 | // only the browser that made the offer can see which request claimed it |
| 367 | // and approve it. |
| 368 | type nativeOffer struct { |
| 369 | Target string |
| 370 | Owner [32]byte |
| 371 | Expires time.Time |
| 372 | Request string |
| 373 | } |
| 374 | |
| 375 | // offer finds a live offer, sweeping expired ones. Under n.mu. |
| 376 | func (n *nativeAuthorization) offer(id string) *nativeOffer { |
| 377 | for k, v := range n.offers { |
| 378 | if !time.Now().Before(v.Expires) { |
| 379 | delete(n.offers, k) |
| 380 | } |
| 381 | } |
| 382 | return n.offers[id] |
| 383 | } |
| 384 | |
| 385 | // appOffer is GET /m/{sqid}/app, the holding screen's "Join in the app" |
| 386 | // for a signed-in browser. Authorized like the holding screen itself. |
| 387 | func (a *app) appOffer(w http.ResponseWriter, r *http.Request) { |
| 388 | nativeHeaders(w) |
| 389 | m, _, _, ok := a.roomAccess(w, r) |
| 390 | if !ok { |
| 391 | return |
| 392 | } |
| 393 | c, err := r.Cookie(a.nativeAuth.sessions.CookieName()) |
| 394 | if err != nil { |
| 395 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 396 | return |
| 397 | } |
| 398 | owner := sha256.Sum256([]byte(c.Value)) |
| 399 | n := a.nativeAuth |
| 400 | n.mu.Lock() |
| 401 | n.offer("") |
| 402 | // One offer per browser session: asking again replaces it, so a |
| 403 | // person pressing the button twice cannot fill the table. |
| 404 | for k, v := range n.offers { |
| 405 | if v.Owner == owner { |
| 406 | delete(n.offers, k) |
| 407 | } |
| 408 | } |
| 409 | if len(n.offers) >= 256 { |
| 410 | n.mu.Unlock() |
| 411 | a.ErrorPage(w, r, http.StatusTooManyRequests, "") |
| 412 | return |
| 413 | } |
| 414 | id := nativeRandom() |
| 415 | n.offers[id] = &nativeOffer{Target: "/m/" + m.Sqid, Owner: owner, Expires: time.Now().Add(5 * time.Minute)} |
| 416 | n.mu.Unlock() |
| 417 | http.Redirect(w, r, "/native/offer?o="+id, http.StatusSeeOther) |
| 418 | } |
| 419 | |
| 420 | // offerPage is GET /native/offer?o=…: the page that waits for the app. |
| 421 | // Before the app has claimed the offer it says to open the app and |
| 422 | // offers the link; once it has, it is /native/authorize for that |
| 423 | // request. room.js is not here: app-offer.js asks again with Accept: |
| 424 | // application/json and follows "next", and with no script the page |
| 425 | // refreshes itself (native.html). |
| 426 | func (a *app) offerPage(w http.ResponseWriter, r *http.Request) { |
| 427 | nativeHeaders(w) |
| 428 | id := r.URL.Query().Get("o") |
| 429 | wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json") |
| 430 | var owner [32]byte |
| 431 | if c, err := r.Cookie(a.nativeAuth.sessions.CookieName()); err == nil { |
| 432 | owner = sha256.Sum256([]byte(c.Value)) |
| 433 | } |
| 434 | n := a.nativeAuth |
| 435 | n.mu.Lock() |
| 436 | var offer nativeOffer |
| 437 | found := false |
| 438 | if nativeToken(id) { |
| 439 | if o := n.offer(id); o != nil && o.Owner == owner { |
| 440 | offer, found = *o, true |
| 441 | } |
| 442 | } |
| 443 | n.mu.Unlock() |
| 444 | if !found { |
| 445 | if wantJSON { |
| 446 | streamJSON(w, http.StatusNotFound, map[string]any{"expired": true}) |
| 447 | return |
| 448 | } |
| 449 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 450 | w.WriteHeader(http.StatusNotFound) |
| 451 | 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})) |
| 452 | return |
| 453 | } |
| 454 | if offer.Request != "" { |
| 455 | next := "/native/authorize?id=" + offer.Request |
| 456 | if wantJSON { |
| 457 | streamJSON(w, http.StatusOK, map[string]any{"next": next}) |
| 458 | return |
| 459 | } |
| 460 | http.Redirect(w, r, next, http.StatusSeeOther) |
| 461 | return |
| 462 | } |
| 463 | if wantJSON { |
| 464 | streamJSON(w, http.StatusOK, map[string]any{"waiting": true}) |
| 465 | return |
| 466 | } |
| 467 | render(w, "native", a.page(r, frame{parts: []string{"Join in the app"}, signinPage: true}, map[string]any{ |
| 468 | "Title": "Join in the Calls app", "Waiting": true, |
| 469 | "AppURL": nativeOfferURL(a.cfg.Origin+offer.Target, id), |
| 470 | "Poll": "/native/offer?o=" + id, |
| 471 | "BackHref": offer.Target + "/join", |
| 472 | })) |
| 473 | } |
| 474 | |
| 475 | // nativeOfferURL is the link that opens the app with an offer. |
| 476 | func nativeOfferURL(meeting, offer string) template.URL { |
| 477 | return template.URL("oficina-calls://join?url=" + url.QueryEscape(meeting) + "&offer=" + url.QueryEscape(offer)) |
| 478 | } |
| 479 | |