package api import ( "encoding/json" "net/http" "testing" ) // "Log that device out, now" is the entire argument for keeping sessions in a // table instead of issuing JWTs. These are the tests that the argument is // actually cashed in. func sessionList(t *testing.T, s *Server, token string) []DeviceSession { t.Helper() rec := do(t, s, "GET", "/api/auth/sessions", token, nil) if rec.Code != http.StatusOK { t.Fatalf("sessions: %d %s", rec.Code, rec.Body.String()) } var out []DeviceSession if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } return out } func loginAs(t *testing.T, s *Server, email, password, device string) Session { t.Helper() rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{ "email": email, "password": password, "device": device}) if rec.Code != http.StatusOK { t.Fatalf("login: %d %s", rec.Code, rec.Body.String()) } var sess Session if err := json.Unmarshal(rec.Body.Bytes(), &sess); err != nil { t.Fatal(err) } return sess } func TestAPersonCanSeeAndSignOutTheirOwnDevices(t *testing.T) { s, fs := newServer(t) seedUser(fs) phone := loginAs(t, s, "manager@acme.com", "correct horse battery", "Pixel 8") till := loginAs(t, s, "manager@acme.com", "correct horse battery", "Shop PC") rows := sessionList(t, s, till.Token) if len(rows) != 2 { t.Fatalf("want two devices, got %d: %+v", len(rows), rows) } var phoneID string for _, r := range rows { if r.Device == "Pixel 8" { phoneID = r.ID } // The device making the request must be labelled, or somebody signs // themselves out of the machine in their hand without meaning to. if r.Device == "Shop PC" && !r.Current { t.Error("the calling session is not marked current") } if r.Device == "Pixel 8" && r.Current { t.Error("another device is marked current") } } if phoneID == "" { t.Fatalf("the phone is not in the list: %+v", rows) } if rec := do(t, s, "DELETE", "/api/auth/sessions/"+phoneID, till.Token, nil); rec.Code != http.StatusNoContent { t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) } // Immediately, not when the access token happens to expire. A lost phone is // the case this exists for and twelve hours is not an answer. if rec := do(t, s, "GET", "/api/auth/me", phone.Token, nil); rec.Code != http.StatusUnauthorized { t.Fatalf("the revoked device is still signed in, got %d", rec.Code) } if rec := do(t, s, "GET", "/api/auth/me", till.Token, nil); rec.Code != http.StatusOK { t.Fatalf("the calling device was signed out too, got %d", rec.Code) } } // A session id travels in the list above and is not a secret. The store scopes // the revoke by user id so one cannot be used to sign a colleague out. func TestOneUserCannotRevokeAnothersSession(t *testing.T) { s, fs := newServer(t) seedUser(fs) seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff") victim := loginAs(t, s, "sam@acme.com", "correct horse battery", "Sam's phone") attacker := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop") // The id is obtained the way an attacker would have to: it is not in the // attacker's own list at all, so this uses the real one directly. var victimID string for _, r := range sessionList(t, s, victim.Token) { victimID = r.ID } if rec := do(t, s, "DELETE", "/api/auth/sessions/"+victimID, attacker.Token, nil); rec.Code != http.StatusNotFound { t.Fatalf("one user revoked another's session, got %d", rec.Code) } if rec := do(t, s, "GET", "/api/auth/me", victim.Token, nil); rec.Code != http.StatusOK { t.Fatal("the victim was signed out by somebody else") } } // Somebody who has just lost a phone must not also be signed out of the device // they are holding while they deal with it. func TestSignOutEverywhereElseKeepsTheCurrentDevice(t *testing.T) { s, fs := newServer(t) seedUser(fs) lost := loginAs(t, s, "manager@acme.com", "correct horse battery", "Lost phone") old := loginAs(t, s, "manager@acme.com", "correct horse battery", "Old tablet") here := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop") rec := do(t, s, "POST", "/api/auth/sessions/revoke-others", here.Token, nil) if rec.Code != http.StatusOK { t.Fatalf("revoke others: %d %s", rec.Code, rec.Body.String()) } var out struct { SignedOut int `json:"signed_out"` } _ = json.Unmarshal(rec.Body.Bytes(), &out) if out.SignedOut != 2 { t.Errorf("want two devices signed out, got %d", out.SignedOut) } for name, tok := range map[string]string{"lost phone": lost.Token, "old tablet": old.Token} { if rec := do(t, s, "GET", "/api/auth/me", tok, nil); rec.Code != http.StatusUnauthorized { t.Errorf("%s is still signed in, got %d", name, rec.Code) } } if rec := do(t, s, "GET", "/api/auth/me", here.Token, nil); rec.Code != http.StatusOK { t.Fatal("signing out everywhere else signed out this device too") } } func TestSessionRoutesNeedASession(t *testing.T) { s, fs := newServer(t) seedUser(fs) for _, c := range []struct{ method, path string }{ {"GET", "/api/auth/sessions"}, {"DELETE", "/api/auth/sessions/" + acmeStaffID}, {"POST", "/api/auth/sessions/revoke-others"}, } { if rec := do(t, s, c.method, c.path, "", nil); rec.Code != http.StatusUnauthorized { t.Errorf("%s %s: want 401, got %d", c.method, c.path, rec.Code) } } }