Improve oidc only mode (#474)
All checks were successful
continuous-integration/drone/push Build is passing

## 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: #474
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
This commit is contained in:
Simon 2026-03-16 19:09:07 +01:00 committed by simon
parent 9b0f269ab6
commit c381b86b5e
23 changed files with 579 additions and 54 deletions

View file

@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### Fixed
- **SMTP configuration** Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly. - **SMTP configuration** Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.

View file

@ -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) // TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
Hooks.TabListKeydown = { Hooks.TabListKeydown = {
mounted() { mounted() {

View file

@ -38,6 +38,7 @@
### Sign-in page (OIDC-only mode) ### 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. - `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 ### Sync Logic

View file

@ -49,6 +49,11 @@
- ✅ **Page-level authorization** - LiveView page access control - ✅ **Page-level authorization** - LiveView page access control
- ✅ **System role protection** - Critical roles cannot be deleted - ✅ **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:** **Missing Features:**
- ❌ Password reset flow - ❌ Password reset flow
- ❌ Email verification - ❌ Email verification

View file

@ -362,6 +362,12 @@ defmodule Mv.Accounts.User do
# Authorization Policies # Authorization Policies
# Order matters: Most specific policies first, then general permission check # Order matters: Most specific policies first, then general permission check
policies do 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) # AshAuthentication bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "Allow AshAuthentication internal operations (registration, login)" description "Allow AshAuthentication internal operations (registration, login)"
@ -409,6 +415,10 @@ defmodule Mv.Accounts.User do
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []}, validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
where: [action_is(:register_with_password)] 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 # Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member # Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember

View file

@ -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

View file

@ -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

View file

@ -63,6 +63,11 @@ defmodule MvWeb.CoreComponents do
values: [:info, :error, :success, :warning], values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup" 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" 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" slot :inner_block, doc: "the optional inner block that renders the flash message"
@ -74,6 +79,9 @@ defmodule MvWeb.CoreComponents do
<div <div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id} id={@id}
phx-hook={@auto_clear_ms && "FlashAutoDismiss"}
data-auto-clear-ms={@auto_clear_ms}
data-clear-flash-key={@auto_clear_ms && @kind}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert" role="alert"
class="pointer-events-auto" class="pointer-events-auto"

View file

@ -265,7 +265,7 @@ defmodule MvWeb.Layouts do
aria-live="polite" aria-live="polite"
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none" 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={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} /> <.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} /> <.flash kind={:error} flash={@flash} />

View file

@ -74,7 +74,7 @@
aria-live="polite" aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end" 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-warning-root" kind={:warning} flash={@flash} />
<.flash id="flash-info-root" kind={:info} flash={@flash} /> <.flash id="flash-info-root" kind={:info} flash={@flash} />
<.flash id="flash-error-root" kind={:error} flash={@flash} /> <.flash id="flash-error-root" kind={:error} flash={@flash} />

View file

@ -15,8 +15,23 @@ defmodule MvWeb.AuthController do
use AshAuthentication.Phoenix.Controller use AshAuthentication.Phoenix.Controller
alias Mv.Accounts.User.Errors.PasswordVerificationRequired 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"/" return_to = get_session(conn, :return_to) || ~p"/"
message = message =
@ -134,7 +149,7 @@ defmodule MvWeb.AuthController do
_ -> _ ->
conn conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) |> 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
end end
@ -148,7 +163,7 @@ defmodule MvWeb.AuthController do
:error, :error,
gettext("The authentication server is currently unavailable. Please try again later.") 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 end
# Handle Assent invalid response errors (configuration or malformed responses) # Handle Assent invalid response errors (configuration or malformed responses)
@ -161,7 +176,7 @@ defmodule MvWeb.AuthController do
:error, :error,
gettext("Authentication configuration error. Please contact the administrator.") gettext("Authentication configuration error. Please contact the administrator.")
) )
|> redirect(to: ~p"/sign-in") |> redirect(to: sign_in_path_after_oidc_failure())
end end
# Catch-all clause for any other error types # Catch-all clause for any other error types
@ -171,7 +186,7 @@ defmodule MvWeb.AuthController do
conn conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) |> 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
# Handle generic AuthenticationFailed errors # Handle generic AuthenticationFailed errors
@ -211,10 +226,14 @@ defmodule MvWeb.AuthController do
conn conn
|> put_flash(:error, error_message) |> put_flash(:error, error_message)
|> redirect(to: ~p"/sign-in") |> redirect(to: sign_in_path_after_oidc_failure())
end end
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 # Extract meaningful error message from Ash errors
defp extract_meaningful_error_message(errors) do defp extract_meaningful_error_message(errors) do
# Look for specific error messages in InvalidAttribute errors # Look for specific error messages in InvalidAttribute errors

