eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13)
14
15// newTestBroker stands up a broker with two configured OAuth apps: dnsimple
16// (the legacy default) and github (the multi-service path). Both point their
17// token endpoint at tokenURL.
18func newTestBroker(t *testing.T, tokenURL string) *httptest.Server {
19 t.Helper()
20 db, err := openDB(filepath.Join(t.TempDir(), "broker.db"))
21 if err != nil {
22 t.Fatalf("openDB: %v", err)
23 }
24 s := &Server{
25 db: db,
26 adminHash: hashToken("admintok"),
27 apps: map[string]*oauthApp{
28 "dnsimple": {
29 service: "dnsimple",
30 clientID: "cid",
31 clientSecret: "sec",
32 redirectURI: "https://broker.example/oauth/dnsimple/callback",
33 authorizeURL: "https://dnsimple.com/oauth/authorize",
34 tokenURL: tokenURL,
35 },
36 "github": {
37 service: "github",
38 clientID: "gh-cid",
39 clientSecret: "gh-sec",
40 redirectURI: "https://broker.example/oauth/github/callback",
41 authorizeURL: "https://github.com/login/oauth/authorize",
42 tokenURL: tokenURL,
43 },
44 "stripe": {
45 service: "stripe",
46 clientID: "ca_cid",
47 clientSecret: "sk_sec",
48 redirectURI: "https://broker.example/oauth/stripe/callback",
49 authorizeURL: "https://connect.stripe.com/oauth/authorize",
50 tokenURL: tokenURL,
51 },
52 },
53 http: &http.Client{Timeout: 5 * time.Second},
54 perInstance: newRateLimiter(60, 10),
55 global: newRateLimiter(600, 100),
56 }
57 // expose the db to tests via a helper server that shares it
58 ts := httptest.NewServer(s.handler())
59 t.Cleanup(ts.Close)
60 testDB = db
61 return ts
62}
63
64var testDB *DB // set by newTestBroker so tests can mint tokens directly
65
66// A fake DNSimple token endpoint: returns a token for a well-formed confidential
67// exchange (client_secret present), else 400.
68func fakeDNSimple(t *testing.T) *httptest.Server {
69 t.Helper()
70 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71 _ = r.ParseForm()
72 if r.FormValue("grant_type") == "authorization_code" && r.FormValue("client_secret") != "" && r.FormValue("code") != "" {
73 w.Header().Set("Content-Type", "application/json")
74 w.Write([]byte(`{"access_token":"tok-abc","token_type":"Bearer","account_id":1}`))
75 return
76 }
77 w.WriteHeader(http.StatusBadRequest)
78 w.Write([]byte(`{"message":"invalid grant"}`))
79 }))
80 t.Cleanup(srv.Close)
81 return srv
82}
83
84// The broker holds the secret and exchanges a code for a token; unauthenticated
85// callers are refused.
86func TestExchange(t *testing.T) {
87 dns := fakeDNSimple(t)
88 ts := newTestBroker(t, dns.URL)
89 _, tok, err := testDB.mintToken("https://test.example.com")
90 if err != nil {
91 t.Fatalf("mint: %v", err)
92 }
93
94 req, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"code":"c1","state":"s1"}`))
95 req.Header.Set("Authorization", "Bearer "+tok)
96 resp, err := http.DefaultClient.Do(req)
97 if err != nil {
98 t.Fatalf("exchange: %v", err)
99 }
100 defer resp.Body.Close()
101 if resp.StatusCode != http.StatusOK {
102 t.Fatalf("exchange: got %d", resp.StatusCode)
103 }
104 var out struct {
105 AccessToken string `json:"access_token"`
106 }
107 json.NewDecoder(resp.Body).Decode(&out)
108 if out.AccessToken != "tok-abc" {
109 t.Fatalf("access_token = %q", out.AccessToken)
110 }
111
112 // No bearer token → 401.
113 resp2, _ := http.Post(ts.URL+"/api/exchange", "application/json", strings.NewReader(`{"code":"c"}`))
114 if resp2.StatusCode != http.StatusUnauthorized {
115 t.Fatalf("no-token exchange: got %d want 401", resp2.StatusCode)
116 }
117 resp2.Body.Close()
118}
119
120// The public /config serves the non-secret OAuth params (no secret leaks) so
121// instances need no DNSimple config of their own.
122func TestConfig(t *testing.T) {
123 ts := newTestBroker(t, "http://unused")
124 resp, err := http.Get(ts.URL + "/config")
125 if err != nil {
126 t.Fatalf("config: %v", err)
127 }
128 defer resp.Body.Close()
129 var c map[string]string
130 json.NewDecoder(resp.Body).Decode(&c)
131 if c["client_id"] != "cid" || c["authorize_url"] == "" || c["redirect_uri"] == "" {
132 t.Fatalf("config = %v", c)
133 }
134 if _, leaked := c["client_secret"]; leaked {
135 t.Fatalf("/config leaked the client_secret")
136 }
137}
138
139// The multi-service path: /api/exchange with service=github uses GitHub's own
140// client pair and sends Accept: application/json (GitHub answers form-encoded
141// without it); /config?service= serves per-service params; unknown services
142// are refused everywhere.
143func TestMultiServiceExchangeAndConfig(t *testing.T) {
144 var gotAccept, gotClientID string
145 gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146 _ = r.ParseForm()
147 gotAccept = r.Header.Get("Accept")
148 gotClientID = r.FormValue("client_id")
149 if r.FormValue("client_secret") != "gh-sec" {
150 w.WriteHeader(http.StatusUnauthorized)
151 w.Write([]byte(`{"error":"incorrect_client_credentials","error_description":"The client_id and/or client_secret passed are incorrect."}`))
152 return
153 }
154 w.Header().Set("Content-Type", "application/json")
155 w.Write([]byte(`{"access_token":"gho_tok","token_type":"bearer","scope":""}`))
156 }))
157 t.Cleanup(gh.Close)
158 ts := newTestBroker(t, gh.URL)
159 _, tok, err := testDB.mintToken("https://test.example.com")
160 if err != nil {
161 t.Fatalf("mint: %v", err)
162 }
163
164 req, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"service":"github","code":"c1","state":"s1"}`))
165 req.Header.Set("Authorization", "Bearer "+tok)
166 resp, err := http.DefaultClient.Do(req)
167 if err != nil {
168 t.Fatalf("exchange: %v", err)
169 }
170 defer resp.Body.Close()
171 if resp.StatusCode != http.StatusOK {
172 t.Fatalf("github exchange: got %d", resp.StatusCode)
173 }
174 var out struct {
175 AccessToken string `json:"access_token"`
176 }
177 json.NewDecoder(resp.Body).Decode(&out)
178 if out.AccessToken != "gho_tok" {
179 t.Fatalf("access_token = %q", out.AccessToken)
180 }
181 if gotAccept != "application/json" {
182 t.Fatalf("Accept = %q, want application/json (GitHub answers form-encoded without it)", gotAccept)
183 }
184 if gotClientID != "gh-cid" {
185 t.Fatalf("client_id = %q — the exchange used the wrong service's app", gotClientID)
186 }
187
188 // Per-service public config.
189 respC, err := http.Get(ts.URL + "/config?service=github")
190 if err != nil {
191 t.Fatalf("config: %v", err)
192 }
193 defer respC.Body.Close()
194 var c map[string]string
195 json.NewDecoder(respC.Body).Decode(&c)
196 if c["client_id"] != "gh-cid" || !strings.Contains(c["authorize_url"], "github.com") || !strings.HasSuffix(c["redirect_uri"], "/oauth/github/callback") {
197 t.Fatalf("github config = %v", c)
198 }
199 if _, leaked := c["client_secret"]; leaked {
200 t.Fatalf("/config leaked the client_secret")
201 }
202
203 // Unknown service: config 404s, exchange refuses.
204 respU, _ := http.Get(ts.URL + "/config?service=nope")
205 respU.Body.Close()
206 if respU.StatusCode != http.StatusNotFound {
207 t.Fatalf("unknown-service config: got %d want 404", respU.StatusCode)
208 }
209 reqU, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"service":"nope","code":"c1"}`))
210 reqU.Header.Set("Authorization", "Bearer "+tok)
211 respX, _ := http.DefaultClient.Do(reqU)
212 respX.Body.Close()
213 if respX.StatusCode != http.StatusBadRequest {
214 t.Fatalf("unknown-service exchange: got %d want 400", respX.StatusCode)
215 }
216}
217
218// /api/refresh rotates an expiring pair: grant_type=refresh_token with the
219// service's client pair, and the WHOLE new pair (access + rotated refresh +
220// expiry) comes back for the sidecar to reseal. Unauthenticated → 401.
221// A manifest that declares OAuth scope lists gets them on /config — public
222// data the client uses to put the cap-matched scope set on the authorize URL.
223// Stripe declares ONE scope list (read_write serves both caps: Stripe refuses
224// the read_only OAuth scope without a per-platform support grant, so the
225// connection cap is the read-only enforcement there); HubSpot declares both.
226// Scope-less services (dnsimple) stay scope-less.
227func TestConfigCarriesManifestScopes(t *testing.T) {
228 ts := newTestBroker(t, "https://unused.example")
229 resp, err := http.Get(ts.URL + "/config?service=stripe")
230 if err != nil {
231 t.Fatalf("config: %v", err)
232 }
233 defer resp.Body.Close()
234 var c map[string]any
235 json.NewDecoder(resp.Body).Decode(&c)
236 scopes, _ := c["scopes"].([]any)
237 if len(scopes) != 1 || scopes[0] != "read_write" {
238 t.Fatalf("stripe scopes: %v", c["scopes"])
239 }
240 if _, has := c["scopes_write"]; has {
241 t.Fatalf("stripe declares one scope list; scopes_write should be absent: %v", c)
242 }
243
244 respD, err := http.Get(ts.URL + "/config?service=dnsimple")
245 if err != nil {
246 t.Fatalf("config: %v", err)
247 }
248 defer respD.Body.Close()
249 var d map[string]any
250 json.NewDecoder(respD.Body).Decode(&d)
251 if _, has := d["scopes"]; has {
252 t.Fatalf("dnsimple config grew scopes it never declared: %v", d)
253 }
254}
255
256func TestRefresh(t *testing.T) {
257 var gotGrant, gotRefresh string
258 gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
259 _ = r.ParseForm()
260 gotGrant = r.FormValue("grant_type")
261 gotRefresh = r.FormValue("refresh_token")
262 if r.FormValue("client_secret") != "gh-sec" {
263 w.WriteHeader(http.StatusUnauthorized)
264 return
265 }
266 w.Header().Set("Content-Type", "application/json")
267 w.Write([]byte(`{"access_token":"ghu_new","refresh_token":"ghr_new","expires_in":28800,"token_type":"bearer"}`))
268 }))
269 t.Cleanup(gh.Close)
270 ts := newTestBroker(t, gh.URL)
271 _, tok, err := testDB.mintToken("https://test.example.com")
272 if err != nil {
273 t.Fatalf("mint: %v", err)
274 }
275
276 req, _ := http.NewRequest("POST", ts.URL+"/api/refresh", strings.NewReader(`{"service":"github","refresh_token":"ghr_old"}`))
277 req.Header.Set("Authorization", "Bearer "+tok)
278 resp, err := http.DefaultClient.Do(req)
279 if err != nil {
280 t.Fatalf("refresh: %v", err)
281 }
282 defer resp.Body.Close()
283 if resp.StatusCode != http.StatusOK {
284 t.Fatalf("refresh: got %d", resp.StatusCode)
285 }
286 var out struct {
287 AccessToken string `json:"access_token"`
288 RefreshToken string `json:"refresh_token"`
289 ExpiresIn int64 `json:"expires_in"`
290 }
291 json.NewDecoder(resp.Body).Decode(&out)
292 if out.AccessToken != "ghu_new" || out.RefreshToken != "ghr_new" || out.ExpiresIn != 28800 {
293 t.Fatalf("refresh pair = %+v", out)
294 }
295 if gotGrant != "refresh_token" || gotRefresh != "ghr_old" {
296 t.Fatalf("provider saw grant_type=%q refresh_token=%q", gotGrant, gotRefresh)
297 }
298
299 // No bearer token → 401 before the provider is ever touched.
300 resp2, _ := http.Post(ts.URL+"/api/refresh", "application/json", strings.NewReader(`{"service":"github","refresh_token":"x"}`))
301 resp2.Body.Close()
302 if resp2.StatusCode != http.StatusUnauthorized {
303 t.Fatalf("tokenless refresh: got %d want 401", resp2.StatusCode)
304 }
305}
306
307// The relay bounces a github code to the instance's github callback path, and
308// refuses a service the broker has no app for.
309func TestCallbackRelayGitHub(t *testing.T) {
310 ts := newTestBroker(t, "http://unused")
311 if _, _, err := testDB.mintToken("https://test.example.com"); err != nil {
312 t.Fatalf("mint: %v", err)
313 }
314 noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
315 good := base64.RawURLEncoding.EncodeToString([]byte("https://test.example.com")) + ".nonce123"
316 resp, err := noRedirect.Get(ts.URL + "/oauth/github/callback?code=XYZ&state=" + good)
317 if err != nil {
318 t.Fatalf("callback: %v", err)
319 }
320 defer resp.Body.Close()
321 if resp.StatusCode != http.StatusFound {
322 t.Fatalf("callback: got %d want 302", resp.StatusCode)
323 }
324 if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "https://test.example.com/oauth/github/callback?") || !strings.Contains(loc, "code=XYZ") {
325 t.Fatalf("relay Location = %q", loc)
326 }
327 respU, _ := noRedirect.Get(ts.URL + "/oauth/nope/callback?code=XYZ&state=" + good)
328 respU.Body.Close()
329 if respU.StatusCode != http.StatusNotFound {
330 t.Fatalf("unknown-service callback: got %d want 404", respU.StatusCode)
331 }
332}
333
334// The redirect relay bounces a code back only to a registered instance origin,
335// and never to an unregistered one (no open redirect).
336func TestCallbackRelay(t *testing.T) {
337 ts := newTestBroker(t, "http://unused")
338 if _, _, err := testDB.mintToken("https://test.example.com"); err != nil {
339 t.Fatalf("mint: %v", err)
340 }
341 noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
342
343 good := base64.RawURLEncoding.EncodeToString([]byte("https://test.example.com")) + ".nonce123"
344 resp, err := noRedirect.Get(ts.URL + "/oauth/dnsimple/callback?code=XYZ&state=" + good)
345 if err != nil {
346 t.Fatalf("callback: %v", err)
347 }
348 defer resp.Body.Close()
349 if resp.StatusCode != http.StatusFound {
350 t.Fatalf("callback: got %d want 302", resp.StatusCode)
351 }
352 loc := resp.Header.Get("Location")
353 if !strings.HasPrefix(loc, "https://test.example.com/oauth/dnsimple/callback?") || !strings.Contains(loc, "code=XYZ") || !strings.Contains(loc, "state=") {
354 t.Fatalf("relay Location = %q", loc)
355 }
356
357 // Unregistered origin → refused, no redirect.
358 bad := base64.RawURLEncoding.EncodeToString([]byte("https://evil.example")) + ".n"
359 resp2, _ := noRedirect.Get(ts.URL + "/oauth/dnsimple/callback?code=XYZ&state=" + bad)
360 if resp2.StatusCode != http.StatusBadRequest {
361 t.Fatalf("unregistered origin: got %d want 400", resp2.StatusCode)
362 }
363 resp2.Body.Close()
364}
365
366// The public /registry serves the embedded integrations catalog, CORS-open —
367// public reviewed data that instances' web clients may fetch cross-origin.
368func TestRegistry(t *testing.T) {
369 ts := newTestBroker(t, "http://unused")
370 resp, err := http.Get(ts.URL + "/registry")
371 if err != nil {
372 t.Fatalf("registry: %v", err)
373 }
374 defer resp.Body.Close()
375 if resp.StatusCode != http.StatusOK {
376 t.Fatalf("registry: got %d", resp.StatusCode)
377 }
378 if cors := resp.Header.Get("Access-Control-Allow-Origin"); cors != "*" {
379 t.Fatalf("Access-Control-Allow-Origin = %q, want *", cors)
380 }
381 var out struct {
382 Schema int `json:"schema"`
383 Services []struct {
384 ID string `json:"id"`
385 } `json:"services"`
386 }
387 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
388 t.Fatal(err)
389 }
390 if out.Schema != 1 || len(out.Services) != 5 {
391 t.Fatalf("registry = schema %d, %d services (want 1, 5)", out.Schema, len(out.Services))
392 }
393}
394
395// A state-less arrival at the callback is a human GitHub bounced here after an
396// on-github.com app install ("request OAuth during installation") — they get a
397// humane "all set" page, never a bare "bad state" error.
398func TestCallbackWithoutStateIsFriendly(t *testing.T) {
399 ts := newTestBroker(t, "http://unused")
400 resp, err := http.Get(ts.URL + "/oauth/github/callback?code=XYZ&setup_action=install&installation_id=1")
401 if err != nil {
402 t.Fatalf("callback: %v", err)
403 }
404 defer resp.Body.Close()
405 if resp.StatusCode != http.StatusOK {
406 t.Fatalf("got %d, want a 200 page", resp.StatusCode)
407 }
408 body := make([]byte, 512)
409 n, _ := resp.Body.Read(body)
410 if !strings.Contains(string(body[:n]), "All set") {
411 t.Fatalf("page: %q", body[:n])
412 }
413}
414
415// TestBillingCallback: a "billing." state asks the broker to finish the
416// exchange itself and forward ONLY the connected-account id — never the code,
417// never a token. Errors (provider failure, denied consent, missing account id)
418// come back as ?error with nothing else.
419func TestBillingCallback(t *testing.T) {
420 provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
421 r.ParseForm()
422 switch r.Form.Get("code") {
423 case "GOODCODE":
424 json.NewEncoder(w).Encode(map[string]any{
425 "access_token": "sk_should_never_travel", "stripe_user_id": "acct_123LINKED", "livemode": true,
426 })
427 case "NOACCT": // token but no account id (a non-Connect app misconfig)
428 json.NewEncoder(w).Encode(map[string]any{"access_token": "tok"})
429 default:
430 w.WriteHeader(http.StatusBadRequest)
431 json.NewEncoder(w).Encode(map[string]any{"error": "invalid_grant"})
432 }
433 }))
434 defer provider.Close()
435 ts := newTestBroker(t, provider.URL)
436 if _, _, err := testDB.mintToken("https://space.example.com"); err != nil {
437 t.Fatalf("mint: %v", err)
438 }
439 noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
440 state := base64.RawURLEncoding.EncodeToString([]byte("https://space.example.com")) + ".billing.n0nce"
441
442 get := func(qs string) *url.URL {
443 t.Helper()
444 resp, err := noRedirect.Get(ts.URL + "/oauth/stripe/callback?" + qs)
445 if err != nil {
446 t.Fatalf("callback: %v", err)
447 }
448 defer resp.Body.Close()
449 if resp.StatusCode != http.StatusFound {
450 t.Fatalf("callback: got %d want 302", resp.StatusCode)
451 }
452 u, err := url.Parse(resp.Header.Get("Location"))
453 if err != nil {
454 t.Fatalf("bad Location: %v", err)
455 }
456 return u
457 }
458
459 // Happy path: account id + livemode forwarded; code and token absent.
460 u := get("code=GOODCODE&state=" + state)
461 if u.Host != "space.example.com" || u.Path != "/oauth/stripe/callback" {
462 t.Fatalf("redirect target = %s", u)
463 }
464 q := u.Query()
465 if q.Get("billing_account") != "acct_123LINKED" || q.Get("billing_livemode") != "true" {
466 t.Fatalf("billing params = %v", q)
467 }
468 if q.Get("code") != "" || strings.Contains(u.RawQuery, "sk_should_never_travel") {
469 t.Fatalf("secret material leaked into redirect: %s", u.RawQuery)
470 }
471
472 // Provider rejects the code → generic error, no account.
473 q = get("code=BADCODE&state=" + state).Query()
474 if q.Get("error") != "exchange failed" || q.Get("billing_account") != "" {
475 t.Fatalf("provider-failure params = %v", q)
476 }
477
478 // Token without an account id → explicit error.
479 q = get("code=NOACCT&state=" + state).Query()
480 if q.Get("error") == "" || q.Get("billing_account") != "" {
481 t.Fatalf("no-account params = %v", q)
482 }
483
484 // The member hit Cancel at the consent page → denial relayed, no exchange.
485 q = get("error=access_denied&state=" + state).Query()
486 if q.Get("error") != "access_denied" || q.Get("billing_account") != "" {
487 t.Fatalf("denied params = %v", q)
488 }
489
490 // Unregistered origin: refused outright, billing or not.
491 bad := base64.RawURLEncoding.EncodeToString([]byte("https://evil.example")) + ".billing.n"
492 resp, _ := noRedirect.Get(ts.URL + "/oauth/stripe/callback?code=GOODCODE&state=" + bad)
493 if resp.StatusCode != http.StatusBadRequest {
494 t.Fatalf("unregistered origin: got %d want 400", resp.StatusCode)
495 }
496 resp.Body.Close()
497}
498