- Rename AshAuthentication strategy from :oidc :rauthy to :oidc :oidc; generated actions are now register_with_oidc / sign_in_with_oidc. - Update config keys (:rauthy → :oidc) in dev.exs and runtime.exs. - Update default_redirect_uri to /auth/user/oidc/callback everywhere. - Rename Mv.Accounts helper functions accordingly. - Update Mv.Secrets, AuthController, link_oidc_account_live and all tests. - Update docker-compose.prod.yml, .env.example, README and docs. IMPORTANT: OIDC providers must be updated to use the new redirect URI /auth/user/oidc/callback instead of /auth/user/rauthy/callback.
410 lines
13 KiB
Elixir
410 lines
13 KiB
Elixir
defmodule MvWeb.AuthControllerTest do
|
|
use MvWeb.ConnCase, async: true
|
|
import Phoenix.LiveViewTest
|
|
import Phoenix.ConnTest
|
|
import ExUnit.CaptureLog
|
|
|
|
# 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
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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) == ~p"/sign-in"
|
|
|
|
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) == ~p"/sign-in"
|
|
|
|
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) == ~p"/sign-in"
|
|
|
|
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
|