defmodule PowAssent.Plug do
@moduledoc """
Plug helper methods.
If you wish to configure PowAssent through the Pow plug interface rather than
environment config, please add PowAssent config with `:pow_assent` config:
plug Pow.Plug.Session,
repo: MyApp.Repo,
user: MyApp.User,
pow_assent: [
http_adapter: PowAssent.HTTPAdapter.Mint,
json_library: Poison,
user_identities_context: MyApp.UserIdentities
]
"""
alias Plug.Conn
alias Pow.Config, as: PowConfig
alias PowAssent.{Config, Operations, Store.SessionCache}
alias Pow.{Plug, Store.Backend.EtsCache, UUID}
@doc """
Calls the authorize_url method for the provider strategy.
A generated authorization URL will be returned. If `:session_params` is
returned from the provider, it'll be added to the connection as private key
`:pow_assent_session_params`.
If `:nonce` is set to `true` in the PowAssent provider configuration, a
randomly generated nonce will be added to the provider configuration.
"""
@spec authorize_url(Conn.t(), binary(), binary()) :: {:ok, binary(), Conn.t()} | {:error, any(), Conn.t()}
def authorize_url(conn, provider, redirect_uri) do
{strategy, provider_config} = get_provider_config(conn, provider, redirect_uri)
provider_config
|> maybe_gen_nonce()
|> strategy.authorize_url()
|> maybe_put_session_params(conn)
end
defp maybe_gen_nonce(config) do
case Config.get(config, :nonce, nil) do
true -> Config.put(config, :nonce, gen_nonce())
_any -> config
end
end
defp gen_nonce do
16
|> :crypto.strong_rand_bytes()
|> Base.encode64(padding: false)
end
defp maybe_put_session_params({:ok, %{url: url, session_params: params}}, conn) do
{:ok, url, Conn.put_private(conn, :pow_assent_session_params, params)}
end
defp maybe_put_session_params({:ok, %{url: url}}, conn), do: {:ok, url, conn}
defp maybe_put_session_params({:error, error}, conn), do: {:error, error, conn}
@doc """
Calls the callback method for the provider strategy and will authenticate,
upsert user identity, or create user.
See `callback/4`, `authenticate/2`, `upsert_identity/2`, and `create_user/4`
for more.
To track the state of the flow the following keys may be populated in
`conn.private`:
- `:pow_assent_callback_state` - The state of the flow, that is either
`{:ok, step}` or `{:error, step}`.
- `:pow_assent_callback_params` - The params returned by the strategy
callback phase.
- `:pow_assent_callback_error` - The resulting error of any step.
The value of `:pow_assent_callback_state` may be one of the following:
- `{:error, :strategy}` - An error ocurred during strategy callback
phase. `:pow_assent_callback_error` will be populated with the error.
- `{:ok, :upsert_user_identity}` - User identity was created or updated.
- `{:error, :upsert_user_identity}` - User identity could not be created
or updated. `:pow_assent_callback_error` will be populated with the
changeset.
- `{:ok, :create_user}` - User was created.
- `{:error, :create_user}` - User could not be created.
`:pow_assent_callback_error` will be populated with the changeset.
If `:pow_assent_registration` in `conn.private` is set to `false` then
`create_user/4` will not be called and the `:pow_assent_callback_state` set
to `{:error, :create_user}` with `nil` value for
`:pow_assent_callback_error`.
"""
@spec callback_upsert(Conn.t(), binary(), map(), binary()) :: {:ok, Conn.t()} | {:error, Conn.t()}
def callback_upsert(conn, provider, params, redirect_uri) do
conn
|> callback(provider, params, redirect_uri)
|> handle_callback()
|> maybe_authenticate()
|> maybe_upsert_user_identity()
|> maybe_create_user()
|> case do
%{private: %{pow_assent_callback_state: {:ok, _method}}} = conn ->
{:ok, conn}
conn ->
{:error, conn}
end
end
defp handle_callback({:ok, user_identity_params, user_params, conn}) do
conn
|> Conn.put_private(:pow_assent_callback_state, {:ok, :strategy})
|> Conn.put_private(:pow_assent_callback_params, %{user_identity: user_identity_params, user: user_params})
end
defp handle_callback({:error, error, conn}) do
conn
|> Conn.put_private(:pow_assent_callback_state, {:error, :strategy})
|> Conn.put_private(:pow_assent_callback_error, error)
end
defp maybe_authenticate(%{private: %{pow_assent_callback_state: {:ok, :strategy}, pow_assent_callback_params: params}} = conn) do
user_identity_params = Map.fetch!(params, :user_identity)
case Plug.current_user(conn) do
nil ->
conn
|> authenticate(user_identity_params)
|> case do
{:ok, conn} -> conn
{:error, conn} -> conn
end
_user ->
conn
end
end
defp maybe_authenticate(conn), do: conn
defp maybe_upsert_user_identity(%{private: %{pow_assent_callback_state: {:ok, :strategy}, pow_assent_callback_params: params}} = conn) do
user_identity_params = Map.fetch!(params, :user_identity)
case Plug.current_user(conn) do
nil ->
conn
_user ->
conn
|> upsert_identity(user_identity_params)
|> case do
{:ok, _user_identity, conn} ->
Conn.put_private(conn, :pow_assent_callback_state, {:ok, :upsert_user_identity})
{:error, changeset, conn} ->
conn
|> Conn.put_private(:pow_assent_callback_state, {:error, :upsert_user_identity})
|> Conn.put_private(:pow_assent_callback_error, changeset)
end
end
end
defp maybe_upsert_user_identity(conn), do: conn
defp maybe_create_user(%{private: %{pow_assent_registration: false}} = conn) do
conn
|> Conn.put_private(:pow_assent_callback_state, {:error, :create_user})
|> Conn.put_private(:pow_assent_callback_error, nil)
end
defp maybe_create_user(%{private: %{pow_assent_callback_state: {:ok, :strategy}, pow_assent_callback_params: params}} = conn) do
user_params = Map.fetch!(params, :user)
user_identity_params = Map.fetch!(params, :user_identity)
case Plug.current_user(conn) do
nil ->
conn
|> create_user(user_identity_params, user_params)
|> case do
{:ok, _user, conn} ->
Conn.put_private(conn, :pow_assent_callback_state, {:ok, :create_user})
{:error, changeset, conn} ->
conn
|> Conn.put_private(:pow_assent_callback_state, {:error, :create_user})
|> Conn.put_private(:pow_assent_callback_error, changeset)
end
_user ->
conn
end
end
defp maybe_create_user(conn), do: conn
@doc """
Calls the callback method for the provider strategy.
Returns the user identity params and user params fetched from the provider.
`:session_params` will be added to the provider config if
`:pow_assent_session_params` is present as a private key in the connection.
"""
@spec callback(Conn.t(), binary(), map(), binary()) :: {:ok, map(), map(), Conn.t()} | {:error, any(), Conn.t()}
def callback(conn, provider, params, redirect_uri) do
{strategy, provider_config} = get_provider_config(conn, provider, redirect_uri)
provider_config
|> maybe_set_session_params_config(conn)
|> strategy.callback(params)
|> parse_callback_response(provider, conn)
end
defp maybe_set_session_params_config(config, %{private: %{pow_assent_session_params: params}}), do: Config.put(config, :session_params, params)
defp maybe_set_session_params_config(config, _conn), do: config
defp parse_callback_response({:ok, %{user: user} = response}, provider, conn) do
other_params =
response
|> Map.delete(:user)
|> Map.put(:userinfo, user)
user
|> normalize_username()
|> split_user_identity_params()
|> handle_user_identity_params(other_params, provider, conn)
end
defp parse_callback_response({:error, error}, _provider, conn), do: {:error, error, conn}
defp normalize_username(%{"preferred_username" => username} = params) do
params
|> Map.delete("preferred_username")
|> Map.put("username", username)
end
defp normalize_username(params), do: params
defp split_user_identity_params(%{"sub" => uid} = params) do
user_params = Map.delete(params, "sub")
{%{"uid" => uid}, user_params}
end
defp handle_user_identity_params({user_identity_params, user_params}, other_params, provider, conn) do
user_identity_params = Map.put(user_identity_params, "provider", provider)
other_params = for {key, value} <- other_params, into: %{}, do: {Atom.to_string(key), value}
user_identity_params =
user_identity_params
|> Map.put("provider", provider)
|> Map.merge(other_params)
{:ok, user_identity_params, user_params, conn}
end
@doc """
Authenticates a user with provider and provider user params.
If successful, a new session will be created. After session has been created
the callbacks stored with `put_create_session_callback/2` will run.
"""
@spec authenticate(Conn.t(), map()) :: {:ok, Conn.t()} | {:error, Conn.t()}
def authenticate(conn, %{"provider" => provider, "uid" => uid}) do
config = fetch_config(conn)
provider
|> Operations.get_user_by_provider_uid(uid, config)
|> case do
nil -> {:error, conn}
user -> {:ok, create_session(conn, user, provider, config)}
end
end
defp create_session(conn, user, %{"provider" => provider}, config), do: create_session(conn, user, provider, config)
defp create_session(conn, user, provider, config) when is_binary(provider) do
conn = Plug.create(conn, user)
conn
|> fetch_create_session_callbacks()
|> Enum.reduce(conn, fn callback, conn ->
callback.(conn, provider, config)
end)
end
defp fetch_create_session_callbacks(conn) do
Map.get(conn.private, :pow_assent_create_session_callbacks, [])
end
@doc """
Store a callback method to run after session is created.
"""
@spec put_create_session_callback(Conn.t(), function()) :: Conn.t()
def put_create_session_callback(conn, callback) do
callbacks =
conn
|> fetch_create_session_callbacks()
|> Kernel.++([callback])
Conn.put_private(conn, :pow_assent_create_session_callbacks, callbacks)
end
# TODO: Remove by 0.4.0
@doc false
@deprecated "Use `upsert_identity/2` instead"
@spec create_identity(Conn.t(), map()) :: {:ok, map(), Conn.t()} | {:error, {:bound_to_different_user, map()} | map(), Conn.t()}
def create_identity(conn, user_identity_params), do: upsert_identity(conn, user_identity_params)
@doc """
Will upsert identity for the current user.
If successful, a new session will be created. After session has been created
the callbacks stored with `put_create_session_callback/2` will run.
"""
@spec upsert_identity(Conn.t(), map()) :: {:ok, map(), Conn.t()} | {:error, {:bound_to_different_user, map()} | map(), Conn.t()}
def upsert_identity(conn, user_identity_params) do
config = fetch_config(conn)
user = Plug.current_user(conn)
user
|> Operations.upsert(user_identity_params, config)
|> case do
{:ok, user_identity} -> {:ok, user_identity, create_session(conn, user, user_identity_params, config)}
{:error, error} -> {:error, error, conn}
end
end
@doc """
Create a user with the provider and provider user params.
If successful, a new session will be created. After session has been created
the callbacks stored with `put_create_session_callback/2` will run.
"""
@spec create_user(Conn.t(), map(), map(), map() | nil) :: {:ok, map(), Conn.t()} | {:error, {:bound_to_different_user | :invalid_user_id_field, map()} | map(), Conn.t()}
def create_user(conn, user_identity_params, user_params, user_id_params \\ nil) do
config = fetch_config(conn)
user_identity_params
|> Operations.create_user(user_params, user_id_params, config)
|> case do
{:ok, user} -> {:ok, user, create_session(conn, user, user_identity_params, config)}
{:error, error} -> {:error, error, conn}
end
end
@doc """
Creates a changeset.
"""
@spec change_user(Conn.t(), map()) :: map()
def change_user(conn, params \\ %{}, user_params \\ %{}, user_id_params \\ %{}) do
config = fetch_config(conn)
Operations.user_identity_changeset(params, user_params, user_id_params, config)
end
@doc """
Deletes the associated user identity for the current user and provider.
"""
@spec delete_identity(Conn.t(), binary()) :: {:ok, map(), Conn.t()} | {:error, {:no_password, map()}, Conn.t()}
def delete_identity(conn, provider) do
config = fetch_config(conn)
conn
|> Plug.current_user()
|> Operations.delete(provider, config)
|> case do
{:ok, results} -> {:ok, results, conn}
{:error, error} -> {:error, error, conn}
end
end
@doc """
Lists associated providers for the user.
"""
@spec providers_for_current_user(Conn.t()) :: [atom()]
def providers_for_current_user(conn) do
config = fetch_config(conn)
conn
|> Plug.current_user()
|> get_all_providers_for_user(config)
|> Enum.map(&provider_to_atom!(&1.provider))
end
defp get_all_providers_for_user(nil, _config), do: []
defp get_all_providers_for_user(user, config), do: Operations.all(user, config)
@doc """
Lists available providers for connection.
"""
@spec available_providers(Conn.t() | Config.t()) :: [atom()]
def available_providers(%Conn{} = conn) do
conn
|> fetch_config()
|> available_providers()
end
def available_providers(config) do
config
|> Config.get_providers()
|> Keyword.keys()
end
@doc """
Fetch PowAssent configuration from the Pow configration.
Calls `Pow.Plug.fetch_config/1` and fetches the `pow_assent` key value.
"""
@spec fetch_config(Conn.t()) :: Config.t()
def fetch_config(conn) do
config = Plug.fetch_config(conn)
config
|> Keyword.take([:otp_app, :plug, :repo, :user])
|> Keyword.merge(Keyword.get(config, :pow_assent, []))
end
defp get_provider_config(%Conn{} = conn, provider, redirect_uri) do
conn
|> fetch_config()
|> get_provider_config(provider, redirect_uri)
end
defp get_provider_config(config, provider, redirect_uri) do
provider = provider_to_atom!(provider)
config = Config.get_provider_config(config, provider)
strategy = config[:strategy]
provider_config =
config
|> Keyword.delete(:strategy)
|> Config.put(:redirect_uri, redirect_uri)
{strategy, provider_config}
end
defp provider_to_atom!(provider) when is_binary(provider) do
String.to_existing_atom(provider)
rescue
ArgumentError -> Config.raise_no_provider_config_error(provider)
end
defp provider_to_atom!(provider) when is_atom(provider), do: provider
@cookie_key "auth_session"
@private_session_key :pow_assent_session
@private_session_info_key :pow_assent_session_info
@doc """
Initializes session.
Session data will be fetched and deleted from the PowAssent session store if
an auth session cookie was found. The session data is set for the
`:pow_assent_session_info` key in in `conn.private`.
A `:before_send` callback will be set to store session data. If
`:pow_assent_session` key in `conn.private` has been populated, a random UUID
is generated and used as the key for the stored session data. The UUID is
then stored as the value for the auth session cookie.
## Configuration options
The configuration is fetched with `fetch_config/1`.
* `:session_store` - the session store. This value
defaults to
`{PowAssent.Store.SessionCache, backend: Pow.Store.Backend.EtsCache}`.
The `Pow.Store.Backend.EtsCache` backend store can be changed with the
`:cache_store_backend` option.
* `:auth_session_cookie_key` - The cookie key name to use. Defaults to
"auth_session". If `:otp_app` is used it'll automatically prepend the key
with the `:otp_app` value.
* `:auth_session_cookie_opts` - keyword list of cookie options, see
`Plug.Conn.put_resp_cookie/4` for options. The default options are
`[path: "/"]`.
"""
@spec init_session(Conn.t()) :: Conn.t()
def init_session(conn) do
config = fetch_config(conn)
pow_config = Plug.fetch_config(conn)
{key, conn} = client_store_fetch(conn, config, pow_config)
value = get_session_value(key, config, pow_config) || default_value(conn)
conn
|> maybe_client_store_delete(config)
|> Conn.put_private(@private_session_key, value)
|> Conn.register_before_send(& put_session_value(&1, config, pow_config))
end
defp client_store_fetch(conn, config, pow_config) do
conn = Conn.fetch_cookies(conn)
with token when is_binary(token) <- conn.cookies[cookie_key(config)],
{:ok, token} <- Plug.verify_token(conn, signing_salt(), token, pow_config) do
{token, conn}
else
_any -> {nil, conn}
end
end
defp signing_salt, do: Atom.to_string(__MODULE__)
defp cookie_key(config) do
Config.get(config, :auth_session_cookie_key, default_cookie_key(config))
end
defp default_cookie_key(config) do
Plug.prepend_with_namespace(config, @cookie_key)
end
defp get_session_value(nil, _config, _pow_config), do: nil
defp get_session_value(key, config, pow_config) do
{store, store_config} = store(config, pow_config)
case store.get(store_config, key) do
:not_found ->
nil
value ->
store.delete(store_config, key)
value
end
end
defp store(config, pow_config) do
case Config.get(config, :session_store, default_store(pow_config)) do
{store, store_config} -> {store, store_config}
store -> {store, []}
end
end
defp default_store(pow_config) do
backend = PowConfig.get(pow_config, :cache_store_backend, EtsCache)
{SessionCache, [backend: backend]}
end
defp default_value(%{private: %{@private_session_key => session}}), do: session
defp default_value(_conn), do: %{}
defp maybe_client_store_delete(conn, config) do
conn = Conn.fetch_cookies(conn)
case Map.has_key?(conn.cookies, cookie_key(config)) do
true -> client_store_delete(conn, config)
false -> conn
end
end
defp client_store_delete(conn, config) do
conn
|> Conn.fetch_cookies()
|> Conn.delete_resp_cookie(cookie_key(config), cookie_opts(config))
end
defp cookie_opts(config) do
config
|> Config.get(:auth_session_cookie_opts, [])
|> Keyword.put_new(:path, "/")
end
defp put_session_value(%{private: %{@private_session_info_key => :write, @private_session_key => session}} = conn, config, pow_config) when session != %{} do
{store, store_config} = store(config, pow_config)
key = UUID.generate()
store.put(store_config, key, session)
client_store_put(conn, key, config, pow_config)
end
defp put_session_value(conn, _config, _pow_config), do: conn
defp client_store_put(conn, token, config, pow_config) do
signed_token = Plug.sign_token(conn, signing_salt(), token, pow_config)
conn
|> Conn.fetch_cookies()
|> Conn.put_resp_cookie(cookie_key(config), signed_token, cookie_opts(config))
end
@doc """
Inserts value for key in session.
"""
@spec put_session(Conn.t(), atom(), any()) :: Conn.t()
def put_session(%{private: %{@private_session_key => session}} = conn, key, value) do
session = Map.put(session, key, value)
conn
|> Conn.put_private(@private_session_key, session)
|> Conn.put_private(@private_session_info_key, :write)
end
@doc """
Deletes key from session.
"""
@spec delete_session(Conn.t(), atom()) :: Conn.t()
def delete_session(%{private: %{@private_session_key => session}} = conn, key) do
session = Map.delete(session, key)
Conn.put_private(conn, @private_session_key, session)
end
@doc """
Merges existing provider config with new config in the conn.
The existing config is fetched with `fetch_config/1`, and the new config deep
merged unto it with `PowAssent.Config.merge_provider_config/3`. The updated
config will be set as `:pow_assent` config value for the Pow config for the
conn with `Pow.Plug.put_config/2`.
"""
@spec merge_provider_config(Conn.t(), binary() | atom(), Keyword.t()) :: Conn.t()
def merge_provider_config(conn, provider, new_config) do
pow_config = Pow.Plug.fetch_config(conn)
provider = provider_to_atom!(provider)
pow_assent_config =
conn
|> fetch_config()
|> Config.merge_provider_config(provider, new_config)
Pow.Plug.put_config(conn, Config.put(pow_config, :pow_assent, pow_assent_config))
end
end