Merge pull request 'Fixes missing Rauthy error message closes #289' (#427) from bug/289_rauthy_error_message into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #427
This commit is contained in:
carla 2026-02-23 15:31:58 +01:00
commit f3ca492b49
7 changed files with 394 additions and 18 deletions

View file

@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do
Gettext.gettext(MvWeb.Gettext, "or") Gettext.gettext(MvWeb.Gettext, "or")
end) end)
end end
# Hide AshAuthentication's Flash component since we use flash_group in root layout
# This prevents duplicate flash messages
override AshAuthentication.Phoenix.Components.Flash do
set :message_class_info, "hidden"
set :message_class_error, "hidden"
end
end end

View file

@ -33,6 +33,44 @@
</script> </script>
</head> </head>
<body> <body>
<div
id="flash-group-root"
aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
>
<.flash id="flash-success-root" kind={:success} flash={@flash} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
<.flash id="flash-info-root" kind={:info} flash={@flash} />
<.flash id="flash-error-root" kind={:error} flash={@flash} />
<.flash
id="client-error-root"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={
show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
}
phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error-root"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={
show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
}
phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
{@inner_content} {@inner_content}
</body> </body>
</html> </html>

View file

@ -45,9 +45,7 @@ defmodule MvWeb.AuthController do
- Generic authentication failures - Generic authentication failures
""" """
def failure(conn, activity, reason) do def failure(conn, activity, reason) do
Logger.warning( log_failure_safely(activity, reason)
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
case {activity, reason} do case {activity, reason} do
{{:rauthy, _action}, reason} -> {{:rauthy, _action}, reason} ->
@ -57,10 +55,70 @@ defmodule MvWeb.AuthController do
handle_authentication_failed(conn, caused_by) handle_authentication_failed(conn, caused_by)
_ -> _ ->
redirect_with_error(conn, gettext("Incorrect email or password")) conn
|> put_flash(:error, gettext("Incorrect email or password"))
|> redirect(to: ~p"/sign-in")
end end
end end
# Log authentication failures safely, avoiding sensitive data for {:rauthy, _} activities
defp log_failure_safely({:rauthy, _action} = activity, reason) do
# For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params
case reason do
%Assent.ServerUnreachableError{} = err ->
meta = safe_assent_meta(err)
message = format_safe_log_message("Authentication failure", activity, meta)
Logger.warning(message)
%Assent.InvalidResponseError{} = err ->
meta = safe_assent_meta(err)
message = format_safe_log_message("Authentication failure", activity, meta)
Logger.warning(message)
_ ->
# For other rauthy errors, log only error type, not full details
error_type = get_error_type(reason)
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}"
)
end
end
defp log_failure_safely(activity, reason) do
# For non-rauthy activities, safe to log full reason
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
end
# Extract safe error type identifier without sensitive data
defp get_error_type(%struct{}), do: "#{struct}"
defp get_error_type(atom) when is_atom(atom), do: inspect(atom)
defp get_error_type(_other), do: "[redacted]"
# Format safe log message with metadata included in the message string
defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do
activity_str = "Activity: #{inspect(activity)}"
meta_str = format_meta_string(meta)
"#{base_message} - #{activity_str}#{meta_str}"
end
defp format_meta_string([]), do: ""
defp format_meta_string(meta) when is_list(meta) do
parts =
Enum.map(meta, fn
{:request_url, url} -> "Request URL: #{url}"
{:status, status} -> "Status: #{status}"
{:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}"
_ -> nil
end)
|> Enum.filter(&(&1 != nil))
if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ")
end
# Handle all Rauthy (OIDC) authentication failures # Handle all Rauthy (OIDC) authentication failures
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
handle_oidc_email_collision(conn, errors) handle_oidc_email_collision(conn, errors)
@ -74,14 +132,46 @@ defmodule MvWeb.AuthController do
handle_oidc_email_collision(conn, errors) handle_oidc_email_collision(conn, errors)
_ -> _ ->
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
end end
# Handle Assent server unreachable errors (network/connectivity issues)
defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = _err) do
# Logging already done safely in failure/3 via log_failure_safely/2
# No need to log again here to avoid duplicate logs
conn
|> put_flash(
:error,
gettext("The authentication server is currently unavailable. Please try again later.")
)
|> redirect(to: ~p"/sign-in")
end
# Handle Assent invalid response errors (configuration or malformed responses)
defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = _err) do
# Logging already done safely in failure/3 via log_failure_safely/2
# No need to log again here to avoid duplicate logs
conn
|> put_flash(
:error,
gettext("Authentication configuration error. Please contact the administrator.")
)
|> redirect(to: ~p"/sign-in")
end
# Catch-all clause for any other error types # Catch-all clause for any other error types
defp handle_rauthy_failure(conn, reason) do defp handle_rauthy_failure(conn, _reason) do
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}") # Logging already done safely in failure/3 via log_failure_safely/2
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) # No need to log again here to avoid duplicate logs
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
# Handle generic AuthenticationFailed errors # Handle generic AuthenticationFailed errors
@ -93,14 +183,20 @@ defmodule MvWeb.AuthController do
You can confirm your account using the link we sent to you, or by resetting your password. You can confirm your account using the link we sent to you, or by resetting your password.
""") """)
redirect_with_error(conn, message) conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
else else
redirect_with_error(conn, gettext("Authentication failed. Please try again.")) conn
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
end end
defp handle_authentication_failed(conn, _other) do defp handle_authentication_failed(conn, _other) do
redirect_with_error(conn, gettext("Authentication failed. Please try again.")) conn
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
# Handle OIDC email collision - user needs to verify password to link accounts # Handle OIDC email collision - user needs to verify password to link accounts
@ -112,7 +208,10 @@ defmodule MvWeb.AuthController do
nil -> nil ->
# Check if it's a "different OIDC account" error or email uniqueness error # Check if it's a "different OIDC account" error or email uniqueness error
error_message = extract_meaningful_error_message(errors) error_message = extract_meaningful_error_message(errors)
redirect_with_error(conn, error_message)
conn
|> put_flash(:error, error_message)
|> redirect(to: ~p"/sign-in")
end end
end end
@ -177,13 +276,47 @@ defmodule MvWeb.AuthController do
|> redirect(to: ~p"/auth/link-oidc-account") |> redirect(to: ~p"/auth/link-oidc-account")
end end
# Generic error redirect helper # Extract safe metadata from Assent errors for logging
defp redirect_with_error(conn, message) do # Never logs sensitive data: no tokens, secrets, or full request URLs
conn # Returns keyword list for Logger.warning/2
|> put_flash(:error, message) defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
|> redirect(to: ~p"/sign-in") [
request_url: redact_url(url),
http_adapter: Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end end
# Handle InvalidResponseError which has :response field (HTTPResponse struct)
defp safe_assent_meta(%{response: %{status: status} = response} = err) do
[
status: status,
http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end
defp safe_assent_meta(err) do
# Only extract safe, simple fields
[
http_adapter: Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end
# Redact URL to only show scheme and host, hiding path, query, and fragments
defp redact_url(url) when is_binary(url) do
case URI.parse(url) do
%URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
"#{scheme}://#{host}"
_ ->
"[redacted]"
end
end
defp redact_url(_), do: "[redacted]"
def sign_out(conn, _params) do def sign_out(conn, _params) do
return_to = get_session(conn, :return_to) || ~p"/" return_to = get_session(conn, :return_to) || ~p"/"

View file

@ -27,6 +27,7 @@ msgid "Are you sure?"
msgstr "Bist du sicher?" msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt" msgstr "Verbindung wird wiederhergestellt"
@ -115,11 +116,13 @@ msgid "Show"
msgstr "Anzeigen" msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!" msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden" msgstr "Keine Internetverbindung gefunden"
@ -582,6 +585,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa
msgid "Unable to authenticate with OIDC. Please try again." msgid "Unable to authenticate with OIDC. Please try again."
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut." msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator."
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again." msgid "Unable to sign in. Please try again."

View file

@ -28,6 +28,7 @@ msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
@ -116,11 +117,13 @@ msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
@ -583,6 +586,16 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again." msgid "Unable to authenticate with OIDC. Please try again."
msgstr "" msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again." msgid "Unable to sign in. Please try again."

View file

@ -28,6 +28,7 @@ msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
@ -116,11 +117,13 @@ msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
@ -583,6 +586,16 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again." msgid "Unable to authenticate with OIDC. Please try again."
msgstr "" msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again." msgid "Unable to sign in. Please try again."

View file

@ -2,11 +2,15 @@ defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Phoenix.ConnTest import Phoenix.ConnTest
import ExUnit.CaptureLog
# Helper to create an unauthenticated conn (preserves sandbox metadata) # Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access # Create new conn but preserve sandbox metadata for database access
new_conn = build_conn() new_conn =
build_conn()
|> init_test_session(%{})
|> fetch_flash()
# Copy sandbox metadata from authenticated conn # Copy sandbox metadata from authenticated conn
if authenticated_conn.private[:ecto_sandbox] do if authenticated_conn.private[:ecto_sandbox] do
@ -248,4 +252,159 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end end
# OIDC/Rauthy error handling tests
describe "handle_rauthy_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, {:rauthy, :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, {:rauthy, :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, {:rauthy, :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, {:rauthy, :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, {:rauthy, :callback}, error)
end)
# Should log error type but not full error details
assert log =~ "Authentication failure"
assert log =~ "rauthy"
# Should not log full error struct with inspect
refute log =~ "Assent.InvalidResponseError"
end
test "does not log full reason for unknown rauthy 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, {:rauthy, :callback}, error_with_sensitive_data)
end)
# Should log error type but not full error details
assert log =~ "Authentication failure"
assert log =~ "rauthy"
# 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-rauthy 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-rauthy activities, full reason is safe to log
assert log =~ "Authentication failure"
assert log =~ "password"
assert log =~ "AuthenticationFailed"
end
end
end end