From a8d9fe61210242c35310554678b5c4dd31f3f252 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 16 Mar 2026 14:37:09 +0100 Subject: [PATCH] feat: improve oidc only mode --- docs/feature-roadmap.md | 5 + test/accounts/user_authentication_test.exs | 27 +++++ .../controllers/auth_controller_test.exs | 101 ++++++++++++++++++ .../mv_web/live/global_settings_live_test.exs | 65 +++++++++++ 4 files changed, 198 insertions(+) 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/test/accounts/user_authentication_test.exs b/test/accounts/user_authentication_test.exs index 1b47165..b759e8a 100644 --- a/test/accounts/user_authentication_test.exs +++ b/test/accounts/user_authentication_test.exs @@ -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 diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index e449284..cc090a9 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -283,6 +283,107 @@ 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 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", %{ diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index e48c44b..92da11b 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -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