Skip to content

v0.27.0

Release Date: 2026-10-28

A security release that hardens SystemAuth for production and moves payments onto payforge:

  • SystemAuth fails closed in production. Codes and refresh tokens are used once under concurrency, social sign-in links accounts by email only when the provider owns the address, startup refuses configurations that are unsafe with several instances or without a stable secret, and every response carries security headers.
  • Billing on payforge. The new billing package takes payments with a verified, idempotent webhook handler and server-side checkout, and replaces marketplace/stripe, which is removed.

It also adds a security policy and a production checklist.

The release is breaking. A deployment that does nothing will not start in production until it sets tokens.hmac_secret and either dpop.single_instance or a Redis replay cache, and applications that used marketplace/stripe must move to billing. Read Upgrading first.

Highlights

  • Codes and refresh tokens are used once. Two concurrent requests with the same authorization code or refresh token could both receive tokens. The storage now changes them only if they are still unused or active, so the second request gets an error and no tokens.
  • Email linking only where the provider owns the address. A new social identity links to an existing account by email only when the provider is authoritative for that address.
  • Production checks that fail closed, each with a named waiver that is logged at every start.
  • Security headers on every SystemAuth response.
  • billing applies plans, one-time sales, refunds, disputes and seller accounts to the database and the authorization graph, and serves checkout and the customer portal for the caller's organization.

Security

  • Authorization codes and refresh tokens under concurrency. Fosite reads a code or refresh token before it writes, and the storage did not run the two in one transaction, so concurrent requests could both read the same code or token as valid. Invalidating a code now matches only an unused code, and rotating a refresh token first retires the presented token only while it is active. The request that loses gets invalid_grant or invalid_request. Reusing a rotated refresh token later still revokes its family.
  • Replayed codes in the memory store. The memory store returned no request for a used code, so a replayed code answered server_error and left the tokens it had issued active. It now returns the request and the tokens are revoked.
  • Account linking by email. A new GitHub or Google identity was linked to any existing account whose verified email matched. A provider can keep an address marked verified after the person loses the mailbox, such as a former work address, so whoever held that stale verification could sign in to the account of the address's current owner. An identity now links by email only when the login is authoritative for the address:
  • Google for gmail.com and googlemail.com;
  • Google for a Workspace account's own domain, when the hd claim matches the address's domain.

Other verified emails create an account as before, and an email that already belongs to an account is refused with ErrEmailConflict. GitHub sign-in uses the account's primary email only, never another verified address. - Production checks. Outside development mode SystemAuth now refuses to start: - without tokens.hmac_secret (waiver: ephemeral_hmac_secret); - with a statically configured client that sets allow_bearer_tokens (waiver: bearer_token_clients); - without a DPoP replay cache every instance shares (dpop.replay_cache_redis_url or Options.DPoPReplayCache), unless dpop.single_instance: true declares that one instance serves it.

The token endpoint also refuses, per request and with unauthorized_client, the client_credentials grant from a client using a shared secret (waiver: client_secret_client_credentials) and every request from a client registered with allow_bearer_tokens, so clients registered at runtime are covered too. Waivers go in production.allow; each one, and dpop.single_instance, is logged as a warning at startup. - Security headers. Every SystemAuth response, including the login, consent, sign-up, sign-up code, waitlist, age question and logout pages, their error pages, and JSON and redirect responses, carries: - a content security policy with default-src 'self', base-uri 'none', frame-ancestors 'none', object-src 'none' and form-action 'self', widened only where a form's redirects leave the origin (the consent page, the logout page and the age question); - X-Frame-Options: DENY and X-Content-Type-Options: nosniff; - Referrer-Policy: same-origin; - HSTS when the issuer is https. - Payments. The marketplace/stripe path had several problems that billing on payforge does not: - there was no webhook handler; - a Stripe API-version mismatch made it fail; - an authorization sync that failed was never retried; - there was no replay or ordering protection for events; - success_url and cancel_url came from the request, an open redirect; - raw errors reached clients; - events of another application on the same Stripe account would be retried forever.

Breaking Changes

  • marketplace/stripe is removed, with marketplace.CheckoutService and its request and session types, EntService.WithCheckout, Service.Checkout, the marketplace API's POST /checkout and WithPurchaseAuthorizer. Use billing.Checkout and its endpoints; PurchaseAuthorizer and OrgRolePurchaseAuthorizer stay and are passed as billing.CheckoutConfig.Purchases.
  • Production startup requires tokens.hmac_secret, and dpop.single_instance: true or a Redis replay cache, and refuses statically configured allow_bearer_tokens clients, unless waived.
  • client_credentials with a client secret is refused in production, and discovery stops listing client_credentials, unless waived.
  • Email auto-linking is narrowed to authoritative providers, and GitHub uses the primary email only.
  • Referrer-Policy on SystemAuth pages is same-origin instead of no-referrer, so a same-origin form post keeps the Origin header the form handlers check.
  • marketplace.Subscription.IsActive is true for a past-due subscription, so its plan holds while the provider retries the payment.