View file

@ -19,6 +19,7 @@ defmodule MvWeb.GlobalSettingsLive do
## Events ## Events
- `validate` / `save` - Club settings form - `validate` / `save` - Club settings form
- `toggle_registration_enabled` - Enable/disable direct registration (/register) - `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 - `toggle_join_form_enabled` - Enable/disable the join form
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields - `add_join_form_field` / `remove_join_form_field` - Manage join form fields
- `toggle_join_form_field_required` - Toggle required flag per field - `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_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_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_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_only, Mv.Config.oidc_only?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:registration_enabled, settings.registration_enabled != false) |> assign(:registration_enabled, settings.registration_enabled != false)
@ -625,11 +627,30 @@ defmodule MvWeb.GlobalSettingsLive do
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
checked={@registration_enabled} checked={@registration_enabled}
phx-click="toggle_registration_enabled" phx-click="toggle_registration_enabled"
disabled={@oidc_only}
aria-label={gettext("Allow direct registration (/register)")} aria-label={gettext("Allow direct registration (/register)")}
/> />
<label for="registration-enabled-checkbox" class="cursor-pointer font-medium"> <label
for="registration-enabled-checkbox"
class={
if @oidc_only, do: "cursor-not-allowed opacity-70", else: "cursor-pointer font-medium"
}
>
{gettext("Allow direct registration (/register)")} {gettext("Allow direct registration (/register)")}
</label> </label>
<%= if @oidc_only do %>
<.tooltip
content={gettext("Only OIDC sign-in is active. This option is disabled.")}
position="top"
>
<span
data-testid="oidc-only-registration-hint"
class="cursor-help text-base-content/70"
>
</span>
</.tooltip>
<% end %>
</div> </div>
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3> <h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
@ -638,6 +659,38 @@ defmodule MvWeb.GlobalSettingsLive do
{gettext("Some values are set via environment variables. Those fields are read-only.")} {gettext("Some values are set via environment variables. Those fields are read-only.")}
</p> </p>
<% end %> <% end %>
<div class="flex items-center gap-3 mb-4">
<input
type="checkbox"
id="oidc-only-checkbox"
data-testid="oidc-only-checkbox"
class="checkbox checkbox-sm"
checked={@oidc_only}
phx-click="toggle_oidc_only"
disabled={@oidc_only_env_set or not @oidc_configured}
aria-label={gettext("Only OIDC sign-in (hide password login)")}
/>
<label
for="oidc-only-checkbox"
class={
if @oidc_only_env_set or not @oidc_configured,
do: "cursor-not-allowed opacity-70",
else: "cursor-pointer font-medium"
}
>
{if @oidc_only_env_set do
gettext("Only OIDC sign-in (hide password login)") <>
" (" <> gettext("From OIDC_ONLY") <> ")"
else
gettext("Only OIDC sign-in (hide password login)")
end}
</label>
</div>
<p class="label-text-alt text-base-content/70 mb-4">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
)}
</p>
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4"> <div class="grid gap-4">
<.input <.input
@ -744,27 +797,6 @@ defmodule MvWeb.GlobalSettingsLive do
) )
} }
/> />
<div class="form-control">
<.input
field={@form[:oidc_only]}
type="checkbox"
class="checkbox checkbox-sm"
disabled={@oidc_only_env_set or not @oidc_configured}
label={
if @oidc_only_env_set do
gettext("Only OIDC sign-in (hide password login)") <>
" (" <> gettext("From OIDC_ONLY") <> ")"
else
gettext("Only OIDC sign-in (hide password login)")
end
}
/>
<p class="label-text-alt text-base-content/70 mt-1">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
)}
</p>
</div>
</div> </div>
<.button <.button
:if={ :if={
@ -868,18 +900,19 @@ defmodule MvWeb.GlobalSettingsLive do
saves_vereinfacht = vereinfacht_params?(setting_params_clean) saves_vereinfacht = vereinfacht_params?(setting_params_clean)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
{:ok, _updated_settings} -> {:ok, updated_settings} ->
{:ok, fresh_settings} = Membership.get_settings() # Use the returned record for the form so saved values show immediately;
# get_settings() can return cached data without the new attribute until reload.
test_result = test_result =
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
socket = socket =
socket socket
|> assign(:settings, fresh_settings) |> assign(:settings, updated_settings)
|> assign(:registration_enabled, fresh_settings.registration_enabled != false) |> assign(:registration_enabled, updated_settings.registration_enabled != false)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> assign(:vereinfacht_api_key_set, present?(updated_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:oidc_only, Mv.Config.oidc_only?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:smtp_configured, Mv.Config.smtp_configured?()) |> assign(:smtp_configured, Mv.Config.smtp_configured?())
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password())) |> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
@ -916,19 +949,53 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def handle_event("toggle_registration_enabled", _params, socket) do def handle_event("toggle_registration_enabled", _params, socket) do
settings = socket.assigns.settings if Mv.Config.oidc_only?() do
new_value = not socket.assigns.registration_enabled {:noreply, socket}
else
settings = socket.assigns.settings
new_value = not socket.assigns.registration_enabled
case Membership.update_settings(settings, %{registration_enabled: new_value}) do case Membership.update_settings(settings, %{registration_enabled: new_value}) do
{:ok, updated_settings} -> {:ok, updated_settings} ->
{:noreply, {:noreply,
socket socket
|> assign(:settings, updated_settings) |> assign(:settings, updated_settings)
|> assign(:registration_enabled, updated_settings.registration_enabled != false) |> assign(:registration_enabled, updated_settings.registration_enabled != false)
|> assign_form()} |> assign_form()}
{:error, _} -> {:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))} {:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
end
end
end
@impl true
def handle_event("toggle_oidc_only", _params, socket) do
if socket.assigns.oidc_only_env_set do
{:noreply, socket}
else
settings = socket.assigns.settings
new_value = not socket.assigns.oidc_only
# When enabling OIDC-only, also disable direct registration; when disabling, only change oidc_only.
params =
if new_value,
do: %{oidc_only: true, registration_enabled: false},
else: %{oidc_only: false}
case Membership.update_settings(settings, params) do
{:ok, updated_settings} ->
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:oidc_only, updated_settings.oidc_only == true)
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|> assign_form()
|> put_flash(:success, gettext("Settings updated successfully"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
end
end end
end end

View file

@ -74,7 +74,7 @@ defmodule MvWeb.LiveHelpers do
socket = socket =
socket socket
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.") |> maybe_put_access_denied_flash(user)
|> Phoenix.LiveView.push_navigate(to: redirect_to) |> Phoenix.LiveView.push_navigate(to: redirect_to)
{:halt, socket} {:halt, socket}
@ -82,6 +82,13 @@ defmodule MvWeb.LiveHelpers do
end end
end end
# Only show "no permission" when user is logged in; unauthenticated users are redirected to sign-in without flash.
defp maybe_put_access_denied_flash(socket, nil), do: socket
defp maybe_put_access_denied_flash(socket, _user) do
Phoenix.LiveView.put_flash(socket, :error, "You don't have permission to access this page.")
end
defp ensure_user_role_loaded(socket) do defp ensure_user_role_loaded(socket) do
user = socket.assigns[:current_user] user = socket.assigns[:current_user]

View file

@ -54,7 +54,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
conn conn
|> fetch_session() |> fetch_session()
|> fetch_flash() |> fetch_flash()
|> put_flash(:error, "You don't have permission to access this page.") |> maybe_put_access_denied_flash(user)
|> redirect(to: redirect_to) |> redirect(to: redirect_to)
|> halt() |> halt()
end end
@ -75,6 +75,13 @@ defmodule MvWeb.Plugs.CheckPagePermission do
defp redirect_target(user), do: redirect_target_for_user(user) defp redirect_target(user), do: redirect_target_for_user(user)
# Only set "no permission" flash when user is logged in; unauthenticated users get redirect only, no flash.
defp maybe_put_access_denied_flash(conn, nil), do: conn
defp maybe_put_access_denied_flash(conn, _user) do
put_flash(conn, :error, "You don't have permission to access this page.")
end
@doc """ @doc """
Returns true if the path is public (no auth/permission check). Returns true if the path is public (no auth/permission check).
Used by LiveView hook to skip redirect on sign-in etc. Used by LiveView hook to skip redirect on sign-in etc.

View file

@ -0,0 +1,73 @@
defmodule MvWeb.Plugs.OidcOnlySignInRedirect do
@moduledoc """
When OIDC-only mode is active:
- GET /sign-in redirects to the OIDC flow when OIDC is configured (sign-in page skipped).
- GET /sign-in?oidc_failed=1 is not redirected, so the sign-in page is shown after an OIDC
failure (avoids redirect loop when the provider is down or misconfigured).
- GET /auth/user/password/sign_in_with_token is rejected (redirect to /sign-in with error)
so password sign-in cannot complete.
"""
import Plug.Conn
import Phoenix.Controller
alias Mv.Config
def init(opts), do: opts
def call(conn, _opts) do
conn
|> maybe_redirect_sign_in_to_oidc()
|> maybe_reject_password_token_sign_in()
end
defp maybe_redirect_sign_in_to_oidc(conn) do
if conn.request_path != "/sign-in" or conn.method != "GET" do
conn
else
conn = fetch_query_params(conn)
maybe_redirect_sign_in_to_oidc_checked(conn)
end
end
defp maybe_redirect_sign_in_to_oidc_checked(conn) do
cond do
# Show sign-in page when returning from OIDC failure to avoid redirect loop.
conn.query_params["oidc_failed"] -> conn
Config.oidc_only?() and Config.oidc_configured?() -> redirect_and_halt(conn)
true -> conn
end
end
defp redirect_and_halt(conn) do
conn
|> redirect(to: "/auth/user/oidc")
|> halt()
end
defp maybe_reject_password_token_sign_in(conn) do
if conn.halted, do: conn, else: reject_password_token_sign_in_if_applicable(conn)
end
defp reject_password_token_sign_in_if_applicable(conn) do
path = conn.request_path
password_token_path? =
path =~ ~r|/auth/user/password/sign_in_with_token| and conn.method == "GET"
if password_token_path? and Config.oidc_only?() do
message =
Gettext.dgettext(
MvWeb.Gettext,
"default",
"Only sign-in via Single Sign-On (SSO) is allowed."
)
conn
|> put_flash(:error, message)
|> redirect(to: "/sign-in")
|> halt()
else
conn
end
end
end

View file

@ -18,6 +18,7 @@ defmodule MvWeb.Router do
plug MvWeb.Plugs.CheckPagePermission plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled plug MvWeb.Plugs.JoinFormEnabled
plug MvWeb.Plugs.RegistrationEnabled plug MvWeb.Plugs.RegistrationEnabled
plug MvWeb.Plugs.OidcOnlySignInRedirect
end end
pipeline :api do pipeline :api do

View file

@ -3895,3 +3895,13 @@ msgstr "Rolle %{name}"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User %{email}" msgid "User %{email}"
msgstr "Benutzer*in %{email}" msgstr "Benutzer*in %{email}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only OIDC sign-in is active. This option is disabled."
msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt."

View file

@ -3895,3 +3895,13 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User %{email}" msgid "User %{email}"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only OIDC sign-in is active. This option is disabled."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
msgstr ""

View file

@ -3895,3 +3895,13 @@ msgstr "Role %{name}"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User %{email}" msgid "User %{email}"
msgstr "User %{email}" msgstr "User %{email}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only OIDC sign-in is active. This option is disabled."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
msgstr ""

View file

@ -288,4 +288,31 @@ defmodule Mv.Accounts.UserAuthenticationTest do
end end
end end
end end
describe "register_with_password when OIDC-only is enabled" do
alias Mv.Membership
test "returns error when OIDC-only is enabled" do
{:ok, settings} = Membership.get_settings()
original_oidc_only = Map.get(settings, :oidc_only, false)
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
try do
attrs = %{
email: "newuser#{System.unique_integer([:positive])}@example.com",
password: "SecurePassword123"
}
result =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, attrs)
|> Ash.create()
assert {:error, _} = result
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{oidc_only: original_oidc_only})
end
end
end
end end

