{@error}
defmodule MvWeb.LinkOidcAccountLive do @moduledoc """ LiveView for linking an OIDC account to an existing password account. 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 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 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)} end @impl true def handle_event("submit", %{"password" => password}, socket) do user = socket.assigns.user oidc_user_info = socket.assigns.oidc_user_info # Verify the password using AshAuthentication case verify_password(user.email, password) do {:ok, verified_user} -> # Password correct - link the OIDC account link_oidc_account(socket, verified_user, oidc_user_info) {:error, _reason} -> # 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.")) |> reset_password_form()} end end defp verify_password(email, password) do # Use AshAuthentication password strategy to verify strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User) password_strategy = Enum.find(strategies, fn s -> s.name == :password end) if password_strategy do AshAuthentication.Strategy.Password.Actions.sign_in( password_strategy, %{ "email" => email, "password" => password }, [] ) else {:error, "Password authentication not configured"} end end defp link_oidc_account(socket, user, oidc_user_info) do oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") # Update the user with the OIDC 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} -> # 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( :info, dgettext( "auth", "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." ) ) |> 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, error_message) |> reset_password_form()} end end @impl true def render(assigns) do ~H"""
{@error}