eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
21package main
22
23import (
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
43const 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).
49type 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
58type 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
69func 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).
112func 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.
161func 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
188func (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
220func (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.
233func (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.
267func (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.
280func (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
378func (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.
427func (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.
466func (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
523func (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
533func (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
549func (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
558func (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
573func 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
584func 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
593func 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
601func 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
610type rateLimiter struct {
611 mu sync.Mutex
612 buckets map[string]*bucket
613 burst float64
614 perSec float64
615}
616
617type bucket struct {
618 tokens float64
619 last time.Time
620}
621
622func newRateLimiter(burst, perSec float64) *rateLimiter {
623 return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec}
624}
625
626func (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