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
3import (
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.
23type nativeAuthorization struct {
24 mu sync.Mutex
25 pending map[string]*nativeRequest
26 offers map[string]*nativeOffer
27 sessions *sessions.Sessions
28}
29type nativeRequest struct {
30 Challenge string
31 Target string
32 Expires time.Time
33 Cookie string
34}
35
36func (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}
44func 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}
51func 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.
69func 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}
88func (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}
96func 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.
102func (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}
151func (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}
199func (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}
219func (a *app) nativePendingCookie() string {
220 if strings.HasPrefix(a.cfg.Origin, "https:") {
221 return "__Host-calls_native"
222 }
223 return "calls_native"
224}
225func (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.
230func (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}
245func (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}
274func (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
294func (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.
319func (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.
368type 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.
376func (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.
387func (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).
426func (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.
476func nativeOfferURL(meeting, offer string) template.URL {
477 return template.URL("oficina-calls://join?url=" + url.QueryEscape(meeting) + "&offer=" + url.QueryEscape(offer))
478}
479