package console import ( "io" "net/http" "net/http/httptest" "net/url" "regexp" "sort" "strings" "sync" "testing" "time" ) func TestSafeNext(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"/auth/cli", "/auth/cli"}, {"/invite/abc", "/invite/abc"}, {"/invite/abc?x=1", "/invite/abc?x=1"}, {"//evil.com", ""}, {"/\\evil.com", ""}, {"https://evil.com/x", ""}, {"javascript:alert(1)", ""}, {"evil.com", ""}, {"/a\nb", ""}, {"", ""}, } { if got := safeNext(tc.in); got != tc.want { t.Errorf("safeNext(%q) = %q, want %q", tc.in, got, tc.want) } } } // keymailWalk starts /auth/signin with the given query and answers the // callback, returning the callback's status and Location. func keymailWalk(t *testing.T, fx *consoleFixture, c *http.Client, signinQuery, code string) (int, string) { t.Helper() status, loc, _ := get(t, c, fx.ts.URL+"/auth/signin"+signinQuery) if status != http.StatusSeeOther { t.Fatalf("signin = %d, want 303", status) } u, err := url.Parse(loc) if err != nil { t.Fatal(err) } q := u.Query() fx.fk.mu.Lock() fx.fk.challenge = q.Get("code_challenge") fx.fk.expectCode = code fx.fk.mu.Unlock() status, loc, _ = get(t, c, fx.ts.URL+"/auth/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(q.Get("state"))) return status, loc } func browserCookie(t *testing.T, fx *consoleFixture, c *http.Client, name string) string { t.Helper() u, _ := url.Parse(fx.ts.URL) for _, ck := range c.Jar.Cookies(u) { if ck.Name == name { return ck.Value } } return "" } func TestKeymailCallbackFollowsNext(t *testing.T) { fx := newConsoleFixture(t) fx.setUser("newcomer@keymail.dev") c := newBrowser(t) status, loc := keymailWalk(t, fx, c, "?next="+url.QueryEscape("/auth/cli"), "code-n1") if status != http.StatusSeeOther || loc != "/auth/cli" { t.Fatalf("callback = %d -> %q, want 303 -> /auth/cli", status, loc) } if v := browserCookie(t, fx, c, nextCookie); v != "" { t.Fatalf("next cookie survived the callback: %q", v) } raw := sessionToken(t, fx, c) addr, method, ok := fx.st.SessionInfo(raw, time.Now()) if !ok || addr != "newcomer@keymail.dev" || method != MethodKeymail { t.Fatalf("session = %q %q %v, want newcomer keymail", addr, method, ok) } // Onboarding ran before next: the brand-new user has a personal account. accts, err := fx.st.AccountsFor("newcomer@keymail.dev") if err != nil || len(accts) == 0 { t.Fatalf("accounts for a brand-new user = %v, %v; want a minted personal account", accts, err) } // A keymail sign-in records no step-up. if fx.st.FreshStepUp(raw, "newcomer@keymail.dev", time.Now(), StepUpWindow) { t.Fatal("a keymail sign-in recorded a step-up") } } func TestKeymailCallbackIgnoresUnsafeAndStaleNext(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) // An unsafe next is never stored, and the usual landing applies. status, _, _ := get(t, c, fx.ts.URL+"/auth/signin?next="+url.QueryEscape("//evil.com")) if status != http.StatusSeeOther { t.Fatalf("signin = %d", status) } if v := browserCookie(t, fx, c, nextCookie); v != "" { t.Fatalf("unsafe next was stored: %q", v) } // A stale safe next is cleared by a later signin without one. get(t, c, fx.ts.URL+"/auth/signin?next=/auth/cli") if v := browserCookie(t, fx, c, nextCookie); v != "/auth/cli" { t.Fatalf("next cookie = %q, want /auth/cli", v) } status, loc := keymailWalk(t, fx, c, "", "code-n2") if status != http.StatusSeeOther || len(loc) < 3 || loc[:3] != "/a/" { t.Fatalf("callback with no next = %d -> %q, want 303 into an account", status, loc) } } func TestFinishSigninEmailRecordsStepUp(t *testing.T) { fx := newConsoleFixture(t) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) fx.srv.finishSignin(w, r, "bob@example.com", "", MethodEmail, "/invite/abc") res := w.Result() if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != "/invite/abc" { t.Fatalf("finishSignin = %d -> %q", res.StatusCode, res.Header.Get("Location")) } var raw string var welcomed bool for _, ck := range res.Cookies() { switch ck.Name { case sessionCookie: raw = ck.Value case welcomeCookie: welcomed = true } } if raw == "" || !welcomed { t.Fatalf("cookies: session %q, welcome %v", raw, welcomed) } addr, method, ok := fx.st.SessionInfo(raw, time.Now()) if !ok || addr != "bob@example.com" || method != MethodEmail { t.Fatalf("session = %q %q %v", addr, method, ok) } if !fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) { t.Fatal("an email sign-in recorded no step-up") } if g, _ := fx.st.GrantToken("bob@example.com"); g != "" { t.Fatalf("an email sign-in stored a grant token %q", g) } if accts, err := fx.st.AccountsFor("bob@example.com"); err != nil || len(accts) == 0 { t.Fatalf("no personal account minted: %v, %v", accts, err) } } // ---- the front door (POST /auth/continue) ----------------------------------- // newEmailFixture is the console fixture with a console mailer that reports // ready (or not) and a hook that signals each finished sign-in send. func newEmailFixture(t *testing.T, ready bool) (*consoleFixture, *fakeConsoleMail) { t.Helper() fx := newConsoleFixture(t) m := &fakeConsoleMail{ready: ready} fx.srv.consoleMail = m fx.srv.challengeSent = make(chan struct{}, 16) return fx, m } // lookupAnswers makes the fake Keymail's federation lookup answer status for // addr. func lookupAnswers(fx *consoleFixture, addr string, status int) { fx.fk.mu.Lock() defer fx.fk.mu.Unlock() fx.fk.lookupStatus[addr] = status } func lookupCount(fx *consoleFixture) int { fx.fk.mu.Lock() defer fx.fk.mu.Unlock() return fx.fk.lookups } // postContinue submits the front page's form from the console's own page // (Origin set, as a browser does on a POST). func postContinue(t *testing.T, fx *consoleFixture, c *http.Client, form url.Values) (status int, location, body string) { t.Helper() return postContinueFrom(t, fx, c, fx.ts.URL, form) } func postContinueFrom(t *testing.T, fx *consoleFixture, c *http.Client, origin string, form url.Values) (status int, location, body string) { t.Helper() req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/continue", strings.NewReader(form.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if origin != "" { req.Header.Set("Origin", origin) } res, err := c.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return res.StatusCode, res.Header.Get("Location"), string(raw) } // waitChallengeSent waits for n background sign-in sends to finish. func waitChallengeSent(t *testing.T, fx *consoleFixture, n int) { t.Helper() for i := 0; i < n; i++ { select { case <-fx.srv.challengeSent: case <-time.After(10 * time.Second): t.Fatalf("sign-in send %d of %d never finished", i+1, n) } } } func challengeRows(t *testing.T, fx *consoleFixture) int { t.Helper() var n int if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM email_challenges`).Scan(&n); err != nil { t.Fatal(err) } return n } func userRows(t *testing.T, fx *consoleFixture, addr string) int { t.Helper() var n int if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM users WHERE address = ?`, addr).Scan(&n); err != nil { t.Fatal(err) } return n } var ( signinCodeRe = regexp.MustCompile(`type this code on the page that's waiting: (\d{6})\b`) signinLinkRe = regexp.MustCompile(`/auth/email/link\?t=([A-Za-z0-9_-]+)`) ) // sentSignin is the one message the console mailer got, split into its // recipient, code and link token. func sentSignin(t *testing.T, m *fakeConsoleMail) (to, code, link string) { t.Helper() sent := m.sentCopy() if len(sent) != 1 { t.Fatalf("console mailer got %d messages, want 1: %q", len(sent), sent) } to, body, _ := strings.Cut(sent[0], "|") cm := signinCodeRe.FindStringSubmatch(body) lm := signinLinkRe.FindStringSubmatch(body) if cm == nil || lm == nil { t.Fatalf("sign-in email has no code or link:\n%s", body) } return to, cm[1], lm[1] } func TestContinueKeymailHostGoesToKeymail(t *testing.T) { fx, m := newEmailFixture(t, true) addr := "bob@" + fx.srv.km.Host() status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {addr}}) if status != http.StatusSeeOther || loc != "/auth/signin" { t.Fatalf("continue = %d -> %q, want 303 -> /auth/signin", status, loc) } status, loc, _ = postContinue(t, fx, newBrowser(t), url.Values{"address": {addr}, "next": {"/auth/cli"}}) if want := "/auth/signin?next=" + url.QueryEscape("/auth/cli"); status != http.StatusSeeOther || loc != want { t.Fatalf("continue with next = %d -> %q, want 303 -> %q", status, loc, want) } // An unsafe next is dropped, not carried to Keymail. _, loc, _ = postContinue(t, fx, newBrowser(t), url.Values{"address": {addr}, "next": {"//evil.example"}}) if loc != "/auth/signin" { t.Fatalf("continue with unsafe next -> %q, want /auth/signin", loc) } if n := lookupCount(fx); n != 0 { t.Fatalf("the Keymail host's own address cost %d lookups, want 0", n) } if sent := m.sentCopy(); len(sent) != 0 || challengeRows(t, fx) != 0 { t.Fatalf("a Keymail address got email: sent %q, %d challenges", sent, challengeRows(t, fx)) } } func TestContinueLookup200GoesToKeymail(t *testing.T) { fx, m := newEmailFixture(t, true) lookupAnswers(fx, "bob@example.com", http.StatusOK) status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"bob@example.com"}}) if status != http.StatusSeeOther || loc != "/auth/signin" { t.Fatalf("continue = %d -> %q, want 303 -> /auth/signin", status, loc) } if n := lookupCount(fx); n != 1 { t.Fatalf("lookups = %d, want 1", n) } if sent := m.sentCopy(); len(sent) != 0 || challengeRows(t, fx) != 0 { t.Fatalf("a Keymail-served domain got email: sent %q, %d challenges", sent, challengeRows(t, fx)) } } // checkEmailSent is the email outcome: the check page, one message to addr // with a code and a link, and a challenge for addr with next. func checkEmailSent(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, addr, next string, status int, body string) { t.Helper() if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.sent", "address", addr))) { t.Fatalf("continue = %d, want the check page for %s:\n%s", status, addr, body) } waitChallengeSent(t, fx, 1) to, _, link := sentSignin(t, m) if to != addr { t.Fatalf("sign-in email went to %q, want %q", to, addr) } ch, ok := fx.st.PeekEmailLink(link, time.Now()) if !ok || ch.Address != addr || ch.Purpose != ChallengeSignIn || ch.Next != next { t.Fatalf("challenge = %+v %v, want a sign-in for %s with next %q", ch, ok, addr, next) } if flow := browserCookie(t, fx, c, flowCookie); flow == "" || ch.FlowHash != hashToken(flow) { t.Fatalf("challenge flow %q is not the asking browser's flow %q", ch.FlowHash, flow) } } func TestContinueLookup404SendsEmail(t *testing.T) { fx, m := newEmailFixture(t, true) lookupAnswers(fx, "bob@example.com", http.StatusNotFound) c := newBrowser(t) status, _, body := postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}, "next": {"/auth/cli"}}) checkEmailSent(t, fx, m, c, "bob@example.com", "/auth/cli", status, body) sent := m.sentCopy()[0] if !strings.Contains(sent, fx.ts.URL+"/auth/email/link?t=") { t.Fatalf("link is not on the console's origin:\n%s", sent) } } func TestContinueLookup500SendsEmailForForeignDomain(t *testing.T) { fx, m := newEmailFixture(t, true) lookupAnswers(fx, "bob@example.com", http.StatusInternalServerError) c := newBrowser(t) status, _, body := postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}}) checkEmailSent(t, fx, m, c, "bob@example.com", "", status, body) } // Review Focus 1: a stored grant is console state, and console state never // decides the door. func TestContinueGrantDoesNotDecide(t *testing.T) { fx, m := newEmailFixture(t, true) now := time.Now() if _, err := fx.st.EnsureUser("bob@example.com", now); err != nil { t.Fatal(err) } if err := fx.st.SetGrantToken("bob@example.com", "grant-token", now); err != nil { t.Fatal(err) } lookupAnswers(fx, "bob@example.com", http.StatusNotFound) c := newBrowser(t) status, _, body := postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}}) checkEmailSent(t, fx, m, c, "bob@example.com", "", status, body) fx.fk.mu.Lock() defer fx.fk.mu.Unlock() if len(fx.fk.sends) != 0 { t.Fatalf("the stored grant was used to send %d Keymail messages", len(fx.fk.sends)) } } // Review Focus 3. func TestContinueNormalises(t *testing.T) { fx, m := newEmailFixture(t, true) lookupAnswers(fx, "bob@example.com", http.StatusNotFound) c := newBrowser(t) status, _, body := postContinue(t, fx, c, url.Values{"address": {" Bob@Example.COM "}}) checkEmailSent(t, fx, m, c, "bob@example.com", "", status, body) fx2, m2 := newEmailFixture(t, true) for _, bad := range []string{"a", "a@x.com,b@y.com", "", "nobody"} { status, _, body := postContinue(t, fx2, newBrowser(t), url.Values{"address": {bad}}) if status != http.StatusOK || !strings.Contains(body, esc(copyText("home.bad_address"))) { t.Fatalf("continue %q = %d, want the front page with the bad-address error:\n%s", bad, status, body) } if !strings.Contains(body, `action="/auth/continue"`) { t.Fatalf("continue %q: the error page has no form", bad) } } if n := userRows(t, fx2, "victim@example.com"); n != 0 { t.Fatal("a display-name address made a user row") } if n := challengeRows(t, fx2); n != 0 { t.Fatalf("a refused address made %d challenges", n) } if n := lookupCount(fx2); n != 0 { t.Fatalf("a refused address cost %d lookups", n) } if sent := m2.sentCopy(); len(sent) != 0 { t.Fatalf("a refused address was mailed: %q", sent) } } // The anti-oracle: a new address, a known user, a throttled repeat and a // refused submission all get the same page, byte for byte. func TestContinuePageIdentical(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "bob@example.com" lookupAnswers(fx, addr, http.StatusNotFound) form := url.Values{"address": {addr}, "next": {"/auth/cli"}} browsers := []*http.Client{newBrowser(t), newBrowser(t), newBrowser(t), newBrowser(t)} _, _, fresh := postContinue(t, fx, browsers[0], form) waitChallengeSent(t, fx, 1) // Two minutes on, bob is a known user and may be sent another. if _, err := fx.st.EnsureUser(addr, time.Now()); err != nil { t.Fatal(err) } fx.srv.limits.mu.Lock() fx.srv.limits.now = func() time.Time { return time.Now().Add(2 * time.Minute) } fx.srv.limits.mu.Unlock() _, _, known := postContinue(t, fx, browsers[1], form) waitChallengeSent(t, fx, 1) // At once again: the per-address gap refuses the send. _, _, throttled := postContinue(t, fx, browsers[2], form) // And an IP over its submission cap spends no lookup and sends nothing. for fx.srv.limits.submit("127.0.0.1") { } lookupsBefore := lookupCount(fx) _, _, capped := postContinue(t, fx, browsers[3], form) if fresh != known || fresh != throttled || fresh != capped { t.Fatalf("pages differ:\nfresh:\n%s\nknown:\n%s\nthrottled:\n%s\ncapped:\n%s", fresh, known, throttled, capped) } if n := len(m.sentCopy()); n != 2 { t.Fatalf("mailer got %d messages, want 2 (the throttled and capped ones send nothing)", n) } // Two sent, one never-sent for the throttled fresh browser, and nothing // for the capped one. if n := challengeRows(t, fx); n != 3 { t.Fatalf("%d challenges stored, want 3", n) } if lookupCount(fx) != lookupsBefore { t.Fatal("a capped submission still asked Keymail") } // The headers match too: every outcome sets a flow cookie. for i, c := range browsers { if browserCookie(t, fx, c, flowCookie) == "" { t.Errorf("outcome %d set no flow cookie", i) } } for _, want := range []string{ esc(copyText("check.title")), esc(copyText("check.code_label")), esc(copyText("check.code_button")), esc(copyText("check.nothing")), esc(copyText("check.again_button")), `action="/auth/email/code"`, `name="address" value="bob@example.com"`, `name="next" value="/auth/cli"`, } { if !strings.Contains(fresh, want) { t.Errorf("check page is missing %q:\n%s", want, fresh) } } } // Spec 4.2, codex r3-1: with no working mailer an email-bound address is told // so, before any send limit, so the answer is the same throttled or not. func TestContinueNoMailer(t *testing.T) { fx, m := newEmailFixture(t, false) const addr = "bob@example.com" lookupAnswers(fx, addr, http.StatusNotFound) form := url.Values{"address": {addr}} _, _, first := postContinue(t, fx, newBrowser(t), form) if !strings.Contains(first, esc(copyText("home.no_mail"))) || !strings.Contains(first, `action="/auth/continue"`) { t.Fatalf("no-mailer answer is not the front page with home.no_mail:\n%s", first) } // Spend bob's sign-in sends for the hour, as if he had been mailed. fx.srv.limits.mu.Lock() for i := 0; i < 5; i++ { fx.srv.limits.addrSends.add(addr, time.Now()) } fx.srv.limits.mu.Unlock() for i := 2; i <= 6; i++ { if _, _, body := postContinue(t, fx, newBrowser(t), form); body != first { t.Fatalf("request %d differs from the first:\n%s\nvs\n%s", i, body, first) } } // Over the IP's submission cap: no lookup, and still the same answer. for fx.srv.limits.submit("127.0.0.1") { } lookups := lookupCount(fx) if _, _, body := postContinue(t, fx, newBrowser(t), form); body != first { t.Fatalf("over the submission cap the answer differs:\n%s\nvs\n%s", body, first) } if lookupCount(fx) != lookups { t.Fatal("a capped submission still asked Keymail") } if sent := m.sentCopy(); len(sent) != 0 || challengeRows(t, fx) != 0 { t.Fatalf("no mailer, yet sent %q and stored %d challenges", sent, challengeRows(t, fx)) } // The Keymail host's own addresses need no lookup and still go there. if status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"kim@" + fx.srv.km.Host()}}); status != http.StatusSeeOther || loc != "/auth/signin" { t.Fatalf("Keymail-host address over the cap = %d -> %q", status, loc) } fx.srv.limits.mu.Lock() fx.srv.limits.submits = keyedWindow{} fx.srv.limits.mu.Unlock() // A Keymail address still signs in. lookupAnswers(fx, "kim@example.com", http.StatusOK) if status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"kim@example.com"}}); status != http.StatusSeeOther || loc != "/auth/signin" { t.Fatalf("Keymail address with no mailer = %d -> %q", status, loc) } } func TestContinueCrossSiteRefused(t *testing.T) { fx, m := newEmailFixture(t, true) lookupAnswers(fx, "bob@example.com", http.StatusNotFound) form := url.Values{"address": {"bob@example.com"}} if status, _, _ := postContinueFrom(t, fx, newBrowser(t), "https://evil.example", form); status != http.StatusForbidden { t.Fatalf("cross-site continue = %d, want 403", status) } if status, _, _ := postContinueFrom(t, fx, newBrowser(t), "null", form); status != http.StatusForbidden { t.Fatalf("opaque-origin continue = %d, want 403", status) } // No Origin, but the browser says the request came from another site. req, _ := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/continue", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Sec-Fetch-Site", "cross-site") res, err := newBrowser(t).Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusForbidden { t.Fatalf("Sec-Fetch-Site cross-site continue = %d, want 403", res.StatusCode) } if n := lookupCount(fx); n != 0 || len(m.sentCopy()) != 0 || challengeRows(t, fx) != 0 { t.Fatalf("a refused request still acted: %d lookups, %d sent, %d challenges", n, len(m.sentCopy()), challengeRows(t, fx)) } } func TestSameOrigin(t *testing.T) { fx := newConsoleFixture(t) for _, tc := range []struct { origin, site string want bool }{ {"", "", true}, {fx.ts.URL, "", true}, {fx.ts.URL, "cross-site", true}, // Origin decides when present {"https://evil.example", "same-origin", false}, {"null", "", false}, {"", "same-origin", true}, {"", "none", true}, {"", "same-site", false}, {"", "cross-site", false}, } { r := httptest.NewRequest(http.MethodPost, "/auth/continue", nil) if tc.origin != "" { r.Header.Set("Origin", tc.origin) } if tc.site != "" { r.Header.Set("Sec-Fetch-Site", tc.site) } if got := fx.srv.sameOrigin(r); got != tc.want { t.Errorf("sameOrigin(Origin %q, Sec-Fetch-Site %q) = %v, want %v", tc.origin, tc.site, got, tc.want) } } } func TestInvitePageSignedOutPrefills(t *testing.T) { fx, m := newEmailFixture(t, true) tok, err := fx.st.CreateInvite(OpsAccountID, "bob@example.com", "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } c := newBrowser(t) status, _, body := get(t, c, fx.ts.URL+"/invite/"+tok) next := "/invite/" + tok for _, want := range []string{ esc(copyText("home.invite_prompt", "address", "bob@example.com")), `value="bob@example.com"`, `name="next" value="` + esc(next) + `"`, } { if status != http.StatusOK || !strings.Contains(body, want) { t.Fatalf("signed-out invite page = %d, missing %q:\n%s", status, want, body) } } // Reading the page did not use the invitation up. if _, ok := fx.st.PendingInviteAddress(tok, time.Now()); !ok { t.Fatal("the signed-out invite page consumed the invitation") } // The form carries next into the challenge. lookupAnswers(fx, "bob@example.com", http.StatusNotFound) status, _, body = postContinue(t, fx, c, url.Values{"address": {"bob@example.com"}, "next": {next}}) checkEmailSent(t, fx, m, c, "bob@example.com", next, status, body) // An unknown invitation is the plain front page: no banner, no prefill. status, _, body = get(t, newBrowser(t), fx.ts.URL+"/invite/no-such-invite") if status != http.StatusOK || !strings.Contains(body, `action="/auth/continue"`) { t.Fatalf("unknown invite signed out = %d:\n%s", status, body) } if strings.Contains(body, "to accept your invitation") || strings.Contains(body, `name="next"`) || strings.Contains(body, "bob@example.com") { t.Fatalf("unknown invite page carries a banner, next or prefill:\n%s", body) } } func TestCLIPageSignedOutBanner(t *testing.T) { fx := newConsoleFixture(t) status, _, body := get(t, newBrowser(t), fx.ts.URL+"/auth/cli") if status != http.StatusOK || !strings.Contains(body, esc(copyText("home.cli_prompt"))) || !strings.Contains(body, `name="next" value="/auth/cli"`) || !strings.Contains(body, `action="/auth/continue"`) { t.Fatalf("signed-out /auth/cli = %d:\n%s", status, body) } _, _, body = get(t, newBrowser(t), fx.ts.URL+"/auth/cli?code=wxyz4823") if want := `name="next" value="` + esc("/auth/cli?code=WXYZ-4823") + `"`; !strings.Contains(body, want) { t.Fatalf("signed-out /auth/cli?code= lost the code, missing %q:\n%s", want, body) } // A code that is not code-shaped is not carried. _, _, body = get(t, newBrowser(t), fx.ts.URL+"/auth/cli?code="+url.QueryEscape(strings.Repeat("x", 500))) if !strings.Contains(body, `name="next" value="/auth/cli"`) { t.Fatalf("an odd code was carried into next:\n%s", body) } } func TestHomeCarriesSafeNext(t *testing.T) { fx := newConsoleFixture(t) _, _, body := get(t, newBrowser(t), fx.ts.URL+"/?next="+url.QueryEscape("/auth/cli")) if !strings.Contains(body, `name="next" value="/auth/cli"`) { t.Fatalf("home dropped a safe next:\n%s", body) } _, _, body = get(t, newBrowser(t), fx.ts.URL+"/?next="+url.QueryEscape("//evil.example")) if strings.Contains(body, `name="next"`) { t.Fatalf("home carried an unsafe next:\n%s", body) } for _, want := range []string{esc(copyText("home.email_label")), esc(copyText("home.email_button")), esc(copyText("home.small")), `placeholder="` + esc(copyText("home.email_placeholder")) + `"`} { if !strings.Contains(body, want) { t.Errorf("home is missing %q", want) } } if strings.Contains(body, "Sign in with Keymail") || strings.Contains(body, `href="/auth/signin"`) { t.Fatal("home still links the Keymail ceremony directly") } } // ---- signing in by the emailed link or code --------------------------------- // postFrom posts form to path on browser c with the given Origin ("" sends // none). func postFrom(t *testing.T, fx *consoleFixture, c *http.Client, path, origin string, form url.Values) (status int, location, body string) { t.Helper() req, err := http.NewRequest(http.MethodPost, fx.ts.URL+path, strings.NewReader(form.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if origin != "" { req.Header.Set("Origin", origin) } res, err := c.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return res.StatusCode, res.Header.Get("Location"), string(raw) } // askSignin submits addr (with next) at the front door from browser c and // returns the code and link token of the email that went out. func askSignin(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, addr, next string) (code, link string) { t.Helper() before := len(m.sentCopy()) form := url.Values{"address": {addr}} if next != "" { form.Set("next", next) } if status, _, body := postContinue(t, fx, c, form); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.sent", "address", addr))) { t.Fatalf("continue for %s = %d:\n%s", addr, status, body) } waitChallengeSent(t, fx, 1) sent := m.sentCopy() if len(sent) != before+1 { t.Fatalf("console mailer has %d messages, want %d", len(sent), before+1) } _, body, _ := strings.Cut(sent[len(sent)-1], "|") cm, lm := signinCodeRe.FindStringSubmatch(body), signinLinkRe.FindStringSubmatch(body) if cm == nil || lm == nil { t.Fatalf("sign-in email has no code or link:\n%s", body) } return cm[1], lm[1] } // advanceLimits moves the rate limits' clock d past the wall clock, so one // address may be sent another email. func advanceLimits(fx *consoleFixture, d time.Duration) { fx.srv.limits.mu.Lock() defer fx.srv.limits.mu.Unlock() fx.srv.limits.now = func() time.Time { return time.Now().Add(d) } } var linkNonceRe = regexp.MustCompile(`name="nonce" value="([^"]+)"`) // openLink GETs the emailed link on browser c and returns the status, the // page and the form's nonce ("" when there is none). func openLink(t *testing.T, fx *consoleFixture, c *http.Client, link string) (int, string, string) { t.Helper() status, _, body := get(t, c, fx.ts.URL+"/auth/email/link?t="+url.QueryEscape(link)) nonce := "" if m := linkNonceRe.FindStringSubmatch(body); m != nil { nonce = m[1] } return status, body, nonce } // postLink submits the confirm page's form from the console's own origin. func postLink(t *testing.T, fx *consoleFixture, c *http.Client, link, nonce string) (int, string, string) { t.Helper() return postFrom(t, fx, c, "/auth/email/link", fx.ts.URL, url.Values{"t": {link}, "nonce": {nonce}}) } // postCode submits the check page's code form from the console's own origin. func postCode(t *testing.T, fx *consoleFixture, c *http.Client, code string) (int, string, string) { t.Helper() return postFrom(t, fx, c, "/auth/email/code", fx.ts.URL, url.Values{"code": {code}}) } // emailSession asserts browser c holds a live email-method session for addr // with a fresh step-up, and returns the raw token. func emailSession(t *testing.T, fx *consoleFixture, c *http.Client, addr string) string { t.Helper() raw := browserCookie(t, fx, c, sessionCookie) got, method, ok := fx.st.SessionInfo(raw, time.Now()) if raw == "" || !ok || got != addr || method != MethodEmail { t.Fatalf("session = %q %q %v, want an email session for %s", got, method, ok, addr) } if !fx.st.FreshStepUp(raw, addr, time.Now(), StepUpWindow) { t.Fatal("an email sign-in recorded no step-up") } return raw } func signedOut(t *testing.T, fx *consoleFixture, c *http.Client) bool { t.Helper() raw := browserCookie(t, fx, c, sessionCookie) if raw == "" { return true } _, ok := fx.st.SessionAddress(raw, time.Now()) return !ok } // Review Focus 2: a mail scanner's GET (twice) leaves the link good; the // person's POST signs them in once. func TestEmailLinkGetDoesNotConsume(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "bob@example.com" lookupAnswers(fx, addr, http.StatusNotFound) asker := newBrowser(t) _, link := askSignin(t, fx, m, asker, addr, "") scanner := newBrowser(t) for i := 0; i < 2; i++ { status, body, _ := openLink(t, fx, scanner, link) if status != http.StatusOK || !strings.Contains(body, esc(copyText("link.title", "address", addr))) { t.Fatalf("scanner GET %d = %d:\n%s", i, status, body) } } if !signedOut(t, fx, scanner) { t.Fatal("a GET signed the scanner in") } // The person, in another browser (a forwarded link opens there too). c := newBrowser(t) status, body, nonce := openLink(t, fx, c, link) if status != http.StatusOK || nonce == "" { t.Fatalf("GET = %d, nonce %q:\n%s", status, nonce, body) } for _, want := range []string{ esc(copyText("link.title", "address", addr)), esc(copyText("link.button")), `action="/auth/email/link"`, `name="t" value="` + link + `"`, } { if !strings.Contains(body, want) { t.Errorf("confirm page is missing %q:\n%s", want, body) } } if browserCookie(t, fx, c, linkConfirmCookie) != nonce { t.Fatal("the GET did not set the nonce cookie to the form's nonce") } status, loc, _ := postLink(t, fx, c, link, nonce) if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("POST = %d -> %q, want 303 into an account", status, loc) } emailSession(t, fx, c, addr) if browserCookie(t, fx, c, linkConfirmCookie) != "" { t.Fatal("the nonce cookie outlived the sign-in") } // Spent: a second POST and a GET both say expired. c2 := newBrowser(t) if status, body, _ := openLink(t, fx, c2, link); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) { t.Fatalf("GET after use = %d:\n%s", status, body) } // The nonce went with the sign-in, so a replay from this browser is refused // as foreign; one that does hold a nonce finds the link spent. if status, _, _ := postLink(t, fx, c, link, nonce); status != http.StatusForbidden { t.Fatalf("second POST from the same browser = %d, want 403", status) } u, _ := url.Parse(fx.ts.URL) c2.Jar.SetCookies(u, []*http.Cookie{{Name: linkConfirmCookie, Value: "n2", Path: "/"}}) if status, _, body := postLink(t, fx, c2, link, "n2"); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) { t.Fatalf("second POST = %d:\n%s", status, body) } if !signedOut(t, fx, c2) { t.Fatal("a spent link signed a second browser in") } if status, body, _ := openLink(t, fx, newBrowser(t), "no-such-link"); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) { t.Fatalf("unknown link = %d:\n%s", status, body) } } // Login CSRF (codex 1): a valid token POSTed without this browser's nonce, or // from another site, is refused before anything is spent. func TestEmailLinkLoginCSRF(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "mallory@example.com" lookupAnswers(fx, addr, http.StatusNotFound) _, link := askSignin(t, fx, m, newBrowser(t), addr, "") refused := func(status int, body string) bool { return status == http.StatusForbidden && strings.Contains(body, esc(copyText("link.wrong_site"))) } // The victim's browser has no nonce cookie: an auto-submitted form from // the attacker's page (no Origin, as old browsers send) carries only the // token and a nonce of the attacker's choosing. victim := newBrowser(t) for _, nonce := range []string{"", "attacker-nonce"} { status, _, body := postFrom(t, fx, victim, "/auth/email/link", "", url.Values{"t": {link}, "nonce": {nonce}}) if !refused(status, body) { t.Fatalf("POST with nonce %q and no cookie = %d:\n%s", nonce, status, body) } } // A nonce cookie from another link page does not match a forged form. if _, _, n := openLink(t, fx, victim, link); n == "" { t.Fatal("no nonce on the confirm page") } if status, _, body := postFrom(t, fx, victim, "/auth/email/link", "", url.Values{"t": {link}, "nonce": {"attacker-nonce"}}); !refused(status, body) { t.Fatalf("mismatched nonce = %d:\n%s", status, body) } // A foreign Origin is refused even with a matching nonce. _, _, nonce := openLink(t, fx, victim, link) if status, _, body := postFrom(t, fx, victim, "/auth/email/link", "https://evil.example", url.Values{"t": {link}, "nonce": {nonce}}); !refused(status, body) { t.Fatalf("foreign Origin = %d:\n%s", status, body) } if !signedOut(t, fx, victim) { t.Fatal("a refused POST signed the victim in") } // Nothing was spent: the link still signs its owner in. if _, ok := fx.st.PeekEmailLink(link, time.Now()); !ok { t.Fatal("a refused POST spent the link") } owner := newBrowser(t) _, _, nonce = openLink(t, fx, owner, link) if status, loc, _ := postLink(t, fx, owner, link, nonce); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("owner's POST after the refusals = %d -> %q", status, loc) } emailSession(t, fx, owner, addr) } // The code is the cross-device path, but only on the browser that asked: a // second browser with the code and no challenge cookie gets nowhere. func TestEmailCodeCrossDevice(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "bob@example.com" lookupAnswers(fx, addr, http.StatusNotFound) a := newBrowser(t) code, _ := askSignin(t, fx, m, a, addr, "") b := newBrowser(t) status, _, body := postCode(t, fx, b, code) if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_used_up"))) || !signedOut(t, fx, b) { t.Fatalf("code from a browser that never asked = %d:\n%s", status, body) } // The page keeps the address its form carried, so "Send a new link" works. _, _, body = postFrom(t, fx, b, "/auth/email/code", fx.ts.URL, url.Values{"code": {code}, "address": {addr}, "next": {"/auth/cli"}}) 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"))} { if !strings.Contains(body, want) { t.Fatalf("used-up page is missing %q:\n%s", want, body) } } // Cross-site posts are refused before the code is looked at. if status, _, _ := postFrom(t, fx, a, "/auth/email/code", "https://evil.example", url.Values{"code": {code}}); status != http.StatusForbidden { t.Fatalf("cross-site code POST = %d, want 403", status) } status, loc, _ := postCode(t, fx, a, code) if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("code on the asking browser = %d -> %q", status, loc) } emailSession(t, fx, a, addr) if challengeRows(t, fx) != 0 { t.Fatal("the challenge outlived the sign-in") } // Used once. if status, _, body := postCode(t, fx, a, code); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_used_up"))) { t.Fatalf("the code a second time = %d:\n%s", status, body) } } // Spec r2-2: wrong codes count per address across fresh challenges. Past the // cap the code box refuses without checking, while a link still signs in. func TestEmailCodeGuessCap(t *testing.T) { fx := newConsoleFixture(t) const addr = "bob@example.com" const flow = "a-flow-token-of-at-least-thirty-two-characters" c := newBrowser(t) u, _ := url.Parse(fx.ts.URL) c.Jar.SetCookies(u, []*http.Cookie{{Name: flowCookie, Value: flow, Path: "/"}}) // Each new challenge in the flow replaces the one before. fresh := func() (link, code string) { link, code, err := fx.st.CreateEmailChallenge(EmailChallenge{Purpose: ChallengeSignIn, Address: addr, FlowHash: hashToken(flow)}, time.Now()) if err != nil { t.Fatal(err) } return link, code } wrong := func(n int, code string) { t.Helper() for i := 1; i <= n; i++ { status, _, body := postCode(t, fx, c, wrongCode(code)) want := "check.code_wrong" if i == emailCodeAttempts { want = "check.code_used_up" // the fifth burns the challenge } if status != http.StatusOK || !strings.Contains(body, esc(copyText(want))) { t.Fatalf("wrong code %d = %d, want %s:\n%s", i, status, want, body) } } } _, code := fresh() wrong(5, code) _, code = fresh() wrong(5, code) link, code := fresh() // The eleventh wrong code, and even the right one, is throttled unchecked. for _, try := range []string{wrongCode(code), code} { status, _, body := postCode(t, fx, c, try) if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_throttled"))) { t.Fatalf("code past the cap = %d:\n%s", status, body) } } if !signedOut(t, fx, c) { t.Fatal("a throttled code signed in") } // The throttle burned nothing: this challenge's link still signs in. if challengeRows(t, fx) != 1 { t.Fatalf("%d challenges stand, want the third", challengeRows(t, fx)) } other := newBrowser(t) _, _, nonce := openLink(t, fx, other, link) if status, loc, _ := postLink(t, fx, other, link, nonce); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("link past the code cap = %d -> %q", status, loc) } emailSession(t, fx, other, addr) } // A brand-new address signed in by code from the CLI's page lands back there, // already holding a personal account. func TestNewEmailUserGetsAccount(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "newbie@example.com" lookupAnswers(fx, addr, http.StatusNotFound) c := newBrowser(t) code, _ := askSignin(t, fx, m, c, addr, "/auth/cli") if userRows(t, fx, addr) != 0 { t.Fatal("asking for a sign-in email created a user") } status, loc, _ := postCode(t, fx, c, code) if status != http.StatusSeeOther || loc != "/auth/cli" { t.Fatalf("code = %d -> %q, want 303 -> /auth/cli", status, loc) } emailSession(t, fx, c, addr) if accts, err := fx.st.AccountsFor(addr); err != nil || len(accts) == 0 { t.Fatalf("accounts for a new email user = %v, %v; want a personal account", accts, err) } if browserCookie(t, fx, c, welcomeCookie) == "" { t.Fatal("no welcome for a brand-new user") } } // Review Focus 5: asking twice from one browser leaves only the newest email // working, link and code alike. func TestReplacedChallenge(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "bob@example.com" lookupAnswers(fx, addr, http.StatusNotFound) c := newBrowser(t) code1, link1 := askSignin(t, fx, m, c, addr, "") advanceLimits(fx, 2*time.Minute) code2, link2 := askSignin(t, fx, m, c, addr, "") if status, body, _ := openLink(t, fx, newBrowser(t), link1); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) { t.Fatalf("first link = %d:\n%s", status, body) } if code1 != code2 { if status, _, body := postCode(t, fx, c, code1); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_wrong"))) { t.Fatalf("first code = %d:\n%s", status, body) } } if !signedOut(t, fx, c) { t.Fatal("the first email signed in") } if status, _, _ := openLink(t, fx, newBrowser(t), link2); status != http.StatusOK { t.Fatalf("second link = %d, want its confirm page", status) } if status, loc, _ := postCode(t, fx, c, code2); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("second code = %d -> %q", status, loc) } emailSession(t, fx, c, addr) } // Spec 11: the old Apps-inbox link is retired. Any token, even one the old // table still holds, gets the expired message and no session. func TestLegacyConfirmRetired(t *testing.T) { fx := newConsoleFixture(t) raw, err := fx.st.CreateConfirmation("bob@example.com", time.Now()) if err != nil { t.Fatal(err) } c := newBrowser(t) for _, tok := range []string{raw, "made-up"} { status, _, body := get(t, c, fx.ts.URL+"/auth/confirm?token="+url.QueryEscape(tok)) if status != http.StatusOK || !strings.Contains(body, "Link expired") || !strings.Contains(body, esc(copyText("link.expired"))) { t.Fatalf("legacy confirm %q = %d:\n%s", tok, status, body) } } if !signedOut(t, fx, c) { t.Fatal("the retired confirm link opened a session") } } // The sign-in forms and every answer of the front door set the flow cookie. // Its value comes only from what the browser sent: an accepted send, a // refused one, a capped one and a Keymail redirect all answer a browser with // the flow it already had, and the one email's code still signs in. func TestContinueFlowCookie(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "bob@example.com" lookupAnswers(fx, addr, http.StatusNotFound) tok, err := fx.st.CreateInvite(OpsAccountID, addr, "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } for _, path := range []string{"/", "/invite/" + tok, "/invite/no-such-invite", "/auth/cli"} { c := newBrowser(t) if status, _, _ := get(t, c, fx.ts.URL+path); status != http.StatusOK || browserCookie(t, fx, c, flowCookie) == "" { t.Fatalf("signed-out %s = %d, flow cookie %q", path, status, browserCookie(t, fx, c, flowCookie)) } } // A browser with no flow is given one by the front door itself. bare := newBrowser(t) postContinue(t, fx, bare, url.Values{"address": {"carol@example.com"}}) if browserCookie(t, fx, bare, flowCookie) == "" { t.Fatal("continue with no flow set none") } waitChallengeSent(t, fx, 1) c := newBrowser(t) get(t, c, fx.ts.URL+"/") flow := browserCookie(t, fx, c, flowCookie) same := func(what string) { t.Helper() if got := browserCookie(t, fx, c, flowCookie); got != flow { t.Fatalf("%s changed the flow cookie: %q, want %q", what, got, flow) } } code, _ := askSignin(t, fx, m, c, addr, "") same("an accepted send") postContinue(t, fx, c, url.Values{"address": {addr}}) same("a refused send") postContinue(t, fx, c, url.Values{"address": {"kim@" + fx.srv.km.Host()}}) same("a Keymail redirect") for fx.srv.limits.submit("127.0.0.1") { } postContinue(t, fx, c, url.Values{"address": {addr}}) same("a capped submission") if n := len(m.sentCopy()); n != 2 { t.Fatalf("mailer got %d messages, want 2 (carol's and bob's one)", n) } if status, _, body := postCode(t, fx, c, code); status != http.StatusSeeOther { t.Fatalf("code after refused requests = %d:\n%s", status, body) } emailSession(t, fx, c, addr) } // postCheckCode submits the check page's code form as the page renders it: // the code with the page's address and next. func postCheckCode(t *testing.T, fx *consoleFixture, c *http.Client, code, addr, next string) (int, http.Header, string) { t.Helper() form := url.Values{"code": {code}, "address": {addr}} if next != "" { form.Set("next", next) } req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/email/code", strings.NewReader(form.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", fx.ts.URL) res, err := c.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return res.StatusCode, res.Header, string(raw) } // setCookieNames is the sorted names a response sets. func setCookieNames(h http.Header) string { var names []string for _, c := range (&http.Response{Header: h}).Cookies() { names = append(names, c.Name) } sort.Strings(names) return strings.Join(names, ",") } // F2 (final review): a wrong code typed after a refused request reads exactly // as one typed after a sent email, so the code box is no throttling oracle. func TestEmailCodeRefusedReadsAsSent(t *testing.T) { fx, m := newEmailFixture(t, true) const addr, next = "bob@example.com", "/auth/cli" lookupAnswers(fx, addr, http.StatusNotFound) sent := newBrowser(t) askSignin(t, fx, m, sent, addr, next) // At once, from a fresh browser: the per-address gap refuses the send. refused := newBrowser(t) if status, _, body := postContinue(t, fx, refused, url.Values{"address": {addr}, "next": {next}}); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.sent", "address", addr))) { t.Fatalf("refused continue = %d:\n%s", status, body) } if n := len(m.sentCopy()); n != 1 { t.Fatalf("mailer got %d messages, want 1", n) } // The refused browser's challenge has a random code nobody saw, so every // wrong code here is one no six-digit code can be: wrongCode(code) is // wrong only for the sent browser. for _, try := range []string{"abcdef", "12345"} { sStatus, sHdr, sBody := postCheckCode(t, fx, sent, try, addr, next) rStatus, rHdr, rBody := postCheckCode(t, fx, refused, try, addr, next) if sStatus != http.StatusOK || !strings.Contains(sBody, esc(copyText("check.code_wrong"))) { t.Fatalf("wrong code %q after a sent email = %d:\n%s", try, sStatus, sBody) } if rStatus != sStatus || rBody != sBody { t.Fatalf("wrong code %q: refused answered %d, sent %d; bodies:\nrefused:\n%s\nsent:\n%s", try, rStatus, sStatus, rBody, sBody) } if setCookieNames(rHdr) != setCookieNames(sHdr) { t.Fatalf("wrong code %q set cookies %q after a refusal, %q after a send", try, setCookieNames(rHdr), setCookieNames(sHdr)) } } // The refused browser's challenge can never sign in: nobody has its code // or link, and the five attempts burn it as they would a sent one. for i := 3; i <= emailCodeAttempts; i++ { _, _, sBody := postCheckCode(t, fx, sent, "abcdef", addr, next) _, _, rBody := postCheckCode(t, fx, refused, "abcdef", addr, next) if rBody != sBody { t.Fatalf("attempt %d differs:\nrefused:\n%s\nsent:\n%s", i, rBody, sBody) } } if !signedOut(t, fx, refused) || !signedOut(t, fx, sent) { t.Fatal("a wrong code signed in") } // A refusal in a flow that already has a live challenge, for another // address, leaves that challenge in place; the code page still shows what // this request asked for, exactly as a sent one does. const a, b = "ann@example.com", "ben@example.com" lookupAnswers(fx, a, http.StatusNotFound) lookupAnswers(fx, b, http.StatusNotFound) asker := newBrowser(t) askSignin(t, fx, m, asker, a, "") sentB := newBrowser(t) askSignin(t, fx, m, sentB, b, "") // ben was just sent one, so asker's request for ben is refused. before := len(m.sentCopy()) postContinue(t, fx, asker, url.Values{"address": {b}}) if len(m.sentCopy()) != before { t.Fatal("the refused request for ben sent mail") } _, _, sBody := postCheckCode(t, fx, sentB, "abcdef", b, "") _, _, rBody := postCheckCode(t, fx, asker, "abcdef", b, "") if rBody != sBody || !strings.Contains(sBody, esc(copyText("check.sent", "address", b))) { t.Fatalf("a wrong code after a refusal beside a live challenge differs:\nrefused:\n%s\nsent:\n%s", rBody, sBody) } } // F3 (final review): two submissions leave one browser before either answer // arrives, with no challenge yet. One is sent, the other refused; whichever // answer the browser keeps, the emailed code signs in. func TestContinueOverlappingFirstSubmissions(t *testing.T) { const addr = "bob@example.com" // submit posts the front door's form with the browser's flow cookie set by // hand (two requests that left before either answer carry the same // cookies) and returns the flow cookie the answer sets. submit := func(t *testing.T, fx *consoleFixture, flow string) string { t.Helper() req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/auth/continue", strings.NewReader(url.Values{"address": {addr}}.Encode())) if err != nil { t.Error(err) return "" } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", fx.ts.URL) req.AddCookie(&http.Cookie{Name: flowCookie, Value: flow}) res, err := http.DefaultTransport.RoundTrip(req) if err != nil { t.Error(err) return "" } defer res.Body.Close() io.Copy(io.Discard, res.Body) for _, c := range res.Cookies() { if c.Name == flowCookie { return c.Value } } t.Error("an answer set no flow cookie") return "" } // world is a fresh console and a browser that has loaded the front page. world := func(t *testing.T) (*consoleFixture, *fakeConsoleMail, *http.Client, string) { fx, m := newEmailFixture(t, true) lookupAnswers(fx, addr, http.StatusNotFound) c := newBrowser(t) get(t, c, fx.ts.URL+"/") flow := browserCookie(t, fx, c, flowCookie) if flow == "" { t.Fatal("the front page set no flow") } return fx, m, c, flow } // finish keeps the answers' cookies in the given order, the last one // winning as in a browser, and signs in with the one email's code. finish := func(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, answers []string) { t.Helper() u, _ := url.Parse(fx.ts.URL) for _, v := range answers { c.Jar.SetCookies(u, []*http.Cookie{{Name: flowCookie, Value: v, Path: "/"}}) } waitChallengeSent(t, fx, 1) _, code, _ := sentSignin(t, m) if status, loc, body := postCode(t, fx, c, code); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("the emailed code = %d -> %q:\n%s", status, loc, body) } emailSession(t, fx, c, addr) } t.Run("concurrent", func(t *testing.T) { fx, m, c, flow := world(t) var ( start = make(chan struct{}) wg sync.WaitGroup mu sync.Mutex order []string ) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() <-start v := submit(t, fx, flow) mu.Lock() order = append(order, v) mu.Unlock() }() } close(start) wg.Wait() if len(m.sentCopy()) > 1 { t.Fatalf("two overlapping submissions sent %d emails", len(m.sentCopy())) } finish(t, fx, m, c, order) }) for _, last := range []string{"sent answer last", "refused answer last"} { t.Run(last, func(t *testing.T) { fx, m, c, flow := world(t) first, second := submit(t, fx, flow), submit(t, fx, flow) answers := []string{second, first} // the sent (first) answer lands last if last == "refused answer last" { answers = []string{first, second} } finish(t, fx, m, c, answers) }) } } // F1 (final review): an IP that has spent its submissions on the Keymail // host's own addresses (no lookup, no email slot) gets no email for an // address it can no longer have looked up, here one Keymail serves, and // its 31st submission writes no challenge row at all. func TestContinueSubmitCapSendsNothing(t *testing.T) { fx, m := newEmailFixture(t, true) const addr = "kim@example.com" lookupAnswers(fx, addr, http.StatusOK) for i := 0; i < submitsPerIPHour; i++ { if status, loc, _ := postContinue(t, fx, newBrowser(t), url.Values{"address": {"kim@" + fx.srv.km.Host()}}); status != http.StatusSeeOther || loc != "/auth/signin" { t.Fatalf("Keymail-host submission %d = %d -> %q", i+1, status, loc) } } c := newBrowser(t) status, _, body := postContinue(t, fx, c, url.Values{"address": {addr}}) if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.sent", "address", addr))) { t.Fatalf("capped continue = %d, want the ordinary check page:\n%s", status, body) } if n := lookupCount(fx); n != 0 { t.Fatalf("over the cap Keymail was asked %d times", n) } // The send runs off the request: give a stray one time to land. select { case <-fx.srv.challengeSent: t.Fatal("a capped submission started a send") case <-time.After(200 * time.Millisecond): } if sent := m.sentCopy(); len(sent) != 0 { t.Fatalf("a capped submission sent %q", sent) } // The 31st submission from this IP, in a fresh flow, wrote no challenge // at all, so the code box finds nothing. if n := challengeRows(t, fx); n != 0 { t.Fatalf("%d challenges after a capped submission, want none", n) } if status, _, body := postCode(t, fx, c, "000000"); status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_used_up"))) || !signedOut(t, fx, c) { t.Fatalf("a code after a capped submission = %d:\n%s", status, body) } }