fix(stripe): resolve referenceId once in middleware and thread to handlers

This commit is contained in:
Taesu
2026-06-19 14:56:15 -07:00
parent 21448b1b77
commit d3aed8ed42
4 changed files with 129 additions and 74 deletions
@@ -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.
+13 -11
View File
@@ -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 };
});
+10 -63
View File
@@ -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<Subscription>({
@@ -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<Subscription>({
@@ -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<Subscription>({
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;
@@ -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,
}) => {