This commit is contained in:
parent
e8f27690a1
commit
a8d9fe6121
4 changed files with 198 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,107 @@ 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 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", %{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue