Improve oidc only mode (#474)
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
9b0f269ab6
commit
c381b86b5e
23 changed files with 579 additions and 54 deletions
|
|
@ -288,4 +288,31 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -283,6 +283,141 @@ defmodule MvWeb.AuthControllerTest do
|
|||
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||
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
|
||||
describe "handle_oidc_failure/2" do
|
||||
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)
|
||||
|
||||
assert redirected_to(conn) == ~p"/sign-in"
|
||||
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"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)
|
||||
|
||||
assert redirected_to(conn) == ~p"/sign-in"
|
||||
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"Authentication configuration error. Please contact the administrator."
|
||||
|
|
@ -334,7 +469,7 @@ defmodule MvWeb.AuthControllerTest do
|
|||
|
||||
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) ==
|
||||
"Unable to authenticate with OIDC. Please try again."
|
||||
|
|
|
|||
|
|
@ -110,4 +110,69 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -181,13 +181,14 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
|||
end
|
||||
|
||||
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([])
|
||||
|
||||
assert conn.halted
|
||||
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."
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue