Fixes light dark mode toggle closes #429 #434
8 changed files with 412 additions and 21 deletions
|
|
@ -385,6 +385,8 @@ def process_user(user), do: {:ok, perform_action(user)}
|
||||||
|
|
||||||
### 2.3 Error Handling
|
### 2.3 Error Handling
|
||||||
|
|
||||||
|
**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging.
|
||||||
|
|
||||||
**Use Tagged Tuples:**
|
**Use Tagged Tuples:**
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
|
|
@ -623,6 +625,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle.
|
||||||
|
|
||||||
|
**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes.
|
||||||
|
|
||||||
**Component Design:**
|
**Component Design:**
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
|
|
@ -1267,6 +1273,9 @@ gettext("Welcome to Mila")
|
||||||
# With interpolation
|
# With interpolation
|
||||||
gettext("Hello, %{name}!", name: user.name)
|
gettext("Hello, %{name}!", name: user.name)
|
||||||
|
|
||||||
|
# Plural: always pass count binding when message uses %{count}
|
||||||
|
ngettext("Found %{count} member", "Found %{count} members", @count, count: @count)
|
||||||
|
|
||||||
# Domain-specific translations
|
# Domain-specific translations
|
||||||
dgettext("auth", "Sign in with email")
|
dgettext("auth", "Sign in with email")
|
||||||
```
|
```
|
||||||
|
|
@ -1507,6 +1516,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing).
|
||||||
|
|
||||||
#### 4.3.5 Component Tests
|
#### 4.3.5 Component Tests
|
||||||
|
|
||||||
Test function components:
|
Test function components:
|
||||||
|
|
@ -1876,6 +1887,8 @@ policies do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth.
|
||||||
|
|
||||||
**Actor Handling in LiveViews:**
|
**Actor Handling in LiveViews:**
|
||||||
|
|
||||||
Always use the `current_actor/1` helper for consistent actor access:
|
Always use the `current_actor/1` helper for consistent actor access:
|
||||||
|
|
@ -2707,7 +2720,9 @@ Building accessible applications ensures that all users, including those with di
|
||||||
|
|
||||||
### 8.2 ARIA Labels and Roles
|
### 8.2 ARIA Labels and Roles
|
||||||
|
|
||||||
**Use ARIA Attributes When Necessary:**
|
**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs.
|
||||||
|
|
||||||
|
**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide.
|
||||||
|
|
||||||
```heex
|
```heex
|
||||||
<!-- Icon-only buttons need labels -->
|
<!-- Icon-only buttons need labels -->
|
||||||
|
|
@ -2931,11 +2946,11 @@ end
|
||||||
**Announce Dynamic Content:**
|
**Announce Dynamic Content:**
|
||||||
|
|
||||||
```heex
|
```heex
|
||||||
<!-- Search results announcement -->
|
<!-- Search results announcement (count: required so %{count} is replaced and pluralisation works) -->
|
||||||
<div role="status" aria-live="polite" aria-atomic="true">
|
<div role="status" aria-live="polite" aria-atomic="true">
|
||||||
<%= if @searched do %>
|
<%= if @searched do %>
|
||||||
<span class="sr-only">
|
<span class="sr-only">
|
||||||
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
|
<%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,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>
|
||||||
|
|
|
||||||
|
|
@ -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"/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue