package console // Passkey step-up (stepup.go, spec §7a). The properties under test are the // ones that are easy to lose and expensive to lose: the gate cannot be skipped // server-side, the held action cannot be swapped or replayed, the whole thing // fails CLOSED when Keymail can't or won't re-authenticate, and it never // becomes an oracle telling a stranger which accounts exist. import ( "net/http" "net/url" "strconv" "strings" "testing" "time" ) // completeStepUp plays the browser's half of a re-authentication: it takes the // redirect the gate produced, checks the console really asked Keymail for a // fresh gesture, and lands the callback. What comes back is the RESUMED // action's own response — the point of the whole round trip. func completeStepUp(t *testing.T, fx *consoleFixture, c *http.Client, loc string) (status int, location, body string) { t.Helper() u, err := url.Parse(loc) if err != nil { t.Fatal(err) } km, _ := url.Parse(fx.fk.srv.URL) if u.Host != km.Host || u.Path != "/oauth/authorize" { t.Fatalf("step-up redirect went to %v, want %s/oauth/authorize", u, km.Host) } q := u.Query() if q.Get("prompt") != "login" { t.Fatalf("step-up authorize did not ask for a fresh gesture: %v", q) } if q.Get("max_age") != strconv.Itoa(int(StepUpWindow.Seconds())) { t.Fatalf("step-up max_age = %q, want %d", q.Get("max_age"), int(StepUpWindow.Seconds())) } if q.Get("redirect_uri") != fx.ts.URL+"/auth/stepup/callback" { t.Fatalf("step-up redirect_uri = %q, want the step-up callback", q.Get("redirect_uri")) } code := "stepup-" + q.Get("state")[:8] fx.fk.mu.Lock() fx.fk.challenge = q.Get("code_challenge") fx.fk.expectCode = code if fx.fk.authTime.IsZero() { fx.fk.authTime = time.Now() } fx.fk.mu.Unlock() return get(t, c, fx.ts.URL+"/auth/stepup/callback?code="+url.QueryEscape(code)+ "&state="+url.QueryEscape(q.Get("state"))) } // postGated is postForm for an action behind the step-up gate: it transparently // walks the passkey round trip when one is demanded, and is otherwise postForm. func postGated(t *testing.T, fx *consoleFixture, c *http.Client, u string, form url.Values) (int, string, string) { t.Helper() status, loc, body := postForm(t, c, u, form) if status == http.StatusSeeOther && strings.HasPrefix(loc, fx.fk.srv.URL) { return completeStepUp(t, fx, c, loc) } return status, loc, body } // memberFixture signs in an owner and adds a second member, which is the // smallest world in which removal, transfer and deletion all mean something. func memberFixture(t *testing.T) (*consoleFixture, *http.Client, string) { t.Helper() fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-stepup") if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } return fx, c, sessionToken(t, fx, c) } // TestStepUpIsUnskippable is the whole feature in one test: a signed-in owner // with a perfectly good CSRF token cannot remove a member by POSTing. The gate // is server-side state, not a button that renders differently. func TestStepUpIsUnskippable(t *testing.T) { fx, c, csrf := memberFixture(t) 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("un-stepped-up removal = %d -> %q, want a redirect into Keymail", status, loc) } // Nothing happened yet — the redirect is a question, not a deferral. if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { t.Fatalf("membership changed before the passkey confirmation (role %q)", role) } // The held action is parked, and it did NOT park a live session token with // it: sessions are stored hashed here, and a form blob is no exemption. var stashed, leaked int fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions WHERE form LIKE ?`, "%"+csrf+"%").Scan(&leaked) if stashed != 1 { t.Fatalf("%d pending actions parked, want exactly 1", stashed) } if leaked != 0 { t.Fatal("a pending action stored the raw session token") } // Complete the gesture: the held action resumes and lands. status, loc, body := completeStepUp(t, fx, c, loc) if status != http.StatusSeeOther { t.Fatalf("resumed removal = %d -> %q:\n%s", status, loc, body) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != "" { t.Fatalf("kim is still a %q after a confirmed removal", role) } // And presence persists for the window, so a second action in the same // few minutes does not demand a second touch. if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } if status, _, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusSeeOther { t.Fatalf("second removal inside the window = %d, want 303", status) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != "" { t.Fatalf("second removal did not take (role %q)", role) } } // TestStepUpFailsClosed covers every way the identity provider can decline to // prove presence. Not one of them may end with the action going through. func TestStepUpFailsClosed(t *testing.T) { t.Run("keymail cannot re-authenticate", func(t *testing.T) { fx, c, csrf := memberFixture(t) fx.fk.mu.Lock() fx.fk.reauth = false // a deployment whose IdP lacks prompt=login fx.fk.mu.Unlock() status, _, body := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusServiceUnavailable { t.Fatalf("removal against a step-up-less Keymail = %d, want 503", status) } if !strings.Contains(body, "passkey") { t.Fatalf("the refusal does not say why:\n%s", body) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { t.Fatal("membership changed despite a fail-closed refusal") } }) t.Run("keymail unreachable", func(t *testing.T) { fx, c, csrf := memberFixture(t) fx.fk.srv.Close() // the IdP is simply not there status, _, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusServiceUnavailable { t.Fatalf("removal with Keymail down = %d, want 503", status) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { t.Fatal("membership changed despite Keymail being unreachable") } }) t.Run("no auth_time in the grant", func(t *testing.T) { // The subtle one: Keymail says it supports step-up, the ceremony // completes, and the token response carries no auth_time. That is not // proof of anything and must not be treated as any. fx, c, csrf := memberFixture(t) status, loc, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusSeeOther { t.Fatalf("gate = %d, want 303", status) } u, _ := url.Parse(loc) q := u.Query() fx.fk.mu.Lock() fx.fk.challenge, fx.fk.expectCode = q.Get("code_challenge"), "nostamp" fx.fk.authTime = time.Time{} // reports nothing fx.fk.mu.Unlock() status, _, _ = get(t, c, fx.ts.URL+"/auth/stepup/callback?code=nostamp&state="+url.QueryEscape(q.Get("state"))) if status != http.StatusForbidden { t.Fatalf("callback with no auth_time = %d, want 403", status) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { t.Fatal("membership changed on an auth_time-less grant") } }) t.Run("stale auth_time", func(t *testing.T) { fx, c, csrf := memberFixture(t) fx.fk.mu.Lock() fx.fk.authTime = time.Now().Add(-2 * StepUpWindow) // yesterday's gesture fx.fk.mu.Unlock() status, loc, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusSeeOther { t.Fatalf("gate = %d, want 303", status) } status, _, _ = completeStepUp(t, fx, c, loc) if status != http.StatusForbidden { t.Fatalf("callback with a stale auth_time = %d, want 403", status) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { t.Fatal("membership changed on a stale gesture") } }) t.Run("somebody else's passkey", func(t *testing.T) { // Keymail's identity service is anonymous: a fresh assertion proves // SOME passkey was verified. The address comparison is what makes it // proof about THIS session's owner. fx, c, csrf := memberFixture(t) status, loc, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusSeeOther { t.Fatalf("gate = %d, want 303", status) } fx.setUser("attacker@keymail.dev") // their own passkey, their own address status, _, body := completeStepUp(t, fx, c, loc) if status != http.StatusForbidden { t.Fatalf("step-up with another identity = %d, want 403:\n%s", status, body) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { t.Fatal("membership changed on somebody else's gesture") } }) } // TestPendingActionIsNotACapability pins the held action's three limits: it // cannot be replayed, it cannot be redeemed by another session, and the // browser cannot change what it says. func TestPendingActionIsNotACapability(t *testing.T) { fx, _, csrf := memberFixture(t) now := time.Now() raw, err := fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", fx.path("/members/remove"), "address=kim%40keymail.dev", now) if err != nil { t.Fatal(err) } // Another session cannot spend it, even knowing the token. other := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, other, "code-other") if _, ok := fx.st.TakePendingAction(raw, sessionToken(t, fx, other), now); ok { t.Fatal("a pending action was redeemed by a different session") } // ...and that failed attempt burned it, so the rightful session cannot // either — burn-then-check, exactly like the OAuth code. if _, ok := fx.st.TakePendingAction(raw, csrf, now); ok { t.Fatal("a pending action survived a wrong-session attempt") } raw, err = fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", fx.path("/members/remove"), "", now) if err != nil { t.Fatal(err) } if _, ok := fx.st.TakePendingAction(raw, csrf, now); !ok { t.Fatal("the rightful session could not redeem its own pending action") } if _, ok := fx.st.TakePendingAction(raw, csrf, now); ok { t.Fatal("a pending action was redeemed twice") } raw, err = fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", fx.path("/members/remove"), "", now) if err != nil { t.Fatal(err) } if _, ok := fx.st.TakePendingAction(raw, csrf, now.Add(stepUpFlowTTL+time.Second)); ok { t.Fatal("an expired pending action was redeemed") } } // TestStepUpIsNotAnOracle is the property the account gate exists for: a // signed-in stranger POSTing at an owner-only action must get the same // byte-identical 404 an unrouted URL gets — never a redirect into Keymail, // which would confirm the account is real. func TestStepUpIsNotAnOracle(t *testing.T) { fx, _, _ := memberFixture(t) stranger := newBrowser(t) fx.setUser("stranger@keymail.dev") signUp(t, fx, stranger, "code-stranger") csrf := sessionToken(t, fx, stranger) _, _, missing := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/members/remove", url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) for _, path := range []string{"/members/remove", "/members/transfer", "/delete"} { status, loc, body := postForm(t, stranger, fx.u(path), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}, "confirm": {"ops"}}) if status != http.StatusNotFound { t.Fatalf("%s as a non-member = %d -> %q, want 404", path, status, loc) } if body != missing { t.Fatalf("%s as a non-member answers differently from a nonexistent account:\n%q\nvs\n%q", path, body, missing) } } // A member who is not an owner gets the same silence. member := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, member, "code-kim") mcsrf := sessionToken(t, fx, member) status, _, body := postForm(t, member, fx.u("/members/remove"), url.Values{"csrf": {mcsrf}, "address": {"paul@keymail.dev"}}) if status != http.StatusNotFound || body != missing { t.Fatalf("a member probing an owner action = %d, body %q", status, body) } // And no pending action was parked for any of them: the gate never ran. var stashed int fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) if stashed != 0 { t.Fatalf("%d pending actions parked by requests that should never have reached the gate", stashed) } } // TestCSRFStillOutranksStepUp: the two checks are independent, and the older // one runs first. A cross-site POST must be refused outright, not parked as a // pending action for the victim to unknowingly confirm. func TestCSRFStillOutranksStepUp(t *testing.T) { fx, c, _ := memberFixture(t) for _, path := range []string{"/members/remove", "/members/transfer", "/delete"} { status, _, _ := postForm(t, c, fx.u(path), url.Values{"csrf": {"wrong"}, "address": {"kim@keymail.dev"}, "confirm": {"ops"}}) if status != http.StatusForbidden { t.Fatalf("%s with a bad CSRF token = %d, want 403", path, status) } } var stashed int fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) if stashed != 0 { t.Fatal("a cross-site POST parked a pending action") } } // TestOwnershipActionsBehindStepUp walks transfer and deletion the way a // browser does, and pins the guards that keep an account administrable. func TestOwnershipActionsBehindStepUp(t *testing.T) { fx, c, csrf := memberFixture(t) // Ownership goes to an existing member, and the old owner steps down. status, loc, body := postGated(t, fx, c, fx.u("/members/transfer"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusSeeOther { t.Fatalf("transfer = %d -> %q:\n%s", status, loc, body) } if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleOwner { t.Fatalf("kim's role after transfer = %q, want owner", role) } if role, _ := fx.st.Role(OpsAccountID, "paul@keymail.dev"); role != RoleMember { t.Fatalf("paul's role after transfer = %q, want member", role) } // And paul, now a member, meets the same 404 every non-owner does. if status, _, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusNotFound { t.Fatalf("the former owner can still act = %d, want 404", status) } // Deletion: an account holding apps is refused even with a confirmed // passkey, and ops is refused outright. fx2, c2, csrf2 := memberFixture(t) status, _, _ = postGated(t, fx2, c2, fx2.u("/delete"), url.Values{"csrf": {csrf2}, "confirm": {"ops"}}) if status != http.StatusSeeOther { t.Fatalf("delete = %d, want a 303 back to the account", status) } if a, _ := fx2.st.AccountBySqid(fx2.sqid); a == nil { t.Fatal("the ops account was deleted") } } // TestLastOwnerStillProtected: step-up does not become a way around the // invariant that every account keeps somebody who can administer it. func TestLastOwnerStillProtected(t *testing.T) { fx, c, csrf := memberFixture(t) status, _, _ := postGated(t, fx, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"paul@keymail.dev"}}) if status != http.StatusSeeOther { t.Fatalf("self-removal = %d, want a 303 with a refusal flash", status) } if role, _ := fx.st.Role(OpsAccountID, "paul@keymail.dev"); role != RoleOwner { t.Fatalf("the last owner removed themselves (role now %q)", role) } } // TestSignOutClearsPresence: proof of presence belongs to a session and dies // with it. func TestSignOutClearsPresence(t *testing.T) { fx, c, csrf := memberFixture(t) status, loc, _ := postForm(t, c, fx.u("/members/remove"), url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) if status != http.StatusSeeOther { t.Fatalf("gate = %d", status) } if _, _, _ = completeStepUp(t, fx, c, loc); !fx.st.FreshStepUp(csrf, "paul@keymail.dev", time.Now(), StepUpWindow) { t.Fatal("no presence recorded after a completed step-up") } if status, _, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {csrf}}); status != http.StatusSeeOther { t.Fatalf("signout = %d", status) } if fx.st.FreshStepUp(csrf, "paul@keymail.dev", time.Now(), StepUpWindow) { t.Fatal("presence outlived the session it was proved for") } }