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

@ -5,41 +5,139 @@ defmodule MvWeb.LinkOidcAccountLive do
This page is shown when a user tries to log in via OIDC using an email
that already exists with a password-only account. The user must verify
their password before the OIDC account can be linked.
## Flow
1. User attempts OIDC login with email that has existing password account
2. System raises `PasswordVerificationRequired` error
3. AuthController redirects here with user_id and oidc_user_info in session
4. User enters password to verify identity
5. On success, oidc_id is linked to user account
6. User is redirected to complete OIDC login
"""
use MvWeb, :live_view
require Ash.Query
require Logger
@impl true
def mount(_params, session, socket) do
user_id = Map.get(session, "oidc_linking_user_id")
oidc_user_info = Map.get(session, "oidc_linking_user_info")
if user_id && oidc_user_info do
# Load the user
case Ash.get(Mv.Accounts.User, user_id) do
{:ok, user} ->
{:ok,
socket
|> assign(:user, user)
|> assign(:oidc_user_info, oidc_user_info)
|> assign(:password, "")
|> assign(:error, nil)
|> assign(:form, to_form(%{"password" => ""}))}
{:error, _} ->
{:ok,
socket
|> put_flash(:error, dgettext("auth", "Session expired. Please try again."))
|> redirect(to: ~p"/sign-in")}
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
oidc_user_info when not is_nil(oidc_user_info) <-
Map.get(session, "oidc_linking_user_info"),
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
# Check if user is passwordless
if passwordless?(user) do
# Auto-link passwordless user immediately
{:ok, auto_link_passwordless_user(socket, user, oidc_user_info)}
else
# Show password form for password-protected user
{:ok, initialize_socket(socket, user, oidc_user_info)}
end
else
{:ok,
socket
|> put_flash(:error, dgettext("auth", "Invalid session. Please try again."))
|> redirect(to: ~p"/sign-in")}
nil ->
{:ok, redirect_with_error(socket, dgettext("auth", "Invalid session. Please try again."))}
{:error, _} ->
{:ok, redirect_with_error(socket, dgettext("auth", "Session expired. Please try again."))}
end
end
defp passwordless?(user) do
is_nil(user.hashed_password)
end
defp reload_user!(user_id) do
Mv.Accounts.User
|> Ash.Query.filter(id == ^user_id)
|> Ash.read_one!()
end
defp reset_password_form(socket) do
assign(socket, :form, to_form(%{"password" => ""}))
end
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
case user.id
|> reload_user!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
{:ok, updated_user} ->
Logger.info(
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
)
socket
|> put_flash(
:info,
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
)
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")
{:error, error} ->
Logger.warning(
"Failed to auto-link passwordless account: user_id=#{user.id}, error=#{inspect(error)}"
)
error_message = extract_user_friendly_error(error)
socket
|> put_flash(:error, error_message)
|> redirect(to: ~p"/sign-in")
end
end
defp extract_user_friendly_error(%Ash.Error.Invalid{errors: errors}) do
# Check for specific error types
Enum.find_value(errors, fn
%Ash.Error.Changes.InvalidAttribute{field: :oidc_id, message: message} ->
if String.contains?(message, "already been taken") do
dgettext(
"auth",
"This OIDC account is already linked to another user. Please contact support."
)
else
nil
end
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
if String.contains?(message, "already been taken") do
dgettext(
"auth",
"The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
)
else
nil
end
_ ->
nil
end) ||
dgettext("auth", "Failed to link account. Please try again or contact support.")
end
defp extract_user_friendly_error(_error) do
dgettext("auth", "Failed to link account. Please try again or contact support.")
end
defp initialize_socket(socket, user, oidc_user_info) do
socket
|> assign(:user, user)
|> assign(:oidc_user_info, oidc_user_info)
|> assign(:password, "")
|> assign(:error, nil)
|> reset_password_form()
end
defp redirect_with_error(socket, message) do
socket
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
end
@impl true
def handle_event("validate", %{"password" => password}, socket) do
{:noreply, assign(socket, :password, password)}
@ -57,11 +155,13 @@ defmodule MvWeb.LinkOidcAccountLive do
link_oidc_account(socket, verified_user, oidc_user_info)
{:error, _reason} ->
# Password incorrect
# Password incorrect - log security event
Logger.warning("Failed password verification for OIDC linking: user_email=#{user.email}")
{:noreply,
socket
|> assign(:error, dgettext("auth", "Incorrect password. Please try again."))
|> assign(:form, to_form(%{"password" => ""}))}
|> reset_password_form()}
end
end
@ -88,17 +188,20 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Update the user with the OIDC ID
case Mv.Accounts.User
|> Ash.Query.filter(id == ^user.id)
|> Ash.read_one!()
case user.id
|> reload_user!()
|> Ash.Changeset.for_update(:link_oidc_id, %{
oidc_id: oidc_id,
oidc_user_info: oidc_user_info
})
|> Ash.update() do
{:ok, _updated_user} ->
{:ok, updated_user} ->
# After successful linking, redirect to OIDC login
# Since the user now has an oidc_id, the next OIDC login will succeed
Logger.info(
"OIDC account successfully linked after password verification: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
)
{:noreply,
socket
|> put_flash(
@ -111,13 +214,16 @@ defmodule MvWeb.LinkOidcAccountLive do
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")}
{:error, error} ->
Logger.warning(
"Failed to link OIDC account after password verification: user_id=#{user.id}, error=#{inspect(error)}"
)
error_message = extract_user_friendly_error(error)
{:noreply,
socket
|> assign(
:error,
dgettext("auth", "Failed to link account: %{error}", error: inspect(error))
)
|> assign(:form, to_form(%{"password" => ""}))}
|> assign(:error, error_message)
|> reset_password_form()}
end
end