refactor and docs

This commit is contained in:
Moritz 2025-11-06 14:02:29 +01:00 committed by moritz
parent 4ba03821a2
commit 5ce220862f
13 changed files with 1321 additions and 174 deletions

View file

@ -1,9 +1,21 @@
require Logger
defmodule MvWeb.AuthController do
@moduledoc """
Handles authentication callbacks for password and OIDC authentication.
This controller manages:
- Successful authentication (password, OIDC, password reset, email confirmation)
- Authentication failures with appropriate error handling
- OIDC account linking flow when email collision occurs
- Sign out functionality
"""
use MvWeb, :controller
use AshAuthentication.Phoenix.Controller
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
def success(conn, activity, user, _token) do
return_to = get_session(conn, :return_to) || ~p"/"
@ -23,107 +35,149 @@ defmodule MvWeb.AuthController do
|> redirect(to: return_to)
end
@doc """
Handles authentication failures and routes to appropriate error handling.
Manages:
- OIDC email collisions (triggers password verification flow)
- Generic OIDC authentication failures
- Unconfirmed account errors
- Generic authentication failures
"""
def failure(conn, activity, reason) do
# Log the error for debugging
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
case {activity, reason} do
# OIDC registration with existing email requires password verification (direct error)
{{:rauthy, :register}, %Ash.Error.Invalid{errors: errors}} ->
handle_oidc_email_collision(conn, errors)
{{:rauthy, _action}, reason} ->
handle_rauthy_failure(conn, reason)
# OIDC registration with existing email (wrapped in AuthenticationFailed)
{{:rauthy, :register},
%AshAuthentication.Errors.AuthenticationFailed{
caused_by: %Ash.Error.Invalid{errors: errors}
}} ->
handle_oidc_email_collision(conn, errors)
# OIDC sign-in failure (wrapped)
{{:rauthy, :sign_in}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
# Check if it's actually a registration issue
case caused_by do
%Ash.Error.Invalid{errors: errors} ->
handle_oidc_email_collision(conn, errors)
_ ->
# Real sign-in failure
conn
|> put_flash(:error, gettext("Unable to sign in with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end
# OIDC callback failure (can be either sign-in or registration)
{{:rauthy, :callback}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
case caused_by do
%Ash.Error.Invalid{errors: errors} ->
handle_oidc_email_collision(conn, errors)
_ ->
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end
{_,
%AshAuthentication.Errors.AuthenticationFailed{
caused_by: %Ash.Error.Forbidden{
errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}]
}
}} ->
message =
gettext("""
You have already signed in another way, but have not confirmed your account.
You can confirm your account using the link we sent to you, or by resetting your password.
""")
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
handle_authentication_failed(conn, caused_by)
_ ->
message = gettext("Incorrect email or password")
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
redirect_with_error(conn, gettext("Incorrect email or password"))
end
end
# Handle OIDC email collision - user needs to verify password
defp handle_oidc_email_collision(conn, errors) do
password_verification_error =
Enum.find(errors, fn err ->
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
end)
# Handle all Rauthy (OIDC) authentication failures
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
handle_oidc_email_collision(conn, errors)
end
case password_verification_error do
%Mv.Accounts.User.Errors.PasswordVerificationRequired{
user_id: user_id,
oidc_user_info: oidc_user_info
} ->
# Store the OIDC info in session for the linking flow
conn
|> put_session(:oidc_linking_user_id, user_id)
|> put_session(:oidc_linking_user_info, oidc_user_info)
|> put_flash(
:info,
gettext(
"An account with this email already exists. Please verify your password to link your OIDC account."
)
)
|> redirect(to: ~p"/auth/link-oidc-account")
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
caused_by: caused_by
}) do
case caused_by do
%Ash.Error.Invalid{errors: errors} ->
handle_oidc_email_collision(conn, errors)
_ ->
# Other validation errors - show generic error
conn
|> put_flash(:error, gettext("Unable to sign in. Please try again."))
|> redirect(to: ~p"/sign-in")
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
end
end
# Handle generic AuthenticationFailed errors
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do
message =
gettext("""
You have already signed in another way, but have not confirmed your account.
You can confirm your account using the link we sent to you, or by resetting your password.
""")
redirect_with_error(conn, message)
else
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
end
end
defp handle_authentication_failed(conn, _other) do
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
end
# Handle OIDC email collision - user needs to verify password to link accounts
defp handle_oidc_email_collision(conn, errors) do
case find_password_verification_error(errors) do
%PasswordVerificationRequired{user_id: user_id, oidc_user_info: oidc_user_info} ->
redirect_to_account_linking(conn, user_id, oidc_user_info)
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)
end
end
# Extract meaningful error message from Ash errors
defp extract_meaningful_error_message(errors) do
# Look for specific error messages in InvalidAttribute errors
meaningful_error =
Enum.find_value(errors, fn
%Ash.Error.Changes.InvalidAttribute{message: message, field: :email}
when is_binary(message) ->
cond do
# Email update conflict during OIDC login
String.contains?(message, "Cannot update email to") and
String.contains?(message, "already registered to another account") ->
gettext(
"Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
)
# Different OIDC account error
String.contains?(message, "already linked to a different OIDC account") ->
gettext(
"This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
)
true ->
nil
end
%Ash.Error.Changes.InvalidAttribute{message: message}
when is_binary(message) ->
# Return any other meaningful message
if String.length(message) > 20 and
not String.contains?(message, "has already been taken") do
message
else
nil
end
_ ->
nil
end)
meaningful_error || gettext("Unable to sign in. Please try again.")
end
# Find PasswordVerificationRequired error in error list
defp find_password_verification_error(errors) do
Enum.find(errors, &match?(%PasswordVerificationRequired{}, &1))
end
# Redirect to account linking page with OIDC info stored in session
defp redirect_to_account_linking(conn, user_id, oidc_user_info) do
conn
|> put_session(:oidc_linking_user_id, user_id)
|> put_session(:oidc_linking_user_info, oidc_user_info)
|> put_flash(
:info,
gettext(
"An account with this email already exists. Please verify your password to link your OIDC account."
)
)
|> 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")
end
def sign_out(conn, _params) do
return_to = get_session(conn, :return_to) || ~p"/"