| 1 | package console |
| 2 | |
| 3 | // Passkey step-up (stepup.go, spec §7a). The properties under test are the |
| 4 | // ones that are easy to lose and expensive to lose: the gate cannot be skipped |
| 5 | // server-side, the held action cannot be swapped or replayed, the whole thing |
| 6 | // fails CLOSED when Keymail can't or won't re-authenticate, and it never |
| 7 | // becomes an oracle telling a stranger which accounts exist. |
| 8 | |
| 9 | import ( |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | ) |
| 17 | |
| 18 | // completeStepUp plays the browser's half of a re-authentication: it takes the |
| 19 | // redirect the gate produced, checks the console really asked Keymail for a |
| 20 | // fresh gesture, and lands the callback. What comes back is the RESUMED |
| 21 | // action's own response — the point of the whole round trip. |
| 22 | func completeStepUp(t *testing.T, fx *consoleFixture, c *http.Client, loc string) (status int, location, body string) { |
| 23 | t.Helper() |
| 24 | u, err := url.Parse(loc) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | km, _ := url.Parse(fx.fk.srv.URL) |
| 29 | if u.Host != km.Host || u.Path != "/oauth/authorize" { |
| 30 | t.Fatalf("step-up redirect went to %v, want %s/oauth/authorize", u, km.Host) |
| 31 | } |
| 32 | q := u.Query() |
| 33 | if q.Get("prompt") != "login" { |
| 34 | t.Fatalf("step-up authorize did not ask for a fresh gesture: %v", q) |
| 35 | } |
| 36 | if q.Get("max_age") != strconv.Itoa(int(StepUpWindow.Seconds())) { |
| 37 | t.Fatalf("step-up max_age = %q, want %d", q.Get("max_age"), int(StepUpWindow.Seconds())) |
| 38 | } |
| 39 | if q.Get("redirect_uri") != fx.ts.URL+"/auth/stepup/callback" { |
| 40 | t.Fatalf("step-up redirect_uri = %q, want the step-up callback", q.Get("redirect_uri")) |
| 41 | } |
| 42 | code := "stepup-" + q.Get("state")[:8] |
| 43 | fx.fk.mu.Lock() |
| 44 | fx.fk.challenge = q.Get("code_challenge") |
| 45 | fx.fk.expectCode = code |
| 46 | if fx.fk.authTime.IsZero() { |
| 47 | fx.fk.authTime = time.Now() |
| 48 | } |
| 49 | fx.fk.mu.Unlock() |
| 50 | return get(t, c, fx.ts.URL+"/auth/stepup/callback?code="+url.QueryEscape(code)+ |
| 51 | "&state="+url.QueryEscape(q.Get("state"))) |
| 52 | } |
| 53 | |
| 54 | // postGated is postForm for an action behind the step-up gate: it transparently |
| 55 | // walks the passkey round trip when one is demanded, and is otherwise postForm. |
| 56 | func postGated(t *testing.T, fx *consoleFixture, c *http.Client, u string, form url.Values) (int, string, string) { |
| 57 | t.Helper() |
| 58 | status, loc, body := postForm(t, c, u, form) |
| 59 | if status == http.StatusSeeOther && strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 60 | return completeStepUp(t, fx, c, loc) |
| 61 | } |
| 62 | return status, loc, body |
| 63 | } |
| 64 | |
| 65 | // memberFixture signs in an owner and adds a second member, which is the |
| 66 | // smallest world in which removal, transfer and deletion all mean something. |
| 67 | func memberFixture(t *testing.T) (*consoleFixture, *http.Client, string) { |
| 68 | t.Helper() |
| 69 | fx := newConsoleFixture(t) |
| 70 | c := newBrowser(t) |
| 71 | signUp(t, fx, c, "code-stepup") |
| 72 | if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | return fx, c, sessionToken(t, fx, c) |
| 76 | } |
| 77 | |
| 78 | // TestStepUpIsUnskippable is the whole feature in one test: a signed-in owner |
| 79 | // with a perfectly good CSRF token cannot remove a member by POSTing. The gate |
| 80 | // is server-side state, not a button that renders differently. |
| 81 | func TestStepUpIsUnskippable(t *testing.T) { |
| 82 | fx, c, csrf := memberFixture(t) |
| 83 | |
| 84 | status, loc, _ := postForm(t, c, fx.u("/members/remove"), |
| 85 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 86 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 87 | t.Fatalf("un-stepped-up removal = %d -> %q, want a redirect into Keymail", status, loc) |
| 88 | } |
| 89 | // Nothing happened yet — the redirect is a question, not a deferral. |
| 90 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { |
| 91 | t.Fatalf("membership changed before the passkey confirmation (role %q)", role) |
| 92 | } |
| 93 | // The held action is parked, and it did NOT park a live session token with |
| 94 | // it: sessions are stored hashed here, and a form blob is no exemption. |
| 95 | var stashed, leaked int |
| 96 | fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) |
| 97 | fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions WHERE form LIKE ?`, "%"+csrf+"%").Scan(&leaked) |
| 98 | if stashed != 1 { |
| 99 | t.Fatalf("%d pending actions parked, want exactly 1", stashed) |
| 100 | } |
| 101 | if leaked != 0 { |
| 102 | t.Fatal("a pending action stored the raw session token") |
| 103 | } |
| 104 | |
| 105 | // Complete the gesture: the held action resumes and lands. |
| 106 | status, loc, body := completeStepUp(t, fx, c, loc) |
| 107 | if status != http.StatusSeeOther { |
| 108 | t.Fatalf("resumed removal = %d -> %q:\n%s", status, loc, body) |
| 109 | } |
| 110 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != "" { |
| 111 | t.Fatalf("kim is still a %q after a confirmed removal", role) |
| 112 | } |
| 113 | |
| 114 | // And presence persists for the window, so a second action in the same |
| 115 | // few minutes does not demand a second touch. |
| 116 | if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if status, _, _ := postForm(t, c, fx.u("/members/remove"), |
| 120 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusSeeOther { |
| 121 | t.Fatalf("second removal inside the window = %d, want 303", status) |
| 122 | } |
| 123 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != "" { |
| 124 | t.Fatalf("second removal did not take (role %q)", role) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // TestStepUpFailsClosed covers every way the identity provider can decline to |
| 129 | // prove presence. Not one of them may end with the action going through. |
| 130 | func TestStepUpFailsClosed(t *testing.T) { |
| 131 | t.Run("keymail cannot re-authenticate", func(t *testing.T) { |
| 132 | fx, c, csrf := memberFixture(t) |
| 133 | fx.fk.mu.Lock() |
| 134 | fx.fk.reauth = false // a deployment whose IdP lacks prompt=login |
| 135 | fx.fk.mu.Unlock() |
| 136 | status, _, body := postForm(t, c, fx.u("/members/remove"), |
| 137 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 138 | if status != http.StatusServiceUnavailable { |
| 139 | t.Fatalf("removal against a step-up-less Keymail = %d, want 503", status) |
| 140 | } |
| 141 | if !strings.Contains(body, "passkey") { |
| 142 | t.Fatalf("the refusal does not say why:\n%s", body) |
| 143 | } |
| 144 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { |
| 145 | t.Fatal("membership changed despite a fail-closed refusal") |
| 146 | } |
| 147 | }) |
| 148 | |
| 149 | t.Run("keymail unreachable", func(t *testing.T) { |
| 150 | fx, c, csrf := memberFixture(t) |
| 151 | fx.fk.srv.Close() // the IdP is simply not there |
| 152 | status, _, _ := postForm(t, c, fx.u("/members/remove"), |
| 153 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 154 | if status != http.StatusServiceUnavailable { |
| 155 | t.Fatalf("removal with Keymail down = %d, want 503", status) |
| 156 | } |
| 157 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { |
| 158 | t.Fatal("membership changed despite Keymail being unreachable") |
| 159 | } |
| 160 | }) |
| 161 | |
| 162 | t.Run("no auth_time in the grant", func(t *testing.T) { |
| 163 | // The subtle one: Keymail says it supports step-up, the ceremony |
| 164 | // completes, and the token response carries no auth_time. That is not |
| 165 | // proof of anything and must not be treated as any. |
| 166 | fx, c, csrf := memberFixture(t) |
| 167 | status, loc, _ := postForm(t, c, fx.u("/members/remove"), |
| 168 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 169 | if status != http.StatusSeeOther { |
| 170 | t.Fatalf("gate = %d, want 303", status) |
| 171 | } |
| 172 | u, _ := url.Parse(loc) |
| 173 | q := u.Query() |
| 174 | fx.fk.mu.Lock() |
| 175 | fx.fk.challenge, fx.fk.expectCode = q.Get("code_challenge"), "nostamp" |
| 176 | fx.fk.authTime = time.Time{} // reports nothing |
| 177 | fx.fk.mu.Unlock() |
| 178 | status, _, _ = get(t, c, fx.ts.URL+"/auth/stepup/callback?code=nostamp&state="+url.QueryEscape(q.Get("state"))) |
| 179 | if status != http.StatusForbidden { |
| 180 | t.Fatalf("callback with no auth_time = %d, want 403", status) |
| 181 | } |
| 182 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { |
| 183 | t.Fatal("membership changed on an auth_time-less grant") |
| 184 | } |
| 185 | }) |
| 186 | |
| 187 | t.Run("stale auth_time", func(t *testing.T) { |
| 188 | fx, c, csrf := memberFixture(t) |
| 189 | fx.fk.mu.Lock() |
| 190 | fx.fk.authTime = time.Now().Add(-2 * StepUpWindow) // yesterday's gesture |
| 191 | fx.fk.mu.Unlock() |
| 192 | status, loc, _ := postForm(t, c, fx.u("/members/remove"), |
| 193 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 194 | if status != http.StatusSeeOther { |
| 195 | t.Fatalf("gate = %d, want 303", status) |
| 196 | } |
| 197 | status, _, _ = completeStepUp(t, fx, c, loc) |
| 198 | if status != http.StatusForbidden { |
| 199 | t.Fatalf("callback with a stale auth_time = %d, want 403", status) |
| 200 | } |
| 201 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { |
| 202 | t.Fatal("membership changed on a stale gesture") |
| 203 | } |
| 204 | }) |
| 205 | |
| 206 | t.Run("somebody else's passkey", func(t *testing.T) { |
| 207 | // Keymail's identity service is anonymous: a fresh assertion proves |
| 208 | // SOME passkey was verified. The address comparison is what makes it |
| 209 | // proof about THIS session's owner. |
| 210 | fx, c, csrf := memberFixture(t) |
| 211 | status, loc, _ := postForm(t, c, fx.u("/members/remove"), |
| 212 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 213 | if status != http.StatusSeeOther { |
| 214 | t.Fatalf("gate = %d, want 303", status) |
| 215 | } |
| 216 | fx.setUser("attacker@keymail.dev") // their own passkey, their own address |
| 217 | status, _, body := completeStepUp(t, fx, c, loc) |
| 218 | if status != http.StatusForbidden { |
| 219 | t.Fatalf("step-up with another identity = %d, want 403:\n%s", status, body) |
| 220 | } |
| 221 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember { |
| 222 | t.Fatal("membership changed on somebody else's gesture") |
| 223 | } |
| 224 | }) |
| 225 | } |
| 226 | |
| 227 | // TestPendingActionIsNotACapability pins the held action's three limits: it |
| 228 | // cannot be replayed, it cannot be redeemed by another session, and the |
| 229 | // browser cannot change what it says. |
| 230 | func TestPendingActionIsNotACapability(t *testing.T) { |
| 231 | fx, _, csrf := memberFixture(t) |
| 232 | now := time.Now() |
| 233 | |
| 234 | raw, err := fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", |
| 235 | fx.path("/members/remove"), "address=kim%40keymail.dev", now) |
| 236 | if err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | // Another session cannot spend it, even knowing the token. |
| 240 | other := newBrowser(t) |
| 241 | fx.setUser("kim@keymail.dev") |
| 242 | signUp(t, fx, other, "code-other") |
| 243 | if _, ok := fx.st.TakePendingAction(raw, sessionToken(t, fx, other), now); ok { |
| 244 | t.Fatal("a pending action was redeemed by a different session") |
| 245 | } |
| 246 | // ...and that failed attempt burned it, so the rightful session cannot |
| 247 | // either — burn-then-check, exactly like the OAuth code. |
| 248 | if _, ok := fx.st.TakePendingAction(raw, csrf, now); ok { |
| 249 | t.Fatal("a pending action survived a wrong-session attempt") |
| 250 | } |
| 251 | |
| 252 | raw, err = fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", fx.path("/members/remove"), "", now) |
| 253 | if err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | if _, ok := fx.st.TakePendingAction(raw, csrf, now); !ok { |
| 257 | t.Fatal("the rightful session could not redeem its own pending action") |
| 258 | } |
| 259 | if _, ok := fx.st.TakePendingAction(raw, csrf, now); ok { |
| 260 | t.Fatal("a pending action was redeemed twice") |
| 261 | } |
| 262 | |
| 263 | raw, err = fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", fx.path("/members/remove"), "", now) |
| 264 | if err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | if _, ok := fx.st.TakePendingAction(raw, csrf, now.Add(stepUpFlowTTL+time.Second)); ok { |
| 268 | t.Fatal("an expired pending action was redeemed") |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // TestStepUpIsNotAnOracle is the property the account gate exists for: a |
| 273 | // signed-in stranger POSTing at an owner-only action must get the same |
| 274 | // byte-identical 404 an unrouted URL gets — never a redirect into Keymail, |
| 275 | // which would confirm the account is real. |
| 276 | func TestStepUpIsNotAnOracle(t *testing.T) { |
| 277 | fx, _, _ := memberFixture(t) |
| 278 | stranger := newBrowser(t) |
| 279 | fx.setUser("stranger@keymail.dev") |
| 280 | signUp(t, fx, stranger, "code-stranger") |
| 281 | csrf := sessionToken(t, fx, stranger) |
| 282 | |
| 283 | _, _, missing := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/members/remove", |
| 284 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 285 | |
| 286 | for _, path := range []string{"/members/remove", "/members/transfer", "/delete"} { |
| 287 | status, loc, body := postForm(t, stranger, fx.u(path), |
| 288 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}, "confirm": {"ops"}}) |
| 289 | if status != http.StatusNotFound { |
| 290 | t.Fatalf("%s as a non-member = %d -> %q, want 404", path, status, loc) |
| 291 | } |
| 292 | if body != missing { |
| 293 | t.Fatalf("%s as a non-member answers differently from a nonexistent account:\n%q\nvs\n%q", |
| 294 | path, body, missing) |
| 295 | } |
| 296 | } |
| 297 | // A member who is not an owner gets the same silence. |
| 298 | member := newBrowser(t) |
| 299 | fx.setUser("kim@keymail.dev") |
| 300 | signUp(t, fx, member, "code-kim") |
| 301 | mcsrf := sessionToken(t, fx, member) |
| 302 | status, _, body := postForm(t, member, fx.u("/members/remove"), |
| 303 | url.Values{"csrf": {mcsrf}, "address": {"paul@keymail.dev"}}) |
| 304 | if status != http.StatusNotFound || body != missing { |
| 305 | t.Fatalf("a member probing an owner action = %d, body %q", status, body) |
| 306 | } |
| 307 | // And no pending action was parked for any of them: the gate never ran. |
| 308 | var stashed int |
| 309 | fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) |
| 310 | if stashed != 0 { |
| 311 | t.Fatalf("%d pending actions parked by requests that should never have reached the gate", stashed) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | // TestCSRFStillOutranksStepUp: the two checks are independent, and the older |
| 316 | // one runs first. A cross-site POST must be refused outright, not parked as a |
| 317 | // pending action for the victim to unknowingly confirm. |
| 318 | func TestCSRFStillOutranksStepUp(t *testing.T) { |
| 319 | fx, c, _ := memberFixture(t) |
| 320 | for _, path := range []string{"/members/remove", "/members/transfer", "/delete"} { |
| 321 | status, _, _ := postForm(t, c, fx.u(path), |
| 322 | url.Values{"csrf": {"wrong"}, "address": {"kim@keymail.dev"}, "confirm": {"ops"}}) |
| 323 | if status != http.StatusForbidden { |
| 324 | t.Fatalf("%s with a bad CSRF token = %d, want 403", path, status) |
| 325 | } |
| 326 | } |
| 327 | var stashed int |
| 328 | fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) |
| 329 | if stashed != 0 { |
| 330 | t.Fatal("a cross-site POST parked a pending action") |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | // TestOwnershipActionsBehindStepUp walks transfer and deletion the way a |
| 335 | // browser does, and pins the guards that keep an account administrable. |
| 336 | func TestOwnershipActionsBehindStepUp(t *testing.T) { |
| 337 | fx, c, csrf := memberFixture(t) |
| 338 | |
| 339 | // Ownership goes to an existing member, and the old owner steps down. |
| 340 | status, loc, body := postGated(t, fx, c, fx.u("/members/transfer"), |
| 341 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 342 | if status != http.StatusSeeOther { |
| 343 | t.Fatalf("transfer = %d -> %q:\n%s", status, loc, body) |
| 344 | } |
| 345 | if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleOwner { |
| 346 | t.Fatalf("kim's role after transfer = %q, want owner", role) |
| 347 | } |
| 348 | if role, _ := fx.st.Role(OpsAccountID, "paul@keymail.dev"); role != RoleMember { |
| 349 | t.Fatalf("paul's role after transfer = %q, want member", role) |
| 350 | } |
| 351 | // And paul, now a member, meets the same 404 every non-owner does. |
| 352 | if status, _, _ := postForm(t, c, fx.u("/members/remove"), |
| 353 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusNotFound { |
| 354 | t.Fatalf("the former owner can still act = %d, want 404", status) |
| 355 | } |
| 356 | |
| 357 | // Deletion: an account holding apps is refused even with a confirmed |
| 358 | // passkey, and ops is refused outright. |
| 359 | fx2, c2, csrf2 := memberFixture(t) |
| 360 | status, _, _ = postGated(t, fx2, c2, fx2.u("/delete"), url.Values{"csrf": {csrf2}, "confirm": {"ops"}}) |
| 361 | if status != http.StatusSeeOther { |
| 362 | t.Fatalf("delete = %d, want a 303 back to the account", status) |
| 363 | } |
| 364 | if a, _ := fx2.st.AccountBySqid(fx2.sqid); a == nil { |
| 365 | t.Fatal("the ops account was deleted") |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | // TestLastOwnerStillProtected: step-up does not become a way around the |
| 370 | // invariant that every account keeps somebody who can administer it. |
| 371 | func TestLastOwnerStillProtected(t *testing.T) { |
| 372 | fx, c, csrf := memberFixture(t) |
| 373 | status, _, _ := postGated(t, fx, c, fx.u("/members/remove"), |
| 374 | url.Values{"csrf": {csrf}, "address": {"paul@keymail.dev"}}) |
| 375 | if status != http.StatusSeeOther { |
| 376 | t.Fatalf("self-removal = %d, want a 303 with a refusal flash", status) |
| 377 | } |
| 378 | if role, _ := fx.st.Role(OpsAccountID, "paul@keymail.dev"); role != RoleOwner { |
| 379 | t.Fatalf("the last owner removed themselves (role now %q)", role) |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | // TestSignOutClearsPresence: proof of presence belongs to a session and dies |
| 384 | // with it. |
| 385 | func TestSignOutClearsPresence(t *testing.T) { |
| 386 | fx, c, csrf := memberFixture(t) |
| 387 | status, loc, _ := postForm(t, c, fx.u("/members/remove"), |
| 388 | url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}) |
| 389 | if status != http.StatusSeeOther { |
| 390 | t.Fatalf("gate = %d", status) |
| 391 | } |
| 392 | if _, _, _ = completeStepUp(t, fx, c, loc); !fx.st.FreshStepUp(csrf, "paul@keymail.dev", time.Now(), StepUpWindow) { |
| 393 | t.Fatal("no presence recorded after a completed step-up") |
| 394 | } |
| 395 | if status, _, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {csrf}}); status != http.StatusSeeOther { |
| 396 | t.Fatalf("signout = %d", status) |
| 397 | } |
| 398 | if fx.st.FreshStepUp(csrf, "paul@keymail.dev", time.Now(), StepUpWindow) { |
| 399 | t.Fatal("presence outlived the session it was proved for") |
| 400 | } |
| 401 | } |
| 402 | |