carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "io"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "regexp"
9 "sort"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14)
15
16func TestSafeNext(t *testing.T) {
17 for _, tc := range []struct{ in, want string }{
18 {"/auth/cli", "/auth/cli"},
19 {"/invite/abc", "/invite/abc"},
20 {"/invite/abc?x=1", "/invite/abc?x=1"},
21 {"//evil.com", ""},
22 {"/\\evil.com", ""},
23 {"https://evil.com/x", ""},
24 {"javascript:alert(1)", ""},
25 {"evil.com", ""},
26 {"/a\nb", ""},
27 {"", ""},
28 } {
29 if got := safeNext(tc.in); got != tc.want {
30 t.Errorf("safeNext(%q) = %q, want %q", tc.in, got, tc.want)
31 }
32 }
33}
34
35// keymailWalk starts /auth/signin with the given query and answers the
36// callback, returning the callback's status and Location.
37func keymailWalk(t *testing.T, fx *consoleFixture, c *http.Client, signinQuery, code string) (int, string) {
38 t.Helper()
39 status, loc, _ := get(t, c, fx.ts.URL+"/auth/signin"+signinQuery)
40 if status != http.StatusSeeOther {
41 t.Fatalf("signin = %d, want 303", status)
42 }
43 u, err := url.Parse(loc)
44 if err != nil {
45 t.Fatal(err)
46 }
47 q := u.Query()
48 fx.fk.mu.Lock()
49 fx.fk.challenge = q.Get("code_challenge")
50 fx.fk.expectCode = code
51 fx.fk.mu.Unlock()
52 status, loc, _ = get(t, c, fx.ts.URL+"/auth/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(q.Get("state")))
53 return status, loc
54}
55
56func browserCookie(t *testing.T, fx *consoleFixture, c *http.Client, name string) string {
57 t.Helper()
58 u, _ := url.Parse(fx.ts.URL)
59 for _, ck := range c.Jar.Cookies(u) {
60 if ck.Name == name {
61 return ck.Value
62 }
63 }
64 return ""
65}
66
67func TestKeymailCallbackFollowsNext(t *testing.T) {
68 fx := newConsoleFixture(t)
69 fx.setUser("newcomer@keymail.dev")
70 c := newBrowser(t)
71
72 status, loc := keymailWalk(t, fx, c, "?next="+url.QueryEscape("/auth/cli"), "code-n1")
73 if status != http.StatusSeeOther || loc != "/auth/cli" {
74 t.Fatalf("callback = %d -> %q, want 303 -> /auth/cli", status, loc)
75 }
76 if v := browserCookie(t, fx, c, nextCookie); v != "" {
77 t.Fatalf("next cookie survived the callback: %q", v)
78 }
79 raw := sessionToken(t, fx, c)
80 addr, method, ok := fx.st.SessionInfo(raw, time.Now())
81 if !ok || addr != "newcomer@keymail.dev" || method != MethodKeymail {
82 t.Fatalf("session = %q %q %v, want newcomer keymail", addr, method, ok)
83 }
84 // Onboarding ran before next: the brand-new user has a personal account.
85 accts, err := fx.st.AccountsFor("newcomer@keymail.dev")
86 if err != nil || len(accts) == 0 {
87 t.Fatalf("accounts for a brand-new user = %v, %v; want a minted personal account", accts, err)
88 }
89 // A keymail sign-in records no step-up.
90 if fx.st.FreshStepUp(raw, "newcomer@keymail.dev", time.Now(), StepUpWindow) {
91 t.Fatal("a keymail sign-in recorded a step-up")
92 }
93}
94
95func TestKeymailCallbackIgnoresUnsafeAndStaleNext(t *testing.T) {
96 fx := newConsoleFixture(t)
97 c := newBrowser(t)
98
99 // An unsafe next is never stored, and the usual landing applies.
100 status, _, _ := get(t, c, fx.ts.URL+"/auth/signin?next="+url.QueryEscape("//evil.com"))
101 if status != http.StatusSeeOther {
102 t.Fatalf("signin = %d", status)
103 }
104 if v := browserCookie(t, fx, c, nextCookie); v != "" {
105 t.Fatalf("unsafe next was stored: %q", v)
106 }
107
108 // A stale safe next is cleared by a later signin without one.
109 get(t, c, fx.ts.URL+"/auth/signin?next=/auth/cli")
110 if v := browserCookie(t, fx, c, nextCookie); v != "/auth/cli" {
111 t.Fatalf("next cookie = %q, want /auth/cli", v)
112 }
113 status, loc := keymailWalk(t, fx, c, "", "code-n2")
114 if status != http.StatusSeeOther || len(loc) < 3 || loc[:3] != "/a/" {
115 t.Fatalf("callback with no next = %d -> %q, want 303 into an account", status, loc)
116 }
117}
118
119func TestFinishSigninEmailRecordsStepUp(t *testing.T) {
120 fx := newConsoleFixture(t)
121 w := httptest.NewRecorder()
122 r := httptest.NewRequest(http.MethodGet, "/", nil)
123 fx.srv.finishSignin(w, r, "bob@example.com", "", MethodEmail, "/invite/abc")
124 res := w.Result()
125 if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != "/invite/abc" {
126 t.Fatalf("finishSignin = %d -> %q", res.StatusCode, res.Header.Get("Location"))
127 }
128 var raw string
129 var welcomed bool
130 for _, ck := range res.Cookies() {
131 switch ck.Name {
132 case sessionCookie:
133 raw = ck.Value
134 case welcomeCookie:
135 welcomed = true
136 }
137 }
138 if raw == "" || !welcomed {
139 t.Fatalf("cookies: session %q, welcome %v", raw, welcomed)
140 }
141 addr, method, ok := fx.st.SessionInfo(raw, time.Now())
142 if !ok || addr != "bob@example.com" || method != MethodEmail {
143 t.Fatalf("session = %q %q %v", addr, method, ok)
144 }
145 if !fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) {
146 t.Fatal("an email sign-in recorded no step-up")
147 }
148 if g, _ := fx.st.GrantToken("bob@example.com"); g != "" {
149 t.Fatalf("an email sign-in stored a grant token %q", g)
150 }
151 if accts, err := fx.st.AccountsFor("bob@example.com"); err != nil || len(accts) == 0 {
152 t.Fatalf("no personal account minted: %v, %v", accts, err)
153 }
154}
155
156// ---- the front door (POST /auth/continue) -----------------------------------
157
158// newEmailFixture is the console fixture with a console mailer that reports
159// ready (or not) and a hook that signals each finished sign-in send.
160func newEmailFixture(t *testing.T, ready bool) (*consoleFixture, *fakeConsoleMail) {
161 t.Helper()
162 fx := newConsoleFixture(t)
163 m := &fakeConsoleMail{ready: ready}
164 fx.srv.consoleMail = m
165 fx.srv.challengeSent = make(chan struct{}, 16)
166 return fx, m
167}
168
169// lookupAnswers makes the fake Keymail's federation lookup answer status for
170// addr.
171func lookupAnswers(fx *consoleFixture, addr string, status int) {
172 fx.fk.mu.Lock()
173 defer fx.fk.mu.Unlock()
174 fx.fk.lookupStatus[addr] = status
175}
176
177func lookupCount(fx *consoleFixture) int {
178 fx.fk.mu.Lock()
179 defer fx.fk.mu.Unlock()
180 return fx.fk.lookups
181}
182
183// postContinue submits the front page's form from the console's own page
184// (Origin set, as a browser does on a POST).
185func postContinue(t *testing.T, fx *consoleFixture, c *http.Client, form url.Values) (status int, location, body string) {
186 t.Helper()
187 return postContinueFrom(t, fx, c, fx.ts.URL, form)
188}
189
190func postContinueFrom(t *testing.T, fx *consoleFixture, c *http.Client, origin string, form url.Values) (status int, location, body string) {
191 t.Helper()
192 req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/continue", strings.NewReader(form.Encode()))
193 if err != nil {
194 t.Fatal(err)
195 }
196 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
197 if origin != "" {
198 req.Header.Set("Origin", origin)
199 }
200 res, err := c.Do(req)
201 if err != nil {
202 t.Fatal(err)
203 }
204 defer res.Body.Close()
205 raw, err := io.ReadAll(res.Body)
206 if err != nil {
207 t.Fatal(err)
208 }
209 return res.StatusCode, res.Header.Get("Location"), string(raw)
210}
211
212// waitChallengeSent waits for n background sign-in sends to finish.
213func waitChallengeSent(t *testing.T, fx *consoleFixture, n int) {
214 t.Helper()
215 for i := 0; i < n; i++ {
216 select {
217 case <-fx.srv.challengeSent:
218 case <-time.After(10 * time.Second):
219 t.Fatalf("sign-in send %d of %d never finished", i+1, n)
220 }
221 }
222}
223
224func challengeRows(t *testing.T, fx *consoleFixture) int {
225 t.Helper()
226 var n int
227 if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM email_challenges`).Scan(&n); err != nil {
228 t.Fatal(err)
229 }
230 return n
231}
232
233func userRows(t *testing.T, fx *consoleFixture, addr string) int {
234 t.Helper()
235 var n int
236 if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM users WHERE address = ?`, addr).Scan(&n); err != nil {
237 t.Fatal(err)
238 }
239 return n
240}
241
242var (
243 signinCodeRe = regexp.MustCompile(`type this code on the page that's waiting: (\d{6})\b`)
244 signinLinkRe = regexp.MustCompile(`/auth/email/link\?t=([A-Za-z0-9_-]+)`)
245)
246
247// sentSignin is the one message the console mailer got, split into its
248// recipient, code and link token.
249func sentSignin(t *testing.T, m *fakeConsoleMail) (to, code, link string) {
250 t.Helper()
251 sent := m.sentCopy()
252 if len(sent) != 1 {
253 t.Fatalf("console mailer got %d messages, want 1: %q", len(sent), sent)
254 }
255 to, body, _ := strings.Cut(sent[0], "|")
256 cm := signinCodeRe.FindStringSubmatch(body)
257 lm := signinLinkRe.FindStringSubmatch(body)
258 if cm == nil || lm == nil {
259 t.Fatalf("sign-in email has no code or link:\n%s", body)
260 }
261 return to, cm[1], lm[1]
262}
263
264func TestContinueKeymailHostGoesToKeymail(t *testing.T) {
265 fx, m := newEmailFixture(t, true)
266 addr := "bob@" + fx.srv.km.Host()
267
268 status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {addr}})
269 if status != http.StatusSeeOther || loc != "/auth/signin" {
270 t.Fatalf("continue = %d -> %q, want 303 -> /auth/signin", status, loc)
271 }
272 status, loc, _ = postContinue(t, fx, newBrowser(t), url.Values{"address": {addr}, "next": {"/auth/cli"}})
273 if want := "/auth/signin?next=" + url.QueryEscape("/auth/cli"); status != http.StatusSeeOther || loc != want {
274 t.Fatalf("continue with next = %d -> %q, want 303 -> %q", status, loc, want)
275 }
276 // An unsafe next is dropped, not carried to Keymail.
277 _, loc, _ = postContinue(t, fx, newBrowser(t), url.Values{"address": {addr}, "next": {"//evil.example"}})
278 if loc != "/auth/signin" {
279 t.Fatalf("continue with unsafe next -> %q, want /auth/signin", loc)
280 }
281 if n := lookupCount(fx); n != 0 {
282 t.Fatalf("the Keymail host's own address cost %d lookups, want 0", n)
283 }
284 if sent := m.sentCopy(); len(sent) != 0 || challengeRows(t, fx) != 0 {
285 t.Fatalf("a Keymail address got email: sent %q, %d challenges", sent, challengeRows(t, fx))
286 }
287}
288
289func TestContinueLookup200GoesToKeymail(t *testing.T) {
290 fx, m := newEmailFixture(t, true)
291 lookupAnswers(fx, "bob@example.com", http.StatusOK)
292 status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"bob@example.com"}})
293 if status != http.StatusSeeOther || loc != "/auth/signin" {
294 t.Fatalf("continue = %d -> %q, want 303 -> /auth/signin", status, loc)
295 }
296 if n := lookupCount(fx); n != 1 {
297 t.Fatalf("lookups = %d, want 1", n)
298 }
299 if sent := m.sentCopy(); len(sent) != 0 || challengeRows(t, fx) != 0 {
300 t.Fatalf("a Keymail-served domain got email: sent %q, %d challenges", sent, challengeRows(t, fx))
301 }
302}
303
304// checkEmailSent is the email outcome: the check page, one message to addr
305// with a code and a link, and a challenge for addr with next.
306func checkEmailSent(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, addr, next string, status int, body string) {
307 t.Helper()
308 if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.sent", "address", addr))) {
309 t.Fatalf("continue = %d, want the check page for %s:\n%s", status, addr, body)
310 }
311 waitChallengeSent(t, fx, 1)
312 to, _, link := sentSignin(t, m)
313 if to != addr {
314 t.Fatalf("sign-in email went to %q, want %q", to, addr)
315 }
316 ch, ok := fx.st.PeekEmailLink(link, time.Now())
317 if !ok || ch.Address != addr || ch.Purpose != ChallengeSignIn || ch.Next != next {
318 t.Fatalf("challenge = %+v %v, want a sign-in for %s with next %q", ch, ok, addr, next)
319 }
320 if flow := browserCookie(t, fx, c, flowCookie); flow == "" || ch.FlowHash != hashToken(flow) {
321 t.Fatalf("challenge flow %q is not the asking browser's flow %q", ch.FlowHash, flow)
322 }
323}
324
325func TestContinueLookup404SendsEmail(t *testing.T) {
326 fx, m := newEmailFixture(t, true)
327 lookupAnswers(fx, "bob@example.com", http.StatusNotFound)
328 c := newBrowser(t)
329 status, _, body := postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}, "next": {"/auth/cli"}})
330 checkEmailSent(t, fx, m, c, "bob@example.com", "/auth/cli", status, body)
331 sent := m.sentCopy()[0]
332 if !strings.Contains(sent, fx.ts.URL+"/auth/email/link?t=") {
333 t.Fatalf("link is not on the console's origin:\n%s", sent)
334 }
335}
336
337func TestContinueLookup500SendsEmailForForeignDomain(t *testing.T) {
338 fx, m := newEmailFixture(t, true)
339 lookupAnswers(fx, "bob@example.com", http.StatusInternalServerError)
340 c := newBrowser(t)
341 status, _, body := postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}})
342 checkEmailSent(t, fx, m, c, "bob@example.com", "", status, body)
343}
344
345// Review Focus 1: a stored grant is console state, and console state never
346// decides the door.
347func TestContinueGrantDoesNotDecide(t *testing.T) {
348 fx, m := newEmailFixture(t, true)
349 now := time.Now()
350 if _, err := fx.st.EnsureUser("bob@example.com", now); err != nil {
351 t.Fatal(err)
352 }
353 if err := fx.st.SetGrantToken("bob@example.com", "grant-token", now); err != nil {
354 t.Fatal(err)
355 }
356 lookupAnswers(fx, "bob@example.com", http.StatusNotFound)
357 c := newBrowser(t)
358 status, _, body := postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}})
359 checkEmailSent(t, fx, m, c, "bob@example.com", "", status, body)
360 fx.fk.mu.Lock()
361 defer fx.fk.mu.Unlock()
362 if len(fx.fk.sends) != 0 {
363 t.Fatalf("the stored grant was used to send %d Keymail messages", len(fx.fk.sends))
364 }
365}
366
367// Review Focus 3.
368func TestContinueNormalises(t *testing.T) {
369 fx, m := newEmailFixture(t, true)
370 lookupAnswers(fx, "bob@example.com", http.StatusNotFound)
371 c := newBrowser(t)
372 status, _, body := postContinue(t, fx, c, url.Values{"address": {" Bob@Example.COM "}})
373 checkEmailSent(t, fx, m, c, "bob@example.com", "", status, body)
374
375 fx2, m2 := newEmailFixture(t, true)
376 for _, bad := range []string{"a<victim@example.com>", "a@x.com,b@y.com", "", "nobody"} {
377 status, _, body := postContinue(t, fx2, newBrowser(t), url.Values{"address": {bad}})
378 if status != http.StatusOK || !strings.Contains(body, esc(copyText("home.bad_address"))) {
379 t.Fatalf("continue %q = %d, want the front page with the bad-address error:\n%s", bad, status, body)
380 }
381 if !strings.Contains(body, `action="/auth/continue"`) {
382 t.Fatalf("continue %q: the error page has no form", bad)
383 }
384 }
385 if n := userRows(t, fx2, "victim@example.com"); n != 0 {
386 t.Fatal("a display-name address made a user row")
387 }
388 if n := challengeRows(t, fx2); n != 0 {
389 t.Fatalf("a refused address made %d challenges", n)
390 }
391 if n := lookupCount(fx2); n != 0 {
392 t.Fatalf("a refused address cost %d lookups", n)
393 }
394 if sent := m2.sentCopy(); len(sent) != 0 {
395 t.Fatalf("a refused address was mailed: %q", sent)
396 }
397}
398
399// The anti-oracle: a new address, a known user, a throttled repeat and a
400// refused submission all get the same page, byte for byte.
401func TestContinuePageIdentical(t *testing.T) {
402 fx, m := newEmailFixture(t, true)
403 const addr = "bob@example.com"
404 lookupAnswers(fx, addr, http.StatusNotFound)
405 form := url.Values{"address": {addr}, "next": {"/auth/cli"}}
406
407 browsers := []*http.Client{newBrowser(t), newBrowser(t), newBrowser(t), newBrowser(t)}
408 _, _, fresh := postContinue(t, fx, browsers[0], form)
409 waitChallengeSent(t, fx, 1)
410
411 // Two minutes on, bob is a known user and may be sent another.
412 if _, err := fx.st.EnsureUser(addr, time.Now()); err != nil {
413 t.Fatal(err)
414 }
415 fx.srv.limits.mu.Lock()
416 fx.srv.limits.now = func() time.Time { return time.Now().Add(2 * time.Minute) }
417 fx.srv.limits.mu.Unlock()
418 _, _, known := postContinue(t, fx, browsers[1], form)
419 waitChallengeSent(t, fx, 1)
420
421 // At once again: the per-address gap refuses the send.
422 _, _, throttled := postContinue(t, fx, browsers[2], form)
423
424 // And an IP over its submission cap spends no lookup and sends nothing.
425 for fx.srv.limits.submit("127.0.0.1") {
426 }
427 lookupsBefore := lookupCount(fx)
428 _, _, capped := postContinue(t, fx, browsers[3], form)
429
430 if fresh != known || fresh != throttled || fresh != capped {
431 t.Fatalf("pages differ:\nfresh:\n%s\nknown:\n%s\nthrottled:\n%s\ncapped:\n%s", fresh, known, throttled, capped)
432 }
433 if n := len(m.sentCopy()); n != 2 {
434 t.Fatalf("mailer got %d messages, want 2 (the throttled and capped ones send nothing)", n)
435 }
436 // Two sent, one never-sent for the throttled fresh browser, and nothing
437 // for the capped one.
438 if n := challengeRows(t, fx); n != 3 {
439 t.Fatalf("%d challenges stored, want 3", n)
440 }
441 if lookupCount(fx) != lookupsBefore {
442 t.Fatal("a capped submission still asked Keymail")
443 }
444 // The headers match too: every outcome sets a flow cookie.
445 for i, c := range browsers {
446 if browserCookie(t, fx, c, flowCookie) == "" {
447 t.Errorf("outcome %d set no flow cookie", i)
448 }
449 }
450 for _, want := range []string{
451 esc(copyText("check.title")), esc(copyText("check.code_label")), esc(copyText("check.code_button")),
452 esc(copyText("check.nothing")), esc(copyText("check.again_button")),
453 `action="/auth/email/code"`, `name="address" value="bob@example.com"`, `name="next" value="/auth/cli"`,
454 } {
455 if !strings.Contains(fresh, want) {
456 t.Errorf("check page is missing %q:\n%s", want, fresh)
457 }
458 }
459}
460
461// Spec 4.2, codex r3-1: with no working mailer an email-bound address is told
462// so, before any send limit, so the answer is the same throttled or not.
463func TestContinueNoMailer(t *testing.T) {
464 fx, m := newEmailFixture(t, false)
465 const addr = "bob@example.com"
466 lookupAnswers(fx, addr, http.StatusNotFound)
467 form := url.Values{"address": {addr}}
468 _, _, first := postContinue(t, fx, newBrowser(t), form)
469 if !strings.Contains(first, esc(copyText("home.no_mail"))) || !strings.Contains(first, `action="/auth/continue"`) {
470 t.Fatalf("no-mailer answer is not the front page with home.no_mail:\n%s", first)
471 }
472 // Spend bob's sign-in sends for the hour, as if he had been mailed.
473 fx.srv.limits.mu.Lock()
474 for i := 0; i < 5; i++ {
475 fx.srv.limits.addrSends.add(addr, time.Now())
476 }
477 fx.srv.limits.mu.Unlock()
478 for i := 2; i <= 6; i++ {
479 if _, _, body := postContinue(t, fx, newBrowser(t), form); body != first {
480 t.Fatalf("request %d differs from the first:\n%s\nvs\n%s", i, body, first)
481 }
482 }
483 // Over the IP's submission cap: no lookup, and still the same answer.
484 for fx.srv.limits.submit("127.0.0.1") {
485 }
486 lookups := lookupCount(fx)
487 if _, _, body := postContinue(t, fx, newBrowser(t), form); body != first {
488 t.Fatalf("over the submission cap the answer differs:\n%s\nvs\n%s", body, first)
489 }
490 if lookupCount(fx) != lookups {
491 t.Fatal("a capped submission still asked Keymail")
492 }
493 if sent := m.sentCopy(); len(sent) != 0 || challengeRows(t, fx) != 0 {
494 t.Fatalf("no mailer, yet sent %q and stored %d challenges", sent, challengeRows(t, fx))
495 }
496 // The Keymail host's own addresses need no lookup and still go there.
497 if status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"kim@" + fx.srv.km.Host()}}); status != http.StatusSeeOther || loc != "/auth/signin" {
498 t.Fatalf("Keymail-host address over the cap = %d -> %q", status, loc)
499 }
500 fx.srv.limits.mu.Lock()
501 fx.srv.limits.submits = keyedWindow{}
502 fx.srv.limits.mu.Unlock()
503 // A Keymail address still signs in.
504 lookupAnswers(fx, "kim@example.com", http.StatusOK)
505 if status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"kim@example.com"}}); status != http.StatusSeeOther || loc != "/auth/signin" {
506 t.Fatalf("Keymail address with no mailer = %d -> %q", status, loc)
507 }
508}
509
510func TestContinueCrossSiteRefused(t *testing.T) {
511 fx, m := newEmailFixture(t, true)
512 lookupAnswers(fx, "bob@example.com", http.StatusNotFound)
513 form := url.Values{"address": {"bob@example.com"}}
514 if status, _, _ := postContinueFrom(t, fx, newBrowser(t), "https://evil.example", form); status != http.StatusForbidden {
515 t.Fatalf("cross-site continue = %d, want 403", status)
516 }
517 if status, _, _ := postContinueFrom(t, fx, newBrowser(t), "null", form); status != http.StatusForbidden {
518 t.Fatalf("opaque-origin continue = %d, want 403", status)
519 }
520 // No Origin, but the browser says the request came from another site.
521 req, _ := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/continue", strings.NewReader(form.Encode()))
522 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
523 req.Header.Set("Sec-Fetch-Site", "cross-site")
524 res, err := newBrowser(t).Do(req)
525 if err != nil {
526 t.Fatal(err)
527 }
528 res.Body.Close()
529 if res.StatusCode != http.StatusForbidden {
530 t.Fatalf("Sec-Fetch-Site cross-site continue = %d, want 403", res.StatusCode)
531 }
532 if n := lookupCount(fx); n != 0 || len(m.sentCopy()) != 0 || challengeRows(t, fx) != 0 {
533 t.Fatalf("a refused request still acted: %d lookups, %d sent, %d challenges", n, len(m.sentCopy()), challengeRows(t, fx))
534 }
535}
536
537func TestSameOrigin(t *testing.T) {
538 fx := newConsoleFixture(t)
539 for _, tc := range []struct {
540 origin, site string
541 want bool
542 }{
543 {"", "", true},
544 {fx.ts.URL, "", true},
545 {fx.ts.URL, "cross-site", true}, // Origin decides when present
546 {"https://evil.example", "same-origin", false},
547 {"null", "", false},
548 {"", "same-origin", true},
549 {"", "none", true},
550 {"", "same-site", false},
551 {"", "cross-site", false},
552 } {
553 r := httptest.NewRequest(http.MethodPost, "/auth/continue", nil)
554 if tc.origin != "" {
555 r.Header.Set("Origin", tc.origin)
556 }
557 if tc.site != "" {
558 r.Header.Set("Sec-Fetch-Site", tc.site)
559 }
560 if got := fx.srv.sameOrigin(r); got != tc.want {
561 t.Errorf("sameOrigin(Origin %q, Sec-Fetch-Site %q) = %v, want %v", tc.origin, tc.site, got, tc.want)
562 }
563 }
564}
565
566func TestInvitePageSignedOutPrefills(t *testing.T) {
567 fx, m := newEmailFixture(t, true)
568 tok, err := fx.st.CreateInvite(OpsAccountID, "bob@example.com", "paul@keymail.dev", time.Now())
569 if err != nil {
570 t.Fatal(err)
571 }
572 c := newBrowser(t)
573 status, _, body := get(t, c, fx.ts.URL+"/invite/"+tok)
574 next := "/invite/" + tok
575 for _, want := range []string{
576 esc(copyText("home.invite_prompt", "address", "bob@example.com")),
577 `value="bob@example.com"`,
578 `name="next" value="` + esc(next) + `"`,
579 } {
580 if status != http.StatusOK || !strings.Contains(body, want) {
581 t.Fatalf("signed-out invite page = %d, missing %q:\n%s", status, want, body)
582 }
583 }
584 // Reading the page did not use the invitation up.
585 if _, ok := fx.st.PendingInviteAddress(tok, time.Now()); !ok {
586 t.Fatal("the signed-out invite page consumed the invitation")
587 }
588 // The form carries next into the challenge.
589 lookupAnswers(fx, "bob@example.com", http.StatusNotFound)
590 status, _, body = postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}, "next": {next}})
591 checkEmailSent(t, fx, m, c, "bob@example.com", next, status, body)
592
593 // An unknown invitation is the plain front page: no banner, no prefill.
594 status, _, body = get(t, newBrowser(t), fx.ts.URL+"/invite/no-such-invite")
595 if status != http.StatusOK || !strings.Contains(body, `action="/auth/continue"`) {
596 t.Fatalf("unknown invite signed out = %d:\n%s", status, body)
597 }
598 if strings.Contains(body, "to accept your invitation") || strings.Contains(body, `name="next"`) || strings.Contains(body, "bob@example.com") {
599 t.Fatalf("unknown invite page carries a banner, next or prefill:\n%s", body)
600 }
601}
602
603func TestCLIPageSignedOutBanner(t *testing.T) {
604 fx := newConsoleFixture(t)
605 status, _, body := get(t, newBrowser(t), fx.ts.URL+"/auth/cli")
606 if status != http.StatusOK || !strings.Contains(body, esc(copyText("home.cli_prompt"))) ||
607 !strings.Contains(body, `name="next" value="/auth/cli"`) || !strings.Contains(body, `action="/auth/continue"`) {
608 t.Fatalf("signed-out /auth/cli = %d:\n%s", status, body)
609 }
610 _, _, body = get(t, newBrowser(t), fx.ts.URL+"/auth/cli?code=wxyz4823")
611 if want := `name="next" value="` + esc("/auth/cli?code=WXYZ-4823") + `"`; !strings.Contains(body, want) {
612 t.Fatalf("signed-out /auth/cli?code= lost the code, missing %q:\n%s", want, body)
613 }
614 // A code that is not code-shaped is not carried.
615 _, _, body = get(t, newBrowser(t), fx.ts.URL+"/auth/cli?code="+url.QueryEscape(strings.Repeat("x", 500)))
616 if !strings.Contains(body, `name="next" value="/auth/cli"`) {
617 t.Fatalf("an odd code was carried into next:\n%s", body)
618 }
619}
620
621func TestHomeCarriesSafeNext(t *testing.T) {
622 fx := newConsoleFixture(t)
623 _, _, body := get(t, newBrowser(t), fx.ts.URL+"/?next="+url.QueryEscape("/auth/cli"))
624 if !strings.Contains(body, `name="next" value="/auth/cli"`) {
625 t.Fatalf("home dropped a safe next:\n%s", body)
626 }
627 _, _, body = get(t, newBrowser(t), fx.ts.URL+"/?next="+url.QueryEscape("//evil.example"))
628 if strings.Contains(body, `name="next"`) {
629 t.Fatalf("home carried an unsafe next:\n%s", body)
630 }
631 for _, want := range []string{esc(copyText("home.email_label")), esc(copyText("home.email_button")),
632 esc(copyText("home.small")), `placeholder="` + esc(copyText("home.email_placeholder")) + `"`} {
633 if !strings.Contains(body, want) {
634 t.Errorf("home is missing %q", want)
635 }
636 }
637 if strings.Contains(body, "Sign in with Keymail") || strings.Contains(body, `href="/auth/signin"`) {
638 t.Fatal("home still links the Keymail ceremony directly")
639 }
640}
641
642// ---- signing in by the emailed link or code ---------------------------------
643
644// postFrom posts form to path on browser c with the given Origin ("" sends
645// none).
646func postFrom(t *testing.T, fx *consoleFixture, c *http.Client, path, origin string, form url.Values) (status int, location, body string) {
647 t.Helper()
648 req, err := http.NewRequest(http.MethodPost, fx.ts.URL+path, strings.NewReader(form.Encode()))
649 if err != nil {
650 t.Fatal(err)
651 }
652 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
653 if origin != "" {
654 req.Header.Set("Origin", origin)
655 }
656 res, err := c.Do(req)
657 if err != nil {
658 t.Fatal(err)
659 }
660 defer res.Body.Close()
661 raw, err := io.ReadAll(res.Body)
662 if err != nil {
663 t.Fatal(err)
664 }
665 return res.StatusCode, res.Header.Get("Location"), string(raw)
666}
667
668// askSignin submits addr (with next) at the front door from browser c and
669// returns the code and link token of the email that went out.
670func askSignin(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, addr, next string) (code, link string) {
671 t.Helper()
672 before := len(m.sentCopy())
673 form := url.Values{"address": {addr}}
674 if next != "" {
675 form.Set("next", next)
676 }
677 if status, _, body := postContinue(t, fx, c, form); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.sent", "address", addr))) {
678 t.Fatalf("continue for %s = %d:\n%s", addr, status, body)
679 }
680 waitChallengeSent(t, fx, 1)
681 sent := m.sentCopy()
682 if len(sent) != before+1 {
683 t.Fatalf("console mailer has %d messages, want %d", len(sent), before+1)
684 }
685 _, body, _ := strings.Cut(sent[len(sent)-1], "|")
686 cm, lm := signinCodeRe.FindStringSubmatch(body), signinLinkRe.FindStringSubmatch(body)
687 if cm == nil || lm == nil {
688 t.Fatalf("sign-in email has no code or link:\n%s", body)
689 }
690 return cm[1], lm[1]
691}
692
693// advanceLimits moves the rate limits' clock d past the wall clock, so one
694// address may be sent another email.
695func advanceLimits(fx *consoleFixture, d time.Duration) {
696 fx.srv.limits.mu.Lock()
697 defer fx.srv.limits.mu.Unlock()
698 fx.srv.limits.now = func() time.Time { return time.Now().Add(d) }
699}
700
701var linkNonceRe = regexp.MustCompile(`name="nonce" value="([^"]+)"`)
702
703// openLink GETs the emailed link on browser c and returns the status, the
704// page and the form's nonce ("" when there is none).
705func openLink(t *testing.T, fx *consoleFixture, c *http.Client, link string) (int, string, string) {
706 t.Helper()
707 status, _, body := get(t, c, fx.ts.URL+"/auth/email/link?t="+url.QueryEscape(link))
708 nonce := ""
709 if m := linkNonceRe.FindStringSubmatch(body); m != nil {
710 nonce = m[1]
711 }
712 return status, body, nonce
713}
714
715// postLink submits the confirm page's form from the console's own origin.
716func postLink(t *testing.T, fx *consoleFixture, c *http.Client, link, nonce string) (int, string, string) {
717 t.Helper()
718 return postFrom(t, fx, c, "/auth/email/link", fx.ts.URL, url.Values{"t": {link}, "nonce": {nonce}})
719}
720
721// postCode submits the check page's code form from the console's own origin.
722func postCode(t *testing.T, fx *consoleFixture, c *http.Client, code string) (int, string, string) {
723 t.Helper()
724 return postFrom(t, fx, c, "/auth/email/code", fx.ts.URL, url.Values{"code": {code}})
725}
726
727// emailSession asserts browser c holds a live email-method session for addr
728// with a fresh step-up, and returns the raw token.
729func emailSession(t *testing.T, fx *consoleFixture, c *http.Client, addr string) string {
730 t.Helper()
731 raw := browserCookie(t, fx, c, sessionCookie)
732 got, method, ok := fx.st.SessionInfo(raw, time.Now())
733 if raw == "" || !ok || got != addr || method != MethodEmail {
734 t.Fatalf("session = %q %q %v, want an email session for %s", got, method, ok, addr)
735 }
736 if !fx.st.FreshStepUp(raw, addr, time.Now(), StepUpWindow) {
737 t.Fatal("an email sign-in recorded no step-up")
738 }
739 return raw
740}
741
742func signedOut(t *testing.T, fx *consoleFixture, c *http.Client) bool {
743 t.Helper()
744 raw := browserCookie(t, fx, c, sessionCookie)
745 if raw == "" {
746 return true
747 }
748 _, ok := fx.st.SessionAddress(raw, time.Now())
749 return !ok
750}
751
752// Review Focus 2: a mail scanner's GET (twice) leaves the link good; the
753// person's POST signs them in once.
754func TestEmailLinkGetDoesNotConsume(t *testing.T) {
755 fx, m := newEmailFixture(t, true)
756 const addr = "bob@example.com"
757 lookupAnswers(fx, addr, http.StatusNotFound)
758 asker := newBrowser(t)
759 _, link := askSignin(t, fx, m, asker, addr, "")
760
761 scanner := newBrowser(t)
762 for i := 0; i < 2; i++ {
763 status, body, _ := openLink(t, fx, scanner, link)
764 if status != http.StatusOK || !strings.Contains(body, esc(copyText("link.title", "address", addr))) {
765 t.Fatalf("scanner GET %d = %d:\n%s", i, status, body)
766 }
767 }
768 if !signedOut(t, fx, scanner) {
769 t.Fatal("a GET signed the scanner in")
770 }
771
772 // The person, in another browser (a forwarded link opens there too).
773 c := newBrowser(t)
774 status, body, nonce := openLink(t, fx, c, link)
775 if status != http.StatusOK || nonce == "" {
776 t.Fatalf("GET = %d, nonce %q:\n%s", status, nonce, body)
777 }
778 for _, want := range []string{
779 esc(copyText("link.title", "address", addr)), esc(copyText("link.button")),
780 `action="/auth/email/link"`, `name="t" value="` + link + `"`,
781 } {
782 if !strings.Contains(body, want) {
783 t.Errorf("confirm page is missing %q:\n%s", want, body)
784 }
785 }
786 if browserCookie(t, fx, c, linkConfirmCookie) != nonce {
787 t.Fatal("the GET did not set the nonce cookie to the form's nonce")
788 }
789 status, loc, _ := postLink(t, fx, c, link, nonce)
790 if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
791 t.Fatalf("POST = %d -> %q, want 303 into an account", status, loc)
792 }
793 emailSession(t, fx, c, addr)
794 if browserCookie(t, fx, c, linkConfirmCookie) != "" {
795 t.Fatal("the nonce cookie outlived the sign-in")
796 }
797
798 // Spent: a second POST and a GET both say expired.
799 c2 := newBrowser(t)
800 if status, body, _ := openLink(t, fx, c2, link); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) {
801 t.Fatalf("GET after use = %d:\n%s", status, body)
802 }
803 // The nonce went with the sign-in, so a replay from this browser is refused
804 // as foreign; one that does hold a nonce finds the link spent.
805 if status, _, _ := postLink(t, fx, c, link, nonce); status != http.StatusForbidden {
806 t.Fatalf("second POST from the same browser = %d, want 403", status)
807 }
808 u, _ := url.Parse(fx.ts.URL)
809 c2.Jar.SetCookies(u, []*http.Cookie{{Name: linkConfirmCookie, Value: "n2", Path: "/"}})
810 if status, _, body := postLink(t, fx, c2, link, "n2"); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) {
811 t.Fatalf("second POST = %d:\n%s", status, body)
812 }
813 if !signedOut(t, fx, c2) {
814 t.Fatal("a spent link signed a second browser in")
815 }
816 if status, body, _ := openLink(t, fx, newBrowser(t), "no-such-link"); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) {
817 t.Fatalf("unknown link = %d:\n%s", status, body)
818 }
819}
820
821// Login CSRF (codex 1): a valid token POSTed without this browser's nonce, or
822// from another site, is refused before anything is spent.
823func TestEmailLinkLoginCSRF(t *testing.T) {
824 fx, m := newEmailFixture(t, true)
825 const addr = "mallory@example.com"
826 lookupAnswers(fx, addr, http.StatusNotFound)
827 _, link := askSignin(t, fx, m, newBrowser(t), addr, "")
828
829 refused := func(status int, body string) bool {
830 return status == http.StatusForbidden && strings.Contains(body, esc(copyText("link.wrong_site")))
831 }
832 // The victim's browser has no nonce cookie: an auto-submitted form from
833 // the attacker's page (no Origin, as old browsers send) carries only the
834 // token and a nonce of the attacker's choosing.
835 victim := newBrowser(t)
836 for _, nonce := range []string{"", "attacker-nonce"} {
837 status, _, body := postFrom(t, fx, victim, "/auth/email/link", "", url.Values{"t": {link}, "nonce": {nonce}})
838 if !refused(status, body) {
839 t.Fatalf("POST with nonce %q and no cookie = %d:\n%s", nonce, status, body)
840 }
841 }
842 // A nonce cookie from another link page does not match a forged form.
843 if _, _, n := openLink(t, fx, victim, link); n == "" {
844 t.Fatal("no nonce on the confirm page")
845 }
846 if status, _, body := postFrom(t, fx, victim, "/auth/email/link", "", url.Values{"t": {link}, "nonce": {"attacker-nonce"}}); !refused(status, body) {
847 t.Fatalf("mismatched nonce = %d:\n%s", status, body)
848 }
849 // A foreign Origin is refused even with a matching nonce.
850 _, _, nonce := openLink(t, fx, victim, link)
851 if status, _, body := postFrom(t, fx, victim, "/auth/email/link", "https://evil.example", url.Values{"t": {link}, "nonce": {nonce}}); !refused(status, body) {
852 t.Fatalf("foreign Origin = %d:\n%s", status, body)
853 }
854 if !signedOut(t, fx, victim) {
855 t.Fatal("a refused POST signed the victim in")
856 }
857 // Nothing was spent: the link still signs its owner in.
858 if _, ok := fx.st.PeekEmailLink(link, time.Now()); !ok {
859 t.Fatal("a refused POST spent the link")
860 }
861 owner := newBrowser(t)
862 _, _, nonce = openLink(t, fx, owner, link)
863 if status, loc, _ := postLink(t, fx, owner, link, nonce); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
864 t.Fatalf("owner's POST after the refusals = %d -> %q", status, loc)
865 }
866 emailSession(t, fx, owner, addr)
867}
868
869// The code is the cross-device path, but only on the browser that asked: a
870// second browser with the code and no challenge cookie gets nowhere.
871func TestEmailCodeCrossDevice(t *testing.T) {
872 fx, m := newEmailFixture(t, true)
873 const addr = "bob@example.com"
874 lookupAnswers(fx, addr, http.StatusNotFound)
875 a := newBrowser(t)
876 code, _ := askSignin(t, fx, m, a, addr, "")
877
878 b := newBrowser(t)
879 status, _, body := postCode(t, fx, b, code)
880 if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_used_up"))) || !signedOut(t, fx, b) {
881 t.Fatalf("code from a browser that never asked = %d:\n%s", status, body)
882 }
883 // The page keeps the address its form carried, so "Send a new link" works.
884 _, _, body = postFrom(t, fx, b, "/auth/email/code", fx.ts.URL, url.Values{"code": {code}, "address": {addr}, "next": {"/auth/cli"}})
885 for _, want := range []string{esc(copyText("check.sent", "address", addr)), `name="address" value="bob@example.com"`, `name="next" value="/auth/cli"`, esc(copyText("check.code_used_up"))} {
886 if !strings.Contains(body, want) {
887 t.Fatalf("used-up page is missing %q:\n%s", want, body)
888 }
889 }
890 // Cross-site posts are refused before the code is looked at.
891 if status, _, _ := postFrom(t, fx, a, "/auth/email/code", "https://evil.example", url.Values{"code": {code}}); status != http.StatusForbidden {
892 t.Fatalf("cross-site code POST = %d, want 403", status)
893 }
894 status, loc, _ := postCode(t, fx, a, code)
895 if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
896 t.Fatalf("code on the asking browser = %d -> %q", status, loc)
897 }
898 emailSession(t, fx, a, addr)
899 if challengeRows(t, fx) != 0 {
900 t.Fatal("the challenge outlived the sign-in")
901 }
902 // Used once.
903 if status, _, body := postCode(t, fx, a, code); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_used_up"))) {
904 t.Fatalf("the code a second time = %d:\n%s", status, body)
905 }
906}
907
908// Spec r2-2: wrong codes count per address across fresh challenges. Past the
909// cap the code box refuses without checking, while a link still signs in.
910func TestEmailCodeGuessCap(t *testing.T) {
911 fx := newConsoleFixture(t)
912 const addr = "bob@example.com"
913 const flow = "a-flow-token-of-at-least-thirty-two-characters"
914 c := newBrowser(t)
915 u, _ := url.Parse(fx.ts.URL)
916 c.Jar.SetCookies(u, []*http.Cookie{{Name: flowCookie, Value: flow, Path: "/"}})
917 // Each new challenge in the flow replaces the one before.
918 fresh := func() (link, code string) {
919 link, code, err := fx.st.CreateEmailChallenge(EmailChallenge{Purpose: ChallengeSignIn, Address: addr, FlowHash: hashToken(flow)}, time.Now())
920 if err != nil {
921 t.Fatal(err)
922 }
923 return link, code
924 }
925 wrong := func(n int, code string) {
926 t.Helper()
927 for i := 1; i <= n; i++ {
928 status, _, body := postCode(t, fx, c, wrongCode(code))
929 want := "check.code_wrong"
930 if i == emailCodeAttempts {
931 want = "check.code_used_up" // the fifth burns the challenge
932 }
933 if status != http.StatusOK || !strings.Contains(body, esc(copyText(want))) {
934 t.Fatalf("wrong code %d = %d, want %s:\n%s", i, status, want, body)
935 }
936 }
937 }
938 _, code := fresh()
939 wrong(5, code)
940 _, code = fresh()
941 wrong(5, code)
942 link, code := fresh()
943
944 // The eleventh wrong code, and even the right one, is throttled unchecked.
945 for _, try := range []string{wrongCode(code), code} {
946 status, _, body := postCode(t, fx, c, try)
947 if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_throttled"))) {
948 t.Fatalf("code past the cap = %d:\n%s", status, body)
949 }
950 }
951 if !signedOut(t, fx, c) {
952 t.Fatal("a throttled code signed in")
953 }
954 // The throttle burned nothing: this challenge's link still signs in.
955 if challengeRows(t, fx) != 1 {
956 t.Fatalf("%d challenges stand, want the third", challengeRows(t, fx))
957 }
958 other := newBrowser(t)
959 _, _, nonce := openLink(t, fx, other, link)
960 if status, loc, _ := postLink(t, fx, other, link, nonce); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
961 t.Fatalf("link past the code cap = %d -> %q", status, loc)
962 }
963 emailSession(t, fx, other, addr)
964}
965
966// A brand-new address signed in by code from the CLI's page lands back there,
967// already holding a personal account.
968func TestNewEmailUserGetsAccount(t *testing.T) {
969 fx, m := newEmailFixture(t, true)
970 const addr = "newbie@example.com"
971 lookupAnswers(fx, addr, http.StatusNotFound)
972 c := newBrowser(t)
973 code, _ := askSignin(t, fx, m, c, addr, "/auth/cli")
974 if userRows(t, fx, addr) != 0 {
975 t.Fatal("asking for a sign-in email created a user")
976 }
977 status, loc, _ := postCode(t, fx, c, code)
978 if status != http.StatusSeeOther || loc != "/auth/cli" {
979 t.Fatalf("code = %d -> %q, want 303 -> /auth/cli", status, loc)
980 }
981 emailSession(t, fx, c, addr)
982 if accts, err := fx.st.AccountsFor(addr); err != nil || len(accts) == 0 {
983 t.Fatalf("accounts for a new email user = %v, %v; want a personal account", accts, err)
984 }
985 if browserCookie(t, fx, c, welcomeCookie) == "" {
986 t.Fatal("no welcome for a brand-new user")
987 }
988}
989
990// Review Focus 5: asking twice from one browser leaves only the newest email
991// working, link and code alike.
992func TestReplacedChallenge(t *testing.T) {
993 fx, m := newEmailFixture(t, true)
994 const addr = "bob@example.com"
995 lookupAnswers(fx, addr, http.StatusNotFound)
996 c := newBrowser(t)
997 code1, link1 := askSignin(t, fx, m, c, addr, "")
998 advanceLimits(fx, 2*time.Minute)
999 code2, link2 := askSignin(t, fx, m, c, addr, "")
1000
1001 if status, body, _ := openLink(t, fx, newBrowser(t), link1); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) {
1002 t.Fatalf("first link = %d:\n%s", status, body)
1003 }
1004 if code1 != code2 {
1005 if status, _, body := postCode(t, fx, c, code1); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_wrong"))) {
1006 t.Fatalf("first code = %d:\n%s", status, body)
1007 }
1008 }
1009 if !signedOut(t, fx, c) {
1010 t.Fatal("the first email signed in")
1011 }
1012 if status, _, _ := openLink(t, fx, newBrowser(t), link2); status != http.StatusOK {
1013 t.Fatalf("second link = %d, want its confirm page", status)
1014 }
1015 if status, loc, _ := postCode(t, fx, c, code2); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
1016 t.Fatalf("second code = %d -> %q", status, loc)
1017 }
1018 emailSession(t, fx, c, addr)
1019}
1020
1021// Spec 11: the old Apps-inbox link is retired. Any token, even one the old
1022// table still holds, gets the expired message and no session.
1023func TestLegacyConfirmRetired(t *testing.T) {
1024 fx := newConsoleFixture(t)
1025 raw, err := fx.st.CreateConfirmation("bob@example.com", time.Now())
1026 if err != nil {
1027 t.Fatal(err)
1028 }
1029 c := newBrowser(t)
1030 for _, tok := range []string{raw, "made-up"} {
1031 status, _, body := get(t, c, fx.ts.URL+"/auth/confirm?token="+url.QueryEscape(tok))
1032 if status != http.StatusOK || !strings.Contains(body, "Link expired") || !strings.Contains(body, esc(copyText("link.expired"))) {
1033 t.Fatalf("legacy confirm %q = %d:\n%s", tok, status, body)
1034 }
1035 }
1036 if !signedOut(t, fx, c) {
1037 t.Fatal("the retired confirm link opened a session")
1038 }
1039}
1040
1041// The sign-in forms and every answer of the front door set the flow cookie.
1042// Its value comes only from what the browser sent: an accepted send, a
1043// refused one, a capped one and a Keymail redirect all answer a browser with
1044// the flow it already had, and the one email's code still signs in.
1045func TestContinueFlowCookie(t *testing.T) {
1046 fx, m := newEmailFixture(t, true)
1047 const addr = "bob@example.com"
1048 lookupAnswers(fx, addr, http.StatusNotFound)
1049 tok, err := fx.st.CreateInvite(OpsAccountID, addr, "paul@keymail.dev", time.Now())
1050 if err != nil {
1051 t.Fatal(err)
1052 }
1053 for _, path := range []string{"/", "/invite/" + tok, "/invite/no-such-invite", "/auth/cli"} {
1054 c := newBrowser(t)
1055 if status, _, _ := get(t, c, fx.ts.URL+path); status != http.StatusOK || browserCookie(t, fx, c, flowCookie) == "" {
1056 t.Fatalf("signed-out %s = %d, flow cookie %q", path, status, browserCookie(t, fx, c, flowCookie))
1057 }
1058 }
1059 // A browser with no flow is given one by the front door itself.
1060 bare := newBrowser(t)
1061 postContinue(t, fx, bare, url.Values{"address": {"carol@example.com"}})
1062 if browserCookie(t, fx, bare, flowCookie) == "" {
1063 t.Fatal("continue with no flow set none")
1064 }
1065 waitChallengeSent(t, fx, 1)
1066
1067 c := newBrowser(t)
1068 get(t, c, fx.ts.URL+"/")
1069 flow := browserCookie(t, fx, c, flowCookie)
1070 same := func(what string) {
1071 t.Helper()
1072 if got := browserCookie(t, fx, c, flowCookie); got != flow {
1073 t.Fatalf("%s changed the flow cookie: %q, want %q", what, got, flow)
1074 }
1075 }
1076 code, _ := askSignin(t, fx, m, c, addr, "")
1077 same("an accepted send")
1078 postContinue(t, fx, c, url.Values{"address": {addr}})
1079 same("a refused send")
1080 postContinue(t, fx, c, url.Values{"address": {"kim@" + fx.srv.km.Host()}})
1081 same("a Keymail redirect")
1082 for fx.srv.limits.submit("127.0.0.1") {
1083 }
1084 postContinue(t, fx, c, url.Values{"address": {addr}})
1085 same("a capped submission")
1086
1087 if n := len(m.sentCopy()); n != 2 {
1088 t.Fatalf("mailer got %d messages, want 2 (carol's and bob's one)", n)
1089 }
1090 if status, _, body := postCode(t, fx, c, code); status != http.StatusSeeOther {
1091 t.Fatalf("code after refused requests = %d:\n%s", status, body)
1092 }
1093 emailSession(t, fx, c, addr)
1094}
1095
1096// postCheckCode submits the check page's code form as the page renders it:
1097// the code with the page's address and next.
1098func postCheckCode(t *testing.T, fx *consoleFixture, c *http.Client, code, addr, next string) (int, http.Header, string) {
1099 t.Helper()
1100 form := url.Values{"code": {code}, "address": {addr}}
1101 if next != "" {
1102 form.Set("next", next)
1103 }
1104 req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/email/code", strings.NewReader(form.Encode()))
1105 if err != nil {
1106 t.Fatal(err)
1107 }
1108 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1109 req.Header.Set("Origin", fx.ts.URL)
1110 res, err := c.Do(req)
1111 if err != nil {
1112 t.Fatal(err)
1113 }
1114 defer res.Body.Close()
1115 raw, err := io.ReadAll(res.Body)
1116 if err != nil {
1117 t.Fatal(err)
1118 }
1119 return res.StatusCode, res.Header, string(raw)
1120}
1121
1122// setCookieNames is the sorted names a response sets.
1123func setCookieNames(h http.Header) string {
1124 var names []string
1125 for _, c := range (&http.Response{Header: h}).Cookies() {
1126 names = append(names, c.Name)
1127 }
1128 sort.Strings(names)
1129 return strings.Join(names, ",")
1130}
1131
1132// F2 (final review): a wrong code typed after a refused request reads exactly
1133// as one typed after a sent email, so the code box is no throttling oracle.
1134func TestEmailCodeRefusedReadsAsSent(t *testing.T) {
1135 fx, m := newEmailFixture(t, true)
1136 const addr, next = "bob@example.com", "/auth/cli"
1137 lookupAnswers(fx, addr, http.StatusNotFound)
1138
1139 sent := newBrowser(t)
1140 askSignin(t, fx, m, sent, addr, next)
1141 // At once, from a fresh browser: the per-address gap refuses the send.
1142 refused := newBrowser(t)
1143 if status, _, body := postContinue(t, fx, refused, url.Values{"address": {addr}, "next": {next}}); status != http.StatusOK ||
1144 !strings.Contains(body, esc(copyText("check.sent", "address", addr))) {
1145 t.Fatalf("refused continue = %d:\n%s", status, body)
1146 }
1147 if n := len(m.sentCopy()); n != 1 {
1148 t.Fatalf("mailer got %d messages, want 1", n)
1149 }
1150 // The refused browser's challenge has a random code nobody saw, so every
1151 // wrong code here is one no six-digit code can be: wrongCode(code) is
1152 // wrong only for the sent browser.
1153 for _, try := range []string{"abcdef", "12345"} {
1154 sStatus, sHdr, sBody := postCheckCode(t, fx, sent, try, addr, next)
1155 rStatus, rHdr, rBody := postCheckCode(t, fx, refused, try, addr, next)
1156 if sStatus != http.StatusOK || !strings.Contains(sBody, esc(copyText("check.code_wrong"))) {
1157 t.Fatalf("wrong code %q after a sent email = %d:\n%s", try, sStatus, sBody)
1158 }
1159 if rStatus != sStatus || rBody != sBody {
1160 t.Fatalf("wrong code %q: refused answered %d, sent %d; bodies:\nrefused:\n%s\nsent:\n%s", try, rStatus, sStatus, rBody, sBody)
1161 }
1162 if setCookieNames(rHdr) != setCookieNames(sHdr) {
1163 t.Fatalf("wrong code %q set cookies %q after a refusal, %q after a send", try, setCookieNames(rHdr), setCookieNames(sHdr))
1164 }
1165 }
1166 // The refused browser's challenge can never sign in: nobody has its code
1167 // or link, and the five attempts burn it as they would a sent one.
1168 for i := 3; i <= emailCodeAttempts; i++ {
1169 _, _, sBody := postCheckCode(t, fx, sent, "abcdef", addr, next)
1170 _, _, rBody := postCheckCode(t, fx, refused, "abcdef", addr, next)
1171 if rBody != sBody {
1172 t.Fatalf("attempt %d differs:\nrefused:\n%s\nsent:\n%s", i, rBody, sBody)
1173 }
1174 }
1175 if !signedOut(t, fx, refused) || !signedOut(t, fx, sent) {
1176 t.Fatal("a wrong code signed in")
1177 }
1178
1179 // A refusal in a flow that already has a live challenge, for another
1180 // address, leaves that challenge in place; the code page still shows what
1181 // this request asked for, exactly as a sent one does.
1182 const a, b = "ann@example.com", "ben@example.com"
1183 lookupAnswers(fx, a, http.StatusNotFound)
1184 lookupAnswers(fx, b, http.StatusNotFound)
1185 asker := newBrowser(t)
1186 askSignin(t, fx, m, asker, a, "")
1187 sentB := newBrowser(t)
1188 askSignin(t, fx, m, sentB, b, "")
1189 // ben was just sent one, so asker's request for ben is refused.
1190 before := len(m.sentCopy())
1191 postContinue(t, fx, asker, url.Values{"address": {b}})
1192 if len(m.sentCopy()) != before {
1193 t.Fatal("the refused request for ben sent mail")
1194 }
1195 _, _, sBody := postCheckCode(t, fx, sentB, "abcdef", b, "")
1196 _, _, rBody := postCheckCode(t, fx, asker, "abcdef", b, "")
1197 if rBody != sBody || !strings.Contains(sBody, esc(copyText("check.sent", "address", b))) {
1198 t.Fatalf("a wrong code after a refusal beside a live challenge differs:\nrefused:\n%s\nsent:\n%s", rBody, sBody)
1199 }
1200}
1201
1202// F3 (final review): two submissions leave one browser before either answer
1203// arrives, with no challenge yet. One is sent, the other refused; whichever
1204// answer the browser keeps, the emailed code signs in.
1205func TestContinueOverlappingFirstSubmissions(t *testing.T) {
1206 const addr = "bob@example.com"
1207 // submit posts the front door's form with the browser's flow cookie set by
1208 // hand (two requests that left before either answer carry the same
1209 // cookies) and returns the flow cookie the answer sets.
1210 submit := func(t *testing.T, fx *consoleFixture, flow string) string {
1211 t.Helper()
1212 req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/continue", strings.NewReader(url.Values{"address": {addr}}.Encode()))
1213 if err != nil {
1214 t.Error(err)
1215 return ""
1216 }
1217 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1218 req.Header.Set("Origin", fx.ts.URL)
1219 req.AddCookie(&http.Cookie{Name: flowCookie, Value: flow})
1220 res, err := http.DefaultTransport.RoundTrip(req)
1221 if err != nil {
1222 t.Error(err)
1223 return ""
1224 }
1225 defer res.Body.Close()
1226 io.Copy(io.Discard, res.Body)
1227 for _, c := range res.Cookies() {
1228 if c.Name == flowCookie {
1229 return c.Value
1230 }
1231 }
1232 t.Error("an answer set no flow cookie")
1233 return ""
1234 }
1235 // world is a fresh console and a browser that has loaded the front page.
1236 world := func(t *testing.T) (*consoleFixture, *fakeConsoleMail, *http.Client, string) {
1237 fx, m := newEmailFixture(t, true)
1238 lookupAnswers(fx, addr, http.StatusNotFound)
1239 c := newBrowser(t)
1240 get(t, c, fx.ts.URL+"/")
1241 flow := browserCookie(t, fx, c, flowCookie)
1242 if flow == "" {
1243 t.Fatal("the front page set no flow")
1244 }
1245 return fx, m, c, flow
1246 }
1247 // finish keeps the answers' cookies in the given order, the last one
1248 // winning as in a browser, and signs in with the one email's code.
1249 finish := func(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, answers []string) {
1250 t.Helper()
1251 u, _ := url.Parse(fx.ts.URL)
1252 for _, v := range answers {
1253 c.Jar.SetCookies(u, []*http.Cookie{{Name: flowCookie, Value: v, Path: "/"}})
1254 }
1255 waitChallengeSent(t, fx, 1)
1256 _, code, _ := sentSignin(t, m)
1257 if status, loc, body := postCode(t, fx, c, code); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
1258 t.Fatalf("the emailed code = %d -> %q:\n%s", status, loc, body)
1259 }
1260 emailSession(t, fx, c, addr)
1261 }
1262
1263 t.Run("concurrent", func(t *testing.T) {
1264 fx, m, c, flow := world(t)
1265 var (
1266 start = make(chan struct{})
1267 wg sync.WaitGroup
1268 mu sync.Mutex
1269 order []string
1270 )
1271 for i := 0; i < 2; i++ {
1272 wg.Add(1)
1273 go func() {
1274 defer wg.Done()
1275 <-start
1276 v := submit(t, fx, flow)
1277 mu.Lock()
1278 order = append(order, v)
1279 mu.Unlock()
1280 }()
1281 }
1282 close(start)
1283 wg.Wait()
1284 if len(m.sentCopy()) > 1 {
1285 t.Fatalf("two overlapping submissions sent %d emails", len(m.sentCopy()))
1286 }
1287 finish(t, fx, m, c, order)
1288 })
1289 for _, last := range []string{"sent answer last", "refused answer last"} {
1290 t.Run(last, func(t *testing.T) {
1291 fx, m, c, flow := world(t)
1292 first, second := submit(t, fx, flow), submit(t, fx, flow)
1293 answers := []string{second, first} // the sent (first) answer lands last
1294 if last == "refused answer last" {
1295 answers = []string{first, second}
1296 }
1297 finish(t, fx, m, c, answers)
1298 })
1299 }
1300}
1301
1302// F1 (final review): an IP that has spent its submissions on the Keymail
1303// host's own addresses (no lookup, no email slot) gets no email for an
1304// address it can no longer have looked up, here one Keymail serves, and
1305// its 31st submission writes no challenge row at all.
1306func TestContinueSubmitCapSendsNothing(t *testing.T) {
1307 fx, m := newEmailFixture(t, true)
1308 const addr = "kim@example.com"
1309 lookupAnswers(fx, addr, http.StatusOK)
1310 for i := 0; i < submitsPerIPHour; i++ {
1311 if status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"kim@" + fx.srv.km.Host()}}); status != http.StatusSeeOther || loc != "/auth/signin" {
1312 t.Fatalf("Keymail-host submission %d = %d -> %q", i+1, status, loc)
1313 }
1314 }
1315 c := newBrowser(t)
1316 status, _, body := postContinue(t, fx, c, url.Values{"address": {addr}})
1317 if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.sent", "address", addr))) {
1318 t.Fatalf("capped continue = %d, want the ordinary check page:\n%s", status, body)
1319 }
1320 if n := lookupCount(fx); n != 0 {
1321 t.Fatalf("over the cap Keymail was asked %d times", n)
1322 }
1323 // The send runs off the request: give a stray one time to land.
1324 select {
1325 case <-fx.srv.challengeSent:
1326 t.Fatal("a capped submission started a send")
1327 case <-time.After(200 * time.Millisecond):
1328 }
1329 if sent := m.sentCopy(); len(sent) != 0 {
1330 t.Fatalf("a capped submission sent %q", sent)
1331 }
1332 // The 31st submission from this IP, in a fresh flow, wrote no challenge
1333 // at all, so the code box finds nothing.
1334 if n := challengeRows(t, fx); n != 0 {
1335 t.Fatalf("%d challenges after a capped submission, want none", n)
1336 }
1337 if status, _, body := postCode(t, fx, c, "000000"); status != http.StatusOK ||
1338 !strings.Contains(body, esc(copyText("check.code_used_up"))) || !signedOut(t, fx, c) {
1339 t.Fatalf("a code after a capped submission = %d:\n%s", status, body)
1340 }
1341}
1342