eleven / messenger Public

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>
Paul Campbell 2c7a4936b7d928a679c98b2d28819603732397e1 parent 5dc5b8c
7 files changed, +254 −42
  • broker/broker_test.go +85 −0
  • broker/main.go +53 −0
  • docs/community-and-tiers.md +10 −3
  • subscriptions_handlers.go +7 −5
  • web/static/apps/admin.js +31 −18
  • web/static/oauth-callback.js +8 −3
  • web/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 {