| 1 | // The Eleven connect broker: the one place OAuth client_id + client_secret |
| 2 | // pairs live (one per integration service), so instances (and their bot |
| 3 | // sidecars) never hold a secret — the same move as the push relay for the APNs |
| 4 | // .p8. See docs/connected-services.md + docs/integrations.md. |
| 5 | // |
| 6 | // It does two jobs, both for CONFIDENTIAL OAuth clients (DNSimple offers no |
| 7 | // public/PKCE client; GitHub's exchange requires the secret even with PKCE): |
| 8 | // |
| 9 | // 1. Redirect relay (GET /oauth/{service}/callback) — each service's single |
| 10 | // registered redirect_uri for the whole fleet. The provider redirects here |
| 11 | // with a one-time ?code; the broker bounces it back to the originating |
| 12 | // instance (read from an allowlisted `state`). It sees only a single-use |
| 13 | // code — no secret, no token — so a relay compromise leaks nothing usable. |
| 14 | // |
| 15 | // 2. Token exchange (POST /api/exchange) — a bot sidecar authenticates with a |
| 16 | // per-instance bearer token and sends {service, code, state}; the broker |
| 17 | // swaps it for an access token using the client_secret it holds, and |
| 18 | // returns the token. It stores nothing; the sidecar seals the token to the |
| 19 | // member. This is the one place that holds secrets and transiently sees a |
| 20 | // user token. |
| 21 | package main |
| 22 | |
| 23 | import ( |
| 24 | "crypto/subtle" |
| 25 | "encoding/base64" |
| 26 | "encoding/json" |
| 27 | "flag" |
| 28 | "fmt" |
| 29 | "io" |
| 30 | "log" |
| 31 | "net" |
| 32 | "net/http" |
| 33 | "net/url" |
| 34 | "os" |
| 35 | "strconv" |
| 36 | "strings" |
| 37 | "sync" |
| 38 | "time" |
| 39 | |
| 40 | "lchat/integrations" |
| 41 | ) |
| 42 | |
| 43 | const maxBodyBytes = 8 << 10 |
| 44 | |
| 45 | // oauthApp is one provider's confidential OAuth client — the secret this |
| 46 | // broker exists to guard, plus the public endpoints of its flow. One per |
| 47 | // service; DNSimple keeps its legacy env names, every other service is |
| 48 | // configured generically (loadOAuthApps). |
| 49 | type oauthApp struct { |
| 50 | service string |
| 51 | clientID string |
| 52 | clientSecret string |
| 53 | redirectURI string // the broker's own registered callback; sent verbatim at exchange |
| 54 | authorizeURL string // where the browser sends the user to consent (public) |
| 55 | tokenURL string // the provider's code→token endpoint (overridable for tests) |
| 56 | } |
| 57 | |
| 58 | type Server struct { |
| 59 | db *DB |
| 60 | adminHash string // sha256 of LCHAT_ADMIN_TOKEN; constant-time compared |
| 61 | |
| 62 | apps map[string]*oauthApp // service id → its confidential client |
| 63 | http *http.Client |
| 64 | |
| 65 | perInstance *rateLimiter |
| 66 | global *rateLimiter |
| 67 | } |
| 68 | |
| 69 | func main() { |
| 70 | addr := flag.String("addr", envOr("ADDR", ":1750"), "listen address (env: ADDR)") |
| 71 | dbPath := flag.String("db", envOr("LCHAT_BROKER_DB", "broker.db"), "SQLite path (env: LCHAT_BROKER_DB)") |
| 72 | flag.Parse() |
| 73 | |
| 74 | // Fail loud: a misconfigured broker breaks connecting services fleet-wide. |
| 75 | // DNSimple stays required (the legacy contract of the deployed unit); other |
| 76 | // services are additive — configured when their env pair is present. |
| 77 | admin := os.Getenv("LCHAT_ADMIN_TOKEN") |
| 78 | apps := loadOAuthApps() |
| 79 | if admin == "" || apps["dnsimple"] == nil { |
| 80 | log.Fatal("broker: LCHAT_ADMIN_TOKEN, LCHAT_DNSIMPLE_CLIENT_ID, LCHAT_DNSIMPLE_CLIENT_SECRET, LCHAT_DNSIMPLE_REDIRECT_URI are required") |
| 81 | } |
| 82 | |
| 83 | db, err := openDB(*dbPath) |
| 84 | if err != nil { |
| 85 | log.Fatalf("open db: %v", err) |
| 86 | } |
| 87 | s := &Server{ |
| 88 | db: db, |
| 89 | adminHash: hashToken(admin), |
| 90 | apps: apps, |
| 91 | http: &http.Client{Timeout: 15 * time.Second}, |
| 92 | perInstance: newRateLimiter(60, 10), |
| 93 | global: newRateLimiter(600, 100), |
| 94 | } |
| 95 | services := make([]string, 0, len(apps)) |
| 96 | for id := range apps { |
| 97 | services = append(services, id) |
| 98 | } |
| 99 | log.Printf("connect broker listening on %s (db: %s, oauth apps: %s)", *addr, *dbPath, strings.Join(services, ", ")) |
| 100 | if err := serve(*addr, s.handler()); err != nil { |
| 101 | log.Fatal(err) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // loadOAuthApps builds the per-service confidential clients from env. |
| 106 | // DNSimple reads its legacy LCHAT_DNSIMPLE_* names. Every other OAuth-capable |
| 107 | // registry service reads LCHAT_OAUTH_<ID>_CLIENT_ID / _CLIENT_SECRET (unset → |
| 108 | // that service simply isn't offered), with the provider endpoints defaulting |
| 109 | // to the manifest's auth.oauth block and the redirect defaulting to this |
| 110 | // broker's own /oauth/<id>/callback (derived from the DNSimple redirect's |
| 111 | // origin, or set explicitly with LCHAT_OAUTH_<ID>_REDIRECT_URI). |
| 112 | func loadOAuthApps() map[string]*oauthApp { |
| 113 | apps := map[string]*oauthApp{} |
| 114 | dnsRedirect := os.Getenv("LCHAT_DNSIMPLE_REDIRECT_URI") // e.g. https://connect.elevenmessenger.com/oauth/dnsimple/callback |
| 115 | if id, secret := os.Getenv("LCHAT_DNSIMPLE_CLIENT_ID"), os.Getenv("LCHAT_DNSIMPLE_CLIENT_SECRET"); id != "" && secret != "" && dnsRedirect != "" { |
| 116 | apps["dnsimple"] = &oauthApp{ |
| 117 | service: "dnsimple", |
| 118 | clientID: id, |
| 119 | clientSecret: secret, |
| 120 | redirectURI: dnsRedirect, |
| 121 | authorizeURL: envOr("LCHAT_DNSIMPLE_AUTHORIZE_URL", "https://dnsimple.com/oauth/authorize"), |
| 122 | tokenURL: envOr("LCHAT_DNSIMPLE_TOKEN_URL", "https://api.dnsimple.com/v2/oauth/access_token"), |
| 123 | } |
| 124 | } |
| 125 | // The broker's public origin, for deriving other services' redirect URIs. |
| 126 | selfOrigin := "" |
| 127 | if u, err := url.Parse(dnsRedirect); err == nil && u.Host != "" { |
| 128 | selfOrigin = u.Scheme + "://" + u.Host |
| 129 | } |
| 130 | for _, m := range integrations.MustRegistry().List() { |
| 131 | if m.ID == "dnsimple" || m.Auth.OAuth == nil || (m.Auth.Kind != "oauth" && m.Auth.Kind != "both") { |
| 132 | continue |
| 133 | } |
| 134 | prefix := "LCHAT_OAUTH_" + strings.ToUpper(strings.ReplaceAll(m.ID, "-", "_")) |
| 135 | id, secret := os.Getenv(prefix+"_CLIENT_ID"), os.Getenv(prefix+"_CLIENT_SECRET") |
| 136 | if id == "" || secret == "" { |
| 137 | continue |
| 138 | } |
| 139 | redirect := os.Getenv(prefix + "_REDIRECT_URI") |
| 140 | if redirect == "" && selfOrigin != "" { |
| 141 | redirect = selfOrigin + "/oauth/" + m.ID + "/callback" |
| 142 | } |
| 143 | if redirect == "" { |
| 144 | log.Printf("broker: %s has a client id but no derivable redirect uri — skipped", m.ID) |
| 145 | continue |
| 146 | } |
| 147 | apps[m.ID] = &oauthApp{ |
| 148 | service: m.ID, |
| 149 | clientID: id, |
| 150 | clientSecret: secret, |
| 151 | redirectURI: redirect, |
| 152 | authorizeURL: envOr(prefix+"_AUTHORIZE_URL", m.Auth.OAuth.AuthorizeURL), |
| 153 | tokenURL: envOr(prefix+"_TOKEN_URL", m.Auth.OAuth.TokenURL), |
| 154 | } |
| 155 | } |
| 156 | return apps |
| 157 | } |
| 158 | |
| 159 | // serve listens on addr: a "/"-prefixed addr is a unix socket (how the router |
| 160 | // fronts this service); otherwise a TCP address. Mirrors the push relay. |
| 161 | func serve(addr string, h http.Handler) error { |
| 162 | // ReadHeaderTimeout caps how long a client may dribble request headers |
| 163 | // (Slowloris): the TCP branch binds straight to public TCP on a container |
| 164 | // platform. Same 10s the messenger's own listeners use (main.go). |
| 165 | srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second} |
| 166 | if !strings.HasPrefix(addr, "/") { |
| 167 | srv.Addr = addr |
| 168 | return srv.ListenAndServe() |
| 169 | } |
| 170 | if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil { |
| 171 | c.Close() |
| 172 | return fmt.Errorf("socket %s is already in use by another process", addr) |
| 173 | } |
| 174 | if err := os.Remove(addr); err != nil && !os.IsNotExist(err) { |
| 175 | return fmt.Errorf("remove stale socket %s: %w", addr, err) |
| 176 | } |
| 177 | ln, err := net.Listen("unix", addr) |
| 178 | if err != nil { |
| 179 | return err |
| 180 | } |
| 181 | defer ln.Close() |
| 182 | if err := os.Chmod(addr, 0o600); err != nil { |
| 183 | return fmt.Errorf("chmod socket %s: %w", addr, err) |
| 184 | } |
| 185 | return srv.Serve(ln) |
| 186 | } |
| 187 | |
| 188 | func (s *Server) handler() http.Handler { |
| 189 | mux := http.NewServeMux() |
| 190 | mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) |
| 191 | mux.HandleFunc("GET /", s.root) |
| 192 | |
| 193 | // Public config: the non-secret OAuth params an instance needs to start a |
| 194 | // flow (client_id, authorize URL, the broker's redirect_uri), per service — |
| 195 | // so instances need NO provider config of their own; they just ask the |
| 196 | // broker. ?service defaults to dnsimple (the pre-multi-service clients). |
| 197 | mux.HandleFunc("GET /config", s.configHandler) |
| 198 | |
| 199 | // The public integrations registry (docs/integrations.md): the reviewed |
| 200 | // manifests, so every instance's web catalog can grow between deploys. |
| 201 | mux.HandleFunc("GET /registry", s.registryHandler) |
| 202 | |
| 203 | // Redirect relay: each service's single registered redirect target for the |
| 204 | // whole fleet. |
| 205 | mux.HandleFunc("GET /oauth/{service}/callback", s.callback) |
| 206 | // Token exchange: a sidecar swaps a code for a token. Bearer instance token. |
| 207 | mux.HandleFunc("POST /api/exchange", s.exchange) |
| 208 | // Token refresh: a sidecar rotates an expiring pair (GitHub Apps). Same |
| 209 | // auth, same trust shape — nothing persisted. |
| 210 | mux.HandleFunc("POST /api/refresh", s.refresh) |
| 211 | |
| 212 | // Admin: mint / list / revoke per-instance tokens (rotation needs no redeploy). |
| 213 | mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintHandler)) |
| 214 | mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listHandler)) |
| 215 | mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeHandler)) |
| 216 | |
| 217 | return securityHeaders(mux) |
| 218 | } |
| 219 | |
| 220 | func (s *Server) root(w http.ResponseWriter, r *http.Request) { |
| 221 | if r.URL.Path != "/" { |
| 222 | http.NotFound(w, r) |
| 223 | return |
| 224 | } |
| 225 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 226 | w.Write([]byte("Eleven connect broker. Holds the OAuth app secret; relays codes; exchanges tokens. See docs/connected-services.md.\n")) |
| 227 | } |
| 228 | |
| 229 | // configHandler serves one service's public OAuth params (no secret) so an |
| 230 | // instance can start a flow without holding any provider config of its own. A |
| 231 | // service the broker has no app for is a 404 — the instance renders that as |
| 232 | // "OAuth not offered" and falls back to paste-a-token. |
| 233 | func (s *Server) configHandler(w http.ResponseWriter, r *http.Request) { |
| 234 | service := r.URL.Query().Get("service") |
| 235 | if service == "" { |
| 236 | service = "dnsimple" // pre-multi-service clients never send the param |
| 237 | } |
| 238 | app := s.apps[service] |
| 239 | if app == nil { |
| 240 | http.Error(w, "unknown service", http.StatusNotFound) |
| 241 | return |
| 242 | } |
| 243 | out := map[string]any{ |
| 244 | "service": app.service, |
| 245 | "client_id": app.clientID, |
| 246 | "authorize_url": app.authorizeURL, |
| 247 | "redirect_uri": app.redirectURI, |
| 248 | } |
| 249 | // Manifest-declared provider scopes ride along (public data), so the client |
| 250 | // can put the cap-matched scope set on the authorize URL: scopes for a |
| 251 | // read-only connection, scopes_write for read & write. Absent for services |
| 252 | // that scope elsewhere (GitHub Apps at install; DNSimple not at all). |
| 253 | if m := integrations.MustRegistry().Get(app.service); m != nil && m.Auth.OAuth != nil { |
| 254 | if len(m.Auth.OAuth.Scopes) > 0 { |
| 255 | out["scopes"] = m.Auth.OAuth.Scopes |
| 256 | } |
| 257 | if len(m.Auth.OAuth.ScopesWrite) > 0 { |
| 258 | out["scopes_write"] = m.Auth.OAuth.ScopesWrite |
| 259 | } |
| 260 | } |
| 261 | writeJSON(w, http.StatusOK, out) |
| 262 | } |
| 263 | |
| 264 | // registryHandler serves the embedded integrations registry. It is public, |
| 265 | // reviewed data (no secrets live in a manifest) and deliberately CORS-open — |
| 266 | // instances' web clients may fetch the catalog cross-origin. |
| 267 | func (s *Server) registryHandler(w http.ResponseWriter, r *http.Request) { |
| 268 | w.Header().Set("Access-Control-Allow-Origin", "*") |
| 269 | writeJSON(w, http.StatusOK, map[string]any{"schema": 1, "services": integrations.MustRegistry().List()}) |
| 270 | } |
| 271 | |
| 272 | // ---- redirect relay -------------------------------------------------------- |
| 273 | |
| 274 | // callback is the fleet's single registered redirect_uri per service. The |
| 275 | // provider sends a one-time ?code (or ?error) + the ?state we minted. `state` |
| 276 | // is base64url(origin) + "." + nonce; we decode the origin, refuse it unless |
| 277 | // an admin has registered it (originAllowed — so this can never be an open |
| 278 | // redirector), and 302 the code+state on to that instance's own callback page, |
| 279 | // which hands it to the app. We hold nothing and see only a single-use code. |
| 280 | func (s *Server) callback(w http.ResponseWriter, r *http.Request) { |
| 281 | service := r.PathValue("service") |
| 282 | if s.apps[service] == nil { |
| 283 | http.Error(w, "unknown service", http.StatusNotFound) |
| 284 | return |
| 285 | } |
| 286 | q := r.URL.Query() |
| 287 | state := q.Get("state") |
| 288 | dot := strings.IndexByte(state, '.') |
| 289 | if dot <= 0 { |
| 290 | // No (or malformed) state usually means GitHub sent someone here after |
| 291 | // an app INSTALL made on github.com itself ("request OAuth during |
| 292 | // installation" bounces to the registered callback with a code but no |
| 293 | // state, since our flow never started). The install worked; a human is |
| 294 | // looking at this page — say something humane instead of "bad state". |
| 295 | w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") |
| 296 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 297 | w.WriteHeader(http.StatusOK) |
| 298 | w.Write([]byte(`<!doctype html><meta charset="utf-8"><title>All set</title>` + |
| 299 | `<body style="font:16px/1.5 system-ui;max-width:34em;margin:4rem auto;color:#333">` + |
| 300 | `<h2>All set ✓</h2><p>If you were installing or updating an app on the provider's site, ` + |
| 301 | `that worked. You can close this tab and head back to your chat — reconnect or refresh there if needed.</p>`)) |
| 302 | return |
| 303 | } |
| 304 | originBytes, err := base64.RawURLEncoding.DecodeString(state[:dot]) |
| 305 | if err != nil { |
| 306 | http.Error(w, "bad state", http.StatusBadRequest) |
| 307 | return |
| 308 | } |
| 309 | origin := string(originBytes) |
| 310 | if !s.db.originAllowed(origin) { |
| 311 | // Never redirect to an unregistered origin. |
| 312 | http.Error(w, "unknown instance", http.StatusBadRequest) |
| 313 | return |
| 314 | } |
| 315 | dest, err := url.Parse(origin + "/oauth/" + service + "/callback") |
| 316 | if err != nil { |
| 317 | http.Error(w, "bad origin", http.StatusBadRequest) |
| 318 | return |
| 319 | } |
| 320 | // A billing-flow state (rest = "billing.<nonce>") asks the broker to finish |
| 321 | // the dance itself: exchange the code right here, keep NOTHING, and send |
| 322 | // the instance only the connected-account id (Stripe's stripe_user_id). |
| 323 | // The admin's browser then links that id under its own admin session — the |
| 324 | // broker can't write to spaces, and no token from this exchange ever |
| 325 | // leaves (or outlives) this handler. Auth shape: the code is the proof of |
| 326 | // a real consent (single-use, provider-minted), the origin is |
| 327 | // registry-gated like every relay, and the exchange is rate-limited. |
| 328 | if strings.HasPrefix(state[dot+1:], "billing.") { |
| 329 | fwd := url.Values{} |
| 330 | fwd.Set("state", state) |
| 331 | code := q.Get("code") |
| 332 | switch { |
| 333 | case q.Get("error") != "": |
| 334 | fwd.Set("error", q.Get("error")) |
| 335 | case code == "": |
| 336 | fwd.Set("error", "no code") |
| 337 | case !s.perInstance.allow(origin) || !s.global.allow("*"): |
| 338 | fwd.Set("error", "rate limited") |
| 339 | default: |
| 340 | pair, err := s.tokenRequest(s.apps[service], url.Values{ |
| 341 | "grant_type": {"authorization_code"}, |
| 342 | "code": {code}, |
| 343 | "redirect_uri": {s.apps[service].redirectURI}, |
| 344 | "state": {state}, |
| 345 | }) |
| 346 | acct, _ := pair["stripe_user_id"].(string) |
| 347 | if err != nil { |
| 348 | // Log for us (never the code or token), generic error onward. |
| 349 | log.Printf("billing exchange[%s %s]: %v", origin, service, err) |
| 350 | fwd.Set("error", "exchange failed") |
| 351 | } else if acct == "" { |
| 352 | fwd.Set("error", "the provider didn't return an account id") |
| 353 | } else { |
| 354 | fwd.Set("billing_account", acct) |
| 355 | if lm, ok := pair["livemode"].(bool); ok { |
| 356 | fwd.Set("billing_livemode", strconv.FormatBool(lm)) |
| 357 | } |
| 358 | } |
| 359 | } |
| 360 | dest.RawQuery = fwd.Encode() |
| 361 | http.Redirect(w, r, dest.String(), http.StatusFound) |
| 362 | return |
| 363 | } |
| 364 | fwd := url.Values{} |
| 365 | fwd.Set("state", state) |
| 366 | if code := q.Get("code"); code != "" { |
| 367 | fwd.Set("code", code) |
| 368 | } |
| 369 | if e := q.Get("error"); e != "" { // relay a user cancellation/denial too |
| 370 | fwd.Set("error", e) |
| 371 | } |
| 372 | dest.RawQuery = fwd.Encode() |
| 373 | http.Redirect(w, r, dest.String(), http.StatusFound) |
| 374 | } |
| 375 | |
| 376 | // ---- token exchange -------------------------------------------------------- |
| 377 | |
| 378 | func (s *Server) exchange(w http.ResponseWriter, r *http.Request) { |
| 379 | origin, ok := s.db.authInstance(bearerToken(r)) |
| 380 | if !ok { |
| 381 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 382 | return |
| 383 | } |
| 384 | if !s.perInstance.allow(origin) || !s.global.allow("*") { |
| 385 | http.Error(w, "rate limited", http.StatusTooManyRequests) |
| 386 | return |
| 387 | } |
| 388 | var body struct { |
| 389 | Service string `json:"service"` |
| 390 | Code string `json:"code"` |
| 391 | State string `json:"state"` |
| 392 | } |
| 393 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&body); err != nil || body.Code == "" { |
| 394 | http.Error(w, "bad request", http.StatusBadRequest) |
| 395 | return |
| 396 | } |
| 397 | if body.Service == "" { |
| 398 | body.Service = "dnsimple" // pre-multi-service sidecars never send it |
| 399 | } |
| 400 | app := s.apps[body.Service] |
| 401 | if app == nil { |
| 402 | http.Error(w, "unknown service", http.StatusBadRequest) |
| 403 | return |
| 404 | } |
| 405 | |
| 406 | pair, err := s.tokenRequest(app, url.Values{ |
| 407 | "grant_type": {"authorization_code"}, |
| 408 | "code": {body.Code}, |
| 409 | "redirect_uri": {app.redirectURI}, |
| 410 | "state": {body.State}, |
| 411 | }) |
| 412 | if err != nil { |
| 413 | // Log the failure for us (never the code or token), return a generic error. |
| 414 | log.Printf("exchange[%s %s]: %v", origin, body.Service, err) |
| 415 | http.Error(w, "exchange failed", http.StatusBadGateway) |
| 416 | return |
| 417 | } |
| 418 | // The tokens are returned to the caller and never persisted or logged. |
| 419 | writeJSON(w, http.StatusOK, pair) |
| 420 | } |
| 421 | |
| 422 | // refresh rotates an expiring access token (providers with expiring |
| 423 | // user tokens — GitHub Apps). Same trust shape as exchange: the caller is an |
| 424 | // authenticated sidecar, the secret stays here, nothing is persisted. The |
| 425 | // provider rotates the refresh token too (GitHub's are single-use), so the |
| 426 | // whole new pair goes back for the sidecar to reseal to the member. |
| 427 | func (s *Server) refresh(w http.ResponseWriter, r *http.Request) { |
| 428 | origin, ok := s.db.authInstance(bearerToken(r)) |
| 429 | if !ok { |
| 430 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 431 | return |
| 432 | } |
| 433 | if !s.perInstance.allow(origin) || !s.global.allow("*") { |
| 434 | http.Error(w, "rate limited", http.StatusTooManyRequests) |
| 435 | return |
| 436 | } |
| 437 | var body struct { |
| 438 | Service string `json:"service"` |
| 439 | RefreshToken string `json:"refresh_token"` |
| 440 | } |
| 441 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&body); err != nil || body.RefreshToken == "" { |
| 442 | http.Error(w, "bad request", http.StatusBadRequest) |
| 443 | return |
| 444 | } |
| 445 | app := s.apps[body.Service] |
| 446 | if app == nil { |
| 447 | http.Error(w, "unknown service", http.StatusBadRequest) |
| 448 | return |
| 449 | } |
| 450 | pair, err := s.tokenRequest(app, url.Values{ |
| 451 | "grant_type": {"refresh_token"}, |
| 452 | "refresh_token": {body.RefreshToken}, |
| 453 | }) |
| 454 | if err != nil { |
| 455 | log.Printf("refresh[%s %s]: %v", origin, body.Service, err) |
| 456 | http.Error(w, "refresh failed", http.StatusBadGateway) |
| 457 | return |
| 458 | } |
| 459 | writeJSON(w, http.StatusOK, pair) |
| 460 | } |
| 461 | |
| 462 | // tokenRequest runs one confidential token-endpoint request (exchange or |
| 463 | // refresh — the grant fields differ, the client pair and parsing don't) and |
| 464 | // returns the token pair. Accept: application/json matters: GitHub answers |
| 465 | // form-encoded without it; DNSimple answers JSON either way. |
| 466 | func (s *Server) tokenRequest(app *oauthApp, form url.Values) (map[string]any, error) { |
| 467 | form.Set("client_id", app.clientID) |
| 468 | form.Set("client_secret", app.clientSecret) |
| 469 | if v := form.Get("state"); v == "" { |
| 470 | form.Del("state") |
| 471 | } |
| 472 | req, err := http.NewRequest("POST", app.tokenURL, strings.NewReader(form.Encode())) |
| 473 | if err != nil { |
| 474 | return nil, err |
| 475 | } |
| 476 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 477 | req.Header.Set("Accept", "application/json") |
| 478 | resp, err := s.http.Do(req) |
| 479 | if err != nil { |
| 480 | return nil, err |
| 481 | } |
| 482 | defer resp.Body.Close() |
| 483 | raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) |
| 484 | var out struct { |
| 485 | AccessToken string `json:"access_token"` |
| 486 | RefreshToken string `json:"refresh_token"` |
| 487 | ExpiresIn int64 `json:"expires_in"` |
| 488 | StripeUserID string `json:"stripe_user_id"` // Stripe Connect: the connected acct_… — the billing flow's whole product |
| 489 | Livemode *bool `json:"livemode"` // Stripe Connect: live vs sandbox consent |
| 490 | Message string `json:"message"` // DNSimple's error shape |
| 491 | Error string `json:"error"` // GitHub's error shape |
| 492 | ErrorDesc string `json:"error_description"` // (a 200 can still carry an error) |
| 493 | } |
| 494 | _ = json.Unmarshal(raw, &out) |
| 495 | if resp.StatusCode != http.StatusOK || out.AccessToken == "" { |
| 496 | for _, msg := range []string{out.Message, out.ErrorDesc, out.Error} { |
| 497 | if msg != "" { |
| 498 | return nil, fmt.Errorf("provider %d: %s", resp.StatusCode, msg) |
| 499 | } |
| 500 | } |
| 501 | return nil, fmt.Errorf("provider %d", resp.StatusCode) |
| 502 | } |
| 503 | // Only the fields the sidecar reseals — a provider answering with extras |
| 504 | // (scope, token_type) doesn't ride along. |
| 505 | pair := map[string]any{"access_token": out.AccessToken} |
| 506 | if out.RefreshToken != "" { |
| 507 | pair["refresh_token"] = out.RefreshToken |
| 508 | } |
| 509 | if out.ExpiresIn > 0 { |
| 510 | pair["expires_in"] = out.ExpiresIn |
| 511 | } |
| 512 | if out.StripeUserID != "" { |
| 513 | pair["stripe_user_id"] = out.StripeUserID |
| 514 | } |
| 515 | if out.Livemode != nil { |
| 516 | pair["livemode"] = *out.Livemode |
| 517 | } |
| 518 | return pair, nil |
| 519 | } |
| 520 | |
| 521 | // ---- admin ----------------------------------------------------------------- |
| 522 | |
| 523 | func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc { |
| 524 | return func(w http.ResponseWriter, r *http.Request) { |
| 525 | if subtle.ConstantTimeCompare([]byte(hashToken(bearerToken(r))), []byte(s.adminHash)) != 1 { |
| 526 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 527 | return |
| 528 | } |
| 529 | next(w, r) |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | func (s *Server) mintHandler(w http.ResponseWriter, r *http.Request) { |
| 534 | var body struct { |
| 535 | Label string `json:"label"` // the instance ORIGIN, e.g. https://test.example.com |
| 536 | } |
| 537 | if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body) != nil || body.Label == "" { |
| 538 | http.Error(w, "label (instance origin) required", http.StatusBadRequest) |
| 539 | return |
| 540 | } |
| 541 | id, token, err := s.db.mintToken(body.Label) |
| 542 | if err != nil { |
| 543 | http.Error(w, "db error", http.StatusInternalServerError) |
| 544 | return |
| 545 | } |
| 546 | writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": body.Label, "token": token}) |
| 547 | } |
| 548 | |
| 549 | func (s *Server) listHandler(w http.ResponseWriter, r *http.Request) { |
| 550 | tokens, err := s.db.listTokens() |
| 551 | if err != nil { |
| 552 | http.Error(w, "db error", http.StatusInternalServerError) |
| 553 | return |
| 554 | } |
| 555 | writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens}) |
| 556 | } |
| 557 | |
| 558 | func (s *Server) revokeHandler(w http.ResponseWriter, r *http.Request) { |
| 559 | deleted, err := s.db.revokeToken(r.PathValue("id")) |
| 560 | if err != nil { |
| 561 | http.Error(w, "db error", http.StatusInternalServerError) |
| 562 | return |
| 563 | } |
| 564 | if !deleted { |
| 565 | http.Error(w, "no such token", http.StatusNotFound) |
| 566 | return |
| 567 | } |
| 568 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 569 | } |
| 570 | |
| 571 | // ---- middleware & helpers -------------------------------------------------- |
| 572 | |
| 573 | func securityHeaders(next http.Handler) http.Handler { |
| 574 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 575 | h := w.Header() |
| 576 | h.Set("X-Content-Type-Options", "nosniff") |
| 577 | h.Set("X-Frame-Options", "DENY") |
| 578 | h.Set("Referrer-Policy", "no-referrer") |
| 579 | h.Set("Strict-Transport-Security", "max-age=31536000") |
| 580 | next.ServeHTTP(w, r) |
| 581 | }) |
| 582 | } |
| 583 | |
| 584 | func bearerToken(r *http.Request) string { |
| 585 | const p = "Bearer " |
| 586 | h := r.Header.Get("Authorization") |
| 587 | if len(h) > len(p) && h[:len(p)] == p { |
| 588 | return h[len(p):] |
| 589 | } |
| 590 | return "" |
| 591 | } |
| 592 | |
| 593 | func writeJSON(w http.ResponseWriter, status int, v any) { |
| 594 | w.Header().Set("Content-Type", "application/json") |
| 595 | w.WriteHeader(status) |
| 596 | if err := json.NewEncoder(w).Encode(v); err != nil { |
| 597 | log.Printf("write json: %v", err) |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | func envOr(key, def string) string { |
| 602 | if v := os.Getenv(key); v != "" { |
| 603 | return v |
| 604 | } |
| 605 | return def |
| 606 | } |
| 607 | |
| 608 | // ---- rate limiting (per-key token bucket, copied from push/main.go) -------- |
| 609 | |
| 610 | type rateLimiter struct { |
| 611 | mu sync.Mutex |
| 612 | buckets map[string]*bucket |
| 613 | burst float64 |
| 614 | perSec float64 |
| 615 | } |
| 616 | |
| 617 | type bucket struct { |
| 618 | tokens float64 |
| 619 | last time.Time |
| 620 | } |
| 621 | |
| 622 | func newRateLimiter(burst, perSec float64) *rateLimiter { |
| 623 | return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec} |
| 624 | } |
| 625 | |
| 626 | func (rl *rateLimiter) allow(key string) bool { |
| 627 | rl.mu.Lock() |
| 628 | defer rl.mu.Unlock() |
| 629 | now := time.Now() |
| 630 | if len(rl.buckets) > 10000 { |
| 631 | for k, b := range rl.buckets { |
| 632 | if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour { |
| 633 | delete(rl.buckets, k) |
| 634 | } |
| 635 | } |
| 636 | } |
| 637 | b, ok := rl.buckets[key] |
| 638 | if !ok { |
| 639 | b = &bucket{tokens: rl.burst, last: now} |
| 640 | rl.buckets[key] = b |
| 641 | } |
| 642 | b.tokens += now.Sub(b.last).Seconds() * rl.perSec |
| 643 | if b.tokens > rl.burst { |
| 644 | b.tokens = rl.burst |
| 645 | } |
| 646 | b.last = now |
| 647 | if b.tokens < 1 { |
| 648 | return false |
| 649 | } |
| 650 | b.tokens-- |
| 651 | return true |
| 652 | } |
| 653 | |