feat(auth): streamline verified Google onboarding
This commit is contained in:
parent
51d55fa73f
commit
bcbe417a79
|
|
@ -32,6 +32,7 @@ defmodule WhoNeedHelp.Accounts do
|
|||
"display_name" => :display_name,
|
||||
"email" => :email,
|
||||
"email_verified" => :email_verified,
|
||||
"hosted_domain" => :hosted_domain,
|
||||
"locale" => :locale,
|
||||
"provider_uid" => :provider_uid,
|
||||
"terms_accepted" => :terms_accepted
|
||||
|
|
@ -423,6 +424,21 @@ defmodule WhoNeedHelp.Accounts do
|
|||
end
|
||||
end
|
||||
|
||||
def change_google_registration(identity_attrs, registration_attrs \\ %{})
|
||||
when is_map(registration_attrs) do
|
||||
case normalize_google_identity(identity_attrs) do
|
||||
{:ok, identity_attrs} ->
|
||||
%User{}
|
||||
|> User.registration_changeset(
|
||||
google_registration_attrs(identity_attrs, registration_attrs),
|
||||
validate_unique: false
|
||||
)
|
||||
|
||||
{:error, _reason} ->
|
||||
User.registration_changeset(%User{}, %{}, validate_unique: false)
|
||||
end
|
||||
end
|
||||
|
||||
def register_user_by_google(identity_attrs, registration_attrs)
|
||||
when is_map(registration_attrs) do
|
||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
||||
|
|
@ -451,6 +467,36 @@ defmodule WhoNeedHelp.Accounts do
|
|||
def register_user_by_google(_identity_attrs, _registration_attrs),
|
||||
do: {:error, :invalid_registration}
|
||||
|
||||
def link_google_identity_by_authoritative_email(%User{id: user_id}, identity_attrs) do
|
||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs),
|
||||
true <- WhoNeedHelp.GoogleAuth.authoritative_email?(identity_attrs) do
|
||||
Repo.transact(fn ->
|
||||
user =
|
||||
User
|
||||
|> where([user], user.id == ^user_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
cond do
|
||||
is_nil(user) or user.moderation_status == :suspended ->
|
||||
{:error, :not_found}
|
||||
|
||||
normalized_email(user.email) != identity_attrs.email ->
|
||||
{:error, :email_mismatch}
|
||||
|
||||
true ->
|
||||
with {:ok, user} <- confirm_google_email_owner(user),
|
||||
{:ok, identity} <- upsert_google_identity_for_user(user, identity_attrs) do
|
||||
{:ok, {user, identity}}
|
||||
end
|
||||
end
|
||||
end)
|
||||
else
|
||||
false -> {:error, :email_not_authoritative}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def link_google_identity(%User{id: user_id}, identity_attrs) do
|
||||
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
||||
Repo.transact(fn ->
|
||||
|
|
@ -499,12 +545,7 @@ defmodule WhoNeedHelp.Accounts do
|
|||
if existing_user do
|
||||
{:error, :email_already_registered}
|
||||
else
|
||||
attrs = %{
|
||||
"email" => identity_attrs.email,
|
||||
"display_name" => identity_attrs.display_name,
|
||||
"locale" => registration_value(registration_attrs, "locale", "en"),
|
||||
"terms_accepted" => registration_value(registration_attrs, "terms_accepted", false)
|
||||
}
|
||||
attrs = google_registration_attrs(identity_attrs, registration_attrs)
|
||||
|
||||
with {:ok, user} <- register_user(attrs),
|
||||
{:ok, user} <- user |> User.confirm_changeset() |> Repo.update(),
|
||||
|
|
@ -585,11 +626,30 @@ defmodule WhoNeedHelp.Accounts do
|
|||
end
|
||||
end
|
||||
|
||||
defp confirm_google_email_owner(%User{confirmed_at: nil} = user) do
|
||||
user
|
||||
|> User.confirm_changeset()
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
defp confirm_google_email_owner(%User{} = user), do: {:ok, user}
|
||||
|
||||
defp google_registration_attrs(identity_attrs, registration_attrs) do
|
||||
%{
|
||||
"email" => identity_attrs.email,
|
||||
"display_name" =>
|
||||
registration_value(registration_attrs, "display_name", identity_attrs.display_name),
|
||||
"locale" => registration_value(registration_attrs, "locale", "en"),
|
||||
"terms_accepted" => registration_value(registration_attrs, "terms_accepted", false)
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do
|
||||
provider_uid = registration_value(identity_attrs, "provider_uid", nil)
|
||||
email = registration_value(identity_attrs, "email", nil)
|
||||
email_verified = registration_value(identity_attrs, "email_verified", false)
|
||||
display_name = registration_value(identity_attrs, "display_name", nil)
|
||||
hosted_domain = registration_value(identity_attrs, "hosted_domain", nil)
|
||||
|
||||
cond do
|
||||
email_verified != true ->
|
||||
|
|
@ -610,7 +670,8 @@ defmodule WhoNeedHelp.Accounts do
|
|||
provider_uid: provider_uid,
|
||||
email: email |> String.trim() |> String.downcase(),
|
||||
email_verified: true,
|
||||
display_name: display_name |> String.trim() |> String.slice(0, 80)
|
||||
display_name: display_name |> String.trim() |> String.slice(0, 80),
|
||||
hosted_domain: normalize_hosted_domain(hosted_domain)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
|
@ -618,6 +679,18 @@ defmodule WhoNeedHelp.Accounts do
|
|||
defp normalize_google_identity(_identity_attrs),
|
||||
do: {:error, :invalid_provider_identity}
|
||||
|
||||
defp normalize_hosted_domain(domain) when is_binary(domain) do
|
||||
case domain |> String.trim() |> String.downcase() do
|
||||
"" -> nil
|
||||
normalized -> String.slice(normalized, 0, 255)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_hosted_domain(_domain), do: nil
|
||||
|
||||
defp normalized_email(email) when is_binary(email),
|
||||
do: email |> String.trim() |> String.downcase()
|
||||
|
||||
defp registration_value(attrs, key, default) do
|
||||
case Map.fetch(attrs, key) do
|
||||
{:ok, value} ->
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ defmodule WhoNeedHelp.GoogleAuth do
|
|||
provider_uid: String.t(),
|
||||
email: String.t(),
|
||||
email_verified: true,
|
||||
display_name: String.t()
|
||||
display_name: String.t(),
|
||||
hosted_domain: String.t() | nil
|
||||
}
|
||||
|
||||
@callback enabled?() :: boolean()
|
||||
|
|
@ -33,6 +34,36 @@ defmodule WhoNeedHelp.GoogleAuth do
|
|||
def verify_id_token(id_token, nonce),
|
||||
do: adapter().verify_id_token(id_token, nonce)
|
||||
|
||||
@doc """
|
||||
Returns whether Google is authoritative for the identity's current email.
|
||||
|
||||
Gmail addresses are hosted by Google. A non-empty hosted-domain claim marks
|
||||
a verified Google Workspace identity. Other third-party email addresses still
|
||||
require the user to prove access to the existing local account before linking.
|
||||
"""
|
||||
def authoritative_email?(
|
||||
%{
|
||||
email: email,
|
||||
email_verified: true,
|
||||
hosted_domain: hosted_domain
|
||||
} = identity
|
||||
)
|
||||
when is_binary(email) and is_binary(hosted_domain) do
|
||||
String.trim(hosted_domain) != "" or authoritative_email?(Map.delete(identity, :hosted_domain))
|
||||
end
|
||||
|
||||
def authoritative_email?(%{
|
||||
email: email,
|
||||
email_verified: true
|
||||
})
|
||||
when is_binary(email) do
|
||||
normalized_email = email |> String.trim() |> String.downcase()
|
||||
|
||||
String.ends_with?(normalized_email, "@gmail.com")
|
||||
end
|
||||
|
||||
def authoritative_email?(_identity), do: false
|
||||
|
||||
defp adapter do
|
||||
Application.get_env(
|
||||
:who_need_help,
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
|
|||
provider_uid: provider_uid,
|
||||
email: normalized_email,
|
||||
email_verified: true,
|
||||
display_name: display_name(normalized_email, Map.get(claims, "name"))
|
||||
display_name: display_name(normalized_email, Map.get(claims, "name")),
|
||||
hosted_domain: hosted_domain(Map.get(claims, "hd"))
|
||||
}}
|
||||
end
|
||||
|
||||
|
|
@ -132,6 +133,15 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
|
|||
else: "Google user"
|
||||
end
|
||||
|
||||
defp hosted_domain(domain) when is_binary(domain) do
|
||||
case domain |> String.trim() |> String.downcase() do
|
||||
"" -> nil
|
||||
normalized -> String.slice(normalized, 0, 255)
|
||||
end
|
||||
end
|
||||
|
||||
defp hosted_domain(_domain), do: nil
|
||||
|
||||
defp random_url_token(length) do
|
||||
length
|
||||
|> :crypto.strong_rand_bytes()
|
||||
|
|
|
|||
|
|
@ -71,12 +71,15 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
|||
|> GoogleAuthPending.delete()
|
||||
|> google_account_unavailable(~p"/users/log-in")
|
||||
|
||||
existing_account ->
|
||||
render(conn, :complete,
|
||||
pending: pending,
|
||||
existing_account: not is_nil(existing_account),
|
||||
page_title: gettext("Continue with Google")
|
||||
)
|
||||
%Accounts.User{} = existing_account ->
|
||||
if GoogleAuth.authoritative_email?(identity_attrs(pending)) do
|
||||
connect_authoritative_google_and_log_in(conn, existing_account, pending)
|
||||
else
|
||||
render_google_completion(conn, pending, true)
|
||||
end
|
||||
|
||||
nil ->
|
||||
render_google_completion(conn, pending, false)
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
|
|
@ -88,17 +91,14 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
|||
end
|
||||
|
||||
def complete_registration(conn, %{"google_registration" => params}) when is_map(params) do
|
||||
terms_accepted = params["terms_accepted"] in [true, "true", "on", "1"]
|
||||
|
||||
with true <- terms_accepted,
|
||||
{:ok, pending} <- GoogleAuthPending.fetch(conn),
|
||||
with {:ok, pending} <- GoogleAuthPending.fetch(conn),
|
||||
nil <- Accounts.get_user_by_email(pending.email),
|
||||
{:ok, _limit} <- RateLimiter.check(:registration_email, pending.email),
|
||||
{:ok, {user, _identity}} <-
|
||||
Accounts.register_user_by_google(identity_attrs(pending), %{
|
||||
"locale" => normalize_locale(params["locale"] || pending.locale),
|
||||
"terms_accepted" => true
|
||||
}) do
|
||||
Accounts.register_user_by_google(
|
||||
identity_attrs(pending),
|
||||
normalize_google_registration_params(params, pending)
|
||||
) do
|
||||
_ = ProductAnalytics.increment_for_user(user, "account.registered", "google")
|
||||
|
||||
conn
|
||||
|
|
@ -106,13 +106,10 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
|||
|> put_flash(:info, gettext("Your account was created with Google."))
|
||||
|> UserAuth.log_in_user(user)
|
||||
else
|
||||
false ->
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Confirm that you are 18 or older and accept the safety rules first.")
|
||||
)
|
||||
|> redirect(to: ~p"/auth/google/complete")
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> render_google_completion_from_session(changeset)
|
||||
|
||||
%Accounts.User{} ->
|
||||
conn
|
||||
|
|
@ -354,10 +351,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
|||
|> UserAuth.log_in_user(user)
|
||||
|
||||
{:error, :not_linked} ->
|
||||
case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
|
||||
{:ok, conn} -> redirect(conn, to: ~p"/auth/google/complete")
|
||||
{:error, _reason} -> google_account_unavailable(conn, ~p"/users/log-in")
|
||||
end
|
||||
continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/log-in")
|
||||
|
||||
{:error, _reason} ->
|
||||
google_account_unavailable(conn, ~p"/users/log-in")
|
||||
|
|
@ -365,13 +359,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
|||
end
|
||||
|
||||
defp finish_flow(conn, "register", flow, identity_attrs) do
|
||||
case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
|
||||
{:ok, conn} ->
|
||||
redirect(conn, to: ~p"/auth/google/complete")
|
||||
|
||||
{:error, _reason} ->
|
||||
google_account_unavailable(conn, ~p"/users/register")
|
||||
end
|
||||
continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/register")
|
||||
end
|
||||
|
||||
defp finish_flow(conn, "link", flow, identity_attrs) do
|
||||
|
|
@ -421,6 +409,90 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
|||
end)
|
||||
end
|
||||
|
||||
defp connect_authoritative_google_and_log_in(conn, existing_account, pending) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
with {:ok, {user, identity}} <-
|
||||
Accounts.link_google_identity_by_authoritative_email(
|
||||
existing_account,
|
||||
identity_attrs(pending)
|
||||
),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
user.id,
|
||||
"auth_identity.connected",
|
||||
"auth_identity",
|
||||
identity.id,
|
||||
%{"provider" => "google", "method" => "authoritative_google_email"}
|
||||
) do
|
||||
{:ok, user}
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> GoogleAuthPending.delete()
|
||||
|> put_flash(:info, gettext("Welcome back!"))
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Unable to connect authoritative Google identity (#{error_name(reason)})")
|
||||
|
||||
conn
|
||||
|> GoogleAuthPending.delete()
|
||||
|> google_account_unavailable(~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp continue_unlinked_google(conn, flow, identity_attrs, failure_path) do
|
||||
case Accounts.get_user_by_email(identity_attrs.email) do
|
||||
%Accounts.User{moderation_status: :suspended} ->
|
||||
google_account_unavailable(conn, failure_path)
|
||||
|
||||
%Accounts.User{} = existing_account ->
|
||||
if GoogleAuth.authoritative_email?(identity_attrs) do
|
||||
pending = Map.put(identity_attrs, :locale, flow_locale(flow))
|
||||
connect_authoritative_google_and_log_in(conn, existing_account, pending)
|
||||
else
|
||||
store_pending_google(conn, flow, identity_attrs, failure_path)
|
||||
end
|
||||
|
||||
nil ->
|
||||
store_pending_google(conn, flow, identity_attrs, failure_path)
|
||||
end
|
||||
end
|
||||
|
||||
defp store_pending_google(conn, flow, identity_attrs, failure_path) do
|
||||
case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do
|
||||
{:ok, conn} -> redirect(conn, to: ~p"/auth/google/complete")
|
||||
{:error, _reason} -> google_account_unavailable(conn, failure_path)
|
||||
end
|
||||
end
|
||||
|
||||
defp render_google_completion_from_session(conn, changeset) do
|
||||
case GoogleAuthPending.fetch(conn) do
|
||||
{:ok, pending} -> render_google_completion(conn, pending, false, changeset)
|
||||
{:error, _reason} -> expired_pending_redirect(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp render_google_completion(conn, pending, existing_account, changeset \\ nil) do
|
||||
changeset =
|
||||
changeset ||
|
||||
Accounts.change_google_registration(identity_attrs(pending), %{
|
||||
"display_name" => pending.display_name,
|
||||
"locale" => pending.locale
|
||||
})
|
||||
|
||||
render(conn, :complete,
|
||||
pending: pending,
|
||||
existing_account: existing_account,
|
||||
registration_form: Phoenix.Component.to_form(changeset, as: :google_registration),
|
||||
page_title: gettext("Continue with Google")
|
||||
)
|
||||
end
|
||||
|
||||
defp unlink_google_identity_and_audit(current_user) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, identity} <- Accounts.unlink_google_identity(current_user),
|
||||
|
|
@ -474,10 +546,27 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
|
|||
defp normalize_locale(locale) when locale in @supported_locales, do: locale
|
||||
defp normalize_locale(_locale), do: "en"
|
||||
|
||||
defp normalize_google_registration_params(params, pending) do
|
||||
%{
|
||||
"display_name" => normalize_display_name(params["display_name"]),
|
||||
"locale" => normalize_locale(params["locale"] || pending.locale),
|
||||
"terms_accepted" => params["terms_accepted"] in [true, "true", "on", "1"]
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_display_name(name) when is_binary(name), do: String.trim(name)
|
||||
defp normalize_display_name(_name), do: ""
|
||||
|
||||
defp flow_locale(flow), do: normalize_locale(flow["locale"])
|
||||
|
||||
defp identity_attrs(pending) do
|
||||
Map.take(pending, [:provider_uid, :email, :email_verified, :display_name])
|
||||
Map.take(pending, [
|
||||
:provider_uid,
|
||||
:email,
|
||||
:email_verified,
|
||||
:display_name,
|
||||
:hosted_domain
|
||||
])
|
||||
end
|
||||
|
||||
defp error_name(error) when is_atom(error), do: Atom.to_string(error)
|
||||
|
|
|
|||
|
|
@ -67,17 +67,37 @@
|
|||
</div>
|
||||
|
||||
<.form
|
||||
for={%{}}
|
||||
as={:google_registration}
|
||||
for={@registration_form}
|
||||
action={~p"/auth/google/complete-registration"}
|
||||
class="space-y-3"
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="google_registration[locale]" value={@pending.locale} />
|
||||
<div>
|
||||
<.input
|
||||
field={@registration_form[:display_name]}
|
||||
type="text"
|
||||
label={gettext("Display name")}
|
||||
autocomplete="name"
|
||||
minlength="2"
|
||||
maxlength="80"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{gettext("Other people will see this name on requests, activities, and reviews.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.input
|
||||
field={@registration_form[:locale]}
|
||||
type="select"
|
||||
label={gettext("Interface language")}
|
||||
options={[{"English", "en"}, {"Українська", "uk"}, {"Русский", "ru"}]}
|
||||
required
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={@registration_form[:terms_accepted]}
|
||||
type="checkbox"
|
||||
id="google_completion_terms"
|
||||
name="google_registration[terms_accepted]"
|
||||
value="false"
|
||||
label={gettext("I am 18 or older and accept the Terms and Safety Rules")}
|
||||
required
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do
|
|||
email = value(attrs, :email)
|
||||
email_verified = value(attrs, :email_verified)
|
||||
display_name = value(attrs, :display_name)
|
||||
hosted_domain = normalize_hosted_domain(value(attrs, :hosted_domain))
|
||||
|
||||
cond do
|
||||
email_verified != true ->
|
||||
|
|
@ -108,6 +109,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do
|
|||
email: normalized_email(email),
|
||||
email_verified: true,
|
||||
display_name: display_name |> String.trim() |> String.slice(0, 80),
|
||||
hosted_domain: hosted_domain,
|
||||
locale: normalize_locale(locale)
|
||||
}}
|
||||
end
|
||||
|
|
@ -116,13 +118,28 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do
|
|||
defp normalize(_attrs, _locale), do: {:error, :invalid}
|
||||
|
||||
defp identity_attrs(pending) do
|
||||
Map.take(pending, [:provider_uid, :email, :email_verified, :display_name])
|
||||
Map.take(pending, [
|
||||
:provider_uid,
|
||||
:email,
|
||||
:email_verified,
|
||||
:display_name,
|
||||
:hosted_domain
|
||||
])
|
||||
end
|
||||
|
||||
defp value(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key))
|
||||
|
||||
defp normalized_email(email), do: email |> String.trim() |> String.downcase()
|
||||
|
||||
defp normalize_hosted_domain(domain) when is_binary(domain) do
|
||||
case domain |> String.trim() |> String.downcase() do
|
||||
"" -> nil
|
||||
normalized -> String.slice(normalized, 0, 255)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_hosted_domain(_domain), do: nil
|
||||
|
||||
defp normalize_locale(locale) when locale in @supported_locales, do: locale
|
||||
defp normalize_locale(_locale), do: "en"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ defmodule WhoNeedHelp.GoogleAuthFake do
|
|||
provider_uid: params["uid"] || "google-user-42",
|
||||
email: email,
|
||||
email_verified: true,
|
||||
display_name: params["name"] || "Google Neighbor"
|
||||
display_name: params["name"] || "Google Neighbor",
|
||||
hosted_domain: normalize_hosted_domain(params["hd"])
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
|
@ -60,10 +61,20 @@ defmodule WhoNeedHelp.GoogleAuthFake do
|
|||
provider_uid: provider_uid,
|
||||
email: String.downcase(email),
|
||||
email_verified: true,
|
||||
display_name: name
|
||||
display_name: name,
|
||||
hosted_domain: nil
|
||||
}}
|
||||
else
|
||||
_invalid_or_replayed -> {:error, :invalid_id_token}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_hosted_domain(domain) when is_binary(domain) do
|
||||
case domain |> String.trim() |> String.downcase() do
|
||||
"" -> nil
|
||||
normalized -> normalized
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_hosted_domain(_domain), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule WhoNeedHelp.GoogleAuthTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias WhoNeedHelp.GoogleAuth
|
||||
alias WhoNeedHelp.GoogleAuth.AssentAdapter
|
||||
|
||||
defmodule GoogleJwksHTTPAdapter do
|
||||
|
|
@ -66,13 +67,46 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
|||
provider_uid: "google-subject-123",
|
||||
email: "alice@example.com",
|
||||
email_verified: true,
|
||||
display_name: "Alice Neighbor"
|
||||
display_name: "Alice Neighbor",
|
||||
hosted_domain: nil
|
||||
}
|
||||
|
||||
refute Map.has_key?(identity, :access_token)
|
||||
refute Map.has_key?(identity, :id_token)
|
||||
end
|
||||
|
||||
test "normalizes the hosted-domain claim and applies Google's authoritative-email rules" do
|
||||
assert {:ok, workspace_identity} =
|
||||
AssentAdapter.normalize_identity(%{
|
||||
"sub" => "workspace-subject",
|
||||
"email" => "Owner@Example.ORG",
|
||||
"email_verified" => true,
|
||||
"name" => "Workspace Owner",
|
||||
"hd" => " Example.ORG "
|
||||
})
|
||||
|
||||
assert workspace_identity.hosted_domain == "example.org"
|
||||
assert GoogleAuth.authoritative_email?(workspace_identity)
|
||||
|
||||
assert GoogleAuth.authoritative_email?(%{
|
||||
email: "OWNER@GMAIL.COM",
|
||||
email_verified: true,
|
||||
hosted_domain: nil
|
||||
})
|
||||
|
||||
refute GoogleAuth.authoritative_email?(%{
|
||||
email: "owner@example.org",
|
||||
email_verified: true,
|
||||
hosted_domain: nil
|
||||
})
|
||||
|
||||
refute GoogleAuth.authoritative_email?(%{
|
||||
email: "owner@gmail.com",
|
||||
email_verified: false,
|
||||
hosted_domain: "gmail.com"
|
||||
})
|
||||
end
|
||||
|
||||
test "rejects an unverified or incomplete Google email" do
|
||||
assert {:error, :email_not_verified} =
|
||||
AssentAdapter.normalize_identity(%{
|
||||
|
|
@ -130,7 +164,8 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
|||
provider_uid: "native-subject",
|
||||
email: "native@example.com",
|
||||
email_verified: true,
|
||||
display_name: "Native Neighbor"
|
||||
display_name: "Native Neighbor",
|
||||
hosted_domain: nil
|
||||
}} = AssentAdapter.verify_id_token(token, nonce)
|
||||
|
||||
assert {:error, _reason} = AssentAdapter.verify_id_token(token, "different-nonce")
|
||||
|
|
@ -203,7 +238,8 @@ defmodule WhoNeedHelp.GoogleAuthTest do
|
|||
provider_uid: "cross-client-subject",
|
||||
email: "crossclient@example.com",
|
||||
email_verified: true,
|
||||
display_name: "Cross Client"
|
||||
display_name: "Cross Client",
|
||||
hosted_domain: nil
|
||||
}} = AssentAdapter.verify_id_token(token, nonce)
|
||||
|
||||
unauthorized_token =
|
||||
|
|
|
|||
|
|
@ -148,6 +148,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
|||
|
||||
assert html =~ "Create your Who Need Help account"
|
||||
assert html =~ email
|
||||
assert html =~ ~s(value="Native Neighbor")
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(LazyHTML.from_document(html), "option[value='ru'][selected]")
|
||||
) == 1
|
||||
end
|
||||
|
||||
test "native Google account linking still requires the signed-in sudo owner", %{conn: conn} do
|
||||
|
|
@ -213,7 +218,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
|||
)
|
||||
|> recycle()
|
||||
|> post(~p"/auth/google/complete-registration", %{
|
||||
"google_registration" => %{"locale" => "ru", "terms_accepted" => "true"}
|
||||
"google_registration" => %{
|
||||
"display_name" => "Chosen Helper",
|
||||
"locale" => "ru",
|
||||
"terms_accepted" => "true"
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
|
@ -224,7 +233,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
|||
assert user.confirmed_at
|
||||
assert user.accepted_terms_at
|
||||
assert user.locale == "ru"
|
||||
assert user.display_name == "Google Helper"
|
||||
assert user.display_name == "Chosen Helper"
|
||||
assert is_nil(user.hashed_password)
|
||||
|
||||
assert %AuthIdentity{
|
||||
|
|
@ -259,6 +268,98 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
|||
assert html =~ "Email me a verification link"
|
||||
end
|
||||
|
||||
test "auto-links a verified Gmail owner and keeps the existing public name", %{conn: conn} do
|
||||
email = "existing#{System.unique_integer([:positive])}@gmail.com"
|
||||
user = unconfirmed_user_fixture(%{email: email, display_name: "Local Public Name"})
|
||||
|
||||
refute user.confirmed_at
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/login")
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=gmail-owner&email=#{email}&name=Google%20Profile%20Name"
|
||||
)
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert get_session(conn, :user_token)
|
||||
assert is_nil(get_session(conn, "pending_google_identity"))
|
||||
|
||||
reloaded_user = Accounts.get_user_by_email(email)
|
||||
assert reloaded_user.confirmed_at
|
||||
assert reloaded_user.display_name == "Local Public Name"
|
||||
|
||||
identity =
|
||||
Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "gmail-owner")
|
||||
|
||||
assert identity.user_id == user.id
|
||||
|
||||
assert %AuditEvent{actor_id: actor_id, metadata: metadata} =
|
||||
Repo.get_by!(AuditEvent,
|
||||
action: "auth_identity.connected",
|
||||
target_id: identity.id
|
||||
)
|
||||
|
||||
assert actor_id == user.id
|
||||
assert metadata["method"] == "authoritative_google_email"
|
||||
end
|
||||
|
||||
test "auto-links a verified Google Workspace owner carrying the hosted-domain claim", %{
|
||||
conn: conn
|
||||
} do
|
||||
email = "owner#{System.unique_integer([:positive])}@example.org"
|
||||
user = user_fixture(%{email: email, display_name: "Workspace Member"})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/register", %{
|
||||
"google_registration" => %{"locale" => "uk"}
|
||||
})
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=workspace-owner&email=#{email}&hd=example.org"
|
||||
)
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) == nil
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert get_session(conn, :user_token)
|
||||
|
||||
assert %AuthIdentity{user_id: user_id} =
|
||||
Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "workspace-owner")
|
||||
|
||||
assert user_id == user.id
|
||||
end
|
||||
|
||||
test "does not replace a different Google identity already connected to the Gmail account", %{
|
||||
conn: conn
|
||||
} do
|
||||
email = "protected#{System.unique_integer([:positive])}@gmail.com"
|
||||
user = user_fixture(%{email: email})
|
||||
|
||||
assert {:ok, original_identity} =
|
||||
Accounts.link_google_identity(user, %{
|
||||
provider_uid: "original-google-owner",
|
||||
email: email,
|
||||
email_verified: true,
|
||||
display_name: user.display_name
|
||||
})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/login")
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=different-google-owner&email=#{email}"
|
||||
)
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "cannot be used"
|
||||
refute get_session(conn, :user_token)
|
||||
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "different-google-owner")
|
||||
assert Repo.get!(AuthIdentity, original_identity.id).provider_uid == "original-google-owner"
|
||||
end
|
||||
|
||||
test "logs in only through an identity already linked to the local account", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
|
|
@ -314,7 +415,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
|||
)
|
||||
|> recycle()
|
||||
|> post(~p"/auth/google/complete-registration", %{
|
||||
"google_registration" => %{"locale" => "uk", "terms_accepted" => "true"}
|
||||
"google_registration" => %{
|
||||
"display_name" => "Chosen Continued Helper",
|
||||
"locale" => "uk",
|
||||
"terms_accepted" => "true"
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
|
@ -324,7 +429,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
|||
assert user.confirmed_at
|
||||
assert user.accepted_terms_at
|
||||
assert user.locale == "uk"
|
||||
assert user.display_name == "Continued Helper"
|
||||
assert user.display_name == "Chosen Continued Helper"
|
||||
|
||||
assert %AuthIdentity{user_id: user_id} =
|
||||
Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "continue-google")
|
||||
|
|
@ -344,15 +449,45 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
|
|||
)
|
||||
|> recycle()
|
||||
|> post(~p"/auth/google/complete-registration", %{
|
||||
"google_registration" => %{"locale" => "en", "terms_accepted" => "false"}
|
||||
"google_registration" => %{
|
||||
"display_name" => "No Terms Helper",
|
||||
"locale" => "en",
|
||||
"terms_accepted" => "false"
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/auth/google/complete"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "18 or older"
|
||||
html = html_response(conn, 422)
|
||||
assert html =~ "you must confirm that you are 18+ and accept the rules"
|
||||
refute Accounts.get_user_by_email(email)
|
||||
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "no-terms-google")
|
||||
end
|
||||
|
||||
test "keeps the Google completion form open when the public name is invalid", %{conn: conn} do
|
||||
email = unique_user_email()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> post(~p"/auth/google/login")
|
||||
|> recycle()
|
||||
|> get(
|
||||
~p"/auth/google/callback?code=code&state=google-test-state&uid=invalid-name-google&email=#{email}&name=Initial%20Name"
|
||||
)
|
||||
|> recycle()
|
||||
|> post(~p"/auth/google/complete-registration", %{
|
||||
"google_registration" => %{
|
||||
"display_name" => " ",
|
||||
"locale" => "en",
|
||||
"terms_accepted" => "true"
|
||||
}
|
||||
})
|
||||
|
||||
html = html_response(conn, 422)
|
||||
assert html =~ "Create your Who Need Help account"
|
||||
assert html =~ "can't be blank"
|
||||
refute Accounts.get_user_by_email(email)
|
||||
refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "invalid-name-google")
|
||||
end
|
||||
|
||||
test "links an existing account after one magic-link verification", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
assert_email_sent()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user