feat(users): OIDC-only mode + ForceDisableOIDC kill-switch (bookshelf-tm38.10) #1207

Merged
zombor merged 2 commits from bd-bookshelf-tm38.10 into main 2026-07-23 01:41:12 +00:00
Owner

Summary

  • OIDC-only mode: new app_settings-backed toggle that hides the local login form and auto-redirects unauthenticated users to the OIDC provider. Admin backdoor at GET /login?local=true and POST /login with hidden local=true field bypasses the redirect.
  • Anti-lockout guard: enabling OIDC-only mode when OIDC is not configured+enabled returns ErrOIDCOnlyModeRequiresEnabledOIDC (→ 400), preventing admins from locking themselves out.
  • PERGAMUM_FORCE_DISABLE_OIDC kill-switch: ops flag/env that disables OIDC entirely at runtime (overrides DB settings including OIDC-only mode), allowing local login restoration without DB access.
  • Login template conditionally hides local form (LocalFormEnabled bool in template data).
  • loginHandler refactored: loginInvalidCredentials helper extracted to keep cyclomatic complexity ≤ 15.

Files changed

  • internal/settings/oidc_settings.goOIDCOnlyMode field + ErrOIDCOnlyModeRequiresEnabledOIDC + anti-lockout guard in validateSaveOIDC
  • internal/settings/oidc_handler.goSaveOIDCRequest.OIDCOnlyMode + error translation
  • internal/appwire/appwire.goOIDCRuntimeConfig.OIDCOnlyMode, Deps.ForceDisableOIDC
  • internal/config/config.goForceDisableOIDC flag/env
  • internal/app/app.go — wire ForceDisableOIDC + OIDCOnlyMode into deps
  • internal/users/wire.gogetOIDCOnlyMode helper, wired into RegisterRoutes
  • internal/users/handler.goGetOIDCOnlyMode dep, oidcOnlyMode/localFormEnabled helpers, redirect logic, loginInvalidCredentials extraction
  • templates/pages/login.html — conditional local form, hidden local=true field, conditional divider

Test plan

  • make test passes (all packages green, including internal/users and internal/settings)
  • golangci-lint run ./internal/users/... ./internal/settings/... ./internal/appwire/... ./internal/config/... ./internal/app/... — 0 issues
  • New specs: OIDC-only mode redirect (GET + POST), ForceDisableOIDC override, anti-lockout guard, handler 400 translation — all pass
  • LocalFormEnabled propagated to template and tested via stub renderer

Closes bead bookshelf-tm38.10 on merge.

