server+broker+web: link the space's Stripe account via OAuth, never a pasted id (#1030)
The admin's 'Connect Stripe account' button rides the connect broker's existing consent trip with a 'billing.'-marked state. The broker finishes the exchange itself (it holds the client secret), keeps nothing, and forwards ONLY the stripe_user_id; the admin's own browser session links it through the tierAdmin-gated POST /api/admin/billing/account. No token ever reaches the browser, the instance, or rests on the broker — direct charges authenticate with the platform key + the account id, so the E2EE trust model is untouched. - broker: billing branch in the callback (exchange + discard, rate-limited, origin-gated), stripe_user_id/livemode preserved through tokenRequest - web: openBillingAuthorize + the billing return leg in completeOAuthReturn; admin app's payments row swaps the paste modal for the connect button (and says so when the platform has no broker OAuth configured) - server: getBillingStatus also returns the linked account id for display
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+254
−42
broker/broker_test.go+85 −0broker/main.go+53 −0docs/community-and-tiers.md+10 −3subscriptions_handlers.go+7 −5web/static/apps/admin.js+31 −18web/static/oauth-callback.js+8 −3web/static/shell.js+60 −13
diff --git a/broker/broker_test.go b/broker/broker_test.go| index b2c0ab5..da4df76 100644 |
| --- a/broker/broker_test.go |
| +++ b/broker/broker_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "net/http" |
| "net/http/httptest" |
| + "net/url" |
| "path/filepath" |
| "strings" |
| "testing" |
| @@ -410,3 +411,87 @@ func TestCallbackWithoutStateIsFriendly(t *testing.T) { |
| 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() |
| +} |
diff --git a/broker/main.go b/broker/main.go| index 3b224ba..728abc6 100644 |
| --- a/broker/main.go |
| +++ b/broker/main.go |
| @@ -32,6 +32,7 @@ import ( |
| "net/http" |
| "net/url" |
| "os" |
| + "strconv" |
| "strings" |
| "sync" |
| "time" |
| @@ -316,6 +317,50 @@ func (s *Server) callback(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "bad origin", http.StatusBadRequest) |
| return |
| } |
| + // A billing-flow state (rest = "billing.<nonce>") asks the broker to finish |
| + // the dance itself: exchange the code right here, keep NOTHING, and send |
| + // the instance only the connected-account id (Stripe's stripe_user_id). |
| + // The admin's browser then links that id under its own admin session — the |
| + // broker can't write to spaces, and no token from this exchange ever |
| + // leaves (or outlives) this handler. Auth shape: the code is the proof of |
| + // a real consent (single-use, provider-minted), the origin is |
| + // registry-gated like every relay, and the exchange is rate-limited. |
| + if strings.HasPrefix(state[dot+1:], "billing.") { |
| + fwd := url.Values{} |
| + fwd.Set("state", state) |
| + code := q.Get("code") |
| + switch { |
| + case q.Get("error") != "": |
| + fwd.Set("error", q.Get("error")) |
| + case code == "": |
| + fwd.Set("error", "no code") |
| + case !s.perInstance.allow(origin) || !s.global.allow("*"): |
| + fwd.Set("error", "rate limited") |
| + default: |
| + pair, err := s.tokenRequest(s.apps[service], url.Values{ |
| + "grant_type": {"authorization_code"}, |
| + "code": {code}, |
| + "redirect_uri": {s.apps[service].redirectURI}, |
| + "state": {state}, |
| + }) |
| + acct, _ := pair["stripe_user_id"].(string) |
| + if err != nil { |
| + // Log for us (never the code or token), generic error onward. |
| + log.Printf("billing exchange[%s %s]: %v", origin, service, err) |
| + fwd.Set("error", "exchange failed") |
| + } else if acct == "" { |
| + fwd.Set("error", "the provider didn't return an account id") |
| + } else { |
| + fwd.Set("billing_account", acct) |
| + if lm, ok := pair["livemode"].(bool); ok { |
| + fwd.Set("billing_livemode", strconv.FormatBool(lm)) |
| + } |
| + } |
| + } |
| + dest.RawQuery = fwd.Encode() |
| + http.Redirect(w, r, dest.String(), http.StatusFound) |
| + return |
| + } |
| fwd := url.Values{} |
| fwd.Set("state", state) |
| if code := q.Get("code"); code != "" { |
| @@ -440,6 +485,8 @@ func (s *Server) tokenRequest(app *oauthApp, form url.Values) (map[string]any, e |
| AccessToken string `json:"access_token"` |
| RefreshToken string `json:"refresh_token"` |
| ExpiresIn int64 `json:"expires_in"` |
| + StripeUserID string `json:"stripe_user_id"` // Stripe Connect: the connected acct_… — the billing flow's whole product |
| + Livemode *bool `json:"livemode"` // Stripe Connect: live vs sandbox consent |
| Message string `json:"message"` // DNSimple's error shape |
| Error string `json:"error"` // GitHub's error shape |
| ErrorDesc string `json:"error_description"` // (a 200 can still carry an error) |
| @@ -462,6 +509,12 @@ func (s *Server) tokenRequest(app *oauthApp, form url.Values) (map[string]any, e |
| if out.ExpiresIn > 0 { |
| pair["expires_in"] = out.ExpiresIn |
| } |
| + if out.StripeUserID != "" { |
| + pair["stripe_user_id"] = out.StripeUserID |
| + } |
| + if out.Livemode != nil { |
| + pair["livemode"] = *out.Livemode |
| + } |
| return pair, nil |
| } |
diff --git a/docs/community-and-tiers.md b/docs/community-and-tiers.md| index 623f963..a709a5e 100644 |
| --- a/docs/community-and-tiers.md |
| +++ b/docs/community-and-tiers.md |
| @@ -251,9 +251,16 @@ the hub; a self-hosted instance simply doesn't offer it. |
| > set and a space links a connected account. The **member** Membership surface |
| > ships on all three clients — web (`showMembership` in shell.js) and native |
| > (`MembershipView` in `LChatCore`, wired into iOS Settings + the Mac Settings |
| -> window). Deferred: moving the Stripe platform secret onto the *broker* (the |
| -> hub is instance-env for now — a provider swap), the Stripe-hosted Connect |
| -> *onboarding link* (the admin pastes their `acct_…` today), **native admin |
| +> window). **Account linking is OAuth** (decided 2026-07-15 — no pasted |
| +> `acct_…` ids): the admin's "Connect Stripe account" button rides the connect |
| +> broker's consent trip with a `billing.`-marked state; the broker exchanges |
| +> the code itself, keeps nothing, and forwards only the `stripe_user_id`, |
| +> which the admin's own browser session links via the tierAdmin-gated |
| +> `POST /api/admin/billing/account`. No token ever reaches the browser, the |
| +> instance, or rests on the broker — direct charges need only the platform |
| +> key + the account id. Deferred: moving the Stripe platform secret onto the |
| +> *broker* (the hub is instance-env for now — a provider swap), Stripe-hosted |
| +> Connect onboarding for admins without a Stripe account, **native admin |
| > level management** (create/price/link — web-only for now; the native app |
| > tracks no admin role), and (6) app-store IAP. |
diff --git a/subscriptions_handlers.go b/subscriptions_handlers.go| index fd1e233..5628181 100644 |
| --- a/subscriptions_handlers.go |
| +++ b/subscriptions_handlers.go |
| @@ -40,8 +40,9 @@ func (s *Server) origin(r *http.Request) string { |
| // postBillingAccount (admin) links the space's Stripe connected account — |
| // "acct_…", where members' payments land (the admin is merchant of record). |
| -// The full Stripe-hosted onboarding link belongs on the broker (design doc); |
| -// this stores the resulting account id so the feature can be wired now. |
| +// The id arrives from the OAuth return leg (completeOAuthReturn in shell.js — |
| +// the broker exchanged the code and forwarded only the account id), posted |
| +// under the admin's own session so a callback alone can never link a space. |
| func (s *Server) postBillingAccount(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| Token string `json:"token"` |
| @@ -75,9 +76,10 @@ func (s *Server) getBillingStatus(w http.ResponseWriter, r *http.Request) { |
| acct, _, _ := s.db.getConfig("stripe_connect_account") |
| _, platform := s.pay.(*stripeConnect) // a real provider is wired at the platform level |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "platform": platform, // Stripe keys present on this box |
| - "available": s.pay != nil && s.pay.Configured(), // AND this space linked an account |
| - "account": acct != "", |
| + "platform": platform, // Stripe keys present on this box |
| + "available": s.pay != nil && s.pay.Configured(), // AND this space linked an account |
| + "account": acct != "", |
| + "account_id": acct, // shown to admins ("connected as acct_…"); not a secret |
| }) |
| } |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index 0ce2463..82bef25 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -38,6 +38,9 @@ const N = namespace("admin"); |
| // retries here, briefly, re-reading state each attempt. `posted` guards the |
| // same pass re-sending an entry whose echo hasn't folded back yet. |
| const announcedFor = new Set(); |
| +// The one live billing-changed hook — each render swaps its own closure in, |
| +// so the OAuth return leg repaints the CURRENT payments row, never a stale one. |
| +let billingHook = null; |
| async function announceNew(ctx) { |
| if (announcedFor.has(ctx.threadId)) return; |
| announcedFor.add(ctx.threadId); |
| @@ -529,16 +532,37 @@ export const app = defineApp({ |
| row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--muted)"; |
| const txt = document.createElement("span"); |
| txt.textContent = billingReady |
| - ? "Paid access levels are on — members pay your Stripe account (we take 10%)." |
| - : "Charge for access levels: link your Stripe account to start."; |
| + ? `Paid access levels are on — members pay your Stripe account${st.account_id ? ` (${st.account_id})` : ""}; we take 10%.` |
| + : "Charge for access levels: connect your Stripe account to start."; |
| row.appendChild(txt); |
| - const link = document.createElement("button"); |
| - link.className = "linkish"; |
| - link.textContent = billingReady ? "Change Stripe account" : "Link Stripe account"; |
| - link.onclick = () => editStripeAccount(refreshPay); |
| - row.appendChild(link); |
| + // Linking goes through Stripe's own consent page (OAuth) — never a |
| + // pasted account id. No broker/OAuth on this platform → say so instead. |
| + if (await bridge.billingOAuthReady()) { |
| + const link = document.createElement("button"); |
| + link.className = "linkish"; |
| + link.textContent = billingReady ? "Change Stripe account" : "Connect Stripe account"; |
| + link.onclick = async () => { |
| + link.disabled = true; |
| + const res = await bridge.startBillingConnect(); |
| + if (!res || !res.ok) { |
| + flash((res && res.error) || "Stripe connect isn't available right now"); |
| + link.disabled = false; |
| + } // on ok the whole tab is on its way to Stripe |
| + }; |
| + row.appendChild(link); |
| + } else if (!billingReady) { |
| + txt.textContent = "Charge for access levels: Stripe connection isn't configured on this platform yet."; |
| + } |
| paySec.appendChild(row); |
| }; |
| + // The OAuth return leg (completeOAuthReturn in the shell) links the account |
| + // in the background and announces itself — repaint and surface the word. |
| + if (billingHook) window.removeEventListener("lchat:billing-changed", billingHook); |
| + billingHook = (e) => { |
| + refreshPay(); |
| + if (e.detail && e.detail.text) flash(e.detail.text); |
| + }; |
| + window.addEventListener("lchat:billing-changed", billingHook); |
| const levelsList = document.createElement("div"); |
| levelsSec.appendChild(levelsList); |
| @@ -840,17 +864,6 @@ export const app = defineApp({ |
| if (first) first.focus(); |
| }; |
| - const editStripeAccount = (done) => |
| - configModal( |
| - "Link your Stripe account", |
| - [{ key: "account", label: "Stripe account id (acct_…)", placeholder: "acct_…", value: "" }], |
| - async (v) => { |
| - const ok = await bridge.setBillingAccount(v.account); |
| - if (ok) await done(); |
| - return ok; |
| - }, |
| - ); |
| - |
| const editLevelPrice = (level, done) => |
| configModal( |
| `Price for ${level.name}`, |
diff --git a/web/static/oauth-callback.js b/web/static/oauth-callback.js| index d780eef..6056f95 100644 |
| --- a/web/static/oauth-callback.js |
| +++ b/web/static/oauth-callback.js |
| @@ -11,6 +11,9 @@ |
| var code = q.get("code"); |
| var state = q.get("state"); |
| var error = q.get("error"); |
| + // Billing flow (paid access levels): the broker already exchanged the code |
| + // and hands back only the connected-account id — no code, no token. |
| + var billingAccount = q.get("billing_account"); |
| if (window.opener) { |
| try { |
| window.opener.postMessage({ source: "lchat-oauth", code: code, state: state, error: error }, location.origin); |
| @@ -20,10 +23,12 @@ |
| } |
| try { |
| var pend = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null"); |
| - if (error || !code) { |
| - sessionStorage.setItem("lchat_oauth", JSON.stringify({ service: pend && pend.service, error: error || "no code" })); |
| + if (error || (!code && !billingAccount)) { |
| + // Keep the billing flag so the completion can word the failure right. |
| + sessionStorage.setItem("lchat_oauth", JSON.stringify({ service: pend && pend.service, billing: pend && pend.billing, error: error || "no code" })); |
| } else if (pend && pend.state === state) { |
| - pend.code = code; |
| + if (code) pend.code = code; |
| + if (billingAccount) pend.billingAccount = billingAccount; |
| sessionStorage.setItem("lchat_oauth", JSON.stringify(pend)); |
| } |
| } catch (e) {} |
diff --git a/web/static/shell.js b/web/static/shell.js| index 87a1902..fda76d9 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -4255,10 +4255,64 @@ function openAuthorize(service, cfg, cap) { |
| return { ok: true }; |
| } |
| +// The billing variant of openAuthorize: a space ADMIN connects the space's |
| +// Stripe account for paid access levels. Same broker + consent trip, but the |
| +// state's "billing." marker asks the broker to finish the exchange itself and |
| +// hand back ONLY the connected-account id — no token ever reaches the browser |
| +// or the server. Direct charges authenticate with the platform key + that id, |
| +// which is the same non-secret string admins used to paste by hand. |
| +function openBillingAuthorize(cfg) { |
| + if (!session.userPriv) return { ok: false, error: "connecting Stripe isn't supported in the native app yet" }; |
| + if (!cfg || !cfg.client_id || !cfg.authorize_url || !cfg.redirect_uri) |
| + return { ok: false, error: "Stripe isn't set up for OAuth on this server yet" }; |
| + const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16))); |
| + const state = bytesToB64url(new TextEncoder().encode(location.origin)) + ".billing." + nonce; |
| + sessionStorage.setItem("lchat_oauth", JSON.stringify({ service: "stripe", state, billing: true })); |
| + let url = |
| + `${cfg.authorize_url}?response_type=code&client_id=${encodeURIComponent(cfg.client_id)}` + |
| + `&redirect_uri=${encodeURIComponent(cfg.redirect_uri)}&state=${encodeURIComponent(state)}`; |
| + if (Array.isArray(cfg.scopes) && cfg.scopes.length) url += `&scope=${encodeURIComponent(cfg.scopes.join(" "))}`; |
| + location.assign(url); |
| + return { ok: true }; |
| +} |
| + |
| // On the way back (the app booted at "/" after the callback stashed the code), |
| // finish the exchange. Runs on ws.onopen when roster/threads are loaded; no-ops |
| // unless there's a returned code waiting. Idempotent — clears the pending entry. |
| async function completeOAuthReturn() { |
| + // Billing return first: nothing to exchange (the broker already did) — just |
| + // link the account id under THIS admin session. postBillingAccount is |
| + // tierAdmin-gated, so a non-admin session can't complete a link. |
| + { |
| + let pend; |
| + try { |
| + pend = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null"); |
| + } catch { |
| + pend = null; |
| + } |
| + if (pend && pend.billing) { |
| + sessionStorage.removeItem("lchat_oauth"); |
| + let text; |
| + if (pend.error || !pend.billingAccount) { |
| + text = "Stripe connection cancelled."; |
| + } else { |
| + try { |
| + const r = await fetch("/api/admin/billing/account", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, account: pend.billingAccount }), |
| + }); |
| + text = r.ok |
| + ? "Stripe account connected — you can price your levels now." |
| + : `Couldn't save the Stripe account (${(await r.json().catch(() => ({}))).error || r.status}).`; |
| + } catch { |
| + text = "Couldn't save the Stripe account — try again."; |
| + } |
| + } |
| + window.dispatchEvent(new CustomEvent("lchat:billing-changed", { detail: { text } })); |
| + return; |
| + } |
| + } |
| let pend; |
| try { |
| pend = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null"); |
| @@ -5966,19 +6020,12 @@ setSetupBridge({ |
| return false; |
| } |
| }, |
| - setBillingAccount: async (account) => { |
| - try { |
| - return ( |
| - await fetch("/api/admin/billing/account", { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, account }), |
| - }) |
| - ).ok; |
| - } catch { |
| - return false; |
| - } |
| - }, |
| + // OAuth account linking (the only path — no ids pasted by hand): whether |
| + // the broker offers Stripe OAuth here, and the full-tab consent trip. The |
| + // return leg lands in completeOAuthReturn, which links the account and |
| + // fires "lchat:billing-changed". |
| + billingOAuthReady: async () => !!(await loadOAuthCfg("stripe")), |
| + startBillingConnect: async () => openBillingAuthorize(await loadOAuthCfg("stripe")), |
| // A member's own show/hide for a level (honoured only under the "user" policy). |
| setTierShown: async (id, show) => { |
| try { |