UI for oidc account linking
This commit is contained in:
parent
87e54cb13f
commit
527657d37b
3 changed files with 256 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
162
lib/mv_web/live/auth/link_oidc_account_live.ex
Normal file
162
lib/mv_web/live/auth/link_oidc_account_live.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue