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 "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.
27type 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.
42func 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
124func 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.
134func 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.
175func 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