Added

  • billing:
  • Store, payforge's store on the Ent client: processed event IDs in sf_billing_events, whose unique key makes processing once hold across instances, and the customer-to-organization mapping in sf_billing_customers.
  • NewWebhookHandler: verifies with payforge's reader under its body limit and applies the event under the organization's lock. It answers 400 to a bad signature, 413 to an oversized body, 500 only when reading or handling fails so the provider redelivers, and 200 to duplicate, ignored, other-mode and foreign events. Responses are fixed words; causes are logged.
  • NewSink: payforge's entitlement, sale, seller and dispute sinks and the webhook's checkout completer. A plan sets the organization's platform subscription; a paid one-time checkout grants a license matched through the order recorded at checkout (sf_billing_orders); a full refund (sf_billing_refunds) or an open or lost dispute (sf_billing_disputes) suspends it; a seller's connected account (sf_billing_sellers) records whether it can take charges. Events may arrive in any order, and every apply writes the authorization graph again, so a failed sync is repaired on redelivery.
  • NewCheckout: plan checkout, one-time listing checkout and the customer portal for the caller's organization, with redirect URLs configured on the server (https, on AllowedURLHosts) and fixed JSON errors. NewStripe builds payforge's Stripe client and webhook reader for production.
  • NewSellerGate, the seller publish gate.
  • billing/pglock: payforge's Locker as a PostgreSQL advisory lock held on its own connection, shared by every instance.
  • marketplace.PublishGate, ErrSellerNotReady and EntListingService.WithPublishGate: a paid listing is published only when its seller can take charges.
  • appauth.Options.SingleInstance. appauth.New warns when DPoP proofs use a per-instance replay cache on a public URL; the option declares that one instance serves the API.
  • systemauth.WithProductionPolicy and ProductionPolicy, the token endpoint's per-request production policy; production.allow and dpop.single_instance in SystemAuth's configuration.
  • oauthclient.User.HostedDomain, read from Google's hd claim, and systemauth.AuthoritativeEmail with ExternalLogin.EmailAuthoritative.
  • Six Ent tables: sf_billing_events, sf_billing_customers, sf_billing_orders, sf_billing_refunds, sf_billing_disputes and sf_billing_sellers, created by the schema migration.

Dependencies

  • Added github.com/plexusone/payforge v0.1.0.
  • Added github.com/plexusone/systemspec-pricingpackaging v0.1.0.
  • Removed github.com/stripe/stripe-go/v84; payforge brings stripe-go/v87 indirectly.

Upgrading

Settings an embedded SystemAuth needs to start in production. Values reach the YAML through ${VAR} references, so the variable names are a convention; use your own if you have one.

YAML key Suggested variable Needed when
tokens.hmac_secret SYSTEMAUTH_TOKEN_HMAC_SECRET Always (at least 32 bytes, stable across deploys)
dpop.single_instance: true — One instance serves SystemAuth
dpop.replay_cache_redis_url SYSTEMAUTH_DPOP_REPLAY_CACHE_REDIS_URL Several instances, in place of single_instance
production.allow: [...] — Only to waive a check: ephemeral_hmac_secret, client_secret_client_credentials, bearer_token_clients

dpop.single_instance: true asserts that exactly one process serves SystemAuth, so a per-process DPoP replay cache sees every proof.

  1. Set the token HMAC secret. Generate it once per deployment with openssl rand -hex 32 (at least 32 bytes), keep it in your secret store, and set tokens.hmac_secret: ${SYSTEMAUTH_TOKEN_HMAC_SECRET}. Keep it stable across deploys. If you had none, people sign in once more after the first deploy with it. See Deploying SystemAuth.
  2. Declare the replay cache. With one SystemAuth instance, set dpop.single_instance: true; with several, set dpop.replay_cache_redis_url. For an application served by one instance, set appauth.Options.SingleInstance to silence its warning, or pass a shared Options.ReplayCache. See DPoP.
  3. Move client_credentials clients to service accounts, or list client_secret_client_credentials in production.allow until you can. Clients registered with allow_bearer_tokens need DPoP, or the bearer_token_clients waiver. See Waiving a production check.
  4. Move from marketplace/stripe to billing. Follow Billing: write a pricing spec, mount billing's endpoints, point the Stripe webhook at the new handler, and run the schema migration for the sf_billing_* tables.
  5. Expect some people to sign in the original way. Someone who signed up with one provider and now uses another whose email is not authoritative for the address, such as GitHub for a Google address, is refused with ErrEmailConflict and signs in with the original provider.
  6. Leave Referrer-Policy alone at the proxy. A reverse proxy must pass SystemAuth's security headers through; same-origin is required so same-origin form posts carry an Origin header. See Security headers.

  7. Optional: send failed sign-ins to your sign-in page. Set BFFConfig.LoginErrorPath or appauth.Options.LoginErrorPath, such as /login; the BFF then redirects there with ?error=<CODE> instead of answering JSON. See Relying party.

  8. Optional: drop file-upload workarounds. Cookie-authenticated writes with an allowed Origin now accept any body, so multipart/form-data uploads and bodyless writes work without a JSON body.

Walk the production checklist before going live.

Documentation

For release history, see the changelog.