Let the native core check, use and revoke a session it kept
The apps held their session in memory only, so every launch was a browser sign-in. Keeping it is the host's business; knowing what a kept session still is, and using it without the browser, is this one's. CheckSession answers member, participant or signed out from one GET of /settings, read by status alone. There is no endpoint for the question and none is added: requireParticipant and requireMember already answer it on every server that has run native sign-in — the page for a member, the same 404 for a guest and for an invited address, and the sign-in 303 for a session that has gone. Anything else is an error and never ErrSignedOut, so a host keeps its credential through a bad connection and forgets it only when the instance says so. OpenLink turns a link on the instance the app is signed in to into the meeting to join, with the session in hand: /m/ answers its identifier, /j/ is followed exactly as the browser follows it (which is what consumes the invitation and grants a non-member the meeting), and a link on another instance is refused before the session is sent anywhere. SignOut is the web's own POST /signout with the Origin csrf.Protect reads. The address parsing and the reading of an invitation's redirect came out of BeginAuthentication and Wait so the new functions share them. The bridge answers CheckSession with a value and OpenLink with JSON rather than distinguishing by error, because gomobile hands Swift every error as the same NSError. A server test pins the three statuses the app now reads off pages it does not own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+592
−17
callscore/auth.go+35 −13callscore/session.go+178 −0callscore/session_test.go+179 −0internal/calls/native_auth_test.go+47 −0native/bridge/bridge.go+72 −4native/bridge/bridge_test.go+81 −0
diff --git a/callscore/auth.go b/callscore/auth.go| index ca3e589..550fd58 100644 |
| --- a/callscore/auth.go |
| +++ b/callscore/auth.go |
| @@ -35,16 +35,21 @@ type SessionCookie struct { |
| Value string `json:"value"` |
| } |
| -func BeginAuthentication(ctx context.Context, address string) (*Authentication, error) { |
| +// 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 nil, errors.New("enter a full meeting or invitation link") |
| + return "", "", errors.New("enter a full meeting or invitation link") |
| } |
| - // An address with no path at all is the instance itself, and the |
| - // target is the meeting list: the person signing in 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. |
| - target := "/" |
| + target = "/" |
| if trimmed := strings.Trim(u.Path, "/"); trimmed != "" { |
| parts := strings.Split(trimmed, "/") |
| if len(parts) == 3 && parts[0] == "m" && parts[2] == "join" { |
| @@ -53,11 +58,30 @@ func BeginAuthentication(ctx context.Context, address string) (*Authentication, |
| 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 nil, errors.New("enter a full meeting or invitation link") |
| + return "", "", errors.New("enter a full meeting or invitation link") |
| } |
| target = "/" + strings.Join(parts, "/") |
| } |
| - origin := u.Scheme + "://" + u.Host |
| + 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) { |
| + origin, target, err := meetingTarget(address) |
| + if err != nil { |
| + return nil, err |
| + } |
| transport, err := newTransport(origin, "auth", &http.Client{}) |
| if err != nil { |
| return nil, err |
| @@ -164,11 +188,9 @@ func (a *Authentication) Wait() (*AuthenticationResult, error) { |
| return nil, errors.New("cannot open invitation") |
| } |
| response.Body.Close() |
| - 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 nil, errors.New("invitation is unavailable") |
| + if room, err = invitationRoom(response); err != nil { |
| + return nil, err |
| } |
| - room = strings.TrimSuffix(strings.TrimPrefix(location.Path, "/m/"), "/join") |
| } |
| if _, e := newTransport(a.origin, room, a.client); e != nil { |
| return nil, e |
diff --git a/callscore/session.go b/callscore/session.go| new file mode 100644 |
| index 0000000..6f40203 |
| --- /dev/null |
| +++ b/callscore/session.go |
| @@ -0,0 +1,178 @@ |
| +package callscore |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// ErrSignedOut is the one refusal the app acts on rather than shows: the |
| +// session it is holding no longer signs anyone in, so the copy it kept |
| +// is forgotten and the person signs in again. |
| +var ErrSignedOut = errors.New("sign in to Calls again") |
| + |
| +// Standing is what a held session is on its instance, as far as the app |
| +// needs to know before it decides whether to keep it. |
| +type Standing string |
| + |
| +const ( |
| + // Member: a person on the team. This is the session worth keeping |
| + // across launches — it may start meetings and follow any link. |
| + Member Standing = "member" |
| + // Participant: a live session that is not a member's — a guest who |
| + // gave a name, or a verified address that was only invited. It is |
| + // good for the meeting it came in through and nothing the app would |
| + // want to wake up holding next week. |
| + Participant Standing = "participant" |
| +) |
| + |
| +// CheckSession asks the instance what the held session is, with one GET |
| +// of a member-only page whose body is never read. |
| +// |
| +// There is no endpoint for the question, and none is added: the page |
| +// already answers it by status, on every server that has ever run the |
| +// native sign-in. /settings sits behind requireParticipant and then |
| +// requireMember, so a member's session gets the page, anyone else's |
| +// the 404 both refuse with, and a session that has gone the sign-in |
| +// redirect. Redirects are never followed (safeClient), so that 303 |
| +// arrives as itself. |
| +// |
| +// Anything else is an answer this function cannot read — a network |
| +// failure, a 5xx, a proxy's page — and is reported as an error, never |
| +// as ErrSignedOut: the caller keeps its credential through a bad |
| +// connection and forgets it only when the instance says so. |
| +func CheckSession(ctx context.Context, origin string, client *http.Client) (Standing, error) { |
| + trimmed, err := checkOrigin(origin) |
| + if err != nil { |
| + return "", err |
| + } |
| + safe, err := safeClient(client) |
| + if err != nil { |
| + return "", err |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+"/settings", nil) |
| + if err != nil { |
| + return "", errors.New("cannot build Calls request") |
| + } |
| + res, err := safe.Do(req) |
| + if err != nil { |
| + return "", errors.New("cannot reach Calls") |
| + } |
| + res.Body.Close() |
| + switch { |
| + case res.StatusCode == http.StatusOK: |
| + return Member, nil |
| + case res.StatusCode == http.StatusNotFound: |
| + return Participant, nil |
| + case res.StatusCode == http.StatusUnauthorized, res.StatusCode >= 300 && res.StatusCode < 400: |
| + return "", ErrSignedOut |
| + default: |
| + return "", errors.New("cannot check your Calls sign-in just now") |
| + } |
| +} |
| + |
| +// OpenLink turns a meeting or invitation address on the instance the |
| +// app is already signed in to into the meeting to join, without the |
| +// browser: the session in hand is the person's own, and a second |
| +// sign-in would only mint another one like it. |
| +// |
| +// An address with no path answers no meeting, which is the instance |
| +// itself. A /m/ address answers its identifier without asking: joining |
| +// is where access to it is decided. A /j/ address is followed exactly |
| +// as the browser would follow it with this session — which is what |
| +// consumes the invitation and, for someone who is not a member, grants |
| +// the meeting — and the room is read off the redirect. |
| +// |
| +// An address on another instance is refused. That instance is a |
| +// different sign-in, and the caller runs it. |
| +func OpenLink(ctx context.Context, origin, address string, client *http.Client) (string, error) { |
| + trimmed, err := checkOrigin(origin) |
| + if err != nil { |
| + return "", err |
| + } |
| + linkOrigin, target, err := meetingTarget(address) |
| + if err != nil { |
| + return "", err |
| + } |
| + if linkOrigin != trimmed { |
| + return "", errors.New("that link is for another Calls server") |
| + } |
| + if target == "/" { |
| + return "", nil |
| + } |
| + if room, ok := strings.CutPrefix(target, "/m/"); ok { |
| + if !validRoom(room) { |
| + return "", errors.New("invalid meeting identifier") |
| + } |
| + return room, nil |
| + } |
| + safe, err := safeClient(client) |
| + if err != nil { |
| + return "", err |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, trimmed+target, nil) |
| + if err != nil { |
| + return "", errors.New("cannot build Calls request") |
| + } |
| + res, err := safe.Do(req) |
| + if err != nil { |
| + return "", errors.New("cannot reach Calls") |
| + } |
| + res.Body.Close() |
| + // followLink answers a gone session with the guest name form (200) |
| + // or the sign-in redirect, never with an error: the link itself is |
| + // fine, it is the person who has to say who they are. |
| + if res.StatusCode == http.StatusOK || strings.HasPrefix(res.Header.Get("Location"), "/signin") { |
| + return "", ErrSignedOut |
| + } |
| + room, err := invitationRoom(res) |
| + if err != nil { |
| + return "", err |
| + } |
| + if !validRoom(room) { |
| + return "", errors.New("invitation is unavailable") |
| + } |
| + return room, nil |
| +} |
| + |
| +// SignOut revokes the held session on its instance: the same POST |
| +// /signout the web's button makes, carrying the Origin csrf.Protect |
| +// reads. Either sign-out route answers with a redirect once the row is |
| +// gone. |
| +// |
| +// Best effort, and the caller forgets its copy whatever this answers: a |
| +// person who pressed Sign out is signed out of this app, and a session |
| +// the server could not be told about still lapses at its thirty days. |
| +func SignOut(ctx context.Context, origin string, client *http.Client) error { |
| + trimmed, err := checkOrigin(origin) |
| + if err != nil { |
| + return err |
| + } |
| + safe, err := safeClient(client) |
| + if err != nil { |
| + return err |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/signout", strings.NewReader("")) |
| + if err != nil { |
| + return errors.New("cannot build Calls request") |
| + } |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", trimmed) |
| + res, err := safe.Do(req) |
| + if err != nil { |
| + return errors.New("cannot reach Calls") |
| + } |
| + res.Body.Close() |
| + if res.StatusCode != http.StatusSeeOther { |
| + return errors.New("Calls did not confirm the sign-out") |
| + } |
| + return nil |
| +} |
diff --git a/callscore/session_test.go b/callscore/session_test.go| new file mode 100644 |
| index 0000000..fb2fe63 |
| --- /dev/null |
| +++ b/callscore/session_test.go |
| @@ -0,0 +1,179 @@ |
| +package callscore |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| +) |
| + |
| +func TestCheckSessionReadsTheSettingsPageByStatusAlone(t *testing.T) { |
| + for _, c := range []struct { |
| + status int |
| + want Standing |
| + }{ |
| + {http.StatusOK, Member}, |
| + // requireParticipant's refusal of a guest and requireMember's of |
| + // an invited address are the same 404, and both mean the same |
| + // thing here: live, and not worth keeping. |
| + {http.StatusNotFound, Participant}, |
| + } { |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != "GET" || r.URL.Path != "/settings" { |
| + t.Errorf("asked %s %s", r.Method, r.URL.Path) |
| + } |
| + w.WriteHeader(c.status) |
| + })) |
| + got, err := CheckSession(t.Context(), server.URL, server.Client()) |
| + if err != nil || got != c.want { |
| + t.Errorf("status %d gave %q, %v", c.status, got, err) |
| + } |
| + server.Close() |
| + } |
| +} |
| + |
| +func TestCheckSessionOnlyCallsTheSignInRedirectSignedOut(t *testing.T) { |
| + // The whole point of the distinction: a credential is forgotten when |
| + // the instance says it is gone, and kept through anything else. |
| + for _, c := range []struct { |
| + handler http.HandlerFunc |
| + signedOut bool |
| + }{ |
| + {func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, true}, |
| + {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }, true}, |
| + {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, false}, |
| + {func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadGateway) }, false}, |
| + } { |
| + server := httptest.NewServer(c.handler) |
| + _, err := CheckSession(t.Context(), server.URL, server.Client()) |
| + if err == nil { |
| + t.Fatal("an unreadable answer was read") |
| + } |
| + if errors.Is(err, ErrSignedOut) != c.signedOut { |
| + t.Errorf("signed out = %v, wanted %v (%v)", errors.Is(err, ErrSignedOut), c.signedOut, err) |
| + } |
| + server.Close() |
| + } |
| + // And an instance that cannot be reached at all says nothing about the |
| + // session. |
| + server := httptest.NewServer(http.NotFoundHandler()) |
| + origin := server.URL |
| + server.Close() |
| + if _, err := CheckSession(t.Context(), origin, &http.Client{}); err == nil || errors.Is(err, ErrSignedOut) { |
| + t.Fatal("an unreachable instance signed the app out", err) |
| + } |
| +} |
| + |
| +func TestCheckSessionNeverFollowsTheRedirect(t *testing.T) { |
| + var calls atomic.Int32 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + calls.Add(1) |
| + http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| + })) |
| + defer server.Close() |
| + CheckSession(t.Context(), server.URL, server.Client()) |
| + if calls.Load() != 1 { |
| + t.Fatal("followed the redirect", calls.Load()) |
| + } |
| +} |
| + |
| +func TestOpenLinkAnswersAMeetingWithoutAskingAndTheInstanceAsNone(t *testing.T) { |
| + var calls atomic.Int32 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| + defer server.Close() |
| + for address, want := range map[string]string{ |
| + server.URL + "/m/abc123": "abc123", |
| + server.URL + "/m/abc123/join": "abc123", |
| + server.URL: "", |
| + server.URL + "/": "", |
| + } { |
| + got, err := OpenLink(t.Context(), server.URL, address, server.Client()) |
| + if err != nil || got != want { |
| + t.Errorf("%q gave %q, %v", address, got, err) |
| + } |
| + } |
| + if calls.Load() != 0 { |
| + t.Fatal("asked the server about something it did not need to", calls.Load()) |
| + } |
| +} |
| + |
| +func TestOpenLinkFollowsAnInvitationWithTheSessionAndReadsTheRoom(t *testing.T) { |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/j/token_1" { |
| + t.Errorf("followed %s", r.URL.Path) |
| + } |
| + http.Redirect(w, r, "/m/xyz789/join", http.StatusSeeOther) |
| + })) |
| + defer server.Close() |
| + got, err := OpenLink(t.Context(), server.URL, server.URL+"/j/token_1", server.Client()) |
| + if err != nil || got != "xyz789" { |
| + t.Fatal(got, err) |
| + } |
| +} |
| + |
| +func TestOpenLinkTellsAGoneSessionFromAGoneLink(t *testing.T) { |
| + for _, c := range []struct { |
| + handler http.HandlerFunc |
| + signedOut bool |
| + }{ |
| + // followLink with no session: the guest name form, or the sign-in |
| + // page for a meeting that requires one. |
| + {func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("<form>")) }, true}, |
| + {func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signin", http.StatusSeeOther) }, true}, |
| + // A replaced link is a 404 whoever follows it. |
| + {func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }, false}, |
| + {func(w http.ResponseWriter, r *http.Request) { |
| + http.Redirect(w, r, "https://elsewhere.test/m/abc/join", http.StatusSeeOther) |
| + }, false}, |
| + } { |
| + server := httptest.NewServer(c.handler) |
| + _, err := OpenLink(t.Context(), server.URL, server.URL+"/j/token", server.Client()) |
| + if err == nil { |
| + t.Fatal("accepted a link that did not lead to a meeting") |
| + } |
| + if errors.Is(err, ErrSignedOut) != c.signedOut { |
| + t.Errorf("signed out = %v, wanted %v (%v)", errors.Is(err, ErrSignedOut), c.signedOut, err) |
| + } |
| + server.Close() |
| + } |
| +} |
| + |
| +func TestOpenLinkRefusesAnotherInstanceBeforeSendingItTheSession(t *testing.T) { |
| + var calls atomic.Int32 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| + defer server.Close() |
| + for _, address := range []string{"https://other.test/j/token", "https://other.test/m/abc", "not a link", server.URL + "/settings"} { |
| + if _, err := OpenLink(t.Context(), server.URL, address, server.Client()); err == nil { |
| + t.Errorf("accepted %q", address) |
| + } |
| + } |
| + if calls.Load() != 0 { |
| + t.Fatal("a refused address still reached a server", calls.Load()) |
| + } |
| +} |
| + |
| +func TestSignOutPostsWithTheOriginAndWantsTheRedirect(t *testing.T) { |
| + var origin, method, path atomic.Value |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + origin.Store(r.Header.Get("Origin")) |
| + method.Store(r.Method) |
| + path.Store(r.URL.Path) |
| + http.Redirect(w, r, "/signed-out", http.StatusSeeOther) |
| + })) |
| + defer server.Close() |
| + if err := SignOut(t.Context(), server.URL, server.Client()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if method.Load() != "POST" || path.Load() != "/signout" || origin.Load() != server.URL { |
| + t.Fatal("wrong request", method.Load(), path.Load(), origin.Load()) |
| + } |
| + refused := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.Error(w, "cross-origin form submission refused", http.StatusForbidden) |
| + })) |
| + defer refused.Close() |
| + if err := SignOut(t.Context(), refused.URL, refused.Client()); err == nil || !strings.Contains(err.Error(), "did not confirm") { |
| + t.Fatal("a refused sign-out was reported as done", err) |
| + } |
| +} |
diff --git a/internal/calls/native_auth_test.go b/internal/calls/native_auth_test.go| index ebd70e5..211204c 100644 |
| --- a/internal/calls/native_auth_test.go |
| +++ b/internal/calls/native_auth_test.go |
| @@ -329,3 +329,50 @@ func TestNativeHandoffWithoutAMeetingLandsOnTheList(t *testing.T) { |
| t.Fatal("target not carried back", ex.Body.String()) |
| } |
| } |
| + |
| +// The app keeps a member's native session across launches and reads |
| +// three things off this server by status alone (callscore.CheckSession, |
| +// OpenLink and SignOut): /settings for whether the session is still a |
| +// member's, /j/{token} for the meeting an invitation opens, and |
| +// /signout for revoking it. None of them is a native endpoint, so this |
| +// is where a change to any of those answers is caught before it signs |
| +// every app out, or never does. |
| +func TestNativeSessionReadsWhatTheAppReads(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + m, token, err := f.app.store.createMeeting(t.Context(), "Kept session call", nil, "person:1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + browser := httptest.NewRequest("GET", homeTestOrigin+"/", nil) |
| + for _, c := range f.cookies { |
| + browser.AddCookie(c) |
| + } |
| + session, ok := f.app.nativeAuth.sessions.From(browser) |
| + if !ok { |
| + t.Fatal("no browser session to hand over") |
| + } |
| + value, err := f.app.nativeAuth.sessions.Mint(session) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // From here on the fixture is the app: its own session and nothing |
| + // the browser had. |
| + name := f.app.nativeAuth.sessions.CookieName() |
| + f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}} |
| + if w := f.do("GET", "/settings", ""); w.Code != 200 { |
| + t.Fatal("a member's native session is not read as one", w.Code) |
| + } |
| + if dest := homeRedirect(t, f.do("GET", "/j/"+token, "")); dest != "/m/"+m.Sqid+"/join" { |
| + t.Fatal("invitation did not lead to its meeting", dest) |
| + } |
| + homeRedirect(t, f.do("POST", "/signout", "")) |
| + // f.do dropped the cleared cookie; the app still holds its copy. |
| + f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}} |
| + if dest := homeRedirect(t, f.do("GET", "/settings", "")); !strings.HasPrefix(dest, signinPath) { |
| + t.Fatal("a revoked session is not sent to sign in", dest) |
| + } |
| + if w := f.do("GET", "/j/"+token, ""); w.Code != 200 && !strings.HasPrefix(w.Header().Get("Location"), signinPath) { |
| + t.Fatal("a revoked session followed an invitation", w.Code, w.Header().Get("Location")) |
| + } |
| +} |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| index 8784a66..cafacab 100644 |
| --- a/native/bridge/bridge.go |
| +++ b/native/bridge/bridge.go |
| @@ -22,10 +22,12 @@ type Client struct{ room *callscore.Room } |
| // sessionJar turns the cookies the sign-in handoff returned into the jar |
| // every authenticated call in this package is made on. It is shared by |
| -// NewClient and StartMeeting so the rules about what counts as a session |
| -// — one cookie, the framework's name, thirty-two decoded bytes — are |
| -// written once. Cookies are scoped to the exact supplied origin and kept |
| -// in memory only. |
| +// every function here that takes cookies, so the rules about what counts |
| +// as a session — one cookie, the framework's name, thirty-two decoded |
| +// bytes — are written once. Cookies are scoped to the exact supplied |
| +// origin, and the jar lives only as long as the call that made it: |
| +// keeping the session between launches is the host's business, and |
| +// CallsKit keeps it in the Keychain. |
| func sessionJar(origin, cookiesJSON string) (*cookiejar.Jar, error) { |
| u, err := url.Parse(origin) |
| if err != nil { |
| @@ -115,3 +117,69 @@ func RegisterDevice(origin, cookiesJSON, token, platform string) error { |
| } |
| return callscore.RegisterDevice(context.Background(), origin, &http.Client{Jar: jar}, token, platform) |
| } |
| + |
| +// SignedOut is CheckSession's third answer, beside callscore's Member |
| +// and Participant: the instance says this session no longer signs |
| +// anyone in. It is a value rather than an error because the host acts |
| +// on it — forgets the copy it kept — and gomobile hands Swift every |
| +// error as the same NSError, told apart only by its words. |
| +const SignedOut = "signed-out" |
| + |
| +// CheckSession answers "member", "participant" or "signed-out" for a |
| +// session the host held on to, and an error only when the instance |
| +// could not be asked or its answer could not be read. The host keeps |
| +// its credential through an error and forgets it on "signed-out". |
| +// |
| +// Cookies this package would refuse to send are "signed-out" too: a |
| +// stored copy that no longer parses is no more use than an expired one. |
| +func CheckSession(origin, cookiesJSON string) (string, error) { |
| + jar, err := sessionJar(origin, cookiesJSON) |
| + if err != nil { |
| + return SignedOut, nil |
| + } |
| + standing, err := callscore.CheckSession(context.Background(), origin, &http.Client{Jar: jar}) |
| + if errors.Is(err, callscore.ErrSignedOut) { |
| + return SignedOut, nil |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + return string(standing), nil |
| +} |
| + |
| +// OpenLink resolves a meeting or invitation address on the instance the |
| +// host is signed in to, without the browser, and answers JSON: |
| +// {"room": "..."} — empty for the instance's own address — or |
| +// {"signedOut": true} when the session has gone and the address needs a |
| +// fresh sign-in. JSON rather than a bare room so that "no meeting" and |
| +// "no session" cannot be mistaken for each other. |
| +func OpenLink(origin, cookiesJSON, address string) (string, error) { |
| + type answer struct { |
| + Room string `json:"room"` |
| + SignedOut bool `json:"signedOut,omitempty"` |
| + } |
| + var out answer |
| + jar, err := sessionJar(origin, cookiesJSON) |
| + if err == nil { |
| + out.Room, err = callscore.OpenLink(context.Background(), origin, address, &http.Client{Jar: jar}) |
| + } else { |
| + err = callscore.ErrSignedOut |
| + } |
| + if errors.Is(err, callscore.ErrSignedOut) { |
| + out = answer{SignedOut: true} |
| + } else if err != nil { |
| + return "", err |
| + } |
| + b, err := json.Marshal(out) |
| + return string(b), err |
| +} |
| + |
| +// SignOut asks the instance to revoke the session. The host forgets its |
| +// copy whatever this answers; see callscore.SignOut. |
| +func SignOut(origin, cookiesJSON string) error { |
| + jar, err := sessionJar(origin, cookiesJSON) |
| + if err != nil { |
| + return err |
| + } |
| + return callscore.SignOut(context.Background(), origin, &http.Client{Jar: jar}) |
| +} |
diff --git a/native/bridge/bridge_test.go b/native/bridge/bridge_test.go| index d0789c9..8b9ec9d 100644 |
| --- a/native/bridge/bridge_test.go |
| +++ b/native/bridge/bridge_test.go |
| @@ -2,6 +2,9 @@ package bridge |
| import ( |
| "encoding/base64" |
| + "net/http" |
| + "net/http/httptest" |
| + "sync/atomic" |
| "testing" |
| ) |
| @@ -29,3 +32,81 @@ func TestBridgeRequiresHumanSessionAndSafeOrigin(t *testing.T) { |
| } |
| c.Close() |
| } |
| + |
| +// loopbackSession is a session cookie as the handoff returns it for an |
| +// http://127.0.0.1 instance, which is the one place a test server can |
| +// be and the bridge will still send it. |
| +func loopbackSession() (string, string) { |
| + token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| + return token, `[{"name":"rastrillo_session","value":"` + token + `"}]` |
| +} |
| + |
| +func TestCheckSessionCarriesTheCookieAndAnswersAValue(t *testing.T) { |
| + token, cookies := loopbackSession() |
| + status := http.StatusOK |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if c, err := r.Cookie("rastrillo_session"); err != nil || c.Value != token { |
| + t.Error("session not sent") |
| + } |
| + if status == http.StatusSeeOther { |
| + http.Redirect(w, r, "/signin", status) |
| + return |
| + } |
| + w.WriteHeader(status) |
| + })) |
| + defer server.Close() |
| + for s, want := range map[int]string{http.StatusOK: "member", http.StatusNotFound: "participant", http.StatusSeeOther: SignedOut} { |
| + status = s |
| + got, err := CheckSession(server.URL, cookies) |
| + if err != nil || got != want { |
| + t.Errorf("status %d gave %q, %v", s, got, err) |
| + } |
| + } |
| + status = http.StatusBadGateway |
| + if got, err := CheckSession(server.URL, cookies); err == nil || got == SignedOut { |
| + t.Fatal("a bad gateway signed the app out", got, err) |
| + } |
| +} |
| + |
| +func TestCheckSessionCallsAStoredCopyItCannotSendSignedOut(t *testing.T) { |
| + var calls atomic.Int32 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| + defer server.Close() |
| + for _, cookies := range []string{``, `[]`, `not json`, `[{"name":"rastrillo_session","value":"short"}]`} { |
| + if got, err := CheckSession(server.URL, cookies); err != nil || got != SignedOut { |
| + t.Errorf("%q gave %q, %v", cookies, got, err) |
| + } |
| + } |
| + if calls.Load() != 0 { |
| + t.Fatal("sent a session it should have refused", calls.Load()) |
| + } |
| +} |
| + |
| +func TestOpenLinkKeepsNoMeetingAndNoSessionApart(t *testing.T) { |
| + _, cookies := loopbackSession() |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch r.URL.Path { |
| + case "/j/live": |
| + http.Redirect(w, r, "/m/abc123/join", http.StatusSeeOther) |
| + case "/j/gone": |
| + http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| + })) |
| + defer server.Close() |
| + for address, want := range map[string]string{ |
| + server.URL: `{"room":""}`, |
| + server.URL + "/j/live": `{"room":"abc123"}`, |
| + server.URL + "/j/gone": `{"room":"","signedOut":true}`, |
| + server.URL + "/m/xyz99": `{"room":"xyz99"}`, |
| + } { |
| + got, err := OpenLink(server.URL, cookies, address) |
| + if err != nil || got != want { |
| + t.Errorf("%q gave %s, %v", address, got, err) |
| + } |
| + } |
| + if _, err := OpenLink(server.URL, cookies, server.URL+"/j/replaced"); err == nil { |
| + t.Fatal("a replaced link opened") |
| + } |
| +} |