fix chatbox

This commit is contained in:
2026-08-06 15:43:46 +05:30
parent e5f8144fb3
commit b23ea760c8
15 changed files with 475 additions and 97 deletions

View File

@@ -1,6 +1,11 @@
import {cookies} from 'next/headers';
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {verifyCredentials} from '@/features/auth/mock/users.mock';
import {
LOGIN_ERROR_PARAM,
type LoginErrorCode,
} from '@/features/auth/services/loginErrorCodes';
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
import {
REMEMBERED_MAX_AGE_SECONDS,
SESSION_COOKIE,
@@ -25,6 +30,23 @@ export const dynamic = 'force-dynamic';
* • deciding how long the session lasts (rememberMe is a request, not an
* instruction — the server sets the cookie lifetime)
* • issuing the httpOnly cookie the client can never read or forge
*
* ── Two content types, one endpoint ──────────────────────────────────────
* It answers both `application/json` (the hydrated form, via authRepository)
* and `application/x-www-form-urlencoded` (the browser posting the form
* natively, before React has hydrated or when its bundle never arrived).
*
* That second path is not a nicety, it is the fix for a real leak. The sign-in
* form has named inputs; a <form> with no method and no action submits GET to
* its own URL, so a submit landing in the pre-hydration window rewrote the
* address bar to `/login?email=…&password=…` — putting the password in the
* URL bar, in browser history, in the referrer and in every access log between
* here and the user. Declaring method="post" action="/api/auth/login" means
* the worst case is now a normal POST with the credentials in the body.
*
* The two paths differ ONLY in how the answer is shaped: JSON gets an
* envelope, a native post gets a 303 redirect, because a browser that just
* submitted a form needs somewhere to land, not a document full of braces.
*/
interface LoginRequestBody {
@@ -33,54 +55,140 @@ interface LoginRequestBody {
rememberMe?: unknown;
}
function failure(error: LoginError, status: number): Response {
// Shaped as the app's standard envelope so the client's error path is the
// same one every other endpoint uses; `field` rides alongside for the form.
const body: ApiFailure & {field: LoginError['field']} = {
error: {code: status === 401 ? 'unauthorized' : 'bad_request', message: error.message},
field: error.field,
};
return Response.json(body, {status, headers: {'cache-control': 'no-store'}});
/** What the request asked for, normalised across both content types. */
interface ParsedLogin {
email: string;
password: string;
rememberMe: boolean;
/** Only meaningful on the native path — where to land after success. */
next: string | null;
/** True when the browser posted the form itself, so answer in redirects. */
isFormPost: boolean;
}
export async function POST(req: NextRequest): Promise<Response> {
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
async function parseRequest(req: NextRequest): Promise<ParsedLogin | null> {
const contentType = req.headers.get('content-type') ?? '';
if (
contentType.includes('application/x-www-form-urlencoded') ||
contentType.includes('multipart/form-data')
) {
const form = await req.formData();
return {
email: asString(form.get('email')).trim(),
password: asString(form.get('password')),
// An unchecked checkbox is absent from the payload entirely; any present
// value means checked. Never parsed as a boolean-ish string.
rememberMe: form.get('rememberMe') !== null,
next: asString(form.get('next')) || null,
isFormPost: true,
};
}
let body: LoginRequestBody;
try {
body = (await req.json()) as LoginRequestBody;
} catch {
return failure({field: 'form', message: 'Malformed request body.'}, 400);
return null;
}
return {
email: asString(body.email).trim(),
password: asString(body.password),
rememberMe: body.rememberMe === true,
next: null,
isFormPost: false,
};
}
/**
* A failure, in whichever dialect the caller speaks.
*
* The redirect carries a code, never the submitted values — bouncing the email
* back through the URL to repopulate the field would reintroduce exactly the
* leak this endpoint exists to close.
*/
function failure(
req: NextRequest,
parsed: Pick<ParsedLogin, 'isFormPost' | 'next'> | null,
code: LoginErrorCode,
error: LoginError,
status: number,
): NextResponse {
if (parsed?.isFormPost) {
const target = new URL('/login', req.url);
target.searchParams.set(LOGIN_ERROR_PARAM, code);
// Preserve the deep link so a failed attempt does not cost the user the
// page they were originally trying to reach. Validated, not echoed raw.
const next = resolveRedirectTarget(parsed.next);
if (next !== '/dashboard') target.searchParams.set('next', next);
// 303: the browser must follow with GET, not repeat the POST.
return NextResponse.redirect(target, 303);
}
const email = typeof body.email === 'string' ? body.email.trim() : '';
const password = typeof body.password === 'string' ? body.password : '';
const rememberMe = body.rememberMe === true;
// Shaped as the app's standard envelope so the client's error path is the
// same one every other endpoint uses; `field` rides alongside for the form.
const body: ApiFailure & {field: LoginError['field']} = {
error: {
code: status === 401 ? 'unauthorized' : 'bad_request',
message: error.message,
},
field: error.field,
};
return NextResponse.json(body, {
status,
headers: {'cache-control': 'no-store'},
});
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const parsed = await parseRequest(req);
if (!parsed) {
return failure(
req,
null,
'malformed',
{field: 'form', message: 'Malformed request body.'},
400,
);
}
// Server-side validation, repeated rather than trusted from the client. The
// form validates too, for latency; this validates because the form is not a
// security boundary and a POST can arrive without it.
if (!email) {
return failure({field: 'email', message: 'Enter your email address.'}, 400);
if (!parsed.email) {
return failure(req, parsed, 'email_required', {
field: 'email',
message: 'Enter your email address.',
}, 400);
}
if (!/^\S+@\S+\.\S+$/.test(email)) {
return failure(
{field: 'email', message: 'Enter a valid email address.'},
400,
);
if (!/^\S+@\S+\.\S+$/.test(parsed.email)) {
return failure(req, parsed, 'email_invalid', {
field: 'email',
message: 'Enter a valid email address.',
}, 400);
}
if (!password) {
return failure({field: 'password', message: 'Enter your password.'}, 400);
if (!parsed.password) {
return failure(req, parsed, 'password_required', {
field: 'password',
message: 'Enter your password.',
}, 400);
}
const check = verifyCredentials(email, password);
const check = verifyCredentials(parsed.email, parsed.password);
if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') {
return failure(
{field: 'form', message: 'Invalid email or password.'},
401,
);
return failure(req, parsed, 'invalid_credentials', {
field: 'form',
message: 'Invalid email or password.',
}, 401);
}
const maxAge = rememberMe
const maxAge = parsed.rememberMe
? REMEMBERED_MAX_AGE_SECONDS
: SESSION_MAX_AGE_SECONDS;
@@ -95,23 +203,42 @@ export async function POST(req: NextRequest): Promise<Response> {
maxAge,
);
// A browser-session cookie still needs a server-side expiry, or a tab left
// open for a week would hold a valid token indefinitely.
const store = await cookies();
store.set(
SESSION_COOKIE,
token,
sessionCookieOptions(rememberMe ? maxAge : undefined),
);
const session: AuthSession = {
user: check.user,
expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
};
const payload: ApiSuccess<AuthSession> = {
data: session,
meta: {generatedAt: new Date().toISOString()},
};
return Response.json(payload, {headers: {'cache-control': 'no-store'}});
const response = parsed.isFormPost
? // 303 so the browser re-issues as GET. Without it, a refresh on the
// landing page would re-POST the credentials.
NextResponse.redirect(
new URL(resolveRedirectTarget(parsed.next), req.url),
303,
)
: NextResponse.json(
{
data: session,
meta: {generatedAt: new Date().toISOString()},
} satisfies ApiSuccess<AuthSession>,
{headers: {'cache-control': 'no-store'}},
);
/**
* Set on the response rather than through the `cookies()` store, because
* this response may be a redirect: the header has to ride along with the 303
* itself or the browser follows it to the dashboard still signed out, gets
* bounced back to /login by the proxy, and the sign-in appears to have
* silently failed.
*
* A browser-session cookie still needs a server-side expiry, or a tab left
* open for a week would hold a valid token indefinitely — hence the token's
* own `exp` regardless of whether maxAge is sent.
*/
response.cookies.set(
SESSION_COOKIE,
token,
sessionCookieOptions(parsed.rememberMe ? maxAge : undefined),
);
return response;
}