| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "amadan.net/rastrillo/rastrillo/webauthn/authtest" |
| 12 | ) |
| 13 | |
| 14 | // Mutation-checked 2026-09-07: bypassing Gate or passing the original |
| 15 | // request allows factor bypass or hostile FormValue precedence and fails. |
| 16 | func TestCredentialReturnSecondFactors(t *testing.T) { |
| 17 | for _, first := range []string{"magic", "keymail"} { |
| 18 | for _, factor := range []string{"passkey", "recovery"} { |
| 19 | t.Run(first+factor, func(t *testing.T) { |
| 20 | app := newTestApp(t) |
| 21 | addr := "person@example.invalid" |
| 22 | var session *http.Cookie |
| 23 | if first == "keymail" { |
| 24 | addr = "person@keymail.invalid" |
| 25 | newKeymailStub(t, app, addr) |
| 26 | p, state := beginKeymail(t, app, addr) |
| 27 | session = sessionCookie(callback(t, app, "code-1", state, p)) |
| 28 | } else { |
| 29 | session = signInFully(t, app, addr) |
| 30 | } |
| 31 | authenticator := enrol(t, app, session) |
| 32 | codes, err := app.passkeys.RegenerateRecoveryCodes(subjectOf(t, app, session)) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | intent := intentFrom(returnRequest(app, "GET", "/signin?return_to=/passkey/enrol", nil)) |
| 37 | var gatedCookies []*http.Cookie |
| 38 | if first == "keymail" { |
| 39 | p, state := beginKeymail(t, app, addr) |
| 40 | got := returnRequest(app, "GET", callbackPath+"?code=code-2&state="+url.QueryEscape(state)+"&return_to=/passkey/codes", nil, p, intent) |
| 41 | if sessionCookie(got) != nil || got.Header().Get("Location") != confirmPath { |
| 42 | t.Fatal("Keymail bypassed second factor") |
| 43 | } |
| 44 | gatedCookies = got.Result().Cookies() |
| 45 | } else { |
| 46 | signIn(t, app, addr) |
| 47 | got := returnRequest(app, "POST", linkPath+"?return_to=/passkey/codes", url.Values{"token": {capturedLink(t, app)}, "return_to": {"//evil.invalid"}}, intent) |
| 48 | if sessionCookie(got) != nil || got.Header().Get("Location") != confirmPath { |
| 49 | t.Fatal("magic link bypassed second factor") |
| 50 | } |
| 51 | gatedCookies = got.Result().Cookies() |
| 52 | } |
| 53 | var pending *http.Cookie |
| 54 | for _, c := range gatedCookies { |
| 55 | if strings.Contains(c.Name, "passkey") && c.Value != "" { |
| 56 | pending = c |
| 57 | } |
| 58 | } |
| 59 | if pending == nil { |
| 60 | t.Fatal("no pending second factor") |
| 61 | } |
| 62 | denied := returnRequest(app, "POST", "/passkey/signin/recovery", url.Values{"code": {"invalid"}}, pending) |
| 63 | if sessionCookie(denied) != nil { |
| 64 | t.Fatal("invalid recovery minted session") |
| 65 | } |
| 66 | if factor == "recovery" { |
| 67 | got := returnRequest(app, "POST", "/passkey/signin/recovery", url.Values{"code": {codes[0]}, "return_to": {"/passkey/codes"}}, pending) |
| 68 | if got.Header().Get("Location") != "/passkey/enrol" || sessionCookie(got) == nil { |
| 69 | t.Fatalf("recovery continuation=%q status=%d", got.Header().Get("Location"), got.Code) |
| 70 | } |
| 71 | if dest := returnRequest(app, "GET", "/passkey/enrol", nil, sessionCookie(got)); dest.Code != 200 { |
| 72 | t.Fatalf("recovery freshness=%d", dest.Code) |
| 73 | } |
| 74 | } else { |
| 75 | challenge := challengeFrom(t, postJSON(t, app, "/passkey/signin/begin", pending, nil)) |
| 76 | clientData, authData, sig, err := authenticator.Get(challenge, authtest.Options{RPID: app.credentialHost, Origin: Origin}) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | b64 := base64.RawURLEncoding.EncodeToString |
| 81 | got := postJSON(t, app, "/passkey/signin/finish", pending, map[string]string{"id": b64(authenticator.CredID), "clientDataJSON": b64(clientData), "authenticatorData": b64(authData), "signature": b64(sig)}) |
| 82 | var response struct { |
| 83 | To string `json:"to"` |
| 84 | } |
| 85 | if err := json.Unmarshal(got.Body.Bytes(), &response); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if response.To != "/passkey/enrol" || sessionCookie(got) == nil { |
| 89 | t.Fatalf("passkey continuation=%q status=%d", response.To, got.Code) |
| 90 | } |
| 91 | if dest := returnRequest(app, "GET", "/passkey/enrol", nil, sessionCookie(got)); dest.Code != 200 { |
| 92 | t.Fatalf("passkey freshness=%d", dest.Code) |
| 93 | } |
| 94 | } |
| 95 | }) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |