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
1 change: 1 addition & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB

- `NEXT_PUBLIC_TURNSTILE_SITE_KEY` - Cloudflare Turnstile public site key; used in `apps/web/src/components/auth/sign-in/TurnstileView.tsx`. [PUBLIC]
- `TURNSTILE_SECRET_KEY` - Cloudflare Turnstile secret key; used in test files. `[SECRET]`
- `EMAIL_SIGNUPS_ENABLED` - Set to exact `true` to allow new accounts via email (web magic link and mobile email code). Unset or any other value refuses them. Existing users keep email sign-in either way, and OAuth, passkey, and SSO sign-ups are unaffected. Jest's global setup (`apps/web/src/tests/setup/globalSetup.ts`) defaults it to `true`; keep it out of tracked dotenv files so `pnpm web:env set` can set it. Takes effect on redeploy. [SERVER]
- `NEXT_PUBLIC_STYTCH_PROJECT_ENV` - Stytch public project environment identifier; used in `apps/web/src/components/auth/StytchClient.tsx`. [PUBLIC]
- `NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN` - Stytch public token for client SDKs; used in `apps/web/src/components/auth/StytchClient.tsx`. [PUBLIC]
- `STYTCH_PROJECT_ID` - Stytch project ID for secret-side SDK calls. `[SECRET]`
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/app/api/auth/magic-link/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,40 @@ describe('POST /api/auth/magic-link', () => {
expect(mockSendMagicLinkEmail).toHaveBeenCalledWith(mockMagicLinkToken, undefined);
});

describe('while email signups are disabled', () => {
beforeEach(() => {
process.env.EMAIL_SIGNUPS_ENABLED = 'false';
});
afterEach(() => {
process.env.EMAIL_SIGNUPS_ENABLED = 'true';
});

it('refuses a new user before creating a token', async () => {
const response = await POST(createRequest({ email: 'new@example.com' }));

expect(response.status).toBe(403);
await expect(response.json()).resolves.toEqual({
success: false,
error: 'EMAIL-SIGNUP-DISABLED',
});
expect(mockCreateMagicLinkToken).not.toHaveBeenCalled();
expect(mockSendMagicLinkEmail).not.toHaveBeenCalled();
});

it('still sends a magic link to an existing user', async () => {
mockFindUserByNormalizedEmail.mockResolvedValue({
id: 'existing-user',
google_user_email: 'user@example.com',
} as Awaited<ReturnType<typeof findUserByNormalizedEmail>>);

const response = await POST(createRequest({ email: 'user@example.com' }));

expect(response.status).toBe(200);
expect(mockCreateMagicLinkToken).toHaveBeenCalledWith('user@example.com');
expect(mockSendMagicLinkEmail).toHaveBeenCalled();
});
});

it('rejects magic links for an SSO-protected domain before creating a token', async () => {
mockResolveSsoAuthorityForDomain.mockResolvedValue({
status: 'required',
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/app/api/auth/native/otp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,24 @@ describe('POST /api/auth/native/otp', () => {
expect(mockCreateSignInCode).not.toHaveBeenCalled();
});

it('delivers a real code to a new address while email signups are disabled', async () => {
mockCheckEmailSignInEligibility.mockResolvedValue({
ok: false,
status: 403,
errorCode: 'EMAIL-SIGNUP-DISABLED',
body: { success: false, error: 'EMAIL-SIGNUP-DISABLED' },
});

const response = await POST(createRequest({ email: 'new@example.com' }));

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
success: true,
challengeId: 'c0000000-0000-0000-0000-000000000001',
});
expect(mockSendSignInCodeEmail).toHaveBeenCalledWith('new@example.com', '123456');
});

it('returns opaque 200 with a challengeId for a blocked domain', async () => {
mockCheckEmailSignInEligibility.mockResolvedValue({
ok: false,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/api/auth/native/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export const POST = withRestTiming('/api/auth/native/otp', async (request: Reque
const { email } = validation.data;

const eligibility = await checkEmailSignInEligibility(email, request as NextRequest);
if (!eligibility.ok) {
// Deliver the code so the response does not reveal account existence; the
// token exchange refuses the signup with a step-up shipped apps can render.
if (!eligibility.ok && eligibility.errorCode !== 'EMAIL-SIGNUP-DISABLED') {
if (eligibility.errorCode === 'INVALID_EMAIL') {
await enforceResponseFloor(startTime);
return NextResponse.json({ success: true, challengeId: randomUUID() });
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/app/api/auth/native/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,22 @@ describe('POST /api/auth/native/token', () => {
expect(mockGenerateApiToken).not.toHaveBeenCalled();
});

it('presents a disabled email signup as the step-up shipped apps understand', async () => {
mockCreateOrUpdateUser.mockResolvedValue({ success: false, error: 'EMAIL-SIGNUP-DISABLED' });

const response = await POST(
createRequest({ provider: 'email', email: 'emailuser@example.com', code: '123456' })
);

expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'SIGNUP-STEP-UP',
methods: ['google', 'apple'],
});
expect(mockReleaseSignInCode).toHaveBeenCalled();
expect(mockGenerateApiToken).not.toHaveBeenCalled();
});

it('releases the code on DIFFERENT-OAUTH settlement failure so retry succeeds', async () => {
mockCreateOrUpdateUser.mockResolvedValue({
success: false,
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/app/api/auth/native/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
releaseSignInCode,
consumeSignInCode,
} from '@kilocode/web-shared/lib/auth/magic-link-tokens';
import { hosted_domain_specials } from '@kilocode/web-shared/lib/auth/constants';
import {
hosted_domain_specials,
SIGNUP_STEP_UP_METHODS,
} from '@kilocode/web-shared/lib/auth/constants';
import { consumeSignInTicket } from '@/lib/auth/passkey';
import { findUserById } from '@kilocode/web-shared/lib/user/find-user-by-id';
import {
Expand Down Expand Up @@ -76,6 +79,13 @@ function settlementFailureResponse(failure: Extract<CreateOrUpdateUserResult, {
if (failure.error === 'SIGNUP-STEP-UP') {
return NextResponse.json({ error: failure.error, methods: failure.methods }, { status: 403 });
}
// Shipped apps only understand step-up, which offers their native Google and Apple sign-in.
if (failure.error === 'EMAIL-SIGNUP-DISABLED') {
return NextResponse.json(
{ error: 'SIGNUP-STEP-UP', methods: [...SIGNUP_STEP_UP_METHODS] },
{ status: 403 }
);
}
return NextResponse.json(
{ error: failure.error },
{ status: failure.error === 'SIGNUP-UNAVAILABLE' ? 503 : 403 }
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/app/api/sso/organizations/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,39 @@ describe('POST /api/sso/organizations', () => {
}
);

describe('while email signups are disabled', () => {
beforeEach(() => {
process.env.EMAIL_SIGNUPS_ENABLED = 'false';
mockIsNewAccountEligibleForMagicLink.mockResolvedValue(false);
});
afterEach(() => {
process.env.EMAIL_SIGNUPS_ENABLED = 'true';
});

it('offers a new address only non-email providers and flags the pause', async () => {
const body = await (await POST(request({ email: 'new@example.com' }))).json();

expect(body).toEqual({
kind: 'new',
providers: expect.arrayContaining(['google', 'github']),
emailSignupDisabled: true,
});
expect(body.providers).not.toContain('email');
});

it('keeps Email for an existing account', async () => {
mockGetAllUserProviders.mockResolvedValue({
kind: 'found',
user: { kiloUserId: 'user-1', providers: ['email'], primaryEmail: 'user@example.com' },
});

await expect((await POST(request({ email: 'user@example.com' }))).json()).resolves.toEqual({
kind: 'existing',
providers: ['email'],
});
});
});

it('keeps an unknown Gmail plus alias new and ineligible for Email', async () => {
mockIsNewAccountEligibleForMagicLink.mockResolvedValue(false);

Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/app/api/sso/organizations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type SignInDiscoveryResponse,
} from '@/lib/schemas/sso-organizations';
import { isNewAccountEligibleForMagicLink } from '@/lib/auth/email-signin-eligibility';
import { isEmailSignupDisabled } from '@/lib/auth/email-signup-switch';
import { ProdNonSSOAuthProviders } from '@/lib/auth/provider-metadata';

const warnInSentry = sentryLogger('sso-organizations', 'warning');
Expand Down Expand Up @@ -104,7 +105,11 @@ export async function POST(request: Request): Promise<NextResponse> {
? ProdNonSSOAuthProviders
: ProdNonSSOAuthProviders.filter(provider => provider !== 'email')),
];
return discoveryResponse({ kind: 'new', providers });
return discoveryResponse({
kind: 'new',
providers,
...(isEmailSignupDisabled() ? { emailSignupDisabled: true } : {}),
});
} catch (err: unknown) {
warnInSentry('sso error');
captureException(err, {
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/auth/AuthErrorNotification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ describe('AuthErrorNotification', () => {
expect(html).toContain(message);
});

it('points new email signups at the other sign-in options', () => {
const html = renderToStaticMarkup(
createElement(AuthErrorNotification, { error: 'EMAIL-SIGNUP-DISABLED' })
);

expect(html).toContain('role="alert"');
expect(html).toContain('Email Sign-up Is Paused');
expect(html).toContain('be created with email right now');
expect(html).toContain('choose one of the other');
expect(html).toContain('You can still sign in with email.');
});

it('uses the generic fallback for an unknown callback error', () => {
const html = renderToStaticMarkup(
createElement(AuthErrorNotification, { error: 'untrusted-query-error' })
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/auth/AuthErrorNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ export function AuthErrorNotification({
</div>
);

if (error === 'EMAIL-SIGNUP-DISABLED')
return (
<div data-error-notification role="alert" aria-live="assertive" aria-atomic="true">
<ErrorNotificationBox title="Email Sign-up Is Paused">
New accounts can&apos;t be created with email right now. Please choose one of the other
sign-in options. Already have an account? You can still sign in with email.
</ErrorNotificationBox>
</div>
);

if (error === 'EMAIL-INVALID-FORMAT')
return (
<div data-error-notification role="alert" aria-live="assertive" aria-atomic="true">
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/hooks/useSignInFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,53 @@ describe('useSignInFlow discovery cancellation', () => {
expect(mounted.container.querySelector('#state')?.textContent).toBe('magic-link-sent');
});

it('returns a refused sign-up Email to the provider buttons with the pause notice', async () => {
fetchMock.mockResolvedValueOnce(response({ success: true }));
mockSendMagicLink.mockResolvedValueOnce({
success: false,
error: 'EMAIL-SIGNUP-DISABLED',
} as never);
mounted = mountFlow({ searchParams: {}, isSignUp: true });

act(() => button(mounted!.container, 'show-email').click());
act(() => button(mounted!.container, 'new-email').click());
act(() => button(mounted!.container, 'submit-email').click());
await act(async () => {
button(mounted!.container, 'first').click();
await flushAsyncWork();
await new Promise(resolve => setTimeout(resolve, 0));
});

expect(mounted.container.querySelector('#state')?.textContent).toBe('landing');
expect(mounted.container.querySelector('#email-input')?.textContent).toBe('false');
expect(mounted.container.querySelector('#error')?.textContent).toBe('EMAIL-SIGNUP-DISABLED');
});

it('shows the pause notice beside new-account providers when discovery flags it', async () => {
fetchMock
.mockResolvedValueOnce(response({ success: true }))
.mockResolvedValueOnce(
response({ kind: 'new', providers: ['google', 'github'], emailSignupDisabled: true })
);
mounted = mountFlow();

act(() => button(mounted!.container, 'new-email').click());
act(() => button(mounted!.container, 'submit-email').click());
await act(async () => {
button(mounted!.container, 'first').click();
await flushAsyncWork();
});

expect(mounted.container.querySelector('#state')?.textContent).toBe('provider-select');
expect(mounted.container.querySelector('#error')?.textContent).toBe('EMAIL-SIGNUP-DISABLED');
});

it('accepts the email signup pause as a callback error', () => {
mounted = mountFlow({ searchParams: {}, error: 'EMAIL-SIGNUP-DISABLED' });

expect(mounted.container.querySelector('#error')?.textContent).toBe('EMAIL-SIGNUP-DISABLED');
});

it('does not auto-verify a prefilled explicit sign-up email', async () => {
mounted = mountFlow({ searchParams: { email: 'new@example.com' }, isSignUp: true });

Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/hooks/useSignInFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const callbackAuthErrors = new Set([
'SIGNUP-STEP-UP',
'SIGNUP-UNAVAILABLE',
'EMAIL-ALREADY-USED',
'EMAIL-SIGNUP-DISABLED',
'EMAIL-INVALID-FORMAT',
'EMAIL-MUST-BE-LOWERCASE',
'EMAIL-CANNOT-CONTAIN-PLUS',
Expand Down Expand Up @@ -306,7 +307,16 @@ export function useSignInFlow({
}
setIsVerifying(false);
setShowTurnstile(false);
setError('MAGIC_LINK_DELIVERY_FAILED');
if (result.error === 'EMAIL-SIGNUP-DISABLED') {
setError('EMAIL-SIGNUP-DISABLED');
// On sign-up, return to the provider buttons the notice points at.
if (isSignUp) {
setPendingSignIn(null);
setShowEmailInput(false);
}
} else {
setError('MAGIC_LINK_DELIVERY_FAILED');
}
setFlowState('landing');
return false;
} catch (error) {
Expand All @@ -323,7 +333,7 @@ export function useSignInFlow({
}
}
},
[email, isCurrentDiscoveryRequest, params, saveHint]
[email, isCurrentDiscoveryRequest, isSignUp, params, saveHint]
);

useEffect(
Expand Down Expand Up @@ -486,6 +496,7 @@ export function useSignInFlow({
}

if (checkResult.kind === 'new') {
if (checkResult.emailSignupDisabled) setError('EMAIL-SIGNUP-DISABLED');
setIsNewUser(true);
setIsVerifying(false);
setAvailableProviders(orderNewAccountProviders(checkResult.providers));
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/lib/auth/email-signin-eligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,17 @@ describe('isNewAccountEligibleForMagicLink', () => {
await expect(isNewAccountEligibleForMagicLink('not-an-email')).resolves.toBe(false);
expect(mockIsEmailBlacklistedByDomainAsync).not.toHaveBeenCalled();
});

it.each([undefined, '', 'false', 'TRUE'])(
'rejects every new address unless EMAIL_SIGNUPS_ENABLED is exactly true: %s',
async value => {
if (value === undefined) delete process.env.EMAIL_SIGNUPS_ENABLED;
else process.env.EMAIL_SIGNUPS_ENABLED = value;
try {
await expect(isNewAccountEligibleForMagicLink('new@example.com')).resolves.toBe(false);
} finally {
process.env.EMAIL_SIGNUPS_ENABLED = 'true';
}
}
);
});
Loading
Loading