Skip to content

Commit f5bc4c7

Browse files
baozhoutaoclaude
andauthored
fix(auth): trim whitespace from email/phone identifiers before submit (#3238) (#3239)
Autofill and copy-paste routinely smuggle leading/trailing whitespace into the login identifier; the server then rejects it as "Invalid email". Trim the identifier at every submit path — LoginForm (password, phone+password routing, SSO request body), RegisterForm (signUp + verification callback), and ForgotPasswordForm (reset request) — while leaving the input value as typed. Phone-OTP paths already trimmed. Closes #3238 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 56409c2 commit f5bc4c7

5 files changed

Lines changed: 159 additions & 8 deletions

File tree

packages/auth/src/ForgotPasswordForm.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ export function ForgotPasswordForm({
204204
setError(null);
205205

206206
try {
207-
await forgotPassword(email);
207+
// Trim pasted/autofilled whitespace before the reset request (#3238).
208+
await forgotPassword(email.trim());
208209
setSubmitted(true);
209210
onSuccess?.();
210211
} catch (err) {

packages/auth/src/LoginForm.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,16 +265,19 @@ export function LoginForm({
265265
e.preventDefault();
266266
setError(null);
267267

268+
// Autofill and copy-paste routinely smuggle in leading/trailing whitespace
269+
// that the server rejects as "Invalid email" (#3238) — trim before use.
270+
const identifier = email.trim();
268271
try {
269272
if (mode === 'phone-otp') {
270273
await signInWithPhoneOtp(phone.trim(), otpCode.trim());
271-
} else if (phonePasswordEnabled && looksLikePhoneIdentifier(email)) {
274+
} else if (phonePasswordEnabled && looksLikePhoneIdentifier(identifier)) {
272275
// Unified identifier: a phone-shaped entry routes to phone+password.
273276
// Normalize identically to the backend (strip formatting, no country
274277
// code) or the phoneNumber lookup fails.
275-
await signInWithPhonePassword(normalizePhoneIdentifier(email) ?? email.trim(), password);
278+
await signInWithPhonePassword(normalizePhoneIdentifier(identifier) ?? identifier, password);
276279
} else {
277-
await signIn(email, password);
280+
await signIn(identifier, password);
278281
}
279282
onSuccess?.();
280283
} catch (err) {
@@ -322,8 +325,9 @@ export function LoginForm({
322325
const handleSso = async () => {
323326
if (ssoSubmitting) return;
324327
setError(null);
328+
const identifier = email.trim();
325329
// SSO routes by email domain — a phone-shaped identifier can't map to an IdP.
326-
if (looksLikePhoneIdentifier(email)) {
330+
if (looksLikePhoneIdentifier(identifier)) {
327331
setError('Enter your email address to sign in with SSO.');
328332
return;
329333
}
@@ -333,7 +337,7 @@ export function LoginForm({
333337
const res = await fetch('/api/v1/auth/sign-in/sso', {
334338
method: 'POST',
335339
headers: { 'content-type': 'application/json' },
336-
body: JSON.stringify({ email, callbackURL: base + '/home' }),
340+
body: JSON.stringify({ email: identifier, callbackURL: base + '/home' }),
337341
credentials: 'include',
338342
});
339343
const data = (await res.json().catch(() => ({}))) as { url?: string; message?: string };

packages/auth/src/RegisterForm.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,13 @@ export function RegisterForm({
159159
return;
160160
}
161161

162+
// Autofill and copy-paste routinely smuggle in leading/trailing whitespace
163+
// that the server rejects as "Invalid email" (#3238) — trim before use.
164+
const trimmedEmail = email.trim();
162165
try {
163-
const result = await signUp(name, email, password);
166+
const result = await signUp(name, trimmedEmail, password);
164167
if (result?.requiresVerification) {
165-
onVerificationRequired?.(email);
168+
onVerificationRequired?.(trimmedEmail);
166169
return;
167170
}
168171
onSuccess?.();

packages/auth/src/__tests__/LoginForm.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,70 @@ describe('LoginForm — phone + password sign-in (framework#2780)', () => {
253253
});
254254
});
255255

256+
// #3238: autofill/copy-paste smuggle leading/trailing whitespace into the
257+
// identifier; the server then rejects it ("Invalid email"). The form must trim
258+
// before validating, routing, or sending.
259+
describe('LoginForm — identifier whitespace trimming (#3238)', () => {
260+
it('trims whitespace around the email before signIn', async () => {
261+
const signIn = vi.fn().mockResolvedValue({ user: { id: '1' }, session: { token: 't' } });
262+
renderLogin(createMockClient({}, { signIn }));
263+
264+
fireEvent.change(await screen.findByLabelText('Email'), {
265+
target: { value: ' admin@objectos.ai ' },
266+
});
267+
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pw' } });
268+
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }));
269+
270+
await waitFor(() =>
271+
expect(signIn).toHaveBeenCalledWith({ email: 'admin@objectos.ai', password: 'pw' }),
272+
);
273+
});
274+
275+
it('trims whitespace around the email in the SSO request body', async () => {
276+
const fetchSpy = vi
277+
.spyOn(globalThis, 'fetch')
278+
.mockResolvedValue(new Response(JSON.stringify({ message: 'nope' }), {
279+
status: 404,
280+
headers: { 'content-type': 'application/json' },
281+
}));
282+
try {
283+
renderLogin(createMockClient({ features: { sso: true } }));
284+
const button = await screen.findByRole('button', SSO_BUTTON);
285+
286+
fireEvent.change(screen.getByLabelText('Email'), {
287+
target: { value: ' a@corp.example ' },
288+
});
289+
fireEvent.click(button);
290+
291+
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
292+
const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)) as { email: string };
293+
expect(body.email).toBe('a@corp.example');
294+
} finally {
295+
fetchSpy.mockRestore();
296+
}
297+
});
298+
299+
it('still routes a whitespace-padded phone identifier to signInWithPhonePassword', async () => {
300+
const signIn = vi.fn();
301+
const signInWithPhonePassword = vi.fn().mockResolvedValue({
302+
user: { id: 'u2' },
303+
session: { token: 'pw-tok' },
304+
});
305+
renderLogin(createMockClient({ features: { phoneNumber: true } }, { signIn, signInWithPhonePassword }));
306+
307+
fireEvent.change(await screen.findByLabelText('Email or phone number'), {
308+
target: { value: ' +86 138-0013-8000 ' },
309+
});
310+
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'S3cret!' } });
311+
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }));
312+
313+
await waitFor(() =>
314+
expect(signInWithPhonePassword).toHaveBeenCalledWith('+8613800138000', 'S3cret!'),
315+
);
316+
expect(signIn).not.toHaveBeenCalled();
317+
});
318+
});
319+
256320
describe('LoginForm — SSO button pending state (objectui#2458 item 1)', () => {
257321
it('disables the SSO button while /sign-in/sso is in flight and surfaces failure inline', async () => {
258322
let resolveFetch!: (r: Response) => void;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* #3238: autofill/copy-paste smuggle leading/trailing whitespace into the
3+
* email field; the server then rejects it ("Invalid email"). RegisterForm and
4+
* ForgotPasswordForm must trim the email before sending, matching LoginForm
5+
* (whose trim tests live in LoginForm.test.tsx).
6+
*/
7+
8+
import React from 'react';
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
11+
import { AuthProvider } from '../AuthProvider';
12+
import { RegisterForm } from '../RegisterForm';
13+
import { ForgotPasswordForm } from '../ForgotPasswordForm';
14+
import type { AuthClient, AuthPublicConfig } from '../types';
15+
16+
function createMockClient(
17+
config: AuthPublicConfig,
18+
overrides: Partial<AuthClient> = {},
19+
): AuthClient {
20+
return {
21+
signIn: vi.fn().mockResolvedValue({ user: { id: '1' }, session: { token: 't' } }),
22+
signUp: vi.fn().mockResolvedValue({ user: { id: '2' }, session: null, requiresVerification: false }),
23+
signOut: vi.fn().mockResolvedValue(undefined),
24+
getSession: vi.fn().mockResolvedValue(null),
25+
forgotPassword: vi.fn().mockResolvedValue(undefined),
26+
resetPassword: vi.fn().mockResolvedValue(undefined),
27+
getConfig: vi.fn().mockResolvedValue(config),
28+
...overrides,
29+
} as unknown as AuthClient;
30+
}
31+
32+
function renderWithAuth(client: AuthClient, ui: React.ReactElement) {
33+
return render(
34+
<AuthProvider authUrl="/api/auth" client={client}>
35+
{ui}
36+
</AuthProvider>,
37+
);
38+
}
39+
40+
describe('RegisterForm — email whitespace trimming (#3238)', () => {
41+
it('trims whitespace around the email before signUp', async () => {
42+
const signUp = vi
43+
.fn()
44+
.mockResolvedValue({ user: { id: '2' }, session: null, requiresVerification: false });
45+
renderWithAuth(createMockClient({}, { signUp }), <RegisterForm />);
46+
47+
fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Test User' } });
48+
fireEvent.change(screen.getByLabelText('Email'), {
49+
target: { value: ' new@objectos.ai ' },
50+
});
51+
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } });
52+
fireEvent.change(screen.getByLabelText('Confirm Password'), {
53+
target: { value: 'password123' },
54+
});
55+
fireEvent.click(screen.getByRole('button', { name: 'Create Account' }));
56+
57+
await waitFor(() =>
58+
expect(signUp).toHaveBeenCalledWith({
59+
name: 'Test User',
60+
email: 'new@objectos.ai',
61+
password: 'password123',
62+
}),
63+
);
64+
});
65+
});
66+
67+
describe('ForgotPasswordForm — email whitespace trimming (#3238)', () => {
68+
it('trims whitespace around the email before requesting the reset link', async () => {
69+
const forgotPassword = vi.fn().mockResolvedValue(undefined);
70+
renderWithAuth(createMockClient({}, { forgotPassword }), <ForgotPasswordForm />);
71+
72+
fireEvent.change(await screen.findByLabelText('Email'), {
73+
target: { value: ' reset@objectos.ai ' },
74+
});
75+
fireEvent.click(screen.getByRole('button', { name: 'Send Reset Link' }));
76+
77+
await waitFor(() => expect(forgotPassword).toHaveBeenCalledWith('reset@objectos.ai'));
78+
});
79+
});

0 commit comments

Comments
 (0)