oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package callscore
2
3import (
4 "amadan.net/oficina/calls/protocol"
5 "bytes"
6 "context"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/json"
11 "errors"
12 "io"
13 "net/http"
14 "net/url"
15 "strings"
16 "time"
17)
18
19// Authentication keeps the proof secret in the client while the user approves
20// in their default browser. It works on desktop and mobile without a webview,
21// custom URL scheme, or browser cookie extraction.
22type Authentication struct {
23 origin, id, verifier, target string
24 client *http.Client
25 ctx context.Context
26 cancel context.CancelFunc
27}
28type AuthenticationResult struct {
29 Origin string `json:"origin"`
30 Room string `json:"room"`
31 Cookies []SessionCookie `json:"cookies"`
32}
33type SessionCookie struct {
34 Name string `json:"name"`
35 Value string `json:"value"`
36}
37
38// meetingTarget splits an address a person pasted into the instance it
39// is on and the path the app may ask for there. It is shared by the
40// browser sign-in and by OpenLink, so an address means the same thing
41// whether or not the app already holds a session for its instance.
42//
43// An address with no path at all is the instance itself, and the
44// target is the meeting list: the person has no link because the
45// meeting they are about to be in does not exist yet. Everything
46// downstream of here treats an empty Room as that case.
47func meetingTarget(address string) (origin, target string, err error) {
48 u, err := url.Parse(address)
49 if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
50 return "", "", errors.New("enter a full meeting or invitation link")
51 }
52 target = "/"
53 if trimmed := strings.Trim(u.Path, "/"); trimmed != "" {
54 parts := strings.Split(trimmed, "/")
55 if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" {
56 parts = parts[:2]
57 }
58 if len(parts) != 2 || (parts[0] != "m" && parts[0] != "j") || len(parts[1]) == 0 || len(parts[1]) > 128 || strings.IndexFunc(parts[1], func(r rune) bool {
59 return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-')
60 }) >= 0 {
61 return "", "", errors.New("enter a full meeting or invitation link")
62 }
63 target = "/" + strings.Join(parts, "/")
64 }
65 return u.Scheme + "://" + u.Host, target, nil
66}
67
68// invitationRoom reads the answer to following an invitation with a
69// session: a 303 to the meeting's holding page, relative, on this
70// instance. Anything else — a sign-in page, the guest name form, a 404
71// for a replaced link — is not a meeting this session can enter.
72func invitationRoom(response *http.Response) (string, error) {
73 location, err := url.Parse(response.Header.Get("Location"))
74 if err != nil || response.StatusCode != 303 || location.IsAbs() || location.Host != "" || !strings.HasPrefix(location.Path, "/m/") || !strings.HasSuffix(location.Path, "/join") {
75 return "", errors.New("invitation is unavailable")
76 }
77 return strings.TrimSuffix(strings.TrimPrefix(location.Path, "/m/"), "/join"), nil
78}
79
80func BeginAuthentication(ctx context.Context, address string) (*Authentication, error) {
81 return beginAuthentication(ctx, address, "")
82}
83
84// ErrOfferExpired is BeginOfferAuthentication's refusal of an offer that
85// has gone: five minutes passed, or the offer was already taken up.
86var ErrOfferExpired = errors.New("this Join in the app link has expired or was already used; choose Join in the app on the meeting page again")
87
88// BeginOfferAuthentication is BeginAuthentication for the web's "Join in
89// the app" (2026-10-02): the link that opened the app carries an offer
90// the person's signed-in browser made, and the request names it, so the
91// browser page that made it — not whichever browser BrowserURL would
92// open — asks the person to confirm. Everything else is the same: the
93// verifier stays here, the session is released only to it, once.
94func BeginOfferAuthentication(ctx context.Context, address, offer string) (*Authentication, error) {
95 if !authToken(offer) {
96 return nil, errors.New("this Join in the app link is not valid")
97 }
98 return beginAuthentication(ctx, address, offer)
99}
100
101func beginAuthentication(ctx context.Context, address, offer string) (*Authentication, error) {
102 origin, target, err := meetingTarget(address)
103 if err != nil {
104 return nil, err
105 }
106 transport, err := newTransport(origin, "auth", &http.Client{})
107 if err != nil {
108 return nil, err
109 }
110 ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
111 raw := make([]byte, 32)
112 if _, err = rand.Read(raw); err != nil {
113 cancel()
114 return nil, errors.New("cannot start sign-in")
115 }
116 a := &Authentication{origin: origin, verifier: base64.RawURLEncoding.EncodeToString(raw), target: target, client: transport.http, ctx: ctx, cancel: cancel}
117 hash := sha256.Sum256([]byte(a.verifier))
118 input := map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(hash[:]), "target": a.target}
119 if offer != "" {
120 input["offer"] = offer
121 }
122 res, err := a.post("request", input)
123 if err != nil {
124 cancel()
125 return nil, err
126 }
127 defer res.Body.Close()
128 if offer != "" && res.StatusCode == http.StatusGone {
129 cancel()
130 return nil, ErrOfferExpired
131 }
132 if res.StatusCode != 200 {
133 cancel()
134 return nil, errors.New("this Calls server does not support native sign-in yet, or is temporarily unavailable")
135 }
136 var out struct {
137 ID string `json:"id"`
138 Offer bool `json:"offer"`
139 }
140 if json.NewDecoder(io.LimitReader(res.Body, 2048)).Decode(&out) != nil || !authToken(out.ID) {
141 cancel()
142 return nil, errors.New("invalid sign-in response")
143 }
144 // A server that ignored the offer made an ordinary request, which
145 // only BrowserURL's browser can approve. Said rather than waited on.
146 if offer != "" && !out.Offer {
147 cancel()
148 return nil, errors.New("this Calls server cannot hand a web sign-in to the app; choose Sign in instead")
149 }
150 a.id = out.ID
151 return a, nil
152}
153func authToken(s string) bool {
154 b, e := base64.RawURLEncoding.DecodeString(s)
155 return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
156}
157func (a *Authentication) BrowserURL() string { return a.origin + "/native/start?id=" + a.id }
158func (a *Authentication) Cancel() { a.cancel() }
159func (a *Authentication) post(path string, input any) (*http.Response, error) {
160 b, _ := json.Marshal(input)
161 ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
162 // Keep the request deadline alive through the response body read.
163 req, _ := http.NewRequestWithContext(ctx, "POST", a.origin+"/native/"+path, bytes.NewReader(b))
164 req.Header.Set("Content-Type", "application/json")
165 res, e := a.client.Do(req)
166 if e != nil {
167 cancel()
168 return nil, errors.New("cannot reach Calls; try signing in again")
169 }
170 res.Body = &authBody{ReadCloser: res.Body, cancel: cancel}
171 return res, nil
172}
173
174type authBody struct {
175 io.ReadCloser
176 cancel context.CancelFunc
177}
178
179func (b *authBody) Close() error { defer b.cancel(); return b.ReadCloser.Close() }
180func (a *Authentication) Wait() (*AuthenticationResult, error) {
181 defer a.cancel()
182 timer := time.NewTicker(2 * time.Second)
183 defer timer.Stop()
184 for {
185 select {
186 case <-a.ctx.Done():
187 return nil, errors.New("sign-in cancelled or expired; try again")
188 case <-timer.C:
189 }
190 res, e := a.post("exchange", map[string]string{"id": a.id, "verifier": a.verifier})
191 if e != nil {
192 return nil, e
193 }
194 if res.StatusCode == 202 {
195 res.Body.Close()
196 continue
197 }
198 var out struct {
199 Name string `json:"name"`
200 Value string `json:"value"`
201 Target string `json:"target"`
202 }
203 err := json.NewDecoder(io.LimitReader(res.Body, 2048)).Decode(&out)
204 res.Body.Close()
205 expected := "__Host-rastrillo_session"
206 if strings.HasPrefix(a.origin, "http:") {
207 expected = "rastrillo_session"
208 }
209 if res.StatusCode != 200 || err != nil || out.Name != expected || !authToken(out.Value) || out.Target != a.target {
210 return nil, errors.New("sign-in expired or was refused; try again")
211 }
212 // The meeting list signs in without a meeting: there is no room
213 // to name, no invitation to follow and nothing to validate. The
214 // host reads the empty Room as "signed in, nowhere yet".
215 if a.target == "/" {
216 return &AuthenticationResult{Origin: a.origin, Cookies: []SessionCookie{{Name: out.Name, Value: out.Value}}}, nil
217 }
218 room := strings.TrimPrefix(a.target, "/m/")
219 if strings.HasPrefix(a.target, "/j/") {
220 req, _ := http.NewRequestWithContext(a.ctx, "GET", a.origin+a.target, nil)
221 req.AddCookie(&http.Cookie{Name: out.Name, Value: out.Value})
222 response, err := a.client.Do(req)
223 if err != nil {
224 return nil, errors.New("cannot open invitation")
225 }
226 response.Body.Close()
227 if room, err = invitationRoom(response); err != nil {
228 return nil, err
229 }
230 }
231 if _, e := newTransport(a.origin, room, a.client); e != nil {
232 return nil, e
233 }
234 return &AuthenticationResult{Origin: a.origin, Room: room, Cookies: []SessionCookie{{Name: out.Name, Value: out.Value}}}, nil
235 }
236}
237
238func (a *Authentication) ConfirmationCode() string { return protocol.AuthenticationCode(a.id) }
239