diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts index 6e751f5728..56f276a363 100644 --- a/packages/stripe/src/routes.ts +++ b/packages/stripe/src/routes.ts @@ -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(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. diff --git a/packages/stripe/test/stripe.test.ts b/packages/stripe/test/stripe.test.ts index 2089eff761..1ba291889d 100644 --- a/packages/stripe/test/stripe.test.ts +++ b/packages/stripe/test/stripe.test.ts @@ -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,