mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(stripe): resolve referenceId once in middleware and thread to handlers
This commit is contained in:
@@ -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.
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user