diff --git a/apps/web/src/app/api/sso/organizations/route.test.ts b/apps/web/src/app/api/sso/organizations/route.test.ts index a621d5bb27..19df84b4b7 100644 --- a/apps/web/src/app/api/sso/organizations/route.test.ts +++ b/apps/web/src/app/api/sso/organizations/route.test.ts @@ -1,4 +1,3 @@ -import { checkRateLimit } from '@vercel/firewall'; import { captureMessage } from '@sentry/nextjs'; import { NextRequest, NextResponse } from 'next/server'; import { verifyTurnstileJWT } from '@/lib/auth/verify-turnstile-jwt'; @@ -6,17 +5,14 @@ import { getAllUserProviders, getWorkOSOrganization } from '@/lib/user'; import { resolveSsoAuthorityForDomain } from '@/lib/organizations/organization-sso-policy'; import { isNewAccountEligibleForMagicLink } from '@/lib/auth/email-signin-eligibility'; -jest.mock('@vercel/firewall'); jest.mock('@sentry/nextjs'); jest.mock('@/lib/auth/verify-turnstile-jwt'); jest.mock('@/lib/user'); jest.mock('@/lib/organizations/organization-sso-policy'); jest.mock('@/lib/auth/email-signin-eligibility'); -jest.mock('@/lib/config.server', () => ({ NEXTAUTH_SECRET: 'test-secret' })); -import { discoveryEmailRateLimitKey, POST } from './route'; +import { POST } from './route'; -const mockCheckRateLimit = jest.mocked(checkRateLimit); const mockCaptureMessage = jest.mocked(captureMessage); const mockVerifyTurnstileJWT = jest.mocked(verifyTurnstileJWT); const mockGetAllUserProviders = jest.mocked(getAllUserProviders); @@ -37,7 +33,6 @@ describe('POST /api/sso/organizations', () => { mockVerifyTurnstileJWT.mockResolvedValue({ success: true, token: {} } as Awaited< ReturnType >); - mockCheckRateLimit.mockResolvedValue({ rateLimited: false }); mockGetAllUserProviders.mockResolvedValue({ kind: 'not_found' }); mockIsNewAccountEligibleForMagicLink.mockResolvedValue(true); mockResolveSsoAuthorityForDomain.mockImplementation(async domain => ({ @@ -54,7 +49,7 @@ describe('POST /api/sso/organizations', () => { const response = await POST(request({ email: 'user@example.com' })); expect(response.status).toBe(401); - expect(mockCheckRateLimit).not.toHaveBeenCalled(); + expect(mockGetAllUserProviders).not.toHaveBeenCalled(); }); it('rejects invalid request email', async () => { @@ -199,60 +194,4 @@ describe('POST /api/sso/organizations', () => { expect((await POST(request({ email: 'first.last+tag@gmail.com' }))).status).toBe(503); expect(mockIsNewAccountEligibleForMagicLink).not.toHaveBeenCalled(); }); - - it('uses one HMAC rate-limit key for normalized Gmail equivalents', async () => { - const emailForms = [ - 'first.last+tag@gmail.com', - 'firstlast@gmail.com', - 'first.last@googlemail.com', - ]; - - expect(emailForms.map(discoveryEmailRateLimitKey)).toEqual([ - discoveryEmailRateLimitKey('firstlast@gmail.com'), - discoveryEmailRateLimitKey('firstlast@gmail.com'), - discoveryEmailRateLimitKey('firstlast@gmail.com'), - ]); - - for (const email of emailForms) { - await POST(request({ email })); - } - - const emailLimitKeys = mockCheckRateLimit.mock.calls - .filter(([id]) => id === 'sign-in-discovery-email') - .map(([, options]) => options?.rateLimitKey); - expect(new Set(emailLimitKeys)).toEqual( - new Set([discoveryEmailRateLimitKey('firstlast@gmail.com')]) - ); - }); - - it('rejects a rate-limited discovery request', async () => { - mockCheckRateLimit - .mockResolvedValueOnce({ rateLimited: true }) - .mockResolvedValueOnce({ rateLimited: false }); - - expect((await POST(request({ email: 'user@example.com' }))).status).toBe(429); - expect(mockGetAllUserProviders).not.toHaveBeenCalled(); - }); - - it('fails closed when a rate-limit policy is unavailable', async () => { - mockCheckRateLimit - .mockResolvedValueOnce({ rateLimited: false, error: 'not-found' }) - .mockResolvedValueOnce({ - rateLimited: false, - error: 'blocked', - }); - - expect((await POST(request({ email: 'user@example.com' }))).status).toBe(503); - expect(mockGetAllUserProviders).not.toHaveBeenCalled(); - expect(mockCaptureMessage).toHaveBeenCalledWith('Sign-in discovery rate limit unavailable', { - level: 'error', - tags: { source: 'sso-organizations-rate-limit' }, - extra: { - ipLimiterUnavailable: true, - emailLimiterUnavailable: true, - }, - }); - expect(JSON.stringify(mockCaptureMessage.mock.calls)).not.toContain('not-found'); - expect(JSON.stringify(mockCaptureMessage.mock.calls)).not.toContain('blocked'); - }); }); diff --git a/apps/web/src/app/api/sso/organizations/route.ts b/apps/web/src/app/api/sso/organizations/route.ts index 0406f4a758..7c85a3bb23 100644 --- a/apps/web/src/app/api/sso/organizations/route.ts +++ b/apps/web/src/app/api/sso/organizations/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import { captureException, captureMessage } from '@sentry/nextjs'; +import { captureException } from '@sentry/nextjs'; import { sentryLogger } from '@/lib/utils.server'; import { verifyTurnstileJWT } from '@/lib/auth/verify-turnstile-jwt'; -import { getLowerDomainFromEmail, normalizeEmail } from '@/lib/utils'; +import { getLowerDomainFromEmail } from '@/lib/utils'; import { getAllUserProviders, getWorkOSOrganization } from '@/lib/user'; import { resolveSsoAuthorityForDomain } from '@/lib/organizations/organization-sso-policy'; import { @@ -10,19 +10,10 @@ import { SignInDiscoveryResponseSchema, type SignInDiscoveryResponse, } from '@/lib/schemas/sso-organizations'; -import { checkRateLimit } from '@vercel/firewall'; -import { createHmac } from 'node:crypto'; -import { NEXTAUTH_SECRET } from '@/lib/config.server'; import { isNewAccountEligibleForMagicLink } from '@/lib/auth/email-signin-eligibility'; import { ProdNonSSOAuthProviders } from '@/lib/auth/provider-metadata'; const warnInSentry = sentryLogger('sso-organizations', 'warning'); -const DISCOVERY_IP_RATE_LIMIT_ID = 'sign-in-discovery-ip'; -const DISCOVERY_EMAIL_RATE_LIMIT_ID = 'sign-in-discovery-email'; - -export function discoveryEmailRateLimitKey(email: string): string { - return createHmac('sha256', NEXTAUTH_SECRET).update(normalizeEmail(email)).digest('base64url'); -} function discoveryResponse(response: SignInDiscoveryResponse, init?: ResponseInit): NextResponse { return NextResponse.json(SignInDiscoveryResponseSchema.parse(response), init); @@ -58,28 +49,6 @@ export async function POST(request: Request): Promise { } const { email } = parsedRequest.data; - const [ipLimit, emailLimit] = await Promise.all([ - checkRateLimit(DISCOVERY_IP_RATE_LIMIT_ID, { request }), - checkRateLimit(DISCOVERY_EMAIL_RATE_LIMIT_ID, { - request, - rateLimitKey: discoveryEmailRateLimitKey(email), - }), - ]); - if (ipLimit.rateLimited || emailLimit.rateLimited) { - return NextResponse.json({ error: 'Please try again later.' }, { status: 429 }); - } - if (ipLimit.error || emailLimit.error) { - captureMessage('Sign-in discovery rate limit unavailable', { - level: 'error', - tags: { source: 'sso-organizations-rate-limit' }, - extra: { - ipLimiterUnavailable: Boolean(ipLimit.error), - emailLimiterUnavailable: Boolean(emailLimit.error), - }, - }); - return NextResponse.json({ error: 'Please try again later.' }, { status: 503 }); - } - const providerLookup = await getAllUserProviders(email); if (providerLookup.kind === 'ambiguous') { warnInSentry('Ambiguous sign-in provider lookup');