mitgliederverwaltung/test/mv_web/controllers/auth_controller_test.exs
Simon c381b86b5e
All checks were successful
continuous-integration/drone/push Build is passing
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: #474
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
2026-03-16 19:09:07 +01:00

575 lines
18 KiB
Elixir

defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Phoenix.ConnTest
import ExUnit.CaptureLog
alias Mv.Membership
# Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access
new_conn =
build_conn()
|> init_test_session(%{})
|> fetch_flash()
# Copy sandbox metadata from authenticated conn
if authenticated_conn.private[:ecto_sandbox] do
Plug.Conn.put_private(new_conn, :ecto_sandbox, authenticated_conn.private[:ecto_sandbox])
else
new_conn
end
end
# Basic UI tests
test "GET /sign-in shows sign in form", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/sign-in")
assert html_response(conn, 200) =~ "Sign in"
end
@tag role: :unauthenticated
test "GET /sign-in returns 200 and renders page (exercises AuthOverrides and layout)", %{
conn: conn
} do
{:ok, _view, html} = live(conn, ~p"/sign-in")
assert html =~ "Sign in"
# Public header (logo) from Layouts.app unauthenticated branch
assert html =~ "mila.svg" or html =~ "Mila Logo"
end
test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out")
assert redirected_to(conn) == ~p"/"
end
# Password authentication (LiveView)
test "password user can sign in with valid credentials via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "password@example.com",
password: "secret123",
oidc_id: nil
})
{:ok, view, _html} = live(conn, "/sign-in")
{:error, {:redirect, %{to: to}}} =
view
|> form("#user-password-sign-in-with-password",
user: %{email: "password@example.com", password: "secret123"}
)
|> render_submit()
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "password user with invalid credentials shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "test@example.com",
password: "correct_password",
oidc_id: nil
})
{:ok, view, _html} = live(conn, "/sign-in")
html =
view
|> form("#user-password-sign-in-with-password",
user: %{email: "test@example.com", password: "wrong_password"}
)
|> render_submit()
assert html =~ "Email or password was incorrect"
end
test "password user with non-existent email shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/sign-in")
html =
view
|> form("#user-password-sign-in-with-password",
user: %{email: "nonexistent@example.com", password: "anypassword"}
)
|> render_submit()
assert html =~ "Email or password was incorrect"
end
# Registration (LiveView)
test "user can register with valid credentials via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register")
{:error, {:redirect, %{to: to}}} =
view
|> form("#user-password-register-with-password-wrapper form",
user: %{email: "newuser@example.com", password: "newpassword123"}
)
|> render_submit()
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "registration with existing email shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "existing@example.com",
password: "secret123",
oidc_id: nil
})
{:ok, view, _html} = live(conn, "/register")
html =
view
|> form("#user-password-register-with-password-wrapper form",
user: %{email: "existing@example.com", password: "anotherpassword"}
)
|> render_submit()
assert html =~ "has already been taken"
end
test "registration with weak password shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register")
html =
view
|> form("#user-password-register-with-password-wrapper form",
user: %{email: "weakpass@example.com", password: "123"}
)
|> render_submit()
assert html =~ "length must be greater than or equal to 8"
end
test "when registration is disabled, sign-in page does not show Need an account? toggle", %{
conn: authenticated_conn
} do
{:ok, settings} = Membership.get_settings()
original = Map.get(settings, :registration_enabled, true)
{:ok, _} = Membership.update_settings(settings, %{registration_enabled: false})
try do
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, _view, html} = live(conn, ~p"/sign-in")
refute html =~ "Need an account?"
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{registration_enabled: original})
end
end
# Access control
test "unauthenticated user accessing protected route gets redirected to sign-in", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/members")
assert redirected_to(conn) == ~p"/sign-in"
end
test "authenticated user can access protected route", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/members")
assert conn.status == 200
end
test "password authenticated user can access protected route via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "auth@example.com",
password: "secret123",
oidc_id: nil
})
{:ok, view, _html} = live(conn, "/sign-in")
{:error, {:redirect, %{to: to}}} =
view
|> form("#user-password-sign-in-with-password",
user: %{email: "auth@example.com", password: "secret123"}
)
|> render_submit()
assert to =~ "/auth/user/password/sign_in_with_token"
# After login, user is redirected to /auth/user/password/sign_in_with_token.
# Session handling for protected routes should be tested in integration or E2E tests.
end
# Edge cases
test "user with nil oidc_id can still sign in with password via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "nil_oidc@example.com",
password: "secret123",
oidc_id: nil
})
{:ok, view, _html} = live(conn, "/sign-in")
{:error, {:redirect, %{to: to}}} =
view
|> form("#user-password-sign-in-with-password",
user: %{email: "nil_oidc@example.com", password: "secret123"}
)
|> render_submit()
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "user with empty string oidc_id is handled correctly via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "empty_oidc@example.com",
password: "secret123",
oidc_id: ""
})
{:ok, view, _html} = live(conn, "/sign-in")
{:error, {:redirect, %{to: to}}} =
view
|> form("#user-password-sign-in-with-password",
user: %{email: "empty_oidc@example.com", password: "secret123"}
)
|> render_submit()
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", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
# Create a mock Assent.ServerUnreachableError struct with required fields
error = %Assent.ServerUnreachableError{
http_adapter: Assent.HTTPAdapter.Finch,
request_url: "https://auth.example.com/callback?token=secret123",
reason: %Mint.TransportError{reason: :econnrefused}
}
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
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."
end
test "Assent.InvalidResponseError redirects to sign-in with error flash", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
# Create a mock Assent.InvalidResponseError struct with required field
# InvalidResponseError only has :response field (HTTPResponse struct)
error = %Assent.InvalidResponseError{
response: %Assent.HTTPAdapter.HTTPResponse{
status: 400,
headers: [],
body: "invalid_request"
}
}
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Authentication configuration error. Please contact the administrator."
end
test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
unknown_reason = :oops
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, unknown_reason)
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."
end
end
# Logging security tests - ensure no sensitive data is logged
describe "failure/3 logging security" do
test "does not log full URL with query params for Assent.ServerUnreachableError", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
error = %Assent.ServerUnreachableError{
http_adapter: Assent.HTTPAdapter.Finch,
request_url: "https://auth.example.com/callback?token=secret123&code=abc456",
reason: %Mint.TransportError{reason: :econnrefused}
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
end)
# Should log redacted URL (only scheme and host)
assert log =~ "https://auth.example.com"
# Should NOT log query parameters or tokens
refute log =~ "token=secret123"
refute log =~ "code=abc456"
refute log =~ "callback?token"
end
test "does not log sensitive data for Assent.InvalidResponseError", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
error = %Assent.InvalidResponseError{
response: %Assent.HTTPAdapter.HTTPResponse{
status: 400,
headers: [],
body: "invalid_request"
}
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
end)
# Should log error type but not full error details
assert log =~ "Authentication failure"
assert log =~ "oidc"
# Should not log full error struct with inspect
refute log =~ "Assent.InvalidResponseError"
end
test "does not log full reason for unknown OIDC errors", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
# Simulate an error that might contain sensitive data
error_with_sensitive_data = %{
token: "secret_token_123",
url: "https://example.com/callback?access_token=abc123",
error: :something_went_wrong
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:oidc, :callback}, error_with_sensitive_data)
end)
# Should log error type but not full error details
assert log =~ "Authentication failure"
assert log =~ "oidc"
# Should NOT log sensitive data
refute log =~ "secret_token_123"
refute log =~ "access_token=abc123"
refute log =~ "callback?access_token"
end
test "logs full reason for non-OIDC activities (password auth)", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
reason = %AshAuthentication.Errors.AuthenticationFailed{
caused_by: %Ash.Error.Forbidden{errors: []}
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason)
end)
# For non-OIDC activities, full reason is safe to log
assert log =~ "Authentication failure"
assert log =~ "password"
assert log =~ "AuthenticationFailed"
end
end
end