oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
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.
16func 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