Skip to content

feat: add security.password.enabled for SSO-only deployments - #84

Merged
geekypunk merged 2 commits into
mainfrom
feat/password-login-toggle
Aug 24, 2026
Merged

feat: add security.password.enabled for SSO-only deployments#84
geekypunk merged 2 commits into
mainfrom
feat/password-login-toggle

Conversation

@geekypunk

Copy link
Copy Markdown
Contributor

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 consults security.google.enabled — no branch, no guard
  • /auth/login is permitAll in SecurityConfig unconditionally
  • no security.password.enabled (or equivalent) exists anywhere

So a deployment that puts every human behind Google Workspace — domain-allowlisted, hd-claim verified — still leaves /auth/login open 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 to true so existing installs behave exactly as before.

PasswordlessAuthService

  • Rejects before the rate limiter and before any credential comparison. Nothing needs rate-limiting when the path is closed, and rejecting early avoids leaking whether an account exists.
  • Emits PASSWORD_LOGIN_FAILURE with reason=password_login_disabled, so refusals are auditable instead of 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 discovered at the login screen.
  • The class needed @Slf4j — it had no logger.

SetupController — exposes passwordLoginEnabled on the public /setup/status, alongside the googleEnabled added 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: setupStatus is null on first paint and the field is undefined on 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:

/api/setup/status      -> "googleEnabled":true,"passwordLoginEnabled":false
POST /api/auth/login   -> {"message":"Password sign-in is disabled. Please sign in with Google."}
   (real ADMIN account, account_status=ACTIVE — a valid credential is refused, not just a bad one)
/api/auth/google/start -> 302 (unaffected)
startup log            -> Password sign-in is DISABLED (security.password.enabled=false); Google SSO only.
security_event         -> PASSWORD_LOGIN_FAILURE | {"reason": "password_login_disabled"}

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.

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.
@geekypunk
geekypunk requested a review from a team as a code owner August 24, 2026 20:19
…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.
@geekypunk

Copy link
Copy Markdown
Contributor Author

Follow-up commit after a backwards-compatibility review. One real bug found and fixed.

The unit test would have failed

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, both loginWithPassword tests hit the new rejection branch, and CI would have gone red.

The polarity is the trap, and it is worth stating for anyone touching this later:

field read as unset in test →
rateLimitEnabled if (enabled) feature skipped — harmless
passwordLoginEnabled if (!enabled) rejects every login

Fixed by setting it in setUp() alongside adminMfaEnabled, with a comment so it is not removed as redundant later.

Verified by running them, not by reasoning: PasswordlessAuthServiceTest 2/2, SetupControllerTest 6/6 — 0 failures, 0 errors, 0 skips. (Counts read from the surefire XML: a green exit code alone would not distinguish "passed" from "no tests matched the filter".)

Compatibility review

Concern Finding
Default value true in both application.properties and application-prod.properties
SetupStatusResponse arity one construction site; JSON is by name, so the field is additive
SetupControllerTest only calls the hasLlmConfig() accessor — unaffected
@PostConstruct guard only logs; cannot fail startup
@Slf4j class had no logger — no conflict
New frontend + old backend field is undefined!== false → form renders. Safe
Old frontend + new backend extra JSON field ignored. Safe
Signup.jsx reads setupComplete by name — unaffected

The mixed-version rows are the ones that would bite a real deployment mid-rollout. Both are safe specifically because the frontend guard is !== false and not a truthiness test.

Also refreshed the getStatus() doc comment in client.js, which still listed only the four original fields.

@geekypunk
geekypunk merged commit 7b8ea65 into main Aug 24, 2026
9 checks passed
@geekypunk
geekypunk deleted the feat/password-login-toggle branch August 24, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant