diff --git a/.changeset/billing-recent-auth-actions.md b/.changeset/billing-recent-auth-actions.md new file mode 100644 index 0000000..b230ae0 --- /dev/null +++ b/.changeset/billing-recent-auth-actions.md @@ -0,0 +1,9 @@ +--- +"@codelitdev/platform": minor +--- + +`createBillingActionGrants` takes `recentAuthActions`, the billing actions that need a recent sign-in. The default is every action, as before. Pass only the actions that are hard to undo, such as `["payer_transfer"]`, or `[]` for none. + +**Product changes for this release** + +- Asking for a fresh sign-in before checkout, a plan change, the portal, or a cancellation stops people mid-purchase without protecting anything: the single-use token already stops replay and CSRF, and the provider confirms the payer. Set `recentAuthActions` to the actions your product treats as sensitive. diff --git a/docs/architecture.md b/docs/architecture.md index 3689b41..6c66c01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -511,6 +511,12 @@ Admin auth follows FrontLit: - API and MCP accept configured OAuth tokens and tenant-scoped API keys. - Product code checks membership on every request. - Sensitive actions use recent auth and a single-use action token or equivalent grant. + Sensitive means hard to undo or able to move money or control away from the + owner: deleting a tenant, changing payout or payment credentials, or moving + who pays for a subscription. Buying, changing, or cancelling a plan is not: + the payment provider confirms the payer, the single-use token already stops + replay and CSRF, and asking someone to sign in again before they pay only + loses the sale. ### 8.1 Auth persistence and schema lifecycle diff --git a/docs/decisions/0013-platform-billing-adapters.md b/docs/decisions/0013-platform-billing-adapters.md index 51b54ac..dd6b60d 100644 --- a/docs/decisions/0013-platform-billing-adapters.md +++ b/docs/decisions/0013-platform-billing-adapters.md @@ -43,7 +43,8 @@ encryption keys. These adapters belong next to it, not inside it. - Products delete their own grant, encryption, return-URL, worker, webhook, and error-mapping code, and adopt these adapters. -- Billing actions need a recent sign-in. Products must handle - `recent_authentication_required` by asking the person to sign in again. +- Billing actions need a recent sign-in by default. Products choose which + actions do with `recentAuthActions` (see the architecture guide's rule on + sensitive actions) and handle `recent_authentication_required` for those. - The `exports` test of `@codelitdev/platform` now lists the two billing exports. diff --git a/packages/platform/src/billing/billing.test.ts b/packages/platform/src/billing/billing.test.ts index cf36b5d..919a9eb 100644 --- a/packages/platform/src/billing/billing.test.ts +++ b/packages/platform/src/billing/billing.test.ts @@ -4,6 +4,7 @@ import { BillingConfigurationError, BillingWorkflowError, } from "@codelitdev/billing/core"; +import type { BillingAction } from "@codelitdev/billing/workflows"; import { frozenClock } from "../clock.js"; import { billingErrorResponse, @@ -19,7 +20,12 @@ import { const now = new Date("2026-06-01T12:00:00.000Z"); const target = { kind: "user", id: "user_1" }; -function grants(options: { recentAuthMaxAgeMs?: number } = {}) { +function grants( + options: { + recentAuthMaxAgeMs?: number; + recentAuthActions?: readonly BillingAction[]; + } = {}, +) { return createBillingActionGrants({ store: new MemoryActionGrantStore(), clock: frozenClock(now), @@ -88,6 +94,45 @@ describe("billing action grants", () => { } }); + it("asks for a recent sign-in only for the actions the product names", async () => { + const g = grants({ + recentAuthMaxAgeMs: 60_000, + recentAuthActions: ["payer_transfer"], + }); + const signedInLongAgo = new Date(now.getTime() - 3_600_000); + const checkout = await g.issue({ + actorId: "user_1", + sessionId: "session_1", + sessionCreatedAt: signedInLongAgo, + action: "checkout", + target, + }); + expect(checkout.ok).toBe(true); + const transfer = await g.issue({ + actorId: "user_1", + sessionId: "session_1", + sessionCreatedAt: signedInLongAgo, + action: "payer_transfer", + target, + }); + expect(transfer).toEqual({ ok: false, error: "recent_authentication_required" }); + + const none = grants({ recentAuthMaxAgeMs: 60_000, recentAuthActions: [] }); + for (const action of ["checkout", "cancellation", "payer_transfer"] as const) { + expect( + ( + await none.issue({ + actorId: "user_1", + sessionId: "session_1", + sessionCreatedAt: signedInLongAgo, + action, + target, + }) + ).ok, + ).toBe(true); + } + }); + it("requires a recent sign-in and rejects expired tokens", async () => { const g = grants({ recentAuthMaxAgeMs: 60_000 }); const stale = await g.issue({ diff --git a/packages/platform/src/billing/grants.ts b/packages/platform/src/billing/grants.ts index 0e52078..02af14f 100644 --- a/packages/platform/src/billing/grants.ts +++ b/packages/platform/src/billing/grants.ts @@ -51,10 +51,20 @@ export function createBillingActionGrants(options: { ttlMs?: number; /** How recently the session must have signed in. Default 15 minutes. */ recentAuthMaxAgeMs?: number; + /** + * The actions that need a recent sign-in. Default: every action. Pass the + * ones that are hard to undo, such as `payer_transfer`, or `[]` for none. + * Buying and managing a plan rarely qualify: the provider confirms the + * payer, and the single-use token already stops replay and CSRF. + */ + recentAuthActions?: readonly BillingAction[]; }) { const clock = options.clock ?? systemClock; const ttlMs = options.ttlMs ?? 5 * 60 * 1000; const recentAuthMaxAgeMs = options.recentAuthMaxAgeMs ?? 15 * 60 * 1000; + const recentAuthActions = options.recentAuthActions + ? new Set(options.recentAuthActions) + : null; async function issue(input: { actorId: string; @@ -65,7 +75,11 @@ export function createBillingActionGrants(options: { target: BillingGrantTarget; }): Promise { const now = clock.now(); - if (now.getTime() - input.sessionCreatedAt.getTime() > recentAuthMaxAgeMs) { + const needsRecentAuth = recentAuthActions?.has(input.action) ?? true; + if ( + needsRecentAuth && + now.getTime() - input.sessionCreatedAt.getTime() > recentAuthMaxAgeMs + ) { return { ok: false, error: "recent_authentication_required" }; } await options.store.purgeExpired?.(now);