fix(stripe): replace {CHECKOUT_SESSION_ID} placeholder in success callbackURL (#8568)

This commit is contained in:
Taesu
2026-03-13 23:46:35 -07:00
committed by Alex Yang
parent 331c4c4136
commit 3270499c08
2 changed files with 138 additions and 1 deletions
+10 -1
View File
@@ -1640,7 +1640,7 @@ export const subscriptionSuccess = (options: StripeOptions) => {
use: [originCheck((ctx) => ctx.query.callbackURL)],
},
async (ctx) => {
const callbackURL = ctx.query?.callbackURL || "/";
let callbackURL = ctx.query?.callbackURL || "/";
const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
if (!session) {
@@ -1653,6 +1653,15 @@ export const subscriptionSuccess = (options: StripeOptions) => {
throw ctx.redirect(getUrl(ctx, callbackURL));
}
/**
* Replace the Stripe {CHECKOUT_SESSION_ID} template variable in callbackURL.
* @see https://docs.stripe.com/payments/checkout/custom-success-page?payment-ui=stripe-hosted#modify-the-success-url
*/
callbackURL = callbackURL.replaceAll(
"{CHECKOUT_SESSION_ID}",
ctx.query.checkoutSessionId,
);
// Resolve subscriptionId from Stripe checkout session metadata.
// The metadata is set server-side when creating the checkout session,
// so it cannot be tampered with — no ownership check needed.
+128
View File
@@ -7481,6 +7481,134 @@ describe("stripe", () => {
expect(response.headers.get("location")).toContain(callbackURL);
});
/**
* @see https://github.com/better-auth/better-auth/issues/8255
*/
it("should replace {CHECKOUT_SESSION_ID} placeholder in callbackURL with actual session ID", async () => {
const testSubscriptionId = "sub_placeholder_test";
const testCheckoutSessionId = "cs_placeholder_456";
const testCustomerId = "cus_placeholder_test";
const stripeForTest = {
...stripeOptions.stripeClient,
checkout: {
sessions: {
...stripeOptions.stripeClient.checkout.sessions,
retrieve: vi.fn(),
},
},
subscriptions: {
...stripeOptions.stripeClient.subscriptions,
list: vi.fn().mockResolvedValue({
data: [
{
id: testSubscriptionId,
status: "active",
cancel_at_period_end: false,
cancel_at: null,
canceled_at: null,
trial_start: null,
trial_end: null,
items: {
data: [
{
price: {
id: process.env.STRIPE_PRICE_ID_1,
recurring: { interval: "month" },
},
quantity: 1,
current_period_start: Math.floor(Date.now() / 1000),
current_period_end:
Math.floor(Date.now() / 1000) + 30 * 86400,
},
],
},
},
],
}),
},
};
const testOptions = {
...stripeOptions,
stripeClient: stripeForTest as unknown as Stripe,
};
const {
client,
auth: testAuth,
sessionSetter,
} = await getTestInstance(
{
database: memory,
plugins: [stripe(testOptions)],
},
{
disableTestUser: true,
clientOptions: {
plugins: [stripeClient({ subscription: true })],
},
},
);
const testCtx = await testAuth.$context;
const headers = new Headers();
const userRes = await client.signUp.email(
{
email: "placeholder-test@test.com",
password: "password",
name: "Placeholder Test",
},
{ throw: true },
);
await client.signIn.email(
{ email: "placeholder-test@test.com", password: "password" },
{ throw: true, onSuccess: sessionSetter(headers) },
);
const sub = await testCtx.adapter.create({
model: "subscription",
data: {
referenceId: userRes.user.id,
stripeCustomerId: testCustomerId,
status: "incomplete",
plan: "starter",
},
});
(stripeForTest.checkout.sessions.retrieve as any).mockResolvedValue({
id: testCheckoutSessionId,
metadata: {
userId: userRes.user.id,
subscriptionId: sub.id,
referenceId: userRes.user.id,
},
});
// User passes {CHECKOUT_SESSION_ID} in their successUrl, which gets
// URL-encoded inside callbackURL. Stripe can only replace the literal
// (unencoded) placeholder, so the encoded version stays as-is.
// Better Auth should replace it with the actual session ID before redirecting.
const callbackURL =
"http://localhost:5173/billing/success?session_id={CHECKOUT_SESSION_ID}";
const url = `http://localhost:3000/api/auth/subscription/success?callbackURL=${encodeURIComponent(callbackURL)}&checkoutSessionId=${testCheckoutSessionId}`;
const response = await testAuth.handler(
new Request(url, {
method: "GET",
headers,
redirect: "manual",
}),
);
expect(response.status).toBe(302);
const location = response.headers.get("location")!;
// The placeholder must be replaced with the actual checkout session ID
expect(location).toContain(`session_id=${testCheckoutSessionId}`);
// The literal placeholder must NOT remain in the redirect URL
expect(location).not.toContain("{CHECKOUT_SESSION_ID}");
expect(location).not.toContain("%7BCHECKOUT_SESSION_ID%7D");
});
it("should redirect when checkout session retrieval fails", async () => {
const stripeForTest = {
...stripeOptions.stripeClient,