oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package keymailtest keeps sign-in's Keymail classification inside the
2// test process. It is imported by tests only.
3//
4// rastrillo/auth (at the version this app pins) builds its classifier
5// with no seams: TXT lookups go through net.DefaultResolver and the
6// well-known probe, the federation lookup and the token exchange go
7// through http.DefaultTransport. So those two globals are the hooks
8// (auth_keymail_test.go's isolated OAuth child swaps the same two).
9// Install replaces both for the life of the test binary:
10//
11// - every DNS query is answered NXDOMAIN by a resolver on loopback,
12// so no DNS packet leaves the process, and a domain with no
13// `_keymail` record is probed directly — keymail.dev's own shape;
14// - an https request to a host registered with Serve is answered
15// in-process by that fixture; anything else falls through to the
16// real transport, where the resolver above makes a name
17// unreachable (IP literals, which httptest servers use, still work).
18//
19// Every address at a domain nobody registered therefore classifies as
20// "not Keymail", which is what every pre-existing sign-in test assumes.
21//
22// Unlike Slides' copy, http.DefaultTransport stays an *http.Transport:
23// home/roster clones it with a type assertion when a team runtime has
24// no transport of its own. Install puts a clone in its place, HTTP/1
25// only, with the fixture router registered for "https"
26// (RegisterProtocol); a host nobody registered is skipped
27// (ErrSkipAltProtocol) and dialled as usual.
28package keymailtest
29
30import (
31 "context"
32 "crypto/rand"
33 "encoding/hex"
34 "encoding/json"
35 "fmt"
36 "net"
37 "net/http"
38 "net/http/httptest"
39 "strings"
40 "sync"
41 "time"
42)
43
44var (
45 mu sync.Mutex
46 servers = map[string]*Server{}
47)
48
49// Install swaps net.DefaultResolver and http.DefaultTransport for the
50// stubs described in the package comment and returns a function that
51// puts them back. Call it once, from TestMain.
52func Install() (restore func(), err error) {
53 dns, err := net.ListenPacket("udp", "127.0.0.1:0")
54 if err != nil {
55 return nil, err
56 }
57 go answerNXDOMAIN(dns)
58 oldResolver, oldTransport := net.DefaultResolver, http.DefaultTransport
59 base, ok := oldTransport.(*http.Transport)
60 if !ok {
61 _ = dns.Close()
62 return nil, fmt.Errorf("keymailtest: http.DefaultTransport is %T, not *http.Transport", oldTransport)
63 }
64 tr := base.Clone()
65 // HTTP/1 only: the bundled HTTP/2 claims the "https" slot on first
66 // use, and the fixture router must hold it.
67 tr.Protocols = new(http.Protocols)
68 tr.Protocols.SetHTTP1(true)
69 tr.RegisterProtocol("https", fixtures{})
70 net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
71 return (&net.Dialer{}).DialContext(ctx, "udp", dns.LocalAddr().String())
72 }}
73 http.DefaultTransport = tr
74 return func() {
75 net.DefaultResolver, http.DefaultTransport = oldResolver, oldTransport
76 _ = dns.Close()
77 }, nil
78}
79
80// answerNXDOMAIN echoes each query back as a "no such name" answer.
81func answerNXDOMAIN(conn net.PacketConn) {
82 b := make([]byte, 4096)
83 for {
84 n, addr, err := conn.ReadFrom(b)
85 if err != nil {
86 return
87 }
88 if n < 12 {
89 continue
90 }
91 reply := append([]byte(nil), b[:n]...)
92 reply[2], reply[3] = 0x81, 0x83 // response, recursion, NXDOMAIN
93 for i := 6; i < 12; i++ {
94 reply[i] = 0 // no answer, authority or additional records
95 }
96 _, _ = conn.WriteTo(reply, addr)
97 }
98}
99
100// fixtures answers https requests to a registered host in-process and
101// hands every other request back to the transport.
102type fixtures struct{}
103
104func (fixtures) RoundTrip(r *http.Request) (*http.Response, error) {
105 mu.Lock()
106 s := servers[r.URL.Host]
107 mu.Unlock()
108 if s == nil || r.URL.Scheme != "https" {
109 return nil, http.ErrSkipAltProtocol
110 }
111 rec := httptest.NewRecorder()
112 s.ServeHTTP(rec, r)
113 res := rec.Result()
114 res.Request = r
115 return res, nil
116}
117
118// Server is one Keymail deployment at one host: the well-known
119// document, the federation lookup for the accounts it holds, and the
120// token exchange for the codes Approve hands out.
121type Server struct {
122 host string
123
124 mu sync.Mutex
125 accounts map[string]bool
126 codes map[string]string
127 lookups int
128 tokens int
129}
130
131// Serve registers a Keymail deployment at host (which is also the
132// address domain it answers for, since nothing publishes a `_keymail`
133// record) holding the given accounts. It is unregistered when the test
134// ends.
135func Serve(t interface{ Cleanup(func()) }, host string, accounts ...string) *Server {
136 s := &Server{host: host, accounts: map[string]bool{}, codes: map[string]string{}}
137 for _, a := range accounts {
138 s.accounts[strings.ToLower(a)] = true
139 }
140 mu.Lock()
141 servers[host] = s
142 mu.Unlock()
143 t.Cleanup(func() {
144 mu.Lock()
145 delete(servers, host)
146 mu.Unlock()
147 })
148 return s
149}
150
151// Approve is Keymail's consent screen saying yes for address: it
152// returns the code the browser would carry back to /auth/callback.
153func (s *Server) Approve(address string) string {
154 var b [12]byte
155 _, _ = rand.Read(b[:])
156 code := hex.EncodeToString(b[:])
157 s.mu.Lock()
158 s.codes[code] = address
159 s.mu.Unlock()
160 return code
161}
162
163// Lookups is how many federation lookups reached this deployment.
164func (s *Server) Lookups() int {
165 s.mu.Lock()
166 defer s.mu.Unlock()
167 return s.lookups
168}
169
170// Tokens is how many token exchanges reached this deployment.
171func (s *Server) Tokens() int {
172 s.mu.Lock()
173 defer s.mu.Unlock()
174 return s.tokens
175}
176
177func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
178 w.Header().Set("Content-Type", "application/json")
179 switch r.URL.Path {
180 case "/.well-known/keymail":
181 _ = json.NewEncoder(w).Encode(map[string]any{"version": "1", "reauth": true})
182 case "/api/federation/lookup":
183 s.mu.Lock()
184 s.lookups++
185 ok := s.accounts[strings.ToLower(r.URL.Query().Get("addr"))]
186 s.mu.Unlock()
187 if !ok {
188 w.WriteHeader(http.StatusNotFound)
189 }
190 _ = json.NewEncoder(w).Encode(map[string]bool{"ok": ok})
191 case "/api/oauth/token":
192 var in map[string]string
193 _ = json.NewDecoder(r.Body).Decode(&in)
194 s.mu.Lock()
195 s.tokens++
196 address, ok := s.codes[in["code"]]
197 delete(s.codes, in["code"])
198 s.mu.Unlock()
199 if r.Method != http.MethodPost || !ok {
200 w.WriteHeader(http.StatusBadRequest)
201 _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
202 return
203 }
204 _ = json.NewEncoder(w).Encode(map[string]any{
205 "access_token": "synthetic", "token_type": "bearer",
206 "address": address, "auth_time": time.Now().Unix(),
207 })
208 default:
209 w.WriteHeader(http.StatusNotFound)
210 }
211}
212