## Summary - **OIDC-only mode**: new `app_settings`-backed toggle that hides the local login form and auto-redirects unauthenticated users to the OIDC provider. Admin backdoor at `GET /login?local=true` and `POST /login` with hidden `local=true` field bypasses the redirect. - **Anti-lockout guard**: enabling OIDC-only mode when OIDC is not configured+enabled returns `ErrOIDCOnlyModeRequiresEnabledOIDC` (→ 400), preventing admins from locking themselves out. - **`PERGAMUM_FORCE_DISABLE_OIDC` kill-switch**: ops flag/env that disables OIDC entirely at runtime (overrides DB settings including OIDC-only mode), allowing local login restoration without DB access. - Login template conditionally hides local form (`LocalFormEnabled` bool in template data). - `loginHandler` refactored: `loginInvalidCredentials` helper extracted to keep cyclomatic complexity ≤ 15. ## Files changed - `internal/settings/oidc_settings.go` — `OIDCOnlyMode` field + `ErrOIDCOnlyModeRequiresEnabledOIDC` + anti-lockout guard in `validateSaveOIDC` - `internal/settings/oidc_handler.go` — `SaveOIDCRequest.OIDCOnlyMode` + error translation - `internal/appwire/appwire.go` — `OIDCRuntimeConfig.OIDCOnlyMode`, `Deps.ForceDisableOIDC` - `internal/config/config.go` — `ForceDisableOIDC` flag/env - `internal/app/app.go` — wire `ForceDisableOIDC` + `OIDCOnlyMode` into deps - `internal/users/wire.go` — `getOIDCOnlyMode` helper, wired into `RegisterRoutes` - `internal/users/handler.go` — `GetOIDCOnlyMode` dep, `oidcOnlyMode`/`localFormEnabled` helpers, redirect logic, `loginInvalidCredentials` extraction - `templates/pages/login.html` — conditional local form, hidden `local=true` field, conditional divider ## Test plan - [x] `make test` passes (all packages green, including `internal/users` and `internal/settings`) - [x] `golangci-lint run ./internal/users/... ./internal/settings/... ./internal/appwire/... ./internal/config/... ./internal/app/...` — 0 issues - [x] New specs: OIDC-only mode redirect (GET + POST), ForceDisableOIDC override, anti-lockout guard, handler 400 translation — all pass - [x] `LocalFormEnabled` propagated to template and tested via stub renderer Closes bead bookshelf-tm38.10 on merge.
feat(users): OIDC-only mode + ForceDisableOIDC kill-switch (bookshelf-tm38.10)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 2m42s
/ E2E API (pull_request) Successful in 3m26s
/ Test Race (pull_request) Successful in 4m9s
/ Coverage (pull_request) Successful in 4m34s
/ Lint (pull_request) Successful in 4m58s
/ Integration (pull_request) Successful in 5m35s
/ E2E Browser (pull_request) Successful in 6m49s
87f35fcdd0
- Add OIDCOnlyMode bool to OIDCProviderDetails, OIDCSettingsPage,
  OIDCSettingsUpdate, OIDCRuntimeConfig, and SaveOIDCRequest
- Anti-lockout guard: enabling OIDCOnlyMode requires OIDC to be
  configured and enabled (ErrOIDCOnlyModeRequiresEnabledOIDC)
- GET /login auto-redirects to /auth/oidc/login when OIDC-only mode
  is active; ?local=true query param shows the local form as admin backdoor
- POST /login guards against OIDC-only bypass unless local=true hidden
  field is present
- Login template hides local form when LocalFormEnabled=false
- PERGAMUM_FORCE_DISABLE_OIDC flag/env: ops kill-switch that disables
  OIDC at runtime (overrides DB settings including OIDC-only mode)
- Extract loginInvalidCredentials helper to keep loginHandler CC ≤ 15
- 100% test coverage: black-box specs in package users_test and
  settings_test covering all new branches

Closes bead bookshelf-tm38.10 on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security review — PR #1207 (OIDC-only mode + anti-lockout + PERGAMUM_FORCE_DISABLE_OIDC)

Adversarial review of server-side enforcement of the OIDC-only login policy. Diff only; CI is the source of behavioral truth (tests not re-run).

[BLOCKER] internal/users/handler.go:206 (POST guard) + internal/users/handler.go:86 (localFormEnabled) + templates/pages/login.html:20 — the `local=true` escape hatch is an OPEN, unauthenticated bypass of OIDC-only enforcement.
  When OIDC-only mode is active, the POST /login guard is `oidcOnlyMode(...) && r.FormValue("local") != "true"`. Any anonymous client that submits form field `local=true` skips the OIDC redirect and proceeds to full local-credential authentication — exactly as if OIDC-only mode were off. The value is not a secret: the login template bakes it into its own `<input type="hidden" name="local" value="true">`, and GET /login?local=true re-renders the local form on demand. There is NO admin/session gating despite the "admin backdoor" comment. The branch's own tests confirm the hole: `GET /login?local=true` with a default anonymous client renders the form, and `POST /login` with `local=true` "proceeds with local login (redirect to /)". This defeats the PR's central security claim (server-side enforcement so a client cannot POST creds directly): a client CAN post creds directly by appending a well-known param. Concretely, a user deactivated at the IdP whose local password still exists in the DB can bypass SSO entirely — the exact threat OIDC-only mode is meant to close.
  Fix: remove the `?local=true` / `local=true` request-param escape hatch from loginPageHandler/localFormEnabled AND the POST guard, and drop the hidden `local` input from login.html. Rely solely on the ops-only PERGAMUM_FORCE_DISABLE_OIDC env kill-switch for recovery — it already forces getOIDCOnlyMode/getOIDCEnabled to false (internal/users/wire.go), restoring the local form and local login without any request-settable flag. If a request-level admin escape is genuinely required, gate it behind an authenticated admin session (verified claims), never an unauthenticated query/form value.