View file

@ -283,6 +283,141 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end end
describe "when OIDC-only is enabled" do
setup %{conn: authenticated_conn} do
{:ok, settings} = Membership.get_settings()
original_oidc_only = Map.get(settings, :oidc_only, false)
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, conn: conn, original_oidc_only: original_oidc_only}
end
test "password sign-in is rejected and redirects to sign-in with error", %{
conn: conn,
original_oidc_only: original
} do
try do
_user =
create_test_user(%{
email: "password@example.com",
password: "secret123",
oidc_id: nil
})
{:ok, view, _html} = live(conn, "/sign-in")
result =
view
|> form("#user-password-sign-in-with-password",
user: %{email: "password@example.com", password: "secret123"}
)
|> render_submit()
# When OIDC-only is enabled, password sign-in must not succeed (no redirect to sign_in_with_token).
case result do
{:error, {:redirect, %{to: to}}} ->
refute to =~ "sign_in_with_token",
"Expected password sign-in to be rejected when OIDC-only, got redirect to: #{to}"
_ ->
# LiveView re-rendered (e.g. with flash error) instead of redirecting to success
:ok
end
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{oidc_only: original})
end
end
end
describe "GET /sign-in when OIDC-only" do
test "redirects to OIDC flow when OIDC-only and OIDC are configured", %{
conn: authenticated_conn
} do
{:ok, settings} = Membership.get_settings()
prev = %{
oidc_only: settings.oidc_only,
oidc_client_id: settings.oidc_client_id,
oidc_base_url: settings.oidc_base_url,
oidc_redirect_uri: settings.oidc_redirect_uri
}
{:ok, _} =
Membership.update_settings(settings, %{
oidc_only: true,
oidc_client_id: "test-client",
oidc_base_url: "https://idp.example.com",
oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
oidc_client_secret: "test-secret"
})
try do
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/sign-in")
assert redirected_to(conn) =~ "/auth/user/oidc"
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, prev)
end
end
test "returns 200 when OIDC-only but oidc_failed=1 (avoids redirect loop)", %{
conn: authenticated_conn
} do
{:ok, settings} = Membership.get_settings()
prev = %{
oidc_only: settings.oidc_only,
oidc_client_id: settings.oidc_client_id,
oidc_base_url: settings.oidc_base_url,
oidc_redirect_uri: settings.oidc_redirect_uri
}
{:ok, _} =
Membership.update_settings(settings, %{
oidc_only: true,
oidc_client_id: "test-client",
oidc_base_url: "https://idp.example.com",
oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
oidc_client_secret: "test-secret"
})
try do
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, "/sign-in?oidc_failed=1")
assert conn.status == 200
# Sign-in page is shown, not redirect to OIDC
assert conn.resp_body =~ "Sign in" or conn.resp_body =~ "sign-in"
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, prev)
end
end
test "returns 200 when OIDC-only but OIDC not configured", %{conn: authenticated_conn} do
{:ok, settings} = Membership.get_settings()
original_oidc_only = Map.get(settings, :oidc_only, false)
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
try do
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/sign-in")
assert conn.status == 200
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{oidc_only: original_oidc_only})
end
end
test "returns 200 when OIDC-only is disabled", %{conn: authenticated_conn} do
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/sign-in")
assert conn.status == 200
end
end
# OIDC/Rauthy error handling tests # OIDC/Rauthy error handling tests
describe "handle_oidc_failure/2" do describe "handle_oidc_failure/2" do
test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{ test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{
@ -298,7 +433,7 @@ defmodule MvWeb.AuthControllerTest do
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error) conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
assert redirected_to(conn) == ~p"/sign-in" assert redirected_to(conn) == "/sign-in?oidc_failed=1"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"The authentication server is currently unavailable. Please try again later." "The authentication server is currently unavailable. Please try again later."
@ -320,7 +455,7 @@ defmodule MvWeb.AuthControllerTest do
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error) conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
assert redirected_to(conn) == ~p"/sign-in" assert redirected_to(conn) == "/sign-in?oidc_failed=1"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Authentication configuration error. Please contact the administrator." "Authentication configuration error. Please contact the administrator."
@ -334,7 +469,7 @@ defmodule MvWeb.AuthControllerTest do
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, unknown_reason) conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, unknown_reason)
assert redirected_to(conn) == ~p"/sign-in" assert redirected_to(conn) == "/sign-in?oidc_failed=1"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Unable to authenticate with OIDC. Please try again." "Unable to authenticate with OIDC. Please try again."

