feat: add security.password.enabled for SSO-only deployments - #84
Conversation
Enabling Google SSO does not close the password path. loginWithPassword() never consulted security.google.enabled, /auth/login is permitAll unconditionally, and no toggle existed — so a deployment that puts every human behind Google Workspace still left /auth/login open to every local account. Anyone treating SSO as an exclusive gate was wrong about it. Adds security.password.enabled, defaulting to true so existing installs are unaffected. - PasswordlessAuthService: reject before the rate limiter and before any credential comparison. There is nothing to rate-limit when the path is closed, and rejecting early avoids leaking whether an account exists. Emits PASSWORD_LOGIN_FAILURE with reason=password_login_disabled so the refusal is auditable rather than silent. - @PostConstruct guard: logs an ERROR when password AND google are both disabled — that combination leaves nobody able to sign in, and is otherwise only discoverable at the login screen. - SetupController: expose passwordLoginEnabled on the public status response, alongside googleEnabled. - Login.jsx: hide the password form, drop the now-meaningless "or" divider, and reword the subtitle. Guarded as `!== false` rather than on truthiness: setupStatus is null on first paint and undefined on installs predating the flag, and both must render the form — inverting that would strand users on a login page with no way in if the status call were slow or failed.
…mment PasswordlessAuthServiceTest uses @Injectmocks, and Mockito does not process @value — every such field is set by hand with ReflectionTestUtils. The new passwordLoginEnabled boolean was not, so it defaulted to false and both loginWithPassword tests hit the new rejection branch and failed. Worth spelling out because the polarity is the trap: rateLimitEnabled is also unset in this test and that is harmless, because it is read as `if (rateLimitEnabled)` — absence just skips the feature. Password login is read as `if (!passwordLoginEnabled)`, so absence blocks every login instead. Also refreshes the getStatus() doc comment in client.js, which still listed only the four original fields. Verified: PasswordlessAuthServiceTest 2/2, SetupControllerTest 6/6, no failures, errors or skips.
|
Follow-up commit after a backwards-compatibility review. One real bug found and fixed. The unit test would have failed
The polarity is the trap, and it is worth stating for anyone touching this later:
Fixed by setting it in Verified by running them, not by reasoning: Compatibility review
The mixed-version rows are the ones that would bite a real deployment mid-rollout. Both are safe specifically because the frontend guard is Also refreshed the |
Follow-up to #83, which noted this gap but did not close it.
Problem
Enabling Google SSO does not disable password sign-in, and there is no way to make it exclusive.
PasswordlessAuthService.loginWithPassword()never consultssecurity.google.enabled— no branch, no guard/auth/loginispermitAllinSecurityConfigunconditionallysecurity.password.enabled(or equivalent) exists anywhereSo a deployment that puts every human behind Google Workspace — domain-allowlisted,
hd-claim verified — still leaves/auth/loginopen to every local account. Anyone reasoning "we turned on SSO, so password login is closed" is wrong, and nothing in the codebase says otherwise.This is most acute right after enabling SSO on an install that has seed or demo accounts: those credentials keep working, internet-facing, with SSO fully configured.
Change
Adds
security.password.enabled, defaulting totrueso existing installs behave exactly as before.PasswordlessAuthServicePASSWORD_LOGIN_FAILUREwithreason=password_login_disabled, so refusals are auditable instead of silent.@PostConstructguard logs an ERROR when password and Google are both disabled. That combination leaves nobody able to sign in and is otherwise only discovered at the login screen.@Slf4j— it had no logger.SetupController— exposespasswordLoginEnabledon the public/setup/status, alongside thegoogleEnabledadded in #83.Login.jsx— hides the password form, drops the now-meaningless "or" divider, and rewords the subtitle to "Sign in with your work Google account."The frontend guard is
passwordLoginEnabled !== false, deliberately not a truthiness test:setupStatusisnullon first paint and the field isundefinedon installs predating this flag. Both must render the form. Inverting it would strand users on a login page with no way in whenever the status call was slow or failed.Testing
Verified on a live self-host install with
SECURITY_PASSWORD_ENABLED=false:Login page renders the Google button alone — no password fields, no orphaned divider. With the flag unset, the page and the login behaviour are unchanged.