| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "crypto/sha256" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/rastrillo/rastrillo/sessions" |
| 15 | ) |
| 16 | |
| 17 | // The native sign-in crosses two rastrillo module paths. The app's |
| 18 | // sessions are amadan.net/rastrillo/rastrillo's; rastrillo/native's |
| 19 | // server only takes github.com/carlosframework/rastrillo's, which |
| 20 | // native.go builds over the same database (the one import |
| 21 | // TestNoPreMoveRastrillo allows). These tests drive the whole exchange |
| 22 | // through the router, on an https origin as production is (so the cookie |
| 23 | // is the __Host- one), and a change to either package's sessions table, |
| 24 | // cookie name, token hash or time layout fails here rather than in the |
| 25 | // Mac app: approval of a browser session, the exchange minting a new |
| 26 | // one, that one being an ordinary session to the app, and expiry and |
| 27 | // revocation holding on both sides. |
| 28 | |
| 29 | const exchangeOrigin = "https://docs.example" |
| 30 | |
| 31 | func exchangeApp(t *testing.T) (*App, *http.Cookie) { |
| 32 | t.Helper() |
| 33 | app, err := NewForTestWith(t, Config{Origin: exchangeOrigin}) |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | return app, exchangeSessionCookie(t, signedInForTest(t, app, "alice@example.com")) |
| 38 | } |
| 39 | |
| 40 | func exchangeSessionCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie { |
| 41 | t.Helper() |
| 42 | for _, c := range cookies { |
| 43 | if strings.Contains(c.Name, "session") { |
| 44 | return c |
| 45 | } |
| 46 | } |
| 47 | t.Fatalf("no session cookie in %v", cookies) |
| 48 | return nil |
| 49 | } |
| 50 | |
| 51 | // exchangeDo is a native client's request: JSON, same-origin. |
| 52 | func exchangeDo(t *testing.T, app *App, c *http.Cookie, method, path, body string) *httptest.ResponseRecorder { |
| 53 | t.Helper() |
| 54 | r := httptest.NewRequest(method, path, strings.NewReader(body)) |
| 55 | r.Header.Set("Accept", "application/json") |
| 56 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 57 | if strings.HasPrefix(body, "{") { |
| 58 | r.Header.Set("Content-Type", "application/json") |
| 59 | } |
| 60 | r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 61 | if c != nil { |
| 62 | r.AddCookie(c) |
| 63 | } |
| 64 | w := httptest.NewRecorder() |
| 65 | app.Handler().ServeHTTP(w, r) |
| 66 | return w |
| 67 | } |
| 68 | |
| 69 | func exchangeProof(t *testing.T) string { |
| 70 | t.Helper() |
| 71 | b := make([]byte, 32) |
| 72 | if _, err := rand.Read(b); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | return base64.RawURLEncoding.EncodeToString(b) |
| 76 | } |
| 77 | |
| 78 | // exchangeRequest starts a native sign-in and returns its id and verifier. |
| 79 | func exchangeRequest(t *testing.T, app *App) (id, verifier string) { |
| 80 | t.Helper() |
| 81 | verifier = exchangeProof(t) |
| 82 | sum := sha256.Sum256([]byte(verifier)) |
| 83 | rec := exchangeDo(t, app, nil, http.MethodPost, "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`) |
| 84 | var out struct{ ID string } |
| 85 | if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.ID == "" { |
| 86 | t.Fatalf("POST /native/request = %d %q", rec.Code, rec.Body.String()) |
| 87 | } |
| 88 | return out.ID, verifier |
| 89 | } |
| 90 | |
| 91 | func exchangeApprove(t *testing.T, app *App, browser *http.Cookie, id string) int { |
| 92 | t.Helper() |
| 93 | return exchangeDo(t, app, browser, http.MethodPost, "/native/authorize?id="+id, "").Code |
| 94 | } |
| 95 | |
| 96 | func exchangeFinish(t *testing.T, app *App, id, verifier string) *httptest.ResponseRecorder { |
| 97 | t.Helper() |
| 98 | return exchangeDo(t, app, nil, http.MethodPost, "/native/exchange", `{"id":"`+id+`","verifier":"`+verifier+`"}`) |
| 99 | } |
| 100 | |
| 101 | // exchangeMint runs a whole approved exchange and returns the minted cookie. |
| 102 | func exchangeMint(t *testing.T, app *App, browser *http.Cookie) *http.Cookie { |
| 103 | t.Helper() |
| 104 | id, verifier := exchangeRequest(t, app) |
| 105 | if code := exchangeApprove(t, app, browser, id); code != http.StatusOK { |
| 106 | t.Fatalf("approval = %d", code) |
| 107 | } |
| 108 | rec := exchangeFinish(t, app, id, verifier) |
| 109 | var minted struct{ Name, Value string } |
| 110 | if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &minted) != nil || minted.Value == "" { |
| 111 | t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String()) |
| 112 | } |
| 113 | return &http.Cookie{Name: minted.Name, Value: minted.Value} |
| 114 | } |
| 115 | |
| 116 | // exchangeList is GET / as a native client: 200 signed in, 303 to the |
| 117 | // door signed out. |
| 118 | func exchangeList(t *testing.T, app *App, c *http.Cookie) *httptest.ResponseRecorder { |
| 119 | t.Helper() |
| 120 | return exchangeDo(t, app, c, http.MethodGet, "/", "") |
| 121 | } |
| 122 | |
| 123 | func exchangeSetExpiry(t *testing.T, app *App, token string, at time.Time) { |
| 124 | t.Helper() |
| 125 | res, err := app.db.Writer().Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`, |
| 126 | at.UTC().Format(time.RFC3339), sessions.HashToken(token)) |
| 127 | if err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | if n, _ := res.RowsAffected(); n != 1 { |
| 131 | t.Fatalf("expire session: %d rows", n) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestNativeExchangeMintsASessionTheAppAccepts(t *testing.T) { |
| 136 | app, browser := exchangeApp(t) |
| 137 | if browser.Name != "__Host-rastrillo_session" { |
| 138 | t.Fatalf("browser session cookie is %q; this test means to cover the https name", browser.Name) |
| 139 | } |
| 140 | created := exchangeDo(t, app, browser, http.MethodPost, "/documents", "title=Native+probe") |
| 141 | var doc struct{ Sqid string } |
| 142 | if created.Code != http.StatusCreated || json.Unmarshal(created.Body.Bytes(), &doc) != nil || doc.Sqid == "" { |
| 143 | t.Fatalf("creating a document = %d %q", created.Code, created.Body.String()) |
| 144 | } |
| 145 | id, verifier := exchangeRequest(t, app) |
| 146 | |
| 147 | if rec := exchangeFinish(t, app, id, verifier); rec.Code != http.StatusAccepted { |
| 148 | t.Fatalf("exchange before approval = %d, want 202", rec.Code) |
| 149 | } |
| 150 | if code := exchangeApprove(t, app, browser, id); code != http.StatusOK { |
| 151 | t.Fatalf("approval = %d", code) |
| 152 | } |
| 153 | if rec := exchangeFinish(t, app, id, exchangeProof(t)); rec.Code != http.StatusNotFound { |
| 154 | t.Fatalf("exchange with the wrong verifier = %d, want 404", rec.Code) |
| 155 | } |
| 156 | rec := exchangeFinish(t, app, id, verifier) |
| 157 | var minted struct{ Name, Value string } |
| 158 | if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &minted) != nil { |
| 159 | t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String()) |
| 160 | } |
| 161 | // The pre-move package names the cookie the app reads, and mints a |
| 162 | // new token rather than handing back the browser's. |
| 163 | if minted.Name != browser.Name { |
| 164 | t.Fatalf("exchange minted cookie %q; the app reads %q", minted.Name, browser.Name) |
| 165 | } |
| 166 | if minted.Value == "" || minted.Value == browser.Value { |
| 167 | t.Fatal("exchange did not mint a session of its own") |
| 168 | } |
| 169 | native := &http.Cookie{Name: minted.Name, Value: minted.Value} |
| 170 | list := exchangeList(t, app, native) |
| 171 | if list.Code != http.StatusOK { |
| 172 | t.Fatalf("the app refuses the session native minted: GET / = %d", list.Code) |
| 173 | } |
| 174 | // And it is the same person: their document is in the list. |
| 175 | if !strings.Contains(list.Body.String(), doc.Sqid) { |
| 176 | t.Fatal("the minted session is somebody else: the browser's document is not in its list") |
| 177 | } |
| 178 | // Spent: the same id never exchanges twice. |
| 179 | if rec := exchangeFinish(t, app, id, verifier); rec.Code != http.StatusNotFound { |
| 180 | t.Fatalf("second exchange = %d, want 404", rec.Code) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestNativeSessionExpiryHolds(t *testing.T) { |
| 185 | app, browser := exchangeApp(t) |
| 186 | |
| 187 | // A browser session that has expired cannot approve. |
| 188 | id, _ := exchangeRequest(t, app) |
| 189 | exchangeSetExpiry(t, app, browser.Value, time.Now().Add(-time.Minute)) |
| 190 | if code := exchangeApprove(t, app, browser, id); code == http.StatusOK { |
| 191 | t.Fatal("an expired browser session approved a native sign-in") |
| 192 | } |
| 193 | |
| 194 | // Approved while live, expired before the exchange: refused. |
| 195 | exchangeSetExpiry(t, app, browser.Value, time.Now().Add(time.Hour)) |
| 196 | id, verifier := exchangeRequest(t, app) |
| 197 | if code := exchangeApprove(t, app, browser, id); code != http.StatusOK { |
| 198 | t.Fatalf("approval = %d", code) |
| 199 | } |
| 200 | exchangeSetExpiry(t, app, browser.Value, time.Now().Add(-time.Minute)) |
| 201 | if rec := exchangeFinish(t, app, id, verifier); rec.Code != http.StatusUnauthorized { |
| 202 | t.Fatalf("exchange of an expired approval = %d, want 401", rec.Code) |
| 203 | } |
| 204 | |
| 205 | // A minted session expires like any other. |
| 206 | exchangeSetExpiry(t, app, browser.Value, time.Now().Add(time.Hour)) |
| 207 | native := exchangeMint(t, app, browser) |
| 208 | if code := exchangeList(t, app, native).Code; code != http.StatusOK { |
| 209 | t.Fatalf("live minted session: GET / = %d", code) |
| 210 | } |
| 211 | exchangeSetExpiry(t, app, native.Value, time.Now().Add(-time.Minute)) |
| 212 | if code := exchangeList(t, app, native).Code; code == http.StatusOK { |
| 213 | t.Fatal("the app still accepts a minted session past its expiry") |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | func TestNativeSessionRevocationHolds(t *testing.T) { |
| 218 | app, browser := exchangeApp(t) |
| 219 | |
| 220 | // Signing the browser out between approval and exchange refuses it. |
| 221 | id, verifier := exchangeRequest(t, app) |
| 222 | if code := exchangeApprove(t, app, browser, id); code != http.StatusOK { |
| 223 | t.Fatalf("approval = %d", code) |
| 224 | } |
| 225 | if code := exchangeDo(t, app, browser, http.MethodPost, "/signout", "").Code; code >= 400 { |
| 226 | t.Fatalf("POST /signout = %d", code) |
| 227 | } |
| 228 | if rec := exchangeFinish(t, app, id, verifier); rec.Code != http.StatusUnauthorized { |
| 229 | t.Fatalf("exchange after the browser signed out = %d, want 401", rec.Code) |
| 230 | } |
| 231 | |
| 232 | // The app signing out a minted session (the app's own sessions |
| 233 | // package, deleting a row the pre-move package wrote) ends it, and |
| 234 | // leaves the browser's alone. |
| 235 | app, browser = exchangeApp(t) |
| 236 | native := exchangeMint(t, app, browser) |
| 237 | if code := exchangeDo(t, app, native, http.MethodPost, "/signout", "").Code; code >= 400 { |
| 238 | t.Fatalf("POST /signout with the minted session = %d", code) |
| 239 | } |
| 240 | if code := exchangeList(t, app, native).Code; code == http.StatusOK { |
| 241 | t.Fatal("a signed-out minted session still reaches /") |
| 242 | } |
| 243 | if code := exchangeList(t, app, browser).Code; code != http.StatusOK { |
| 244 | t.Fatalf("signing native out ended the browser's session too: GET / = %d", code) |
| 245 | } |
| 246 | } |
| 247 | |