UI for oidc account linking

This commit is contained in:
Moritz 2025-11-05 19:04:34 +01:00
parent fa40a42156
commit f1ffe53215
Signed by: moritz
GPG key ID: 1020A035E5DD0824
3 changed files with 256 additions and 15 deletions

View file

@ -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

View file

@ -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"""
<div class="mx-auto max-w-sm mt-16">
<.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
)}
</:subtitle>
</.header>
<.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8">
<div class="space-y-6">
<div>
<.input field={@form[:password]} type="password" label={gettext("Password")} required />
</div>
<%= if @error do %>
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{@error}</p>
</div>
<% end %>
<div>
<.button phx-disable-with={gettext("Linking...")} class="w-full">
{gettext("Link Account")}
</.button>
</div>
</div>
</.form>
<div class="mt-4 text-center text-sm">
<.link navigate={~p"/sign-in"} class="text-brand hover:underline">
{gettext("Cancel")}
</.link>
</div>
</div>
"""
end
end

View file

@ -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