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:
+21
-5
@@ -191,16 +191,15 @@ export const api = {
|
|||||||
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
|
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 }> {
|
async passwordLogin(email: string, password: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
|
||||||
const payload = password ? { email, password } : { email };
|
if (!password) throw new Error("Password required");
|
||||||
const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login";
|
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ email, password }),
|
||||||
signal,
|
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) {
|
if (!r.ok) {
|
||||||
const text = await r.text().catch(() => "");
|
const text = await r.text().catch(() => "");
|
||||||
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
|
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
|
||||||
@@ -210,6 +209,23 @@ export const api = {
|
|||||||
return data;
|
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 }> {
|
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean | null }> {
|
||||||
try {
|
try {
|
||||||
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
|
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
|
||||||
|
|||||||
+11
-31
@@ -22,58 +22,43 @@ describe("Login Scene", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Default store mock
|
|
||||||
(useApp as any).mockImplementation((selector: any) => {
|
(useApp as any).mockImplementation((selector: any) => {
|
||||||
const state = {
|
const state = { setScene: mockSetScene, loginAs: mockLoginAs };
|
||||||
setScene: mockSetScene,
|
|
||||||
loginAs: mockLoginAs,
|
|
||||||
};
|
|
||||||
return selector(state);
|
return selector(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default api mock
|
|
||||||
(api.devLoginConfig as any).mockResolvedValue({ enabled: false });
|
(api.devLoginConfig as any).mockResolvedValue({ enabled: false });
|
||||||
|
globalThis.fetch = vi.fn(async () => new Response("", { status: 404 })) as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders standard login form elements", async () => {
|
it("renders standard login form elements", async () => {
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined();
|
expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined();
|
||||||
expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined();
|
expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined();
|
||||||
expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined();
|
expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined();
|
||||||
expect(screen.getAllByText("SIGN IN")[0]).toBeDefined();
|
expect(screen.getAllByText("SIGN IN")[0]).toBeDefined();
|
||||||
expect(screen.getAllByText("CONTINUE WITH MICROSOFT")[0]).toBeDefined();
|
expect(screen.getAllByText(/MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i)[0]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls loginAs and setScene on valid form submission", async () => {
|
it("calls loginAs with password method on valid form submission", async () => {
|
||||||
mockLoginAs.mockResolvedValueOnce(undefined);
|
mockLoginAs.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
|
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
|
||||||
fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } });
|
fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } });
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
|
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123');
|
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123', { method: 'password' });
|
||||||
expect(mockSetScene).toHaveBeenCalledWith('landing');
|
expect(mockSetScene).toHaveBeenCalledWith('landing');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays error on login failure", async () => {
|
it("blocks empty-password SIGN IN — must NOT silently call dev-login", async () => {
|
||||||
mockLoginAs.mockRejectedValueOnce(new Error("Invalid credentials"));
|
|
||||||
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
|
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
|
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Invalid credentials")).toBeDefined();
|
expect(screen.getByText(/Password required/i)).toBeDefined();
|
||||||
expect(mockSetScene).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
expect(mockLoginAs).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows dev-login button when feature flag is enabled", async () => {
|
it("shows dev-login button when feature flag is enabled", async () => {
|
||||||
@@ -86,16 +71,11 @@ describe("Login Scene", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects to SSO endpoint on microsoft click", async () => {
|
it("SSO button is disabled until probe resolves to ready", async () => {
|
||||||
// We can't actually assert on window.location.href in this test environment
|
|
||||||
// without mocking window.location, but we can verify the button renders
|
|
||||||
// and click it to ensure no errors
|
|
||||||
render(<Login />);
|
render(<Login />);
|
||||||
|
const ssoBtn = screen.getAllByRole("button", { name: /MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i })[0] as HTMLButtonElement;
|
||||||
const ssoBtn = screen.getAllByRole("button", { name: /CONTINUE WITH MICROSOFT/i })[0];
|
expect(ssoBtn.disabled).toBe(true);
|
||||||
fireEvent.click(ssoBtn);
|
fireEvent.click(ssoBtn);
|
||||||
|
|
||||||
// We just verify it doesn't try to call the regular login
|
|
||||||
expect(mockLoginAs).not.toHaveBeenCalled();
|
expect(mockLoginAs).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+21
-5
@@ -35,10 +35,14 @@ export default function Login() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
if (!password) {
|
||||||
|
setError("Password required. Use the developer button below if dev-login is enabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await loginAs(email, password || undefined);
|
await loginAs(email, password, { method: "password" });
|
||||||
setScene("landing");
|
setScene("landing");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -48,6 +52,10 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDevLogin = async () => {
|
const handleDevLogin = async () => {
|
||||||
|
if (!devLoginEnabled) {
|
||||||
|
setError("Developer sign-in is disabled in this build.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!email) {
|
if (!email) {
|
||||||
setError("Email required for dev-login");
|
setError("Email required for dev-login");
|
||||||
return;
|
return;
|
||||||
@@ -55,7 +63,7 @@ export default function Login() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await loginAs(email, undefined);
|
await loginAs(email, undefined, { method: "dev" });
|
||||||
setScene("landing");
|
setScene("landing");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -158,8 +166,13 @@ export default function Login() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="sso-btn"
|
className="sso-btn"
|
||||||
onClick={handleSSO}
|
onClick={handleSSO}
|
||||||
disabled={loading || ssoState === "not-configured" || ssoState === "not-wired"}
|
disabled={loading || ssoState !== "ready"}
|
||||||
title={ssoState === "not-configured" ? "Microsoft sign-in is not yet configured for this tenant" : ssoState === "not-wired" ? "Microsoft sign-in is not wired to this environment yet" : "Sign in with Microsoft"}
|
title={
|
||||||
|
ssoState === "ready" ? "Sign in with Microsoft"
|
||||||
|
: ssoState === "not-configured" ? "Microsoft sign-in is not yet configured for this tenant"
|
||||||
|
: ssoState === "not-wired" ? "Microsoft sign-in is not wired to this environment yet"
|
||||||
|
: "Checking Microsoft sign-in availability…"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 21 21">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 21 21">
|
||||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||||
@@ -167,7 +180,10 @@ export default function Login() {
|
|||||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||||
</svg>
|
</svg>
|
||||||
{ssoState === "not-configured" ? "MICROSOFT SIGN-IN · NOT CONFIGURED" : ssoState === "not-wired" ? "MICROSOFT SIGN-IN · NOT WIRED" : "CONTINUE WITH MICROSOFT"}
|
{ssoState === "unknown" ? "MICROSOFT SIGN-IN · CHECKING…"
|
||||||
|
: ssoState === "not-configured" ? "MICROSOFT SIGN-IN · NOT CONFIGURED"
|
||||||
|
: ssoState === "not-wired" ? "MICROSOFT SIGN-IN · NOT WIRED"
|
||||||
|
: "CONTINUE WITH MICROSOFT"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{devLoginEnabled && (
|
{devLoginEnabled && (
|
||||||
|
|||||||
+8
-3
@@ -96,7 +96,7 @@ interface AppState {
|
|||||||
userDisplayName: string | null;
|
userDisplayName: string | null;
|
||||||
isAuthed: boolean;
|
isAuthed: boolean;
|
||||||
setUserEmail: (e: string) => void;
|
setUserEmail: (e: string) => void;
|
||||||
loginAs: (email: string, password?: string) => Promise<void>;
|
loginAs: (email: string, password?: string, options?: { method?: "password" | "dev" }) => Promise<void>;
|
||||||
|
|
||||||
/** Live polling — lightweight tick that only refreshes the active
|
/** Live polling — lightweight tick that only refreshes the active
|
||||||
* scenario's work-items + headline runtime. Full refresh is manual. */
|
* scenario's work-items + headline runtime. Full refresh is manual. */
|
||||||
@@ -258,13 +258,18 @@ export const useApp = create<AppState>((set, get) => {
|
|||||||
userDisplayName: null,
|
userDisplayName: null,
|
||||||
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
|
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
|
||||||
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
|
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
|
||||||
loginAs: async (email, password) => {
|
loginAs: async (email, password, options) => {
|
||||||
api.clearToken();
|
api.clearToken();
|
||||||
api.config = { ...api.config, email };
|
api.config = { ...api.config, email };
|
||||||
set({ userEmail: email });
|
set({ userEmail: email });
|
||||||
persist();
|
persist();
|
||||||
try {
|
try {
|
||||||
await api.signIn(email, password);
|
if (options?.method === "dev") {
|
||||||
|
await api.devLogin(email);
|
||||||
|
} else {
|
||||||
|
if (!password) throw new Error("Password required");
|
||||||
|
await api.passwordLogin(email, password);
|
||||||
|
}
|
||||||
const me = await api.me();
|
const me = await api.me();
|
||||||
set({
|
set({
|
||||||
actor: { mode: "direct_user", user_id: me.user_id },
|
actor: { mode: "direct_user", user_id: me.user_id },
|
||||||
|
|||||||
Reference in New Issue
Block a user