diff --git a/apps/web/src/app/api/auth/magic-link/route.test.ts b/apps/web/src/app/api/auth/magic-link/route.test.ts index e748cf6dcc0..602ea241b16 100644 --- a/apps/web/src/app/api/auth/magic-link/route.test.ts +++ b/apps/web/src/app/api/auth/magic-link/route.test.ts @@ -10,6 +10,18 @@ import { checkRateLimit } from '@vercel/firewall'; import { NextResponse } from 'next/server'; import { NextRequest } from 'next/server'; import { resolveSsoAuthorityForDomain } from '@kilocode/web-shared/lib/organizations/organization-sso-policy'; +import { NEXTAUTH_SECRET } from '@kilocode/web-shared/lib/config.server'; +import jwt from 'jsonwebtoken'; +import { randomUUID } from 'crypto'; + +const cookieStore = new Map(); +jest.mock('next/headers', () => ({ + headers: async () => new Headers({ 'x-forwarded-for': '203.0.113.7' }), + cookies: async () => ({ + get: (name: string) => cookieStore.get(name), + delete: (name: string) => cookieStore.delete(name), + }), +})); jest.mock('@vercel/firewall'); jest.mock('@/lib/auth/verify-turnstile-jwt'); @@ -48,6 +60,7 @@ describe('POST /api/auth/magic-link', () => { beforeEach(() => { jest.clearAllMocks(); + cookieStore.clear(); // Default: Turnstile verification succeeds mockVerifyTurnstileJWT.mockResolvedValue({ @@ -97,6 +110,27 @@ describe('POST /api/auth/magic-link', () => { expect(mockSendMagicLinkEmail).toHaveBeenCalledWith(mockMagicLinkToken, undefined); }); + it('accepts only one magic-link request per Turnstile verification', async () => { + const actual = jest.requireActual<{ verifyTurnstileJWT: typeof verifyTurnstileJWT }>( + '@/lib/auth/verify-turnstile-jwt' + ); + mockVerifyTurnstileJWT.mockImplementation(actual.verifyTurnstileJWT); + cookieStore.set('turnstile_jwt', { + name: 'turnstile_jwt', + value: jwt.sign({ guid: randomUUID(), ip: '203.0.113.7' }, NEXTAUTH_SECRET, { + algorithm: 'HS256', + expiresIn: '5m', + }), + }); + + const first = await POST(createRequest({ email: 'user@example.com' })); + const second = await POST(createRequest({ email: 'other@example.com' })); + + expect(first.status).toBe(200); + expect(second.status).toBe(401); + expect(mockSendMagicLinkEmail).toHaveBeenCalledTimes(1); + }); + it('rejects magic links for an SSO-protected domain before creating a token', async () => { mockResolveSsoAuthorityForDomain.mockResolvedValue({ status: 'required', diff --git a/apps/web/src/app/api/auth/magic-link/route.ts b/apps/web/src/app/api/auth/magic-link/route.ts index 5aa562208aa..ce3a7cc2c7d 100644 --- a/apps/web/src/app/api/auth/magic-link/route.ts +++ b/apps/web/src/app/api/auth/magic-link/route.ts @@ -1,5 +1,6 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; import { createMagicLinkToken } from '@kilocode/web-shared/lib/auth/magic-link-tokens'; import { sendMagicLinkEmail } from '@kilocode/web-shared/lib/email'; import { verifyTurnstileJWT } from '@/lib/auth/verify-turnstile-jwt'; @@ -34,6 +35,9 @@ export async function POST(request: NextRequest) { if (!turnstileResult.success) { return turnstileResult.response; } + // One Turnstile solve covers one magic-link request; the sign-in form solves again for each one. + const userCookies = await cookies(); + userCookies.delete('turnstile_jwt'); const eligibility = await checkEmailSignInEligibility(email, request); if (!eligibility.ok) {