[MINOR] internal/settings/oidc_settings.go:204 — anti-lockout guard checks only Enabled + IssuerURI, not full client config.
  validateSaveOIDC rejects OIDCOnlyMode unless `Enabled && IssuerURI != ""`, but does not require ClientID/ClientSecret. An admin can enable OIDC-only with an incomplete provider config; the OIDC flow then fails and, absent the env kill-switch, users hit a broken login. Recovery via PERGAMUM_FORCE_DISABLE_OIDC is documented and works, so this is not lockout-without-recovery — but requiring a complete/usable provider config before permitting OIDC-only would remove the foot-gun.

Verified clean

  • Enforcement is server-side (GET redirect + POST guard), not UI-only — aside from the bypass above.
  • Kill-switch precedence: PERGAMUM_FORCE_DISABLE_OIDC forces getOIDCEnabled/getOIDCOnlyMode to false at runtime, per-request (not a startup snapshot) — internal/users/wire.go; ops-only (env/flag), not request-settable.
  • Anti-lockout ErrOIDCOnlyModeRequiresEnabledOIDC enforced on every save; existing.OIDCOnlyMode is overwritten each save, so a stored Enabled=false+OnlyMode=true state is unreachable via the handler.
  • Open-redirect: OIDC redirect target is the fixed internal path /auth/oidc/login, not attacker-controlled; next is validated (must start with /, rejects //).
  • Secrets/PII: no password/token logged; failed-login audit uses SanitizeDescription(username) only.
  • CSP: login.html adds only a {{if}} guard + hidden input — no inline style= / inline handlers.

REVIEW VERDICT: 1 blocker, 0 major, 1 minor

## Security review — PR #1207 (OIDC-only mode + anti-lockout + `PERGAMUM_FORCE_DISABLE_OIDC`) Adversarial review of server-side enforcement of the OIDC-only login policy. Diff only; CI is the source of behavioral truth (tests not re-run). ``` [BLOCKER] internal/users/handler.go:206 (POST guard) + internal/users/handler.go:86 (localFormEnabled) + templates/pages/login.html:20 — the `local=true` escape hatch is an OPEN, unauthenticated bypass of OIDC-only enforcement. When OIDC-only mode is active, the POST /login guard is `oidcOnlyMode(...) && r.FormValue("local") != "true"`. Any anonymous client that submits form field `local=true` skips the OIDC redirect and proceeds to full local-credential authentication — exactly as if OIDC-only mode were off. The value is not a secret: the login template bakes it into its own `<input type="hidden" name="local" value="true">`, and GET /login?local=true re-renders the local form on demand. There is NO admin/session gating despite the "admin backdoor" comment. The branch's own tests confirm the hole: `GET /login?local=true` with a default anonymous client renders the form, and `POST /login` with `local=true` "proceeds with local login (redirect to /)". This defeats the PR's central security claim (server-side enforcement so a client cannot POST creds directly): a client CAN post creds directly by appending a well-known param. Concretely, a user deactivated at the IdP whose local password still exists in the DB can bypass SSO entirely — the exact threat OIDC-only mode is meant to close. Fix: remove the `?local=true` / `local=true` request-param escape hatch from loginPageHandler/localFormEnabled AND the POST guard, and drop the hidden `local` input from login.html. Rely solely on the ops-only PERGAMUM_FORCE_DISABLE_OIDC env kill-switch for recovery — it already forces getOIDCOnlyMode/getOIDCEnabled to false (internal/users/wire.go), restoring the local form and local login without any request-settable flag. If a request-level admin escape is genuinely required, gate it behind an authenticated admin session (verified claims), never an unauthenticated query/form value. [MINOR] internal/settings/oidc_settings.go:204 — anti-lockout guard checks only Enabled + IssuerURI, not full client config. validateSaveOIDC rejects OIDCOnlyMode unless `Enabled && IssuerURI != ""`, but does not require ClientID/ClientSecret. An admin can enable OIDC-only with an incomplete provider config; the OIDC flow then fails and, absent the env kill-switch, users hit a broken login. Recovery via PERGAMUM_FORCE_DISABLE_OIDC is documented and works, so this is not lockout-without-recovery — but requiring a complete/usable provider config before permitting OIDC-only would remove the foot-gun. ``` ### Verified clean - Enforcement is server-side (GET redirect + POST guard), not UI-only — aside from the bypass above. - Kill-switch precedence: PERGAMUM_FORCE_DISABLE_OIDC forces getOIDCEnabled/getOIDCOnlyMode to false at runtime, per-request (not a startup snapshot) — internal/users/wire.go; ops-only (env/flag), not request-settable. - Anti-lockout ErrOIDCOnlyModeRequiresEnabledOIDC enforced on every save; existing.OIDCOnlyMode is overwritten each save, so a stored Enabled=false+OnlyMode=true state is unreachable via the handler. - Open-redirect: OIDC redirect target is the fixed internal path /auth/oidc/login, not attacker-controlled; `next` is validated (must start with `/`, rejects `//`). - Secrets/PII: no password/token logged; failed-login audit uses SanitizeDescription(username) only. - CSP: login.html adds only a `{{if}}` guard + hidden input — no inline `style=` / inline handlers. REVIEW VERDICT: 1 blocker, 0 major, 1 minor
fix(users): close OIDC-only-mode local-login bypass (bookshelf-tm38.10)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m6s
/ E2E API (pull_request) Successful in 2m35s
/ Test Race (pull_request) Successful in 4m5s
/ Coverage (pull_request) Successful in 4m27s
/ Lint (pull_request) Successful in 5m20s
/ E2E Browser (pull_request) Successful in 5m43s
/ Integration (pull_request) Successful in 6m1s
4189a5e927
Remove the ?local=true / local=true request-param escape hatch that allowed
any anonymous visitor to bypass OIDC-only mode and authenticate locally.

Changes:
- internal/users/handler.go: drop localFormEnabled's URL-param branch; the
  only bypass is now d.ForceDisableOIDC. POST /login guard checks ForceDisableOIDC
  directly before calling oidcOnlyMode so both GET and POST honour the kill-switch.
- templates/pages/login.html: remove the hidden <input name="local" value="true">
  that the old form injected.
- internal/settings/oidc_settings.go: strengthen anti-lockout guard to also
  require ClientID + ClientSecret (after merging preserved secret from existing
  record) before OIDCOnlyMode=true is accepted.
- Tests inverted to assert bypass is closed; ForceDisableOIDC recovery path
  now correctly tests GetOIDCOnlyMode=true overridden by the kill-switch; new
  tests for POST /login with ForceDisableOIDC and for incomplete client config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security Review — OIDC-only-mode bypass fix (PR #1207)

Adversarial re-review of the BLOCKER fix (?local=true / local=true open unauthenticated bypass). The bypass is closed.

1. No request-param bypass remains. Grepped internal/users/handler.go: the only request-derived reads are next, error (query), and username/password/next (form). There is NO r.URL.Query().Get("local") / r.FormValue("local") or any request value that re-enables the local form or local-credential auth. templates/pages/login.html no longer has a hidden local input — the form is gated purely on the server-computed .LocalFormEnabled.

2. Server-side enforcement on BOTH GET and POST. GET /login (loginPageHandler): onlyMode && !showLocalhttp.Redirect(.../auth/oidc/login) before any template render. POST /login (loginHandler): the guard if !d.ForceDisableOIDC && oidcOnlyMode(...) fires before username/password are read and before Login() is ever called — so the deactivated-at-IdP-but-local-password-still-in-DB user is redirected to SSO and can no longer bypass. showLocal/onlyMode are computed server-side from GetOIDCConfig (app_settings), never from the request.

3. Env kill-switch is the only escape. ForceDisableOIDC originates from config.go (--force-disable-oidc / PERGAMUM_FORCE_DISABLE_OIDC), parsed at startup, threaded through appwire → users.Deps. It is evaluated per-request inside the deps closures (wire.go getOIDCOnlyMode/getOIDCEnabled short-circuit to false when set) and in the handler guards. It cannot be set by any request.

4. Anti-lockout tightening is correct. validateSaveOIDC rejects OIDCOnlyMode unless Enabled && IssuerURI != "", and — after merging a preserved (not re-submitted) ClientSecret — additionally requires non-empty ClientID and ClientSecret (ErrOIDCOnlyModeRequiresEnabledOIDC). No new lockout footgun: an admin cannot enable OIDC-only with an unusable client config, and the PERGAMUM_FORCE_DISABLE_OIDC recovery path restores local login regardless of DB state.

5. No open-redirect / secrets / CSP issues. Redirect target /auth/oidc/login is a hardcoded constant, not user-controlled; the next param keeps its existing /-prefix + //-reject validation. No secrets/PII logged (audit path unchanged, uses SanitizeDescription). login.html adds only {{if}} guards — no inline style=, CSP intact.

[MINOR] internal/users/handler.go:73 — OIDC-only mode fails open to the local form on a GetOIDCConfig error (oidcOnlyMode returns false on error), mirroring the existing getOIDCEnabled behavior.
This is a deliberate anti-lockout availability tradeoff (a transient DB blip shouldn't lock every user out), and local-credential auth still requires a valid password — so it does not reopen the original unauthenticated bypass. Noting for awareness only; no change required.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security Review — OIDC-only-mode bypass fix (PR #1207) Adversarial re-review of the BLOCKER fix (`?local=true` / `local=true` open unauthenticated bypass). **The bypass is closed.** **1. No request-param bypass remains.** Grepped `internal/users/handler.go`: the only request-derived reads are `next`, `error` (query), and `username`/`password`/`next` (form). There is NO `r.URL.Query().Get("local")` / `r.FormValue("local")` or any request value that re-enables the local form or local-credential auth. `templates/pages/login.html` no longer has a hidden `local` input — the form is gated purely on the server-computed `.LocalFormEnabled`. **2. Server-side enforcement on BOTH GET and POST.** GET `/login` (`loginPageHandler`): `onlyMode && !showLocal` → `http.Redirect(.../auth/oidc/login)` before any template render. POST `/login` (`loginHandler`): the guard `if !d.ForceDisableOIDC && oidcOnlyMode(...)` fires **before** `username`/`password` are read and before `Login()` is ever called — so the deactivated-at-IdP-but-local-password-still-in-DB user is redirected to SSO and can no longer bypass. `showLocal`/`onlyMode` are computed server-side from `GetOIDCConfig` (app_settings), never from the request. **3. Env kill-switch is the only escape.** `ForceDisableOIDC` originates from `config.go` (`--force-disable-oidc` / `PERGAMUM_FORCE_DISABLE_OIDC`), parsed at startup, threaded through `appwire → users.Deps`. It is evaluated per-request inside the deps closures (`wire.go` `getOIDCOnlyMode`/`getOIDCEnabled` short-circuit to false when set) and in the handler guards. It cannot be set by any request. **4. Anti-lockout tightening is correct.** `validateSaveOIDC` rejects `OIDCOnlyMode` unless `Enabled && IssuerURI != ""`, and — after merging a preserved (not re-submitted) `ClientSecret` — additionally requires non-empty `ClientID` and `ClientSecret` (`ErrOIDCOnlyModeRequiresEnabledOIDC`). No new lockout footgun: an admin cannot enable OIDC-only with an unusable client config, and the `PERGAMUM_FORCE_DISABLE_OIDC` recovery path restores local login regardless of DB state. **5. No open-redirect / secrets / CSP issues.** Redirect target `/auth/oidc/login` is a hardcoded constant, not user-controlled; the `next` param keeps its existing `/`-prefix + `//`-reject validation. No secrets/PII logged (audit path unchanged, uses `SanitizeDescription`). `login.html` adds only `{{if}}` guards — no inline `style=`, CSP intact. [MINOR] internal/users/handler.go:73 — OIDC-only mode fails **open** to the local form on a `GetOIDCConfig` error (`oidcOnlyMode` returns false on error), mirroring the existing `getOIDCEnabled` behavior. This is a deliberate anti-lockout availability tradeoff (a transient DB blip shouldn't lock every user out), and local-credential auth still requires a valid password — so it does not reopen the original unauthenticated bypass. Noting for awareness only; no change required. REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Code Review: Security BLOCKER Resolution

Status: ✓ APPROVED — all findings resolved

Verification Summary

1. Request-Level Bypass Removal ✓

  • GET /login: No code reading ?local=true query param; form entirely gated by LocalFormEnabled template variable
  • POST /login: No code reading local=true form field; OIDC-only guard fires before credential parsing
  • Template: Hidden <input name="local"> removed; entire form wrapped in {{if .LocalFormEnabled}}

2. Test Inversion Verification ✓

All new tests assert the secure behavior (bypass is closed):

  • GET /login with ?local=true: Asserts redirect to OIDC (HTTP 302), not form render
  • POST /login with local=true field: Asserts redirect to OIDC (HTTP 302), not credential processing
  • POST /login with ForceDisableOIDC: Asserts local auth IS restored via env kill-switch only
  • Comments explicitly state "bypass is closed" and "no longer a bypass"

Tests follow black-box + one-Expect-per-It: package users_test / package settings_test, var-at-top, BeforeEach/JustBeforeEach/It pattern.

3. Anti-Lockout Guard ✓

validateSaveOIDC enforces all required conditions before allowing OIDC-only mode:

  1. Enabled must be true
  2. IssuerURI must be set
  3. ClientID must be set (after load + merge)
  4. ClientSecret must be set (preserved from existing or in update)

Tested scenarios: missing Enabled, missing IssuerURI, missing ClientID, missing ClientSecret, preserved secret OK, full config OK.

Handler translates ErrOIDCOnlyModeRequiresEnabledOIDC → HTTP 400 validation error (not 500).

4. ForceDisableOIDC Precedence ✓

  • Config wiring: ForceDisableOIDC from PERGAMUM_FORCE_DISABLE_OIDC env flag
  • users.wire.go: getOIDCOnlyMode checks ForceDisableOIDC first (returns false if set)
  • Both handlers: OIDC-only guard checks !d.ForceDisableOIDC && oidcOnlyMode(...) — ops kill-switch prevents redirect
  • Tests: ForceDisableOIDC=true with GetOIDCOnlyMode=true asserts form shown + credentials processed

5. Code Quality ✓

  • No new .golangci.yml exclusions: funlen/CC gates unaffected
  • Extract refactor: loginInvalidCredentials extracted to keep loginHandler under CC gate (~20 lines, simple)
  • Helper functions: oidcOnlyMode and localFormEnabled are simple, safe (error handling returns secure defaults)
  • No white-box tests: all tests in *_test.go are black-box
  • 100% coverage: new code paths all exercised by tests

Edge Cases Covered ✓

  • GetOIDCOnlyMode returns error → safe default (form shown)
  • Normal mode (OIDC-only off) → form always shown, no regression
  • OIDC-only + OIDC disabled → anti-lockout guard rejects save
  • Empty ClientSecret with preserved secret → guard accepts (reload works correctly)

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

The fix is complete, secure, and well-tested. The ungated ?local=true OIDC-only bypass is closed. Ready to merge.

## Code Review: Security BLOCKER Resolution **Status:** ✓ APPROVED — all findings resolved ### Verification Summary #### 1. Request-Level Bypass Removal ✓ - **GET /login:** No code reading `?local=true` query param; form entirely gated by `LocalFormEnabled` template variable - **POST /login:** No code reading `local=true` form field; OIDC-only guard fires before credential parsing - **Template:** Hidden `<input name="local">` removed; entire form wrapped in `{{if .LocalFormEnabled}}` #### 2. Test Inversion Verification ✓ All new tests assert the **secure behavior** (bypass is closed): - `GET /login with ?local=true`: Asserts redirect to OIDC (HTTP 302), not form render - `POST /login with local=true field`: Asserts redirect to OIDC (HTTP 302), not credential processing - `POST /login with ForceDisableOIDC`: Asserts local auth IS restored via env kill-switch only - Comments explicitly state "bypass is closed" and "no longer a bypass" Tests follow **black-box + one-Expect-per-It**: `package users_test` / `package settings_test`, var-at-top, BeforeEach/JustBeforeEach/It pattern. #### 3. Anti-Lockout Guard ✓ **validateSaveOIDC** enforces all required conditions before allowing OIDC-only mode: 1. Enabled must be true 2. IssuerURI must be set 3. ClientID must be set (after load + merge) 4. ClientSecret must be set (preserved from existing or in update) Tested scenarios: missing Enabled, missing IssuerURI, missing ClientID, missing ClientSecret, preserved secret OK, full config OK. Handler translates `ErrOIDCOnlyModeRequiresEnabledOIDC` → HTTP 400 validation error (not 500). #### 4. ForceDisableOIDC Precedence ✓ - Config wiring: `ForceDisableOIDC` from `PERGAMUM_FORCE_DISABLE_OIDC` env flag - users.wire.go: `getOIDCOnlyMode` checks ForceDisableOIDC first (returns false if set) - Both handlers: OIDC-only guard checks `!d.ForceDisableOIDC && oidcOnlyMode(...)` — ops kill-switch prevents redirect - Tests: `ForceDisableOIDC=true` with `GetOIDCOnlyMode=true` asserts form shown + credentials processed #### 5. Code Quality ✓ - **No new `.golangci.yml` exclusions:** funlen/CC gates unaffected - **Extract refactor:** `loginInvalidCredentials` extracted to keep `loginHandler` under CC gate (~20 lines, simple) - **Helper functions:** `oidcOnlyMode` and `localFormEnabled` are simple, safe (error handling returns secure defaults) - **No white-box tests:** all tests in `*_test.go` are black-box - **100% coverage:** new code paths all exercised by tests ### Edge Cases Covered ✓ - GetOIDCOnlyMode returns error → safe default (form shown) - Normal mode (OIDC-only off) → form always shown, no regression - OIDC-only + OIDC disabled → anti-lockout guard rejects save - Empty ClientSecret with preserved secret → guard accepts (reload works correctly) --- **REVIEW VERDICT: 0 blocker, 0 major, 0 minor** The fix is **complete, secure, and well-tested.** The ungated `?local=true` OIDC-only bypass is closed. Ready to merge.
zombor force-pushed bd-bookshelf-tm38.10 from 4189a5e927
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m6s
/ E2E API (pull_request) Successful in 2m35s
/ Test Race (pull_request) Successful in 4m5s
/ Coverage (pull_request) Successful in 4m27s
/ Lint (pull_request) Successful in 5m20s
/ E2E Browser (pull_request) Successful in 5m43s
/ Integration (pull_request) Successful in 6m1s
to c7963a4fb1
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m43s
/ E2E API (pull_request) Successful in 2m52s
/ Test Race (pull_request) Successful in 3m54s
/ Coverage (pull_request) Successful in 4m13s
/ Lint (pull_request) Successful in 5m20s
/ Integration (pull_request) Successful in 5m41s
/ E2E Browser (pull_request) Successful in 5m35s
2026-07-23 01:31:23 +00:00
Compare
zombor merged commit 831a86081b into main 2026-07-23 01:41:12 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!1207
No description provided.