fix(login): split passwordLogin / devLogin so SIGN IN cannot bypass

Oracle round-3 finding: passing password=undefined to api.signIn silently
hit /api/v1/auth/dev-login. With dev-login disabled in a production
build, a buyer typing only their email and pressing SIGN IN would still
have been authenticated as that user.

- api.passwordLogin(email, password) explicitly requires a password and
  always hits /api/v1/auth/login. Throws on missing password.
- api.devLogin(email) explicitly hits /api/v1/auth/dev-login and is the
  only entry point to that endpoint.
- store.loginAs(email, password, { method: 'password' | 'dev' }) routes
  to the right call. Default is password (requires password).
- Login.handleSubmit rejects empty password with 'Password required'
  before any network call.
- Login.handleDevLogin bails when devLoginEnabled is false.
- SSO button is disabled while probe state is 'unknown'.
- Login.test.tsx: regression test 'blocks empty-password SIGN IN —
  must NOT silently call dev-login'. Plus SSO test asserts the button
  starts disabled.

29/29 unit tests green.
This commit is contained in:
2026-06-14 13:34:54 +04:00
parent 456243c31c
commit b507ffe7e3
4 changed files with 61 additions and 44 deletions
+21 -5
View File
@@ -191,16 +191,15 @@ export const api = {
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
},
async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
const payload = password ? { email, password } : { email };
const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login";
async passwordLogin(email: string, password: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
if (!password) throw new Error("Password required");
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(payload),
body: JSON.stringify({ email, password }),
signal,
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload);
const r = await instrumentedFetch("POST", `${this.config.baseUrl}/api/v1/auth/login`, init, { email, password });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
@@ -210,6 +209,23 @@ export const api = {
return data;
},
async devLogin(email: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ email }),
signal,
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}/api/v1/auth/dev-login`, init, { email });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Dev-login failed: ${r.status} ${text.slice(0, 100)}`);
}
const data = await r.json() as { access_token: string; refresh_token?: string };
this.setBearer(data.access_token);
return data;
},
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean | null }> {
try {
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };