Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 2 additions & 63 deletions apps/web/src/app/api/sso/organizations/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import { checkRateLimit } from '@vercel/firewall';
import { captureMessage } from '@sentry/nextjs';
import { NextRequest, NextResponse } from 'next/server';
import { verifyTurnstileJWT } from '@/lib/auth/verify-turnstile-jwt';
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);
Expand All @@ -37,7 +33,6 @@ describe('POST /api/sso/organizations', () => {
mockVerifyTurnstileJWT.mockResolvedValue({ success: true, token: {} } as Awaited<
ReturnType<typeof verifyTurnstileJWT>
>);
mockCheckRateLimit.mockResolvedValue({ rateLimited: false });
mockGetAllUserProviders.mockResolvedValue({ kind: 'not_found' });
mockIsNewAccountEligibleForMagicLink.mockResolvedValue(true);
mockResolveSsoAuthorityForDomain.mockImplementation(async domain => ({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
35 changes: 2 additions & 33 deletions apps/web/src/app/api/sso/organizations/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
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 {
SignInDiscoveryRequestSchema,
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);
Expand Down Expand Up @@ -58,28 +49,6 @@ export async function POST(request: Request): Promise<NextResponse> {
}
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');
Expand Down