Packages
ash_authentication
5.0.0-rc.10
5.0.0-rc.12
5.0.0-rc.11
5.0.0-rc.10
5.0.0-rc.9
5.0.0-rc.8
5.0.0-rc.7
5.0.0-rc.6
5.0.0-rc.5
5.0.0-rc.4
5.0.0-rc.3
5.0.0-rc.2
5.0.0-rc.1
5.0.0-rc.0
4.14.1
4.14.0
4.13.7
4.13.6
4.13.5
4.13.4
4.13.3
4.13.2
retired
4.13.1
retired
4.13.0
4.12.0
4.11.0
4.10.0
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.7
4.8.6
4.8.5
4.8.3
4.8.2
4.8.1
4.8.0
4.7.6
4.7.5
4.7.4
4.7.3
4.7.2
4.7.1
4.7.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.6
4.5.5
4.5.4
4.5.3
4.5.2
4.5.1
4.5.0
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.12
4.3.11
4.3.10
4.3.9
4.3.8
4.3.7
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.4
4.0.3
4.0.2
4.0.1
4.0.0
4.0.0-rc.6
4.0.0-rc.5
4.0.0-rc.3
4.0.0-rc.2
4.0.0-rc.1
4.0.0-rc.0
3.12.4
3.12.3
3.12.2
3.12.1
3.12.0
3.11.16
3.11.15
3.11.14
3.11.13
3.11.12
3.11.11
3.11.10
3.11.9
3.11.8
3.11.7
3.11.6
3.11.5
3.11.4
3.11.3
3.11.2
3.11.1
3.11.0
3.10.8
3.10.7
3.10.6
3.10.5
3.10.4
3.10.3
3.10.2
3.10.1
3.10.0
3.9.6
3.9.5
3.9.4
3.9.3
3.9.2
3.9.1
3.9.0
3.8.0
3.7.9
3.7.8
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
3.6.1
3.6.0
3.5.3
3.5.2
3.5.1
3.5.0
3.3.1
3.3.0
3.2.2
3.2.1
3.2.0
3.1.0
3.0.3
Authentication extension for the Ash Framework.
Current section
Files
Jump to
Current section
Files
lib/ash_authentication/strategies/oauth2/user_resolver.ex
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Strategy.OAuth2.UserResolver do
@moduledoc """
Resolves which local user an OAuth2/OIDC sign-in belongs to.
Per OpenID Connect Core only the `iss`/`sub` claim combination uniquely and
stably identifies an end-user, so matching is driven by the user identity
resource - **never** by the email address.
Given the changeset for an OAuth2/OIDC register (upsert) action, the rules are:
1. If an identity already exists for this `(strategy, sub)`, the sign-in
belongs to that user. The changeset's upsert keys are rewritten to that
user's values so the upsert resolves to them (and the provider cannot
change a user's email).
2. Otherwise (a `sub` not seen before):
* If no local account has the provider's email - proceed (a new account
is created; if the email is not trusted and a confirmation add-on is
present, that add-on gates it).
* If an account with that email already has an identity for this strategy
(a *different* `sub`) - reject. A single account cannot have two
identities for the same provider auto-linked.
* If the strategy's `email_verified` claim can be trusted
(`trust_email_verified?` and the claim is true) - link the sign-in to
that account.
* Otherwise the email cannot be trusted to prove ownership. With
`on_untrusted_email_match :reject` (the default) the sign-in is
rejected and the user must sign in with their existing method to link
the provider. With `on_untrusted_email_match :confirm` the upsert is
aborted with a `ConfirmationRequired` error so the caller can issue a
confirmation to the existing account's email and link the provider
only once the recipient proves ownership.
Rejections are surfaced as a generic `AuthenticationFailed` error to avoid
leaking which email addresses are registered.
"""
alias Ash.Changeset
alias AshAuthentication.{
Errors.AuthenticationFailed,
Errors.ConfirmationRequired,
Info,
Strategy.OAuth2,
UserIdentity
}
require Ash.Query
@doc false
@spec resolve(Changeset.t(), OAuth2.t(), keyword) :: Changeset.t()
def resolve(changeset, strategy, opts \\ []) do
user_info = Changeset.get_argument(changeset, :user_info)
case OAuth2.uid_from_user_info(user_info) do
nil ->
reject(changeset, strategy, "Provider did not return a stable `sub`/`uid` claim")
uid ->
case fetch_identity(strategy, uid, opts) do
{:ok, identity} -> coerce_to_existing_user(changeset, strategy, identity, opts)
:error -> resolve_new_identity(changeset, strategy, user_info, opts)
end
end
end
defp resolve_new_identity(changeset, strategy, user_info, opts) do
case fetch_user_by_upsert_identity(changeset, strategy, opts) do
:error ->
# No local account has this email - allow the upsert to create one.
changeset
{:ok, user} ->
cond do
has_identity_for_strategy?(strategy, user, opts) ->
reject(
changeset,
strategy,
"A different #{strategy.name} identity is already linked to this account"
)
email_trusted?(strategy, user_info) ->
# Verified email matches an existing account - link to it. The email
# already matches so the upsert resolves to this user.
changeset
strategy.on_untrusted_email_match == :confirm ->
require_confirmation(changeset, strategy, user, user_info)
true ->
reject(
changeset,
strategy,
"Email could not be verified and an account with this email already exists"
)
end
end
end
defp require_confirmation(changeset, strategy, user, user_info) do
# Abort the upsert without touching the existing account. The caller issues
# a confirmation to the existing account's email and links the provider only
# once the recipient proves ownership.
Changeset.add_error(
changeset,
ConfirmationRequired.exception(
strategy: strategy,
user: user,
user_info: user_info,
oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens)
)
)
end
defp coerce_to_existing_user(changeset, strategy, identity, opts) do
case load_identity_user(strategy, identity, opts) do
{:ok, user} ->
Enum.reduce(upsert_identity_keys(changeset), changeset, fn key, changeset ->
Changeset.force_change_attribute(changeset, key, Map.get(user, key))
end)
:error ->
# Orphaned identity (user no longer exists) - fall back to the upsert.
changeset
end
end
@doc false
@spec fetch_identity(OAuth2.t(), String.t(), keyword) :: {:ok, Ash.Resource.record()} | :error
def fetch_identity(strategy, uid, opts \\ []) do
cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
strategy.identity_resource
|> base_query(opts)
|> Ash.Query.do_filter([
{cfg.strategy_attribute_name, OAuth2.identity_strategy_name(strategy)},
{cfg.uid_attribute_name, uid}
])
|> read_one(identity_domain(strategy), opts)
end
defp load_identity_user(strategy, identity, opts) do
cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
user_id = Map.get(identity, cfg.user_id_attribute_name)
strategy.resource
|> base_query(opts)
|> Ash.Query.do_filter([{user_pk(strategy), user_id}])
|> read_one(Info.domain!(strategy.resource), opts)
end
defp fetch_user_by_upsert_identity(changeset, strategy, opts) do
keys = upsert_identity_keys(changeset)
values = Enum.map(keys, &{&1, Changeset.get_attribute(changeset, &1)})
if keys == [] or Enum.any?(values, fn {_key, value} -> is_nil(value) end) do
:error
else
strategy.resource
|> base_query(opts)
|> Ash.Query.do_filter(values)
|> read_one(Info.domain!(strategy.resource), opts)
end
end
@doc false
@spec has_identity_for_strategy?(OAuth2.t(), Ash.Resource.record(), keyword) :: boolean
def has_identity_for_strategy?(strategy, user, opts \\ []) do
cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
strategy.identity_resource
|> base_query(opts)
|> Ash.Query.do_filter([
{cfg.strategy_attribute_name, OAuth2.identity_strategy_name(strategy)},
{cfg.user_id_attribute_name, Map.get(user, user_pk(strategy))}
])
|> read_one(identity_domain(strategy), opts)
|> case do
{:ok, _identity} -> true
:error -> false
end
end
@doc false
@spec email_trusted?(OAuth2.t(), map) :: boolean
def email_trusted?(%{trust_email_verified?: true}, user_info) do
Map.get(user_info, "email_verified", Map.get(user_info, :email_verified)) in [true, "true"]
end
def email_trusted?(_strategy, _user_info), do: false
defp upsert_identity_keys(changeset) do
with name when not is_nil(name) <- changeset.action.upsert_identity,
identity when not is_nil(identity) <-
Ash.Resource.Info.identity(changeset.resource, name) do
identity.keys
else
_ -> []
end
end
defp user_pk(strategy) do
[pk] = Ash.Resource.Info.primary_key(strategy.resource)
pk
end
defp identity_domain(strategy) do
{:ok, domain} = UserIdentity.Info.user_identity_domain(strategy.identity_resource)
domain
end
defp base_query(resource, opts) do
resource
|> Ash.Query.new()
|> Ash.Query.set_context(%{private: %{ash_authentication?: true}})
|> maybe_set_tenant(opts[:tenant])
end
defp maybe_set_tenant(query, nil), do: query
defp maybe_set_tenant(query, tenant), do: Ash.Query.set_tenant(query, tenant)
defp read_one(query, domain, opts) do
case Ash.read(query, domain: domain, actor: opts[:actor]) do
{:ok, [record | _]} -> {:ok, record}
_ -> :error
end
end
defp reject(changeset, strategy, message) do
Changeset.add_error(
changeset,
AuthenticationFailed.exception(
strategy: strategy,
changeset: changeset,
caused_by: %{
module: __MODULE__,
strategy: strategy,
action: changeset.action.name,
message: message
}
)
)
end
end