From c381b86b5e94d670d66bb79e48f87f1389ed3239 Mon Sep 17 00:00:00 2001
From: Simon
Date: Mon, 16 Mar 2026 19:09:07 +0100
Subject: [PATCH] Improve oidc only mode (#474)
## Description of the implemented changes
The changes were:
- [x] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [x] Refactoring
**OIDC-only mode improvements and UX tweaks (success toasts, unauthenticated redirect).**
## What has been changed?
### OIDC-only mode (new feature)
- **Admin settings:** "Only OIDC sign-in" is an immediate toggle at the top of the OIDC section (no save button). Enabling it also turns off "Allow direct registration". When OIDC-only is on, the registration checkbox is disabled and shows a tooltip (DaisyUI `<.tooltip>`).
- **Backend:** Password sign-in is forbidden via Ash policy (`OidcOnlyActive` check). Password registration is blocked via validation `OidcOnlyBlocksPasswordRegistration`. New plug `OidcOnlySignInRedirect`: when OIDC-only and OIDC are configured, GET `/sign-in` redirects to the OIDC flow; GET `/auth/user/password/sign_in_with_token` is rejected with redirect + flash. `AuthController.success/4` also rejects password sign-in when OIDC-only.
- **Tests:** GlobalSettingsLive (OIDC-only UI), AuthController (redirect and password sign-in rejection), User authentication (register_with_password blocked when OIDC-only).
### UX / behaviour (no new feature flag)
- **Success toasts:** Success flash messages auto-dismiss after 5 seconds via JS hook `FlashAutoDismiss` and optional `auto_clear_ms` on `<.flash>` (used for success in root layout and `flash_group`).
- **Unauthenticated users:** Redirect to sign-in without the "You don't have permission to access this page" flash; that message is only shown to logged-in users who lack access. Logic in `LiveHelpers` and `CheckPagePermission` plug; test updated accordingly.
### Other
- Layouts: comment about unprocessed join-request count no longer uses "TODO" (Credo).
- Gettext: German translation for "Home" (Startseite); POT/PO kept in sync.
- CHANGELOG: Unreleased section updated with the above.
## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added where needed (module docs, comments where non-obvious)
### Accessibility
- [x] New elements are properly defined with html-tags (labels, aria-label on checkboxes)
- [x] Colour contrast follows WCAG criteria (unchanged)
- [x] Aria labels are added when needed (e.g. oidc-only and registration checkboxes)
- [x] Everything is accessible by keyboard (toggles and buttons unchanged)
- [x] Tab-Order is comprehensible
- [x] All interactive elements have a visible focus (existing patterns)
### Testing
- [x] Tests for new code are written (OIDC-only UI, auth controller, user auth; SMTP config builder and mailer)
- [x] All tests pass
- [ ] axe-core dev tools show no critical or major issues (not re-run for this PR; suggest spot-check on settings and sign-in)
## Additional Notes
- **OIDC-only:** When the `OIDC_ONLY` env var is set, the toggle is read-only and shows "(From OIDC_ONLY)". When OIDC is not configured, the toggle is disabled.
- **Invalidation:** Enabling OIDC-only sets `registration_enabled: false` in one update; disabling OIDC-only only updates `oidc_only` (registration left as-is).
- **Review focus:** Plug order in router (OidcOnlySignInRedirect), policy/validation order in User, and that all OIDC-only paths (form, plug, controller) stay consistent.
Reviewed-on: https://git.local-it.org/local-it/mitgliederverwaltung/pulls/474
Co-authored-by: Simon
Co-committed-by: Simon
---
CHANGELOG.md | 7 +
assets/js/app.js | 19 +++
docs/admin-bootstrap-and-oidc-role-sync.md | 1 +
docs/feature-roadmap.md | 5 +
lib/accounts/user.ex | 10 ++
.../oidc_only_blocks_password_registration.ex | 27 ++++
.../authorization/checks/oidc_only_active.ex | 16 ++
lib/mv_web/components/core_components.ex | 8 +
lib/mv_web/components/layouts.ex | 2 +-
lib/mv_web/components/layouts/root.html.heex | 2 +-
lib/mv_web/controllers/auth_controller.ex | 31 +++-
lib/mv_web/live/global_settings_live.ex | 145 +++++++++++++-----
lib/mv_web/live_helpers.ex | 9 +-
lib/mv_web/plugs/check_page_permission.ex | 9 +-
.../plugs/oidc_only_sign_in_redirect.ex | 73 +++++++++
lib/mv_web/router.ex | 1 +
priv/gettext/de/LC_MESSAGES/default.po | 10 ++
priv/gettext/default.pot | 10 ++
priv/gettext/en/LC_MESSAGES/default.po | 10 ++
test/accounts/user_authentication_test.exs | 27 ++++
.../controllers/auth_controller_test.exs | 141 ++++++++++++++++-
.../mv_web/live/global_settings_live_test.exs | 65 ++++++++
.../plugs/check_page_permission_test.exs | 5 +-
23 files changed, 579 insertions(+), 54 deletions(-)
create mode 100644 lib/accounts/user/validations/oidc_only_blocks_password_registration.ex
create mode 100644 lib/mv/authorization/checks/oidc_only_active.ex
create mode 100644 lib/mv_web/plugs/oidc_only_sign_in_redirect.ex
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d39df9b..7cc8ea5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Improved OIDC-only mode** – Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured.
+- **Success toast auto-dismiss** – Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them.
+
+### Changed
+- **Unauthenticated access** – Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access.
+
### Fixed
- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
diff --git a/assets/js/app.js b/assets/js/app.js
index 87f2c25..4c7e3c5 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -113,6 +113,25 @@ Hooks.FocusRestore = {
}
}
+// FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts)
+Hooks.FlashAutoDismiss = {
+ mounted() {
+ const ms = this.el.dataset.autoClearMs
+ if (!ms) return
+ const delay = parseInt(ms, 10)
+ if (delay > 0) {
+ this.timer = setTimeout(() => {
+ const key = this.el.dataset.clearFlashKey || "success"
+ this.pushEvent("lv:clear-flash", {key})
+ }, delay)
+ }
+ },
+
+ destroyed() {
+ if (this.timer) clearTimeout(this.timer)
+ }
+}
+
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
Hooks.TabListKeydown = {
mounted() {
diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md
index 5e26c85..aa5c155 100644
--- a/docs/admin-bootstrap-and-oidc-role-sync.md
+++ b/docs/admin-bootstrap-and-oidc-role-sync.md
@@ -38,6 +38,7 @@
### Sign-in page (OIDC-only mode)
- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings.
+- **Redirect loop fix:** After an OIDC failure (e.g. provider down), the app redirects to `/sign-in?oidc_failed=1`. The plug `OidcOnlySignInRedirect` does not redirect that request back to OIDC, so the sign-in page is shown with the error (no endless redirect).
### Sync Logic
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index 6383660..2ec15a5 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -49,6 +49,11 @@
- ✅ **Page-level authorization** - LiveView page access control
- ✅ **System role protection** - Critical roles cannot be deleted
+**Planned: OIDC-only mode (TDD, tests first):**
+- Admin Settings: When OIDC-only is enabled, disable "Allow direct registration" toggle and show hint (tests in `GlobalSettingsLiveTest`).
+- Backend: Reject password sign-in and `register_with_password` when OIDC-only (tests in `AuthControllerTest`, `Accounts`).
+- GET `/sign-in` redirect to OIDC when OIDC-only and OIDC configured (tests in `AuthControllerTest`). Implementation to follow after tests.
+
**Missing Features:**
- ❌ Password reset flow
- ❌ Email verification
diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex
index 29a2d4b..0127796 100644
--- a/lib/accounts/user.ex
+++ b/lib/accounts/user.ex
@@ -362,6 +362,12 @@ defmodule Mv.Accounts.User do
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
+ # When OIDC-only is active, password sign-in is forbidden (SSO only).
+ policy action(:sign_in_with_password) do
+ forbid_if Mv.Authorization.Checks.OidcOnlyActive
+ authorize_if always()
+ end
+
# AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)"
@@ -409,6 +415,10 @@ defmodule Mv.Accounts.User do
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
where: [action_is(:register_with_password)]
+ # Block password registration when OIDC-only mode is active
+ validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []},
+ where: [action_is(:register_with_password)]
+
# Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
diff --git a/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex b/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex
new file mode 100644
index 0000000..e4d9a35
--- /dev/null
+++ b/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex
@@ -0,0 +1,27 @@
+defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do
+ @moduledoc """
+ Validation that blocks direct registration (register_with_password) when
+ OIDC-only mode is active. In OIDC-only mode, sign-in and registration are
+ only allowed via OIDC (SSO).
+ """
+ use Ash.Resource.Validation
+
+ @impl true
+ def init(opts), do: {:ok, opts}
+
+ @impl true
+ def validate(_changeset, _opts, _context) do
+ if Mv.Config.oidc_only?() do
+ {:error,
+ field: :base,
+ message:
+ Gettext.dgettext(
+ MvWeb.Gettext,
+ "default",
+ "Registration with password is disabled when only OIDC sign-in is active."
+ )}
+ else
+ :ok
+ end
+ end
+end
diff --git a/lib/mv/authorization/checks/oidc_only_active.ex b/lib/mv/authorization/checks/oidc_only_active.ex
new file mode 100644
index 0000000..8d56ca1
--- /dev/null
+++ b/lib/mv/authorization/checks/oidc_only_active.ex
@@ -0,0 +1,16 @@
+defmodule Mv.Authorization.Checks.OidcOnlyActive do
+ @moduledoc """
+ Policy check: true when OIDC-only mode is active (Config.oidc_only?()).
+
+ Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed.
+ """
+ use Ash.Policy.SimpleCheck
+
+ alias Mv.Config
+
+ @impl true
+ def describe(_opts), do: "OIDC-only mode is active"
+
+ @impl true
+ def match?(_actor, _context, _opts), do: Config.oidc_only?()
+end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 8c58c32..b5bd763 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -63,6 +63,11 @@ defmodule MvWeb.CoreComponents do
values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup"
+ attr :auto_clear_ms, :integer,
+ default: nil,
+ doc:
+ "when set, flash is auto-dismissed after this many milliseconds (e.g. 5000 for success toasts)"
+
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
@@ -74,6 +79,9 @@ defmodule MvWeb.CoreComponents do
hide("##{@id}")}
role="alert"
class="pointer-events-auto"
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 5a96001..54f589d 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -265,7 +265,7 @@ defmodule MvWeb.Layouts do
aria-live="polite"
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
>
- <.flash kind={:success} flash={@flash} />
+ <.flash kind={:success} flash={@flash} auto_clear_ms={5000} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex
index 5419b73..bb900aa 100644
--- a/lib/mv_web/components/layouts/root.html.heex
+++ b/lib/mv_web/components/layouts/root.html.heex
@@ -74,7 +74,7 @@
aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
>
- <.flash id="flash-success-root" kind={:success} flash={@flash} />
+ <.flash id="flash-success-root" kind={:success} flash={@flash} auto_clear_ms={5000} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
<.flash id="flash-info-root" kind={:info} flash={@flash} />
<.flash id="flash-error-root" kind={:error} flash={@flash} />
diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex
index 20a76f5..adde4e8 100644
--- a/lib/mv_web/controllers/auth_controller.ex
+++ b/lib/mv_web/controllers/auth_controller.ex
@@ -15,8 +15,23 @@ defmodule MvWeb.AuthController do
use AshAuthentication.Phoenix.Controller
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
+ alias Mv.Config
- def success(conn, activity, user, _token) do
+ def success(conn, {:password, :sign_in} = _activity, user, token) do
+ if Config.oidc_only?() do
+ conn
+ |> put_flash(:error, gettext("Only sign-in via Single Sign-On (SSO) is allowed."))
+ |> redirect(to: sign_in_path_after_oidc_failure())
+ else
+ success_continue(conn, {:password, :sign_in}, user, token)
+ end
+ end
+
+ def success(conn, activity, user, token) do
+ success_continue(conn, activity, user, token)
+ end
+
+ defp success_continue(conn, activity, user, _token) do
return_to = get_session(conn, :return_to) || ~p"/"
message =
@@ -134,7 +149,7 @@ defmodule MvWeb.AuthController do
_ ->
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
end
@@ -148,7 +163,7 @@ defmodule MvWeb.AuthController do
:error,
gettext("The authentication server is currently unavailable. Please try again later.")
)
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
# Handle Assent invalid response errors (configuration or malformed responses)
@@ -161,7 +176,7 @@ defmodule MvWeb.AuthController do
:error,
gettext("Authentication configuration error. Please contact the administrator.")
)
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
# Catch-all clause for any other error types
@@ -171,7 +186,7 @@ defmodule MvWeb.AuthController do
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
# Handle generic AuthenticationFailed errors
@@ -211,10 +226,14 @@ defmodule MvWeb.AuthController do
conn
|> put_flash(:error, error_message)
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
end
+ # Path used when redirecting to sign-in after an OIDC failure. The query param tells
+ # OidcOnlySignInRedirect to show the sign-in page instead of redirecting back to OIDC (avoids loop).
+ defp sign_in_path_after_oidc_failure, do: "/sign-in?oidc_failed=1"
+
# Extract meaningful error message from Ash errors
defp extract_meaningful_error_message(errors) do
# Look for specific error messages in InvalidAttribute errors
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index f69b947..cb57631 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -19,6 +19,7 @@ defmodule MvWeb.GlobalSettingsLive do
## Events
- `validate` / `save` - Club settings form
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
+ - `toggle_oidc_only` - Enable/disable OIDC-only sign-in (immediate, outside OIDC form)
- `toggle_join_form_enabled` - Enable/disable the join form
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
- `toggle_join_form_field_required` - Toggle required flag per field
@@ -80,6 +81,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
+ |> assign(:oidc_only, Mv.Config.oidc_only?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:registration_enabled, settings.registration_enabled != false)
@@ -625,11 +627,30 @@ defmodule MvWeb.GlobalSettingsLive do
class="checkbox checkbox-sm"
checked={@registration_enabled}
phx-click="toggle_registration_enabled"
+ disabled={@oidc_only}
aria-label={gettext("Allow direct registration (/register)")}
/>
-
{gettext("OIDC (Single Sign-On)")}
@@ -638,6 +659,38 @@ defmodule MvWeb.GlobalSettingsLive do
{gettext("Some values are set via environment variables. Those fields are read-only.")}