| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/keymaildev/signin" |
| 16 | ) |
| 17 | |
| 18 | // keymailStub is a federation server good enough to drive the whole |
| 19 | // Keymail upgrade end to end: classification, the authorize redirect and |
| 20 | // the token exchange. |
| 21 | // |
| 22 | // It exists because the Keymail path had NO test at all. Everything |
| 23 | // about it was asserted in comments — that the Gate covers it, that the |
| 24 | // pending cookie is cleared on read, that its attributes are |
| 25 | // load-bearing — and a review proved each of those claims survivable by |
| 26 | // mutation precisely because nothing ever drove the path. |
| 27 | type keymailStub struct { |
| 28 | srv *httptest.Server |
| 29 | host string |
| 30 | address string |
| 31 | // authTime is what the server reports as the moment an |
| 32 | // authenticator gesture happened. Zero means "no gesture", which |
| 33 | // callers must treat as the weaker of the two methods. |
| 34 | authTime time.Time |
| 35 | |
| 36 | mu sync.Mutex |
| 37 | spent map[string]bool |
| 38 | } |
| 39 | |
| 40 | // newKeymailStub wires app.flow to talk to a local federation server for |
| 41 | // one address's domain. |
| 42 | func newKeymailStub(t *testing.T, app *testApp, address string) *keymailStub { |
| 43 | t.Helper() |
| 44 | _, domain, err := signin.SplitAddress(address) |
| 45 | if err != nil { |
| 46 | t.Fatalf("SplitAddress(%q): %v", address, err) |
| 47 | } |
| 48 | |
| 49 | k := &keymailStub{ |
| 50 | address: address, |
| 51 | authTime: time.Now().Truncate(time.Second), |
| 52 | spent: map[string]bool{}, |
| 53 | } |
| 54 | |
| 55 | mux := http.NewServeMux() |
| 56 | mux.HandleFunc("/.well-known/keymail", func(w http.ResponseWriter, r *http.Request) { |
| 57 | writeJSON(t, w, map[string]any{"version": "1", "reauth": false}) |
| 58 | }) |
| 59 | mux.HandleFunc("/api/federation/lookup", func(w http.ResponseWriter, r *http.Request) { |
| 60 | // A claimed inbox is a 200; anything else is "not keymail". |
| 61 | if r.URL.Query().Get("addr") != strings.ToLower(k.address) { |
| 62 | http.Error(w, "no such inbox", http.StatusNotFound) |
| 63 | return |
| 64 | } |
| 65 | writeJSON(t, w, map[string]any{"ok": true}) |
| 66 | }) |
| 67 | mux.HandleFunc("/api/oauth/token", func(w http.ResponseWriter, r *http.Request) { |
| 68 | // A real federation server redeems an authorization code once, |
| 69 | // and once PER CODE — a fresh round trip issues a fresh code. |
| 70 | // The stub must model both halves: spending globally made a |
| 71 | // second legitimate sign-in fail, and not spending at all let a |
| 72 | // replay through. Both versions were written before this |
| 73 | // comment. |
| 74 | var body struct { |
| 75 | Code string `json:"code"` |
| 76 | } |
| 77 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| 78 | http.Error(w, "bad request", http.StatusBadRequest) |
| 79 | return |
| 80 | } |
| 81 | k.mu.Lock() |
| 82 | used := k.spent[body.Code] |
| 83 | k.spent[body.Code] = true |
| 84 | k.mu.Unlock() |
| 85 | if used { |
| 86 | http.Error(w, "code already redeemed", http.StatusBadRequest) |
| 87 | return |
| 88 | } |
| 89 | writeJSON(t, w, map[string]any{ |
| 90 | "access_token": "stub-access-token", |
| 91 | "token_type": "bearer", |
| 92 | "address": k.address, |
| 93 | "auth_time": k.authTime.Unix(), |
| 94 | }) |
| 95 | }) |
| 96 | |
| 97 | k.srv = httptest.NewServer(mux) |
| 98 | t.Cleanup(k.srv.Close) |
| 99 | k.host = strings.TrimPrefix(k.srv.URL, "http://") |
| 100 | |
| 101 | // The delegation record keymail's own dnsFedHost would publish. |
| 102 | app.flow.Classifier.Scheme = "http" |
| 103 | app.flow.Classifier.LookupTXT = func(_ context.Context, name string) ([]string, error) { |
| 104 | if name == "_keymail."+domain { |
| 105 | return []string{"v=1 host=" + k.host}, nil |
| 106 | } |
| 107 | return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} |
| 108 | } |
| 109 | // newFlow builds Base as https://<server>; the stub is plain http. |
| 110 | app.flow.Keymail = func(server string) *signin.Keymail { |
| 111 | return &signin.Keymail{ |
| 112 | Base: "http://" + server, |
| 113 | Origin: Origin, |
| 114 | RedirectPath: callbackPath, |
| 115 | // Bounded, like the real one. Omitting this left exchanges |
| 116 | // on the library's 15s default client, so a stub test would |
| 117 | // have passed against an app whose timeout wiring was gone. |
| 118 | HTTP: &http.Client{Timeout: classifierTimeout}, |
| 119 | } |
| 120 | } |
| 121 | return k |
| 122 | } |
| 123 | |
| 124 | func writeJSON(t *testing.T, w http.ResponseWriter, v any) { |
| 125 | t.Helper() |
| 126 | w.Header().Set("Content-Type", "application/json") |
| 127 | if err := json.NewEncoder(w).Encode(v); err != nil { |
| 128 | t.Errorf("stub encode: %v", err) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // beginKeymail runs POST /signin for an address the stub claims, and |
| 133 | // returns the pending cookie and the state the flow sealed into it. |
| 134 | func beginKeymail(t *testing.T, app *testApp, address string) (*http.Cookie, string) { |
| 135 | t.Helper() |
| 136 | rec := signIn(t, app, address) |
| 137 | _, links := continuationTargets(t, rec.Body.String()) |
| 138 | if len(links) != 1 { |
| 139 | t.Fatal("missing provider continuation") |
| 140 | } |
| 141 | loc := links[0] |
| 142 | assertContinuationPage(t, rec, loc) |
| 143 | if !strings.Contains(loc, "/oauth/authorize") { |
| 144 | t.Fatalf("sign-in went to %q, which is not the keymail authorize URL — "+ |
| 145 | "classification did not take the keymail branch", loc) |
| 146 | } |
| 147 | if _, sent := app.mailer.last(); sent { |
| 148 | t.Fatal("the keymail branch sent a magic link as well") |
| 149 | } |
| 150 | |
| 151 | u, err := url.Parse(loc) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | state := u.Query().Get("state") |
| 156 | if state == "" { |
| 157 | t.Fatal("the authorize URL carries no state") |
| 158 | } |
| 159 | |
| 160 | var pending *http.Cookie |
| 161 | for _, c := range (&http.Response{Header: rec.Header()}).Cookies() { |
| 162 | if c.Name == pendingCookie { |
| 163 | pending = c |
| 164 | } |
| 165 | } |
| 166 | if pending == nil { |
| 167 | t.Fatalf("no %s cookie was set", pendingCookie) |
| 168 | } |
| 169 | return pending, state |
| 170 | } |
| 171 | |
| 172 | // callback drives GET /signin/callback with the given cookies. The code |
| 173 | // is a parameter because a real authorization code is single use: two |
| 174 | // round trips carry two codes, and a replay carries the same one twice. |
| 175 | func callback(t *testing.T, app *testApp, code, state string, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| 176 | t.Helper() |
| 177 | req := httptest.NewRequest(http.MethodGet, |
| 178 | callbackPath+"?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil) |
| 179 | req.Host = credentialHost |
| 180 | for _, c := range cookies { |
| 181 | if c != nil { |
| 182 | req.AddCookie(c) |
| 183 | } |
| 184 | } |
| 185 | w := httptest.NewRecorder() |
| 186 | app.Router().ServeHTTP(w, req) |
| 187 | return w |
| 188 | } |
| 189 | |