oficina / slides Public

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

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// exactly as oficina/docs' auth_keymail_test.go uses them. Install
9// 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.
21package keymailtest
22
23import (
24 "context"
25 "crypto/rand"
26 "encoding/hex"
27 "encoding/json"
28 "net"
29 "net/http"
30 "net/http/httptest"
31 "strings"
32 "sync"
33 "time"
34)
35
36var (
37 mu sync.Mutex
38 servers = map[string]*Server{}
39)
40
41// Install swaps net.DefaultResolver and http.DefaultTransport for the
42// stubs described in the package comment and returns a function that
43// puts them back. Call it once, from TestMain.
44func Install() (restore func(), err error) {
45 dns, err := net.ListenPacket("udp", "127.0.0.1:0")
46 if err != nil {
47 return nil, err
48 }
49 go answerNXDOMAIN(dns)
50 oldResolver, oldTransport := net.DefaultResolver, http.DefaultTransport
51 net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
52 return (&net.Dialer{}).DialContext(ctx, "udp", dns.LocalAddr().String())
53 }}
54 http.DefaultTransport = transport{next: oldTransport}
55 return func() {
56 net.DefaultResolver, http.DefaultTransport = oldResolver, oldTransport
57 _ = dns.Close()
58 }, nil
59}
60
61// answerNXDOMAIN echoes each query back as a "no such name" answer.
62func answerNXDOMAIN(conn net.PacketConn) {
63 b := make([]byte, 4096)
64 for {
65 n, addr, err := conn.ReadFrom(b)
66 if err != nil {
67 return
68 }
69 if n < 12 {
70 continue
71 }
72 reply := append([]byte(nil), b[:n]...)
73 reply[2], reply[3] = 0x81, 0x83 // response, recursion, NXDOMAIN
74 for i := 6; i < 12; i++ {
75 reply[i] = 0 // no answer, authority or additional records
76 }
77 _, _ = conn.WriteTo(reply, addr)
78 }
79}
80
81type transport struct{ next http.RoundTripper }
82
83func (t transport) RoundTrip(r *http.Request) (*http.Response, error) {
84 mu.Lock()
85 s := servers[r.URL.Host]
86 mu.Unlock()
87 if s == nil || r.URL.Scheme != "https" {
88 return t.next.RoundTrip(r)
89 }
90 rec := httptest.NewRecorder()
91 s.ServeHTTP(rec, r)
92 res := rec.Result()
93 res.Request = r
94 return res, nil
95}
96
97// Server is one Keymail deployment at one host: the well-known
98// document, the federation lookup for the accounts it holds, and the
99// token exchange for the codes Approve hands out.
100type Server struct {
101 host string
102
103 mu sync.Mutex
104 accounts map[string]bool
105 codes map[string]string
106 lookups int
107 tokens int
108}
109
110// Serve registers a Keymail deployment at host (which is also the
111// address domain it answers for, since nothing publishes a `_keymail`
112// record) holding the given accounts. It is unregistered when the test
113// ends.
114func Serve(t interface{ Cleanup(func()) }, host string, accounts ...string) *Server {
115 s := &Server{host: host, accounts: map[string]bool{}, codes: map[string]string{}}
116 for _, a := range accounts {
117 s.accounts[strings.ToLower(a)] = true
118 }
119 mu.Lock()
120 servers[host] = s
121 mu.Unlock()
122 t.Cleanup(func() {
123 mu.Lock()
124 delete(servers, host)
125 mu.Unlock()
126 })
127 return s
128}
129
130// Approve is Keymail's consent screen saying yes for address: it
131// returns the code the browser would carry back to /auth/callback.
132func (s *Server) Approve(address string) string {
133 var b [12]byte
134 _, _ = rand.Read(b[:])
135 code := hex.EncodeToString(b[:])
136 s.mu.Lock()
137 s.codes[code] = address
138 s.mu.Unlock()
139 return code
140}
141
142// Lookups is how many federation lookups reached this deployment.
143func (s *Server) Lookups() int {
144 s.mu.Lock()
145 defer s.mu.Unlock()
146 return s.lookups
147}
148
149// Tokens is how many token exchanges reached this deployment.
150func (s *Server) Tokens() int {
151 s.mu.Lock()
152 defer s.mu.Unlock()
153 return s.tokens
154}
155
156func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
157 w.Header().Set("Content-Type", "application/json")
158 switch r.URL.Path {
159 case "/.well-known/keymail":
160 _ = json.NewEncoder(w).Encode(map[string]any{"version": "1", "reauth": true})
161 case "/api/federation/lookup":
162 s.mu.Lock()
163 s.lookups++
164 ok := s.accounts[strings.ToLower(r.URL.Query().Get("addr"))]
165 s.mu.Unlock()
166 if !ok {
167 w.WriteHeader(http.StatusNotFound)
168 }
169 _ = json.NewEncoder(w).Encode(map[string]bool{"ok": ok})
170 case "/api/oauth/token":
171 var in map[string]string
172 _ = json.NewDecoder(r.Body).Decode(&in)
173 s.mu.Lock()
174 s.tokens++
175 address, ok := s.codes[in["code"]]
176 delete(s.codes, in["code"])
177 s.mu.Unlock()
178 if r.Method != http.MethodPost || !ok {
179 w.WriteHeader(http.StatusBadRequest)
180 _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
181 return
182 }
183 _ = json.NewEncoder(w).Encode(map[string]any{
184 "access_token": "synthetic", "token_type": "bearer",
185 "address": address, "auth_time": time.Now().Unix(),
186 })
187 default:
188 w.WriteHeader(http.StatusNotFound)
189 }
190}
191