From f1ffe532151df955fecd2b8e9d81dcc1b64f4acf Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 5 Nov 2025 19:04:34 +0100 Subject: [PATCH] UI for oidc account linking --- lib/mv_web/controllers/auth_controller.ex | 106 ++++++++++-- .../live/auth/link_oidc_account_live.ex | 162 ++++++++++++++++++ lib/mv_web/router.ex | 3 + 3 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 lib/mv_web/live/auth/link_oidc_account_live.ex diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index a8375d1..51d44d4 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -24,28 +24,104 @@ defmodule MvWeb.AuthController do end def failure(conn, activity, reason) do - Logger.error(%{conn: conn, reason: reason}) + # Log the error for debugging + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" + ) - message = - case {activity, reason} do - {_, - %AshAuthentication.Errors.AuthenticationFailed{ - caused_by: %Ash.Error.Forbidden{ - errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}] - } - }} -> + 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) + + # 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. """) - _ -> - gettext("Incorrect email or password") - end + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") + _ -> + message = gettext("Incorrect email or password") + + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") + 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) + + 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") + + _ -> + # Other validation errors - show generic error + conn + |> put_flash(:error, gettext("Unable to sign in. Please try again.")) + |> redirect(to: ~p"/sign-in") + end end def sign_out(conn, _params) do diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex new file mode 100644 index 0000000..8a510b9 --- /dev/null +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -0,0 +1,162 @@ +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. + """ + use MvWeb, :live_view + require Ash.Query + + @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, gettext("Session expired. Please try again.")) + |> redirect(to: ~p"/sign-in")} + end + else + {:ok, + socket + |> put_flash(:error, gettext("Invalid session. Please try again.")) + |> redirect(to: ~p"/sign-in")} + end + 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 + {:noreply, + socket + |> assign(:error, gettext("Incorrect password. Please try again.")) + |> assign(:form, to_form(%{"password" => ""}))} + 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 Mv.Accounts.User + |> Ash.Query.filter(id == ^user.id) + |> Ash.read_one!() + |> 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 + {:noreply, + socket + |> put_flash( + :info, + gettext( + "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." + ) + ) + |> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")} + + {:error, error} -> + {:noreply, + socket + |> assign(:error, gettext("Failed to link account: %{error}", error: inspect(error))) + |> assign(:form, to_form(%{"password" => ""}))} + end + end + + @impl true + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + {gettext("Link OIDC Account")} + <:subtitle> + {gettext( + "An account with email %{email} already exists. Please enter your password to link your OIDC account.", + email: @user.email + )} + + + + <.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8"> +
+
+ <.input field={@form[:password]} type="password" label={gettext("Password")} required /> +
+ + <%= if @error do %> +
+

{@error}

+
+ <% end %> + +
+ <.button phx-disable-with={gettext("Linking...")} class="w-full"> + {gettext("Link Account")} + +
+
+ + +
+ <.link navigate={~p"/sign-in"} class="text-brand hover:underline"> + {gettext("Cancel")} + +
+
+ """ + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index bf2c071..21589d7 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -76,6 +76,9 @@ defmodule MvWeb.Router do post "/set_locale", LocaleController, :set_locale end + # OIDC account linking - user needs to verify password (MUST be before auth_routes!) + live "/auth/link-oidc-account", LinkOidcAccountLive + # ASHAUTHENTICATION GENERATED AUTH ROUTES auth_routes AuthController, Mv.Accounts.User, path: "/auth" sign_out_route AuthController