package console // Re-confirmation by email (spec 6): a session that signed in by email proves // it is still there by an emailed link or code; a Keymail session keeps its // passkey and never gets an email instead (anti-goal: no downgrade). import ( "net/http" "net/url" "regexp" "strings" "testing" "time" ) var ( stepUpCodeRe = regexp.MustCompile(`type this code there: (\d{6})\b`) stepUpLinkRe = regexp.MustCompile(`/auth/email/link\?t=([A-Za-z0-9_-]+)`) ) // emailOwnerWorld is bob@example.com signed in by an emailed code on browser // c, owning his personal account with kim@keymail.dev as a second member, // and with the sign-in's own step-up gone stale, so the next sensitive action // asks for a fresh one. It returns the session token and the account's // path base ("/a/"). func emailOwnerWorld(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client) (raw, base string) { t.Helper() const addr = "bob@example.com" lookupAnswers(fx, addr, http.StatusNotFound) code, _ := askSignin(t, fx, m, c, addr, "") if status, loc, _ := postCode(t, fx, c, code); status != http.StatusSeeOther { t.Fatalf("sign-in code = %d -> %q", status, loc) } raw = emailSession(t, fx, c, addr) accts, err := fx.st.AccountsFor(addr) if err != nil || len(accts) == 0 { t.Fatalf("bob's accounts = %v, %v", accts, err) } if err := fx.st.AddMember(accts[0].ID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } if err := fx.st.ClearStepUp(raw); err != nil { t.Fatal(err) } return raw, "/a/" + accts[0].Sqid } // kimRole is kim's role in bob's account ("" once removed). func kimRole(t *testing.T, fx *consoleFixture) string { t.Helper() role, _ := fx.st.Role(bobAccount(t, fx).ID, "kim@keymail.dev") return role } // askStepUp POSTs the removal of kim from browser c and expects the "Confirm // it's you" page; it returns the code and link token of the step-up email // that went out. func askStepUp(t *testing.T, fx *consoleFixture, m *fakeConsoleMail, c *http.Client, raw, base string) (code, link string) { t.Helper() before := len(m.sentCopy()) status, _, body := postForm(t, c, fx.ts.URL+base+"/members/remove", url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}}) for _, want := range []string{esc(copyText("stepup.title")), esc(copyText("stepup.sent", "address", "bob@example.com")), `action="/auth/email/stepup/code"`, esc(copyText("stepup.code_label")), esc(copyText("stepup.code_button"))} { if status != http.StatusOK || !strings.Contains(body, want) { t.Fatalf("gated removal = %d, missing %q:\n%s", status, want, body) } } if kimRole(t, fx) != RoleMember { t.Fatal("kim was removed before the confirmation") } 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) } to, body, _ := strings.Cut(sent[len(sent)-1], "|") cm, lm := stepUpCodeRe.FindStringSubmatch(body), stepUpLinkRe.FindStringSubmatch(body) if to != "bob@example.com" || cm == nil || lm == nil { t.Fatalf("step-up email to %q has no code or link:\n%s", to, body) } return cm[1], lm[1] } // pendingRows counts the held actions. func pendingRows(t *testing.T, fx *consoleFixture) int { t.Helper() var n int if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&n); err != nil { t.Fatal(err) } return n } func postStepUpCode(t *testing.T, fx *consoleFixture, c *http.Client, code string) (int, string, string) { t.Helper() return postFrom(t, fx, c, "/auth/email/stepup/code", fx.ts.URL, url.Values{"code": {code}}) } func TestEmailSessionStepUpByCode(t *testing.T) { fx, m := newEmailFixture(t, true) c := newBrowser(t) raw, base := emailOwnerWorld(t, fx, m, c) code, _ := askStepUp(t, fx, m, c, raw, base) // The held action is parked server-side, without the session token. stashed := pendingRows(t, fx) var leaked int if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions WHERE form LIKE ?`, "%"+raw+"%").Scan(&leaked); err != nil { t.Fatal(err) } if stashed != 1 || leaked != 0 { t.Fatalf("pending actions: %d parked, %d holding the session token", stashed, leaked) } // Cross-site posts are refused before the code is looked at. if status, _, _ := postFrom(t, fx, c, "/auth/email/stepup/code", "https://evil.example", url.Values{"code": {code}}); status != http.StatusForbidden { t.Fatalf("cross-site step-up code = %d, want 403", status) } // A signed-out browser is sent to the front page. if status, loc, _ := postStepUpCode(t, fx, newBrowser(t), code); status != http.StatusFound || loc != "/" { t.Fatalf("signed-out step-up code = %d -> %q, want 302 -> /", status, loc) } status, _, body := postStepUpCode(t, fx, c, wrongCode(code)) if status != http.StatusOK || !strings.Contains(body, esc(copyText("check.code_wrong"))) || !strings.Contains(body, `action="/auth/email/stepup/code"`) { t.Fatalf("wrong step-up code = %d:\n%s", status, body) } if kimRole(t, fx) != RoleMember { t.Fatal("a wrong code removed kim") } status, loc, body := postStepUpCode(t, fx, c, code) if status != http.StatusSeeOther || loc != base+"/team" { t.Fatalf("right step-up code = %d -> %q, want the resumed removal's 303 to the team page:\n%s", status, loc, body) } if kimRole(t, fx) != "" { t.Fatal("kim is still a member after a confirmed removal") } if !fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) { t.Fatal("no presence recorded after the step-up code") } if challengeRows(t, fx) != 0 { t.Fatal("the step-up challenge is still standing") } // Used once. if status, _, body := postStepUpCode(t, fx, c, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) { t.Fatalf("the step-up code a second time = %d:\n%s", status, body) } // A step-up never signs anyone in: the session is the one that asked. if got := browserCookie(t, fx, c, sessionCookie); got != raw { t.Fatal("the step-up replaced the session") } } func TestEmailSessionStepUpByLink(t *testing.T) { fx, m := newEmailFixture(t, true) c := newBrowser(t) raw, base := emailOwnerWorld(t, fx, m, c) _, link := askStepUp(t, fx, m, c, raw, base) // A GET (twice, as a scanner might from this same browser) spends nothing. var nonce string for i := 0; i < 2; i++ { status, body, n := openLink(t, fx, c, link) if status != http.StatusOK || n == "" || !strings.Contains(body, esc(copyText("stepup.link_title"))) || !strings.Contains(body, esc(copyText("stepup.link_button"))) { t.Fatalf("step-up link GET %d = %d:\n%s", i+1, status, body) } nonce = n } if _, ok := fx.st.PeekEmailLink(link, time.Now()); !ok || kimRole(t, fx) != RoleMember { t.Fatal("opening the step-up link spent it or acted") } // The confirm page's form still needs its nonce. if status, _, _ := postLink(t, fx, c, link, "forged"); status != http.StatusForbidden { t.Fatalf("step-up link POST without its nonce = %d, want 403", status) } _, _, nonce = openLink(t, fx, c, link) status, loc, body := postLink(t, fx, c, link, nonce) if status != http.StatusSeeOther || loc != base+"/team" { t.Fatalf("step-up link POST = %d -> %q:\n%s", status, loc, body) } if kimRole(t, fx) != "" { t.Fatal("kim is still a member after a confirmed removal") } if !fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) { t.Fatal("no presence recorded after the step-up link") } if got := browserCookie(t, fx, c, sessionCookie); got != raw { t.Fatal("the step-up link replaced the session") } if status, body, _ := openLink(t, fx, c, link); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) { t.Fatalf("a used step-up link = %d:\n%s", status, body) } } func TestStepUpLinkOtherBrowser(t *testing.T) { fx, m := newEmailFixture(t, true) c := newBrowser(t) raw, base := emailOwnerWorld(t, fx, m, c) code, link := askStepUp(t, fx, m, c, raw, base) u, _ := url.Parse(fx.ts.URL) // Signed out, and signed in as bob in another session: both get the // wrong-browser page, no confirm form, and nothing is spent. signedOutB := newBrowser(t) otherSession, err := fx.st.CreateSessionMethod("bob@example.com", "", MethodEmail, time.Now()) if err != nil { t.Fatal(err) } otherB := newBrowser(t) otherB.Jar.SetCookies(u, []*http.Cookie{{Name: sessionCookie, Value: otherSession, Path: "/"}}) for name, b := range map[string]*http.Client{"signed out": signedOutB, "another session": otherB} { status, body, nonce := openLink(t, fx, b, link) if status != http.StatusForbidden || nonce != "" || !strings.Contains(body, esc(copyText("stepup.wrong_browser"))) { t.Fatalf("%s: step-up link GET = %d (nonce %q):\n%s", name, status, nonce, body) } // Even a POST carrying a matching nonce of its own is refused unspent. b.Jar.SetCookies(u, []*http.Cookie{{Name: linkConfirmCookie, Value: "n", Path: "/"}}) status, _, body = postLink(t, fx, b, link, "n") if status != http.StatusForbidden || !strings.Contains(body, esc(copyText("stepup.wrong_browser"))) { t.Fatalf("%s: step-up link POST = %d:\n%s", name, status, body) } } // The code, typed in the other session, finds no step-up of that session // and is refused without being checked. if status, _, body := postStepUpCode(t, fx, otherB, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) { t.Fatalf("step-up code from another session = %d:\n%s", status, body) } if kimRole(t, fx) != RoleMember { t.Fatal("another browser's step-up attempt removed kim") } if _, ok := fx.st.PeekEmailLink(link, time.Now()); !ok { t.Fatal("another browser spent the step-up link") } if fx.st.FreshStepUp(otherSession, "bob@example.com", time.Now(), StepUpWindow) { t.Fatal("another session gained presence") } // The right browser still can. _, _, nonce := openLink(t, fx, c, link) if status, loc, _ := postLink(t, fx, c, link, nonce); status != http.StatusSeeOther || loc != base+"/team" { t.Fatalf("step-up link in the right browser = %d -> %q", status, loc) } if kimRole(t, fx) != "" { t.Fatal("the right browser's confirmation did not remove kim") } } // Review Focus 1: a Keymail session re-confirms with its passkey only, even // with a mailer ready, and even when Keymail cannot do it. func TestKeymailSessionNeverEmailStepUp(t *testing.T) { fx, m := newEmailFixture(t, true) c := newBrowser(t) signUp(t, fx, c, "code-keymail-stepup") if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } csrf := sessionToken(t, fx, c) if _, method, _ := fx.st.SessionInfo(csrf, time.Now()); method != MethodKeymail { t.Fatalf("session method = %q, want keymail", method) } status, loc, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("keymail session's gated removal = %d -> %q, want a redirect into Keymail", status, loc) } fx.fk.mu.Lock() fx.fk.reauth = false fx.fk.mu.Unlock() if status, _, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusServiceUnavailable { t.Fatalf("keymail session with no Keymail step-up = %d, want 503", status) } if n := len(m.sentCopy()); n != 0 || challengeRows(t, fx) != 0 { t.Fatalf("a keymail session's step-up sent %d emails and left %d challenges", n, challengeRows(t, fx)) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { t.Fatal("kim was removed without a passkey") } } func TestStepUpChallengeExpiresWithPendingAction(t *testing.T) { fx, m := newEmailFixture(t, true) c := newBrowser(t) raw, base := emailOwnerWorld(t, fx, m, c) code, link := askStepUp(t, fx, m, c, raw, base) // The challenge lives exactly as long as the action it holds: 10 minutes. var cCreated, cExp, pCreated, pExp int64 if err := fx.st.db.QueryRow(`SELECT created_at, expires_at FROM email_challenges`).Scan(&cCreated, &cExp); err != nil { t.Fatal(err) } if err := fx.st.db.QueryRow(`SELECT created_at, expires_at FROM pending_actions`).Scan(&pCreated, &pExp); err != nil { t.Fatal(err) } if cExp-cCreated != int64(stepUpFlowTTL.Seconds()) || cExp != pExp { t.Fatalf("challenge lives %ds to %d, pending action to %d; want %ds, together", cExp-cCreated, cExp, pExp, int64(stepUpFlowTTL.Seconds())) } // Eleven minutes on. shift := int64((11 * time.Minute).Seconds()) for _, table := range []string{"email_challenges", "pending_actions"} { if _, err := fx.st.db.Exec(`UPDATE `+table+` SET created_at = created_at - ?, expires_at = expires_at - ?`, shift, shift); err != nil { t.Fatal(err) } } if status, _, body := postStepUpCode(t, fx, c, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) { t.Fatalf("step-up code after 11 minutes = %d:\n%s", status, body) } if status, body, _ := openLink(t, fx, c, link); status != http.StatusGone || !strings.Contains(body, esc(copyText("link.expired"))) { t.Fatalf("step-up link after 11 minutes = %d:\n%s", status, body) } if kimRole(t, fx) != RoleMember || fx.st.FreshStepUp(raw, "bob@example.com", time.Now(), StepUpWindow) { t.Fatal("an expired step-up acted or recorded presence") } // A live challenge whose held action has gone: confirmed, but nothing to // resume, and the page says so. advanceLimits(fx, 2*time.Minute) code, _ = askStepUp(t, fx, m, c, raw, base) if _, err := fx.st.db.Exec(`DELETE FROM pending_actions`); err != nil { t.Fatal(err) } if status, _, body := postStepUpCode(t, fx, c, code); status != http.StatusGone || !strings.Contains(body, esc(copyText("stepup.expired"))) { t.Fatalf("step-up code with its action gone = %d:\n%s", status, body) } if kimRole(t, fx) != RoleMember { t.Fatal("kim was removed with no held action") } } // The mailer down, and the impatient second click: neither stashes a second // action or sends a second email. func TestEmailSessionStepUpFailsClosed(t *testing.T) { t.Run("no mailer", func(t *testing.T) { fx, m := newEmailFixture(t, true) c := newBrowser(t) raw, base := emailOwnerWorld(t, fx, m, c) m.mu.Lock() m.ready = false m.mu.Unlock() status, _, body := postForm(t, c, fx.ts.URL+base+"/members/remove", url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}}) if status != http.StatusServiceUnavailable || !strings.Contains(body, "Could not hold on to that action. Please try again.") { t.Fatalf("step-up with the mailer down = %d:\n%s", status, body) } stashed := pendingRows(t, fx) if stashed != 0 || challengeRows(t, fx) != 0 || len(m.sentCopy()) != 1 || kimRole(t, fx) != RoleMember { t.Fatalf("mailer down: %d stashed, %d challenges, %d emails", stashed, challengeRows(t, fx), len(m.sentCopy())) } }) t.Run("asked twice", func(t *testing.T) { fx, m := newEmailFixture(t, true) c := newBrowser(t) raw, base := emailOwnerWorld(t, fx, m, c) code, _ := askStepUp(t, fx, m, c, raw, base) status, _, body := postForm(t, c, fx.ts.URL+base+"/members/remove", url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}}) if status != http.StatusOK || !strings.Contains(body, esc(copyText("stepup.sent", "address", "bob@example.com"))) { t.Fatalf("second ask inside the minute = %d:\n%s", status, body) } stashed := pendingRows(t, fx) if stashed != 1 || challengeRows(t, fx) != 1 || len(m.sentCopy()) != 2 { t.Fatalf("second ask: %d stashed, %d challenges, %d emails", stashed, challengeRows(t, fx), len(m.sentCopy())) } // The first email's code still finishes the action. if status, loc, _ := postStepUpCode(t, fx, c, code); status != http.StatusSeeOther || loc != base+"/team" || kimRole(t, fx) != "" { t.Fatalf("first code after a second ask = %d -> %q", status, loc) } // Still inside the minute, with no live step-up challenge left in this // browser, the throttle fails closed. if err := fx.st.ClearStepUp(raw); err != nil { t.Fatal(err) } if err := fx.st.AddMember(bobAccount(t, fx).ID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } status, _, body = postForm(t, c, fx.ts.URL+base+"/members/remove", url.Values{"csrf": {raw}, "address": {"kim@keymail.dev"}}) if status != http.StatusServiceUnavailable || !strings.Contains(body, "Could not hold on to that action. Please try again.") { t.Fatalf("throttled with no live challenge = %d:\n%s", status, body) } }) } func bobAccount(t *testing.T, fx *consoleFixture) Account { t.Helper() accts, err := fx.st.AccountsFor("bob@example.com") if err != nil || len(accts) == 0 { t.Fatalf("bob's accounts = %v, %v", accts, err) } return accts[0] } // The headline: a signed-out new email user logs a terminal in with exactly // one email, because the sign-in itself is a fresh step-up. func TestCLILoginByEmailUserOneEmail(t *testing.T) { fx, m := newEmailFixture(t, true) fx.srv.cliNotified = make(chan struct{}, 1) const addr = "newbie@example.com" lookupAnswers(fx, addr, http.StatusNotFound) c := newBrowser(t) status, _, body := get(t, c, 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"`) { t.Fatalf("signed-out /auth/cli = %d:\n%s", status, body) } code, _ := askSignin(t, fx, m, c, addr, "/auth/cli") if status, loc, _ := postCode(t, fx, c, code); status != http.StatusSeeOther || loc != "/auth/cli" { t.Fatalf("sign-in code = %d -> %q, want 303 -> /auth/cli", status, loc) } csrf := emailSession(t, fx, c, addr) if status, _, _ := get(t, c, fx.ts.URL+"/auth/cli"); status != http.StatusOK { t.Fatalf("signed-in /auth/cli = %d", status) } status, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "newbie@laptop"}, "") if status != http.StatusOK { t.Fatalf("start = %d %v", status, start) } userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") status, _, body = postForm(t, c, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"continue"}, "csrf": {csrf}}) if status != http.StatusOK || !strings.Contains(body, "newbie@laptop") { t.Fatalf("continue = %d:\n%s", status, body) } status, loc, body := postForm(t, c, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}) if status != http.StatusOK || !strings.Contains(body, "Approved") { t.Fatalf("approve = %d -> %q, want approved at once:\n%s", status, loc, body) } // Approved with the sign-in email alone: no step-up challenge, and the // mailer has seen exactly one message (the issuance notice the poll sends // next is not a confirmation). if sent := m.sentCopy(); len(sent) != 1 || challengeRows(t, fx) != 0 { t.Fatalf("at approval the console mailer had %d messages and %d challenges stood, want only the sign-in email: %q", len(sent), challengeRows(t, fx), sent) } _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "") if str(t, poll, "status") != CLIAuthApproved || str(t, poll, "address") != addr || str(t, poll, "token") == "" { t.Fatalf("poll after approval = %v", poll) } select { case <-fx.srv.cliNotified: case <-time.After(5 * time.Second): t.Fatal("no issuance notice attempt") } for _, msg := range m.sentCopy() { if stepUpCodeRe.MatchString(msg) { t.Fatalf("a step-up email went out:\n%s", msg) } } }