| 1 | package callscore |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "sync/atomic" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | func TestAuthenticationProofAndCancellation(t *testing.T) { |
| 17 | id := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| 18 | challengeReceived := make(chan string, 1) |
| 19 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 20 | if r.Header.Get("Cookie") != "" { |
| 21 | t.Error("authentication sent ambient credentials") |
| 22 | } |
| 23 | var in map[string]string |
| 24 | json.NewDecoder(r.Body).Decode(&in) |
| 25 | if r.URL.Path != "/native/request" || !authToken(in["challenge"]) || in["target"] != "/m/abc" { |
| 26 | t.Error("invalid authentication request") |
| 27 | } |
| 28 | challengeReceived <- in["challenge"] |
| 29 | json.NewEncoder(w).Encode(map[string]string{"id": id}) |
| 30 | })) |
| 31 | defer server.Close() |
| 32 | auth, err := BeginAuthentication(t.Context(), server.URL+"/m/abc/join") |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | challenge := <-challengeReceived |
| 37 | sum := sha256.Sum256([]byte(auth.verifier)) |
| 38 | if challenge != base64.RawURLEncoding.EncodeToString(sum[:]) { |
| 39 | t.Fatal("challenge does not bind proof") |
| 40 | } |
| 41 | if strings.Contains(auth.BrowserURL(), auth.verifier) || strings.Contains(auth.BrowserURL(), challenge) { |
| 42 | t.Fatal("proof leaked into browser URL") |
| 43 | } |
| 44 | if auth.ConfirmationCode() == "" { |
| 45 | t.Fatal("missing comparison code") |
| 46 | } |
| 47 | // Cancellation must interrupt the wait promptly, including while idle between polls. |
| 48 | auth.Cancel() |
| 49 | done := make(chan error, 1) |
| 50 | go func() { _, e := auth.Wait(); done <- e }() |
| 51 | select { |
| 52 | case e := <-done: |
| 53 | if e == nil { |
| 54 | t.Fatal("cancel succeeded") |
| 55 | } |
| 56 | case <-time.After(time.Second): |
| 57 | t.Fatal("cancel did not stop polling") |
| 58 | } |
| 59 | } |
| 60 | func TestAuthenticationNeverFollowsRedirect(t *testing.T) { |
| 61 | var calls atomic.Int32 |
| 62 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); http.Redirect(w, r, "/signin", 303) })) |
| 63 | defer server.Close() |
| 64 | if _, err := BeginAuthentication(t.Context(), server.URL+"/m/abc"); err == nil { |
| 65 | t.Fatal("old server accepted") |
| 66 | } |
| 67 | if calls.Load() != 1 { |
| 68 | t.Fatal("followed authentication redirect") |
| 69 | } |
| 70 | } |
| 71 | func TestAuthenticationRejectsUnsafeAddress(t *testing.T) { |
| 72 | for _, address := range []string{"http://example.test/m/abc", "https://alice:secret@example.test/m/abc", "https://example.test/m/abc?token=secret", "https://example.test/j/a#secret", "https://example.test/signin", "https://example.test/j/%2f"} { |
| 73 | if _, err := BeginAuthentication(context.Background(), address); err == nil { |
| 74 | t.Errorf("accepted %q", address) |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestAuthenticationWithoutAPathTargetsTheMeetingList(t *testing.T) { |
| 80 | // The person who has no link, because the meeting they are about to be |
| 81 | // in does not exist yet. The target is the list, and the result carries |
| 82 | // no room: the host reads that as "signed in, nowhere yet". |
| 83 | var target atomic.Value |
| 84 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 85 | var in map[string]string |
| 86 | json.NewDecoder(r.Body).Decode(&in) |
| 87 | switch r.URL.Path { |
| 88 | case "/native/request": |
| 89 | target.Store(in["target"]) |
| 90 | json.NewEncoder(w).Encode(map[string]string{"id": strings.Repeat("A", 43)}) |
| 91 | case "/native/exchange": |
| 92 | json.NewEncoder(w).Encode(map[string]string{ |
| 93 | // 43 base64url zeros: the only 43-character run that both |
| 94 | // decodes to thirty-two bytes and re-encodes to itself, |
| 95 | // which is what authToken insists on. |
| 96 | "name": "rastrillo_session", "value": strings.Repeat("A", 43), "target": "/", |
| 97 | }) |
| 98 | } |
| 99 | })) |
| 100 | defer server.Close() |
| 101 | for _, address := range []string{server.URL, server.URL + "/"} { |
| 102 | a, err := BeginAuthentication(t.Context(), address) |
| 103 | if err != nil { |
| 104 | t.Fatal(address, err) |
| 105 | } |
| 106 | if target.Load() != "/" { |
| 107 | t.Fatal("wrong target", target.Load()) |
| 108 | } |
| 109 | result, err := a.Wait() |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if result.Room != "" { |
| 114 | t.Fatal("a list sign-in named a meeting", result.Room) |
| 115 | } |
| 116 | if len(result.Cookies) != 1 { |
| 117 | t.Fatal("no session") |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestAuthenticationStillRejectsAPathThatIsNotAMeeting(t *testing.T) { |
| 123 | // Widening the empty path to mean "the instance" must not widen |
| 124 | // anything else: a path that is present and wrong is still an error, |
| 125 | // not a quiet fallback to the list. |
| 126 | for _, address := range []string{"https://example.test/settings", "https://example.test/m", "https://example.test/m/a/b", "https://example.test/x/abc"} { |
| 127 | if _, err := BeginAuthentication(t.Context(), address); err == nil { |
| 128 | t.Errorf("accepted %q", address) |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | |