oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "html"
10 "io"
11 "net"
12 "net/http"
13 "net/http/cookiejar"
14 "net/http/httptest"
15 "net/url"
16 "os"
17 "os/exec"
18 "regexp"
19 "strings"
20 "sync"
21 "testing"
22 "time"
23)
24
25// Process isolation keeps the resolver and default transport overrides out of
26// concurrent tests. Every DNS packet and HTTPS request stays in this fixture.
27func TestKeymailOAuthActualIntegration(t *testing.T) {
28 if os.Getenv("DOCS_KEYMAIL_TEST_CHILD") == "1" {
29 keymailOAuthChild(t)
30 return
31 }
32 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
33 defer cancel()
34 cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestKeymailOAuthActualIntegration$", "-test.count=1", "-test.v")
35 cmd.Dir = t.TempDir()
36 for _, e := range os.Environ() {
37 if strings.HasPrefix(e, "DOCS_") || strings.HasPrefix(e, "HOME_") || strings.HasPrefix(e, "CARLOS_") || strings.HasPrefix(e, "AWS_") {
38 continue
39 }
40 cmd.Env = append(cmd.Env, e)
41 }
42 cmd.Env = append(cmd.Env, "DOCS_KEYMAIL_TEST_CHILD=1")
43 out, err := cmd.CombinedOutput()
44 if err != nil {
45 t.Fatalf("isolated OAuth test: %v\n%s", err, out)
46 }
47}
48
49type keymailTestTransport func(*http.Request) (*http.Response, error)
50
51func (f keymailTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
52
53func keymailOAuthChild(t *testing.T) {
54 dns, err := net.ListenPacket("udp", "127.0.0.1:0")
55 if err != nil {
56 t.Fatal(err)
57 }
58 defer dns.Close()
59 go func() {
60 b := make([]byte, 4096)
61 for {
62 n, a, e := dns.ReadFrom(b)
63 if e != nil {
64 return
65 }
66 if n < 12 {
67 continue
68 }
69 reply := append([]byte(nil), b[:n]...)
70 reply[2] = 0x81
71 reply[3] = 0x83
72 for i := 6; i < 12; i++ {
73 reply[i] = 0
74 }
75 _, _ = dns.WriteTo(reply, a)
76 }
77 }()
78 net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
79 return (&net.Dialer{}).DialContext(ctx, "udp", dns.LocalAddr().String())
80 }}
81 const origin = "https://docs.oauth.test"
82 const provider = "mail.oauth.test"
83 for _, tc := range []struct {
84 name, address, returned string
85 admitted, detected, badState, badPKCE bool
86 }{
87 {name: "allowed OAuth", address: "member@mail.oauth.test", admitted: true, detected: true},
88 {name: "denied OAuth", address: "outsider@mail.oauth.test", detected: true},
89 {name: "wrong address", address: "member@mail.oauth.test", returned: "outsider@mail.oauth.test", admitted: true, detected: true},
90 {name: "wrong state", address: "member@mail.oauth.test", admitted: true, detected: true, badState: true},
91 {name: "wrong PKCE", address: "member@mail.oauth.test", admitted: true, detected: true, badPKCE: true},
92 {name: "ordinary mail", address: "member@mail.oauth.test", admitted: true},
93 {name: "denied ordinary mail", address: "outsider@mail.oauth.test"},
94 } {
95 t.Run(tc.name, func(t *testing.T) {
96 var mu sync.Mutex
97 calls := map[string]int{}
98 challenge := ""
99 exchanges := 0
100 http.DefaultTransport = keymailTestTransport(func(r *http.Request) (*http.Response, error) {
101 mu.Lock()
102 defer mu.Unlock()
103 if r.URL.Scheme != "https" || r.URL.Host != provider {
104 t.Errorf("unexpected outbound request %s", r.URL)
105 return nil, fmt.Errorf("fixture refuses outbound request")
106 }
107 calls[r.URL.Path]++
108 status := http.StatusOK
109 var body any
110 switch r.URL.Path {
111 case "/.well-known/keymail":
112 body = map[string]any{"version": "1", "reauth": true}
113 case "/api/federation/lookup":
114 if r.URL.Query().Get("addr") != tc.address {
115 t.Errorf("wrong lookup address")
116 }
117 if !tc.detected {
118 status = http.StatusNotFound
119 }
120 body = map[string]bool{"ok": tc.detected}
121 case "/api/oauth/token":
122 exchanges++
123 var in map[string]string
124 if r.Method != "POST" || json.NewDecoder(io.LimitReader(r.Body, 8192)).Decode(&in) != nil {
125 t.Error("invalid token request")
126 }
127 sum := sha256.Sum256([]byte(in["code_verifier"]))
128 if in["client_id"] != origin || in["redirect_uri"] != origin+"/auth/callback" || in["code"] != "one-code" || base64.RawURLEncoding.EncodeToString(sum[:]) != challenge || exchanges != 1 {
129 status = http.StatusBadRequest
130 body = map[string]string{"error": "invalid grant"}
131 } else {
132 address := tc.address
133 if tc.returned != "" {
134 address = tc.returned
135 }
136 body = map[string]any{"access_token": "synthetic-token", "token_type": "bearer", "address": address, "auth_time": time.Now().Unix()}
137 }
138 default:
139 t.Errorf("unexpected endpoint %s", r.URL.Path)
140 status = http.StatusNotFound
141 }
142 raw, _ := json.Marshal(body)
143 return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(raw))), Request: r}, nil
144 })
145 app, e := NewForTestWith(t, Config{Origin: origin, AllowedAddresses: "member@mail.oauth.test"})
146 if e != nil {
147 t.Fatal(e)
148 }
149 jar, _ := cookiejar.New(nil)
150 request := func(method, path, body string) *httptest.ResponseRecorder {
151 u, _ := url.Parse(origin + path)
152 req := httptest.NewRequest(method, u.String(), strings.NewReader(body))
153 req.Header.Set("Origin", origin)
154 req.Header.Set("Sec-Fetch-Site", "same-origin")
155 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
156 for _, c := range jar.Cookies(u) {
157 req.AddCookie(c)
158 }
159 rec := httptest.NewRecorder()
160 app.Handler().ServeHTTP(rec, req)
161 jar.SetCookies(u, rec.Result().Cookies())
162 return rec
163 }
164 rec := request("POST", "/signin", url.Values{"address": {tc.address}}.Encode())
165 mu.Lock()
166 lookups := calls["/api/federation/lookup"]
167 mu.Unlock()
168 if lookups != 1 {
169 t.Fatalf("actual classifier lookup count=%d, status=%d", lookups, rec.Code)
170 }
171 if !tc.detected {
172 if rec.Code != 303 || rec.Header().Get("Location") != "/signin?sent=1" {
173 t.Fatalf("mail start: %d %s", rec.Code, rec.Header().Get("Location"))
174 }
175 app.signinMail.wait()
176 app.mailRecorder.mu.Lock()
177 sent := app.mailRecorder.sends
178 app.mailRecorder.mu.Unlock()
179 want := 0
180 if tc.admitted {
181 want = 1
182 }
183 if sent != want {
184 t.Fatalf("mail deliveries=%d want%d", sent, want)
185 }
186 return
187 }
188 if rec.Code != 200 || rec.Header().Get("Location") != "" {
189 t.Fatalf("OAuth continuation: %d", rec.Code)
190 }
191 var target *url.URL
192 for _, m := range regexp.MustCompile(`href="([^"]+)"`).FindAllStringSubmatch(rec.Body.String(), -1) {
193 u, e := url.Parse(html.UnescapeString(m[1]))
194 if e == nil && u.Host == provider && u.Path == "/oauth/authorize" {
195 target = u
196 }
197 }
198 if target == nil {
199 t.Fatal("no generated OAuth authorize link")
200 }
201 q := target.Query()
202 if q.Get("client_id") != origin || q.Get("redirect_uri") != origin+"/auth/callback" || q.Get("code_challenge_method") != "S256" || q.Get("prompt") != "login" || q.Get("state") == "" {
203 t.Fatal("OAuth bindings missing")
204 }
205 mu.Lock()
206 challenge = q.Get("code_challenge")
207 if tc.badPKCE {
208 challenge = "wrong"
209 }
210 mu.Unlock()
211 state := q.Get("state")
212 if tc.badState {
213 state = "wrong"
214 }
215 callback := request("GET", "/auth/callback?"+url.Values{"state": {state}, "code": {"one-code"}}.Encode(), "")
216 allowed := tc.admitted && tc.returned == "" && !tc.badState && !tc.badPKCE
217 if !tc.admitted && tc.returned == "" && !tc.badState && !tc.badPKCE {
218 // A verified address the allowlist refuses gets the door,
219 // rendered in the refusal's own 403 — not rastrillo's bare
220 // text, and not a query anybody could type.
221 body := callback.Body.String()
222 if callback.Code != http.StatusForbidden || !strings.Contains(body, `name="address"`) || !strings.Contains(body, "docs.signin.error.refused") && !strings.Contains(body, "can’t sign in to Docs") {
223 t.Fatalf("refused OAuth: %d, want the door with the refusal in a 403:\n%s", callback.Code, body)
224 }
225 spoof := request("GET", "/signin?err=refused", "")
226 if strings.Contains(spoof.Body.String(), "can’t sign in to Docs") {
227 t.Fatal("anybody can show the refusal by visiting /signin?err=refused")
228 }
229 }
230 home := request("GET", "/", "")
231 if allowed {
232 if callback.Code != 303 || home.Code != 200 {
233 t.Fatalf("authorized callback=%d home=%d", callback.Code, home.Code)
234 }
235 } else if home.Code == 200 {
236 t.Fatal("refused OAuth created admitted session")
237 }
238 if !allowed {
239 u, _ := url.Parse(origin)
240 for _, c := range jar.Cookies(u) {
241 if strings.Contains(c.Name, "session") {
242 t.Fatal("refused OAuth minted session")
243 }
244 }
245 }
246 app.signinMail.wait()
247 app.mailRecorder.mu.Lock()
248 sent := app.mailRecorder.sends
249 app.mailRecorder.mu.Unlock()
250 if sent != 0 {
251 t.Fatalf("OAuth sent %d emails", sent)
252 }
253 mu.Lock()
254 got := exchanges
255 mu.Unlock()
256 want := 1
257 if tc.badState {
258 want = 0
259 }
260 if got != want {
261 t.Fatalf("token exchanges=%d want%d", got, want)
262 }
263 // Pending state is cleared after callback: replay cannot reach exchange.
264 request("GET", "/auth/callback?"+url.Values{"state": {q.Get("state")}, "code": {"one-code"}}.Encode(), "")
265 mu.Lock()
266 defer mu.Unlock()
267 if exchanges != want {
268 t.Fatal("callback replay reached token endpoint")
269 }
270 })
271 }
272}
273