View file

@ -110,4 +110,69 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings" assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
end end
end end
describe "Authentication section when OIDC-only is enabled" do
setup %{conn: conn} do
user = create_test_user(%{email: "admin@example.com"})
conn = conn_with_oidc_user(conn, user)
{:ok, settings} = Membership.get_settings()
original_oidc_only = Map.get(settings, :oidc_only, false)
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
{:ok, conn: conn, original_oidc_only: original_oidc_only}
end
@describetag :ui
test "registration checkbox is disabled when OIDC-only is enabled", %{
conn: conn,
original_oidc_only: original
} do
try do
{:ok, view, _html} = live(conn, ~p"/settings")
assert has_element?(view, "#registration-enabled-checkbox[disabled]")
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{oidc_only: original})
end
end
@describetag :ui
test "OIDC-only hint is visible when OIDC-only is enabled", %{
conn: conn,
original_oidc_only: original
} do
try do
{:ok, view, _html} = live(conn, ~p"/settings")
assert has_element?(view, "[data-testid='oidc-only-registration-hint']")
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{oidc_only: original})
end
end
test "when OIDC-only is disabled, registration checkbox is enabled and can be toggled", %{
conn: conn,
original_oidc_only: original
} do
try do
{:ok, settings} = Membership.get_settings()
Membership.update_settings(settings, %{oidc_only: false})
{:ok, view, _html} = live(conn, ~p"/settings")
refute has_element?(view, "#registration-enabled-checkbox[disabled]")
initial_checked =
view |> element("#registration-enabled-checkbox") |> render() =~ "checked"
view
|> element("#registration-enabled-checkbox")
|> render_click()
new_checked = view |> element("#registration-enabled-checkbox") |> render() =~ "checked"
assert new_checked != initial_checked
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{oidc_only: original})
end
end
end
end end

View file

@ -181,13 +181,14 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end end
describe "unauthenticated user" do describe "unauthenticated user" do
test "nil current_user is denied and redirected to \"/sign-in\"" do test "nil current_user is denied and redirected to \"/sign-in\" without access-denied flash" do
conn = conn_without_user("/members") |> CheckPagePermission.call([]) conn = conn_without_user("/members") |> CheckPagePermission.call([])
assert conn.halted assert conn.halted
assert redirected_to(conn) == "/sign-in" assert redirected_to(conn) == "/sign-in"
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) == # Unauthenticated users are redirected to sign-in only; no "no permission" message.
refute Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
"You don't have permission to access this page." "You don't have permission to access this page."
end end
end end