Fixes missing Rauthy error message closes #289 #427

Merged
carla merged 7 commits from bug/289_rauthy_error_message into main 2026-02-23 15:32:00 +01:00
7 changed files with 394 additions and 18 deletions

View file

@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do
Gettext.gettext(MvWeb.Gettext, "or")
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

View file

@ -33,6 +33,44 @@
</script>
</head>
<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}
</body>
</html>

View file

@ -45,9 +45,7 @@ defmodule MvWeb.AuthController do
- Generic authentication failures
"""
def failure(conn, activity, reason) do
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
log_failure_safely(activity, reason)
case {activity, reason} do
{{:rauthy, _action}, reason} ->
@ -57,10 +55,70 @@ defmodule MvWeb.AuthController do
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
# 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
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
handle_oidc_email_collision(conn, errors)
@ -74,14 +132,46 @@ defmodule MvWeb.AuthController do
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
# 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
defp handle_rauthy_failure(conn, reason) do
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
defp handle_rauthy_failure(conn, _reason) 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("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end
# 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.
""")
redirect_with_error(conn, message)
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
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
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
# Handle OIDC email collision - user needs to verify password to link accounts
@ -112,7 +208,10 @@ defmodule MvWeb.AuthController do
nil ->
# Check if it's a "different OIDC account" error or email uniqueness error
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
@ -177,13 +276,47 @@ defmodule MvWeb.AuthController do
|> redirect(to: ~p"/auth/link-oidc-account")
end
# Generic error redirect helper
defp redirect_with_error(conn, message) do
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
# Extract safe metadata from Assent errors for logging
# Never logs sensitive data: no tokens, secrets, or full request URLs
# Returns keyword list for Logger.warning/2
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
[
request_url: redact_url(url),
http_adapter: Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) 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
return_to = get_session(conn, :return_to) || ~p"/"

View file

@ -27,6 +27,7 @@ msgid "Are you sure?"
msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
@ -115,11 +116,13 @@ msgid "Show"
msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
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."
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
#, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again."

View file

@ -28,6 +28,7 @@ msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
@ -116,11 +117,13 @@ msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -583,6 +586,16 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again."
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
#, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again."

View file

@ -28,6 +28,7 @@ msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
@ -116,11 +117,13 @@ msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -583,6 +586,16 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again."
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
#, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again."

View file

@ -2,11 +2,15 @@ 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()
new_conn =
build_conn()
|> init_test_session(%{})
|> fetch_flash()
# Copy sandbox metadata from authenticated conn
if authenticated_conn.private[:ecto_sandbox] do
@ -248,4 +252,159 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
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