package callscore import ( "amadan.net/oficina/calls/protocol" "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "io" "net/http" "net/url" "strings" "time" ) // Authentication keeps the proof secret in the client while the user approves // in their default browser. It works on desktop and mobile without a webview, // custom URL scheme, or browser cookie extraction. type Authentication struct { origin, id, verifier, target string client *http.Client ctx context.Context cancel context.CancelFunc } type AuthenticationResult struct { Origin string `json:"origin"` Room string `json:"room"` Cookies []SessionCookie `json:"cookies"` } type SessionCookie struct { Name string `json:"name"` Value string `json:"value"` } // meetingTarget splits an address a person pasted into the instance it // is on and the path the app may ask for there. It is shared by the // browser sign-in and by OpenLink, so an address means the same thing // whether or not the app already holds a session for its instance. // // An address with no path at all is the instance itself, and the // target is the meeting list: the person has no link because the // meeting they are about to be in does not exist yet. Everything // downstream of here treats an empty Room as that case. func meetingTarget(address string) (origin, target string, err error) { u, err := url.Parse(address) if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" { return "", "", errors.New("enter a full meeting or invitation link") } target = "/" if trimmed := strings.Trim(u.Path, "/"); trimmed != "" { parts := strings.Split(trimmed, "/") if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" { parts = parts[:2] } 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 { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') }) >= 0 { return "", "", errors.New("enter a full meeting or invitation link") } target = "/" + strings.Join(parts, "/") } return u.Scheme + "://" + u.Host, target, nil } // invitationRoom reads the answer to following an invitation with a // session: a 303 to the meeting's holding page, relative, on this // instance. Anything else — a sign-in page, the guest name form, a 404 // for a replaced link — is not a meeting this session can enter. func invitationRoom(response *http.Response) (string, error) { location, err := url.Parse(response.Header.Get("Location")) if err != nil || response.StatusCode != 303 || location.IsAbs() || location.Host != "" || !strings.HasPrefix(location.Path, "/m/") || !strings.HasSuffix(location.Path, "/join") { return "", errors.New("invitation is unavailable") } return strings.TrimSuffix(strings.TrimPrefix(location.Path, "/m/"), "/join"), nil } func BeginAuthentication(ctx context.Context, address string) (*Authentication, error) { return beginAuthentication(ctx, address, "") } // ErrOfferExpired is BeginOfferAuthentication's refusal of an offer that // has gone: five minutes passed, or the offer was already taken up. var 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") // BeginOfferAuthentication is BeginAuthentication for the web's "Join in // the app" (2026-10-02): the link that opened the app carries an offer // the person's signed-in browser made, and the request names it, so the // browser page that made it — not whichever browser BrowserURL would // open — asks the person to confirm. Everything else is the same: the // verifier stays here, the session is released only to it, once. func BeginOfferAuthentication(ctx context.Context, address, offer string) (*Authentication, error) { if !authToken(offer) { return nil, errors.New("this Join in the app link is not valid") } return beginAuthentication(ctx, address, offer) } func beginAuthentication(ctx context.Context, address, offer string) (*Authentication, error) { origin, target, err := meetingTarget(address) if err != nil { return nil, err } transport, err := newTransport(origin, "auth", &http.Client{}) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) raw := make([]byte, 32) if _, err = rand.Read(raw); err != nil { cancel() return nil, errors.New("cannot start sign-in") } a := &Authentication{origin: origin, verifier: base64.RawURLEncoding.EncodeToString(raw), target: target, client: transport.http, ctx: ctx, cancel: cancel} hash := sha256.Sum256([]byte(a.verifier)) input := map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(hash[:]), "target": a.target} if offer != "" { input["offer"] = offer } res, err := a.post("request", input) if err != nil { cancel() return nil, err } defer res.Body.Close() if offer != "" && res.StatusCode == http.StatusGone { cancel() return nil, ErrOfferExpired } if res.StatusCode != 200 { cancel() return nil, errors.New("this Calls server does not support native sign-in yet, or is temporarily unavailable") } var out struct { ID string `json:"id"` Offer bool `json:"offer"` } if json.NewDecoder(io.LimitReader(res.Body, 2048)).Decode(&out) != nil || !authToken(out.ID) { cancel() return nil, errors.New("invalid sign-in response") } // A server that ignored the offer made an ordinary request, which // only BrowserURL's browser can approve. Said rather than waited on. if offer != "" && !out.Offer { cancel() return nil, errors.New("this Calls server cannot hand a web sign-in to the app; choose Sign in instead") } a.id = out.ID return a, nil } func authToken(s string) bool { b, e := base64.RawURLEncoding.DecodeString(s) return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s } func (a *Authentication) BrowserURL() string { return a.origin + "/native/start?id=" + a.id } func (a *Authentication) Cancel() { a.cancel() } func (a *Authentication) post(path string, input any) (*http.Response, error) { b, _ := json.Marshal(input) ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second) // Keep the request deadline alive through the response body read. req, _ := http.NewRequestWithContext(ctx, "POST", a.origin+"/native/"+path, bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") res, e := a.client.Do(req) if e != nil { cancel() return nil, errors.New("cannot reach Calls; try signing in again") } res.Body = &authBody{ReadCloser: res.Body, cancel: cancel} return res, nil } type authBody struct { io.ReadCloser cancel context.CancelFunc } func (b *authBody) Close() error { defer b.cancel(); return b.ReadCloser.Close() } func (a *Authentication) Wait() (*AuthenticationResult, error) { defer a.cancel() timer := time.NewTicker(2 * time.Second) defer timer.Stop() for { select { case <-a.ctx.Done(): return nil, errors.New("sign-in cancelled or expired; try again") case <-timer.C: } res, e := a.post("exchange", map[string]string{"id": a.id, "verifier": a.verifier}) if e != nil { return nil, e } if res.StatusCode == 202 { res.Body.Close() continue } var out struct { Name string `json:"name"` Value string `json:"value"` Target string `json:"target"` } err := json.NewDecoder(io.LimitReader(res.Body, 2048)).Decode(&out) res.Body.Close() expected := "__Host-rastrillo_session" if strings.HasPrefix(a.origin, "http:") { expected = "rastrillo_session" } if res.StatusCode != 200 || err != nil || out.Name != expected || !authToken(out.Value) || out.Target != a.target { return nil, errors.New("sign-in expired or was refused; try again") } // The meeting list signs in without a meeting: there is no room // to name, no invitation to follow and nothing to validate. The // host reads the empty Room as "signed in, nowhere yet". if a.target == "/" { return &AuthenticationResult{Origin: a.origin, Cookies: []SessionCookie{{Name: out.Name, Value: out.Value}}}, nil } room := strings.TrimPrefix(a.target, "/m/") if strings.HasPrefix(a.target, "/j/") { req, _ := http.NewRequestWithContext(a.ctx, "GET", a.origin+a.target, nil) req.AddCookie(&http.Cookie{Name: out.Name, Value: out.Value}) response, err := a.client.Do(req) if err != nil { return nil, errors.New("cannot open invitation") } response.Body.Close() if room, err = invitationRoom(response); err != nil { return nil, err } } if _, e := newTransport(a.origin, room, a.client); e != nil { return nil, e } return &AuthenticationResult{Origin: a.origin, Room: room, Cookies: []SessionCookie{{Name: out.Name, Value: out.Value}}}, nil } } func (a *Authentication) ConfirmationCode() string { return protocol.AuthenticationCode(a.id) }