From d3aed8ed42c8f203fad4dc486e27242d875f46a9 Mon Sep 17 00:00:00 2001 From: Taesu Date: Fri, 19 Jun 2026 14:35:50 -0700 Subject: [PATCH] fix(stripe): resolve referenceId once in middleware and thread to handlers --- .../stripe-subscription-reference-scope.md | 5 + packages/stripe/src/middleware.ts | 24 +++-- packages/stripe/src/routes.ts | 73 ++----------- .../stripe/test/stripe-organization.test.ts | 101 ++++++++++++++++++ 4 files changed, 129 insertions(+), 74 deletions(-) create mode 100644 .changeset/stripe-subscription-reference-scope.md diff --git a/.changeset/stripe-subscription-reference-scope.md b/.changeset/stripe-subscription-reference-scope.md new file mode 100644 index 0000000000..8e0487e73c --- /dev/null +++ b/.changeset/stripe-subscription-reference-scope.md @@ -0,0 +1,5 @@ +--- +"@better-auth/stripe": patch +--- + +Fixed organization subscription actions (cancel, upgrade, restore, and the billing portal) that could act on the wrong organization. diff --git a/packages/stripe/src/middleware.ts b/packages/stripe/src/middleware.ts index 1fa4e42892..74dbc4c7e7 100644 --- a/packages/stripe/src/middleware.ts +++ b/packages/stripe/src/middleware.ts @@ -6,6 +6,7 @@ import type { AuthorizeReferenceAction, CustomerType, StripeCtxSession, + StripeOptions, SubscriptionOptions, } from "./types"; @@ -22,10 +23,11 @@ export const stripeSessionMiddleware = createAuthMiddleware( ); export const referenceMiddleware = ( - subscriptionOptions: SubscriptionOptions, + options: StripeOptions, action: AuthorizeReferenceAction, ) => createAuthMiddleware(async (ctx) => { + const subscriptionOptions = options.subscription as SubscriptionOptions; const ctxSession = ctx.context.session as StripeCtxSession; if (!ctxSession) { throw APIError.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED); @@ -33,8 +35,10 @@ export const referenceMiddleware = ( const customerType: CustomerType = ctx.body?.customerType || ctx.query?.customerType; - const explicitReferenceId = ctx.body?.referenceId || ctx.query?.referenceId; + const explicitReferenceId: string | undefined = + ctx.body?.referenceId || ctx.query?.referenceId; + // Resolve once and expose on the context so the handler reads this exact id instead of re-deriving it. if (customerType === "organization") { // Organization subscriptions always require authorizeReference if (!subscriptionOptions.authorizeReference) { @@ -67,17 +71,14 @@ export const referenceMiddleware = ( if (!isAuthorized) { throw APIError.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED); } - return; + return { referenceId }; } - // User subscriptions - pass if no explicit referenceId - if (!explicitReferenceId) { - return; - } + const referenceId = explicitReferenceId || ctxSession.user.id; - // Pass if referenceId is user id - if (explicitReferenceId === ctxSession.user.id) { - return; + // User subscriptions - pass for own id or no explicit referenceId + if (!explicitReferenceId || explicitReferenceId === ctxSession.user.id) { + return { referenceId }; } if (!subscriptionOptions.authorizeReference) { @@ -93,7 +94,7 @@ export const referenceMiddleware = ( { user: ctxSession.user, session: ctxSession.session, - referenceId: explicitReferenceId, + referenceId, action, }, ctx, @@ -101,4 +102,5 @@ export const referenceMiddleware = ( if (!isAuthorized) { throw APIError.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED); } + return { referenceId }; }); diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts index cbdce7443f..fcc2a23198 100644 --- a/packages/stripe/src/routes.ts +++ b/packages/stripe/src/routes.ts @@ -19,8 +19,6 @@ import { customerMetadata, subscriptionMetadata } from "./metadata"; import { referenceMiddleware, stripeSessionMiddleware } from "./middleware"; import type { CheckoutSessionLocale, - CustomerType, - StripeCtxSession, StripeOptions, Subscription, SubscriptionOptions, @@ -86,40 +84,6 @@ function getUrl(ctx: GenericEndpointContext, url: string) { }`; } -/** - * Determines the reference ID based on customer type. - * - `user` (default): uses userId - * - `organization`: uses activeOrganizationId from session - * @internal - */ -function getReferenceId( - ctxSession: StripeCtxSession, - customerType: CustomerType | undefined, - options: StripeOptions, -): string { - const { user, session } = ctxSession; - const type = customerType || "user"; - - if (type === "organization") { - if (!options.organization?.enabled) { - throw APIError.from( - "BAD_REQUEST", - STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED, - ); - } - - if (!session.activeOrganizationId) { - throw APIError.from( - "BAD_REQUEST", - STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND, - ); - } - return session.activeOrganizationId; - } - - return user.id; -} - /** * Retrieve the subscription a row points to, or `null` if it no longer exists * (`resource_missing`) so callers can treat the row as stale. @@ -304,7 +268,7 @@ export const upgradeSubscription = (options: StripeOptions) => { }, use: [ stripeSessionMiddleware, - referenceMiddleware(subscriptionOptions, "upgrade-subscription"), + referenceMiddleware(options, "upgrade-subscription"), originCheck((c) => { return [ c.body.successUrl as string, @@ -317,9 +281,7 @@ export const upgradeSubscription = (options: StripeOptions) => { async (ctx) => { const { user, session } = ctx.context.session; const customerType = ctx.body.customerType || "user"; - const referenceId = - ctx.body.referenceId || - getReferenceId(ctx.context.session, customerType, options); + const referenceId = ctx.context.referenceId; if (!user.emailVerified && subscriptionOptions.requireEmailVerification) { throw APIError.from( @@ -1287,7 +1249,6 @@ const cancelSubscriptionBodySchema = z.object({ */ export const cancelSubscription = (options: StripeOptions) => { const client = options.stripeClient; - const subscriptionOptions = options.subscription as SubscriptionOptions; return createAuthEndpoint( "/subscription/cancel", { @@ -1300,15 +1261,12 @@ export const cancelSubscription = (options: StripeOptions) => { }, use: [ stripeSessionMiddleware, - referenceMiddleware(subscriptionOptions, "cancel-subscription"), + referenceMiddleware(options, "cancel-subscription"), originCheck((ctx) => ctx.body.returnUrl), ], }, async (ctx) => { - const customerType = ctx.body.customerType || "user"; - const referenceId = - ctx.body.referenceId || - getReferenceId(ctx.context.session, customerType, options); + const referenceId = ctx.context.referenceId; let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({ @@ -1451,7 +1409,6 @@ const restoreSubscriptionBodySchema = z.object({ export const restoreSubscription = (options: StripeOptions) => { const client = options.stripeClient; - const subscriptionOptions = options.subscription as SubscriptionOptions; return createAuthEndpoint( "/subscription/restore", { @@ -1464,14 +1421,11 @@ export const restoreSubscription = (options: StripeOptions) => { }, use: [ stripeSessionMiddleware, - referenceMiddleware(subscriptionOptions, "restore-subscription"), + referenceMiddleware(options, "restore-subscription"), ], }, async (ctx) => { - const customerType = ctx.body.customerType || "user"; - const referenceId = - ctx.body.referenceId || - getReferenceId(ctx.context.session, customerType, options); + const referenceId = ctx.context.referenceId; let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({ @@ -1667,7 +1621,6 @@ const listActiveSubscriptionsQuerySchema = z.optional( * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-list) */ export const listActiveSubscriptions = (options: StripeOptions) => { - const subscriptionOptions = options.subscription as SubscriptionOptions; return createAuthEndpoint( "/subscription/list", { @@ -1680,14 +1633,11 @@ export const listActiveSubscriptions = (options: StripeOptions) => { }, use: [ stripeSessionMiddleware, - referenceMiddleware(subscriptionOptions, "list-subscription"), + referenceMiddleware(options, "list-subscription"), ], }, async (ctx) => { - const customerType = ctx.query?.customerType || "user"; - const referenceId = - ctx.query?.referenceId || - getReferenceId(ctx.context.session, customerType, options); + const referenceId = ctx.context.referenceId; const subscriptions = await ctx.context.adapter.findMany({ model: "subscription", @@ -1944,7 +1894,6 @@ const createBillingPortalBodySchema = z.object({ export const createBillingPortal = (options: StripeOptions) => { const client = options.stripeClient; - const subscriptionOptions = options.subscription as SubscriptionOptions; return createAuthEndpoint( "/subscription/billing-portal", { @@ -1957,16 +1906,14 @@ export const createBillingPortal = (options: StripeOptions) => { }, use: [ stripeSessionMiddleware, - referenceMiddleware(subscriptionOptions, "billing-portal"), + referenceMiddleware(options, "billing-portal"), originCheck((ctx) => ctx.body.returnUrl), ], }, async (ctx) => { const { user } = ctx.context.session; const customerType = ctx.body.customerType || "user"; - const referenceId = - ctx.body.referenceId || - getReferenceId(ctx.context.session, customerType, options); + const referenceId = ctx.context.referenceId; let customerId: string | undefined; diff --git a/packages/stripe/test/stripe-organization.test.ts b/packages/stripe/test/stripe-organization.test.ts index ae95fb4ea2..2466beb187 100644 --- a/packages/stripe/test/stripe-organization.test.ts +++ b/packages/stripe/test/stripe-organization.test.ts @@ -453,6 +453,107 @@ describe("stripe - organization customer", () => { ); }); + // A query referenceId is resolved by the middleware, so the handler must act on + // it and not fall back to the active org. + test("should act on the resolved reference id, not the active organization", async ({ + stripeMock, + }) => { + const orgStripeOptions = buildOrgStripeOptions(stripeMock); + // Lets the cancel succeed against the active org's sub if it is wrongly resolved. + stripeMock.subscriptions.retrieve.mockResolvedValueOnce({ + id: "sub_other_org", + status: "active", + cancel_at_period_end: false, + }); + let authorizedOrgId = ""; + const scopedOptions: StripeOptions = { + ...orgStripeOptions, + subscription: { + ...orgStripeOptions.subscription, + // Only the org the caller owns is authorized. + authorizeReference: async ({ referenceId }) => + referenceId === authorizedOrgId, + }, + }; + + const { client, auth, sessionSetter } = await getTestInstance( + { + plugins: [organization(), stripe(scopedOptions)], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [organizationClient(), stripeClient({ subscription: true })], + }, + }, + ); + const ctx = await auth.$context; + + await client.signUp.email( + { ...testUser, email: "cross-tenant-test@email.com" }, + { throw: true }, + ); + const headers = new Headers(); + await client.signIn.email( + { ...testUser, email: "cross-tenant-test@email.com" }, + { + throw: true, + onSuccess: sessionSetter(headers), + }, + ); + + // Authorized org, passed via the query string. + const ownedOrg = await client.organization.create({ + name: "Owned Org", + slug: "owned-org", + fetchOptions: { headers }, + }); + authorizedOrgId = ownedOrg.data?.id as string; + + // Unauthorized org, but the active one with a live subscription. + const otherOrg = await client.organization.create({ + name: "Other Org", + slug: "other-org", + fetchOptions: { headers }, + }); + const otherOrgId = otherOrg.data?.id as string; + await client.organization.setActive({ + organizationId: otherOrgId, + fetchOptions: { headers }, + }); + await ctx.adapter.update({ + model: "organization", + update: { stripeCustomerId: "cus_other_org" }, + where: [{ field: "id", value: otherOrgId }], + }); + await ctx.adapter.create({ + model: "subscription", + data: { + referenceId: otherOrgId, + stripeCustomerId: "cus_other_org", + stripeSubscriptionId: "sub_other_org", + status: "active", + plan: "starter", + }, + }); + + const res = await client.subscription.cancel({ + customerType: "organization", + returnUrl: "/dashboard", + fetchOptions: { headers, query: { referenceId: authorizedOrgId } }, + }); + + // Targets the authorized owned org (no subscription), never the active org. + expect(res.error?.code).toBe("SUBSCRIPTION_NOT_FOUND"); + expect(stripeMock.billingPortal.sessions.create).not.toHaveBeenCalledWith( + expect.objectContaining({ + flow_data: expect.objectContaining({ + subscription_cancel: { subscription: "sub_other_org" }, + }), + }), + ); + }); + test("should restore subscription for organization", async ({ stripeMock, }) => {