package main import ( "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" ) // newTestBroker stands up a broker with two configured OAuth apps: dnsimple // (the legacy default) and github (the multi-service path). Both point their // token endpoint at tokenURL. func newTestBroker(t *testing.T, tokenURL string) *httptest.Server { t.Helper() db, err := openDB(filepath.Join(t.TempDir(), "broker.db")) if err != nil { t.Fatalf("openDB: %v", err) } s := &Server{ db: db, adminHash: hashToken("admintok"), apps: map[string]*oauthApp{ "dnsimple": { service: "dnsimple", clientID: "cid", clientSecret: "sec", redirectURI: "https://broker.example/oauth/dnsimple/callback", authorizeURL: "https://dnsimple.com/oauth/authorize", tokenURL: tokenURL, }, "github": { service: "github", clientID: "gh-cid", clientSecret: "gh-sec", redirectURI: "https://broker.example/oauth/github/callback", authorizeURL: "https://github.com/login/oauth/authorize", tokenURL: tokenURL, }, "stripe": { service: "stripe", clientID: "ca_cid", clientSecret: "sk_sec", redirectURI: "https://broker.example/oauth/stripe/callback", authorizeURL: "https://connect.stripe.com/oauth/authorize", tokenURL: tokenURL, }, }, http: &http.Client{Timeout: 5 * time.Second}, perInstance: newRateLimiter(60, 10), global: newRateLimiter(600, 100), } // expose the db to tests via a helper server that shares it ts := httptest.NewServer(s.handler()) t.Cleanup(ts.Close) testDB = db return ts } var testDB *DB // set by newTestBroker so tests can mint tokens directly // A fake DNSimple token endpoint: returns a token for a well-formed confidential // exchange (client_secret present), else 400. func fakeDNSimple(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() if r.FormValue("grant_type") == "authorization_code" && r.FormValue("client_secret") != "" && r.FormValue("code") != "" { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"access_token":"tok-abc","token_type":"Bearer","account_id":1}`)) return } w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"message":"invalid grant"}`)) })) t.Cleanup(srv.Close) return srv } // The broker holds the secret and exchanges a code for a token; unauthenticated // callers are refused. func TestExchange(t *testing.T) { dns := fakeDNSimple(t) ts := newTestBroker(t, dns.URL) _, tok, err := testDB.mintToken("https://test.example.com") if err != nil { t.Fatalf("mint: %v", err) } req, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"code":"c1","state":"s1"}`)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("exchange: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("exchange: got %d", resp.StatusCode) } var out struct { AccessToken string `json:"access_token"` } json.NewDecoder(resp.Body).Decode(&out) if out.AccessToken != "tok-abc" { t.Fatalf("access_token = %q", out.AccessToken) } // No bearer token → 401. resp2, _ := http.Post(ts.URL+"/api/exchange", "application/json", strings.NewReader(`{"code":"c"}`)) if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("no-token exchange: got %d want 401", resp2.StatusCode) } resp2.Body.Close() } // The public /config serves the non-secret OAuth params (no secret leaks) so // instances need no DNSimple config of their own. func TestConfig(t *testing.T) { ts := newTestBroker(t, "http://unused") resp, err := http.Get(ts.URL + "/config") if err != nil { t.Fatalf("config: %v", err) } defer resp.Body.Close() var c map[string]string json.NewDecoder(resp.Body).Decode(&c) if c["client_id"] != "cid" || c["authorize_url"] == "" || c["redirect_uri"] == "" { t.Fatalf("config = %v", c) } if _, leaked := c["client_secret"]; leaked { t.Fatalf("/config leaked the client_secret") } } // The multi-service path: /api/exchange with service=github uses GitHub's own // client pair and sends Accept: application/json (GitHub answers form-encoded // without it); /config?service= serves per-service params; unknown services // are refused everywhere. func TestMultiServiceExchangeAndConfig(t *testing.T) { var gotAccept, gotClientID string gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() gotAccept = r.Header.Get("Accept") gotClientID = r.FormValue("client_id") if r.FormValue("client_secret") != "gh-sec" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"incorrect_client_credentials","error_description":"The client_id and/or client_secret passed are incorrect."}`)) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"access_token":"gho_tok","token_type":"bearer","scope":""}`)) })) t.Cleanup(gh.Close) ts := newTestBroker(t, gh.URL) _, tok, err := testDB.mintToken("https://test.example.com") if err != nil { t.Fatalf("mint: %v", err) } req, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"service":"github","code":"c1","state":"s1"}`)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("exchange: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("github exchange: got %d", resp.StatusCode) } var out struct { AccessToken string `json:"access_token"` } json.NewDecoder(resp.Body).Decode(&out) if out.AccessToken != "gho_tok" { t.Fatalf("access_token = %q", out.AccessToken) } if gotAccept != "application/json" { t.Fatalf("Accept = %q, want application/json (GitHub answers form-encoded without it)", gotAccept) } if gotClientID != "gh-cid" { t.Fatalf("client_id = %q — the exchange used the wrong service's app", gotClientID) } // Per-service public config. respC, err := http.Get(ts.URL + "/config?service=github") if err != nil { t.Fatalf("config: %v", err) } defer respC.Body.Close() var c map[string]string json.NewDecoder(respC.Body).Decode(&c) if c["client_id"] != "gh-cid" || !strings.Contains(c["authorize_url"], "github.com") || !strings.HasSuffix(c["redirect_uri"], "/oauth/github/callback") { t.Fatalf("github config = %v", c) } if _, leaked := c["client_secret"]; leaked { t.Fatalf("/config leaked the client_secret") } // Unknown service: config 404s, exchange refuses. respU, _ := http.Get(ts.URL + "/config?service=nope") respU.Body.Close() if respU.StatusCode != http.StatusNotFound { t.Fatalf("unknown-service config: got %d want 404", respU.StatusCode) } reqU, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"service":"nope","code":"c1"}`)) reqU.Header.Set("Authorization", "Bearer "+tok) respX, _ := http.DefaultClient.Do(reqU) respX.Body.Close() if respX.StatusCode != http.StatusBadRequest { t.Fatalf("unknown-service exchange: got %d want 400", respX.StatusCode) } } // /api/refresh rotates an expiring pair: grant_type=refresh_token with the // service's client pair, and the WHOLE new pair (access + rotated refresh + // expiry) comes back for the sidecar to reseal. Unauthenticated → 401. // A manifest that declares OAuth scope lists gets them on /config — public // data the client uses to put the cap-matched scope set on the authorize URL. // Stripe declares ONE scope list (read_write serves both caps: Stripe refuses // the read_only OAuth scope without a per-platform support grant, so the // connection cap is the read-only enforcement there); HubSpot declares both. // Scope-less services (dnsimple) stay scope-less. func TestConfigCarriesManifestScopes(t *testing.T) { ts := newTestBroker(t, "https://unused.example") resp, err := http.Get(ts.URL + "/config?service=stripe") if err != nil { t.Fatalf("config: %v", err) } defer resp.Body.Close() var c map[string]any json.NewDecoder(resp.Body).Decode(&c) scopes, _ := c["scopes"].([]any) if len(scopes) != 1 || scopes[0] != "read_write" { t.Fatalf("stripe scopes: %v", c["scopes"]) } if _, has := c["scopes_write"]; has { t.Fatalf("stripe declares one scope list; scopes_write should be absent: %v", c) } respD, err := http.Get(ts.URL + "/config?service=dnsimple") if err != nil { t.Fatalf("config: %v", err) } defer respD.Body.Close() var d map[string]any json.NewDecoder(respD.Body).Decode(&d) if _, has := d["scopes"]; has { t.Fatalf("dnsimple config grew scopes it never declared: %v", d) } } func TestRefresh(t *testing.T) { var gotGrant, gotRefresh string gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() gotGrant = r.FormValue("grant_type") gotRefresh = r.FormValue("refresh_token") if r.FormValue("client_secret") != "gh-sec" { w.WriteHeader(http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"access_token":"ghu_new","refresh_token":"ghr_new","expires_in":28800,"token_type":"bearer"}`)) })) t.Cleanup(gh.Close) ts := newTestBroker(t, gh.URL) _, tok, err := testDB.mintToken("https://test.example.com") if err != nil { t.Fatalf("mint: %v", err) } req, _ := http.NewRequest("POST", ts.URL+"/api/refresh", strings.NewReader(`{"service":"github","refresh_token":"ghr_old"}`)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("refresh: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("refresh: got %d", resp.StatusCode) } var out struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` } json.NewDecoder(resp.Body).Decode(&out) if out.AccessToken != "ghu_new" || out.RefreshToken != "ghr_new" || out.ExpiresIn != 28800 { t.Fatalf("refresh pair = %+v", out) } if gotGrant != "refresh_token" || gotRefresh != "ghr_old" { t.Fatalf("provider saw grant_type=%q refresh_token=%q", gotGrant, gotRefresh) } // No bearer token → 401 before the provider is ever touched. resp2, _ := http.Post(ts.URL+"/api/refresh", "application/json", strings.NewReader(`{"service":"github","refresh_token":"x"}`)) resp2.Body.Close() if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("tokenless refresh: got %d want 401", resp2.StatusCode) } } // The relay bounces a github code to the instance's github callback path, and // refuses a service the broker has no app for. func TestCallbackRelayGitHub(t *testing.T) { ts := newTestBroker(t, "http://unused") if _, _, err := testDB.mintToken("https://test.example.com"); err != nil { t.Fatalf("mint: %v", err) } noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} good := base64.RawURLEncoding.EncodeToString([]byte("https://test.example.com")) + ".nonce123" resp, err := noRedirect.Get(ts.URL + "/oauth/github/callback?code=XYZ&state=" + good) if err != nil { t.Fatalf("callback: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("callback: got %d want 302", resp.StatusCode) } if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "https://test.example.com/oauth/github/callback?") || !strings.Contains(loc, "code=XYZ") { t.Fatalf("relay Location = %q", loc) } respU, _ := noRedirect.Get(ts.URL + "/oauth/nope/callback?code=XYZ&state=" + good) respU.Body.Close() if respU.StatusCode != http.StatusNotFound { t.Fatalf("unknown-service callback: got %d want 404", respU.StatusCode) } } // The redirect relay bounces a code back only to a registered instance origin, // and never to an unregistered one (no open redirect). func TestCallbackRelay(t *testing.T) { ts := newTestBroker(t, "http://unused") if _, _, err := testDB.mintToken("https://test.example.com"); err != nil { t.Fatalf("mint: %v", err) } noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} good := base64.RawURLEncoding.EncodeToString([]byte("https://test.example.com")) + ".nonce123" resp, err := noRedirect.Get(ts.URL + "/oauth/dnsimple/callback?code=XYZ&state=" + good) if err != nil { t.Fatalf("callback: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("callback: got %d want 302", resp.StatusCode) } loc := resp.Header.Get("Location") if !strings.HasPrefix(loc, "https://test.example.com/oauth/dnsimple/callback?") || !strings.Contains(loc, "code=XYZ") || !strings.Contains(loc, "state=") { t.Fatalf("relay Location = %q", loc) } // Unregistered origin → refused, no redirect. bad := base64.RawURLEncoding.EncodeToString([]byte("https://evil.example")) + ".n" resp2, _ := noRedirect.Get(ts.URL + "/oauth/dnsimple/callback?code=XYZ&state=" + bad) if resp2.StatusCode != http.StatusBadRequest { t.Fatalf("unregistered origin: got %d want 400", resp2.StatusCode) } resp2.Body.Close() } // The public /registry serves the embedded integrations catalog, CORS-open — // public reviewed data that instances' web clients may fetch cross-origin. func TestRegistry(t *testing.T) { ts := newTestBroker(t, "http://unused") resp, err := http.Get(ts.URL + "/registry") if err != nil { t.Fatalf("registry: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("registry: got %d", resp.StatusCode) } if cors := resp.Header.Get("Access-Control-Allow-Origin"); cors != "*" { t.Fatalf("Access-Control-Allow-Origin = %q, want *", cors) } var out struct { Schema int `json:"schema"` Services []struct { ID string `json:"id"` } `json:"services"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if out.Schema != 1 || len(out.Services) != 5 { t.Fatalf("registry = schema %d, %d services (want 1, 5)", out.Schema, len(out.Services)) } } // A state-less arrival at the callback is a human GitHub bounced here after an // on-github.com app install ("request OAuth during installation") — they get a // humane "all set" page, never a bare "bad state" error. func TestCallbackWithoutStateIsFriendly(t *testing.T) { ts := newTestBroker(t, "http://unused") resp, err := http.Get(ts.URL + "/oauth/github/callback?code=XYZ&setup_action=install&installation_id=1") if err != nil { t.Fatalf("callback: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("got %d, want a 200 page", resp.StatusCode) } body := make([]byte, 512) n, _ := resp.Body.Read(body) if !strings.Contains(string(body[:n]), "All set") { t.Fatalf("page: %q", body[:n]) } } // TestBillingCallback: a "billing." state asks the broker to finish the // exchange itself and forward ONLY the connected-account id — never the code, // never a token. Errors (provider failure, denied consent, missing account id) // come back as ?error with nothing else. func TestBillingCallback(t *testing.T) { provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.ParseForm() switch r.Form.Get("code") { case "GOODCODE": json.NewEncoder(w).Encode(map[string]any{ "access_token": "sk_should_never_travel", "stripe_user_id": "acct_123LINKED", "livemode": true, }) case "NOACCT": // token but no account id (a non-Connect app misconfig) json.NewEncoder(w).Encode(map[string]any{"access_token": "tok"}) default: w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{"error": "invalid_grant"}) } })) defer provider.Close() ts := newTestBroker(t, provider.URL) if _, _, err := testDB.mintToken("https://space.example.com"); err != nil { t.Fatalf("mint: %v", err) } noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} state := base64.RawURLEncoding.EncodeToString([]byte("https://space.example.com")) + ".billing.n0nce" get := func(qs string) *url.URL { t.Helper() resp, err := noRedirect.Get(ts.URL + "/oauth/stripe/callback?" + qs) if err != nil { t.Fatalf("callback: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("callback: got %d want 302", resp.StatusCode) } u, err := url.Parse(resp.Header.Get("Location")) if err != nil { t.Fatalf("bad Location: %v", err) } return u } // Happy path: account id + livemode forwarded; code and token absent. u := get("code=GOODCODE&state=" + state) if u.Host != "space.example.com" || u.Path != "/oauth/stripe/callback" { t.Fatalf("redirect target = %s", u) } q := u.Query() if q.Get("billing_account") != "acct_123LINKED" || q.Get("billing_livemode") != "true" { t.Fatalf("billing params = %v", q) } if q.Get("code") != "" || strings.Contains(u.RawQuery, "sk_should_never_travel") { t.Fatalf("secret material leaked into redirect: %s", u.RawQuery) } // Provider rejects the code → generic error, no account. q = get("code=BADCODE&state=" + state).Query() if q.Get("error") != "exchange failed" || q.Get("billing_account") != "" { t.Fatalf("provider-failure params = %v", q) } // Token without an account id → explicit error. q = get("code=NOACCT&state=" + state).Query() if q.Get("error") == "" || q.Get("billing_account") != "" { t.Fatalf("no-account params = %v", q) } // The member hit Cancel at the consent page → denial relayed, no exchange. q = get("error=access_denied&state=" + state).Query() if q.Get("error") != "access_denied" || q.Get("billing_account") != "" { t.Fatalf("denied params = %v", q) } // Unregistered origin: refused outright, billing or not. bad := base64.RawURLEncoding.EncodeToString([]byte("https://evil.example")) + ".billing.n" resp, _ := noRedirect.Get(ts.URL + "/oauth/stripe/callback?code=GOODCODE&state=" + bad) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("unregistered origin: got %d want 400", resp.StatusCode) } resp.Body.Close() }