defmodule Potionx.Plug.PowAssent 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} import Ecto.Query @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 |> check_if_authenticated() end def check_if_authenticated({:ok, conn}) do case Pow.Plug.current_user(conn) do nil -> {:error, conn} _user -> {:ok, Conn.put_private(conn, :pow_assent_callback_state, {:ok, :authenticated})} end end def check_if_authenticated({_, conn}) do {:error, conn} end @spec get_user_by_session_params(map, keyword) :: any @doc """ Finds a user based on the provider and uid or by email if the user has no providers yet. User schema module and repo module will be fetched from the config. """ def get_user_by_session_params(%{"userinfo" => %{"email" => email}, "provider" => provider, "uid" => uid}, config) do user_mod = Pow.Config.user!(config) association = user_mod.__schema__(:association, :user_identities) user_identity_mod = association.queryable from( u in user_mod, left_join: i in ^user_identity_mod, on: u.id == i.user_id, where: u.email == ^email and is_nil(i.id), or_where: i.provider == ^provider and i.uid == ^(to_string(uid)), select: u ) |> Pow.Config.repo!(config).one 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_callback_error: err}} = conn) when not is_nil(err) do conn end defp maybe_create_user(%{private: %{pow_assent_registration: false}} = conn) do conn 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" => _} = session_params) do config = Plug.fetch_config(conn) session_params |> get_user_by_session_params(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(), 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