# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd # # SPDX-License-Identifier: MIT defmodule AshAuthentication.Strategy.OAuth2.Plug do @moduledoc """ Handlers for incoming OAuth2 HTTP requests. """ alias Ash.Error.Framework.AssumptionFailed alias AshAuthentication.{Errors, Info, Strategy, Strategy.OAuth2} alias Assent.HTTPAdapter.Finch alias Plug.Conn import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1, get_context: 1] import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] import Plug.Conn @raw_config_attrs [ :auth_method, :client_authentication_method, :id_token_signed_response_alg, :id_token_ttl_seconds, :openid_configuration_uri ] @doc """ Perform the request phase of OAuth2. Builds a redirection URL based on the provider configuration and redirects the user to that endpoint. """ @spec request(Conn.t(), OAuth2.t()) :: Conn.t() # sobelow_skip ["XSS.SendResp"] def request(conn, strategy) do with {:ok, config} <- config_for(strategy, %{conn: conn}), {:ok, config} <- maybe_add_nonce(config, strategy, %{conn: conn}), {:ok, session_key} <- session_key(strategy), {:ok, %{session_params: session_params, url: url}} <- strategy.assent_strategy.authorize_url(config) do conn |> put_session(session_key, session_params) |> put_resp_header("location", url) |> send_resp(:found, "Redirecting to #{strategy.name}") else {:error, reason} -> store_authentication_result(conn, {:error, reason}) end end @doc """ Perform the callback phase of OAuth2. Responds to a user being redirected back from the remote authentication provider, and validates the passed options, ultimately registering or signing-in a user if the authentication was successful. """ @spec callback(Conn.t(), OAuth2.t()) :: Conn.t() def callback(conn, strategy) do with {:ok, session_key} <- session_key(strategy), {:ok, config} <- config_for(strategy, %{conn: conn}), session_params when is_map(session_params) <- get_session(conn, session_key), conn <- delete_session(conn, session_key), config <- Keyword.put(config, :session_params, session_params), {:ok, %{user: user, token: token}} <- strategy.assent_strategy.callback(config, conn.params), action_opts <- action_opts(conn), {:ok, user} <- register_or_sign_in_user( strategy, %{user_info: user, oauth_tokens: token}, action_opts ) do store_authentication_result(conn, {:ok, user}) else nil -> store_authentication_result(conn, {:error, nil}) {:error, reason} -> store_authentication_result(conn, {:error, reason}) end end defp action_opts(conn) do [actor: get_actor(conn), tenant: get_tenant(conn), context: get_context(conn) || %{}] |> Enum.reject(&is_nil(elem(&1, 1))) end defp config_for(strategy, context) do config = strategy |> Map.take(@raw_config_attrs) with {:ok, config} <- add_secret_value(config, strategy, :base_url, context), {:ok, config} <- add_secret_value(config, strategy, :authorize_url, !!strategy.base_url, context), {:ok, config} <- add_secret_value(config, strategy, :client_id, !!strategy.base_url, context), {:ok, config} <- add_secret_value(config, strategy, :client_secret, !!strategy.base_url, context), {:ok, config} <- add_secret_value(config, strategy, :token_url, !!strategy.base_url, context), {:ok, config} <- add_secret_value(config, strategy, :code_verifier, !!strategy.code_verifier, context), {:ok, config} <- add_secret_value( config, strategy, :authorization_params, !!strategy.authorization_params, context ), {:ok, config} <- add_secret_value( config, strategy, :openid_configuration, !strategy.openid_configuration, context ), {:ok, config} <- add_secret_value( config, strategy, :team_id, strategy.assent_strategy != Assent.Strategy.Apple, context ), {:ok, config} <- add_secret_value( config, strategy, :private_key_id, strategy.assent_strategy != Assent.Strategy.Apple, context ), {:ok, config} <- add_secret_value( config, strategy, :private_key_path, strategy.assent_strategy != Assent.Strategy.Apple, context ), {:ok, config} <- add_secret_value(config, strategy, :trusted_audiences, true, context), {:ok, config} <- add_http_adapter(config), {:ok, config} <- add_secret_value( config, strategy, :user_url, !!strategy.authorize_url || !!strategy.base_url, context ), {:ok, redirect_uri} <- build_redirect_uri(strategy, context), {:ok, jwt_algorithm} <- Info.authentication_tokens_signing_algorithm(strategy.resource) do config = config |> Map.put(:jwt_algorithm, jwt_algorithm) |> Map.put(:redirect_uri, redirect_uri) |> Map.update(:client_authentication_method, nil, &to_string/1) |> Enum.reject(&is_nil(elem(&1, 1))) {:ok, config} end end defp register_or_sign_in_user(strategy, params, opts) when strategy.registration_enabled?, do: Strategy.action(strategy, :register, params, opts) defp register_or_sign_in_user(strategy, params, opts), do: Strategy.action(strategy, :sign_in, params, opts) # We need to temporarily store some information about the request in the # session so that we can verify that there hasn't been a CSRF-related attack. defp session_key(strategy) do case Info.authentication_subject_name(strategy.resource) do {:ok, subject_name} -> {:ok, "#{subject_name}/#{strategy.name}"} :error -> {:error, AssumptionFailed.exception( message: "Resource `#{inspect(strategy.resource)}` has no subject name" )} end end # With OpenID Connect we can pass a "nonce" value into the assent strategy # which is an additional way to ensure that the callback matches the request. defp maybe_add_nonce(config, strategy, context) do case fetch_secret(strategy, :nonce, context) do {:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, Keyword.put(config, :nonce, value)} {:ok, false} -> {:ok, config} {:error, reason} -> {:error, reason} end end defp add_secret_value(config, strategy, secret_name, allow_nil? \\ false, context) do case fetch_secret(strategy, secret_name, context) do {:ok, nil} when allow_nil? -> {:ok, config} {:ok, nil} -> path = [:authentication, :strategies, strategy.name, secret_name] {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)} {:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, Map.put(config, secret_name, value)} {:ok, list} when is_list(list) -> {:ok, Map.put(config, secret_name, list)} {:ok, map} when is_map(map) -> {:ok, Map.put(config, secret_name, map)} {:ok, boolean} when is_boolean(boolean) -> {:ok, Map.put(config, secret_name, boolean)} {:error, reason} -> {:error, reason} end end defp fetch_secret(strategy, secret_name, context) do path = [:authentication, :strategies, strategy.name, secret_name] with {:ok, {secret_module, secret_opts}} <- Map.fetch(strategy, secret_name), {:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <- AshAuthentication.Secret.secret_for( secret_module, path, strategy.resource, secret_opts, context ) do {:ok, secret} else {:ok, secret} -> {:ok, secret} _ -> {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)} end end defp build_redirect_uri(strategy, context) do with {:ok, subject_name} <- Info.authentication_subject_name(strategy.resource), {:ok, redirect_uri} <- fetch_secret(strategy, :redirect_uri, context), {:ok, %URI{} = uri} <- string_to_uri(redirect_uri) do suffix = Path.join([to_string(subject_name), to_string(strategy.name), "callback"]) # Don't append the path if the secret ends with the path already path = if String.ends_with?(uri.path, suffix) do uri.path else Path.join([uri.path, suffix]) end {:ok, to_string(%URI{uri | path: path})} else :error -> {:error, AssumptionFailed.exception( message: "Resource `#{inspect(strategy.resource)}` has no subject name" )} {:error, reason} -> {:error, reason} end end defp string_to_uri(input) when is_binary(input) do case URI.new(input) do {:ok, %URI{path: nil} = uri} -> {:ok, %URI{uri | path: "/"}} {:ok, %URI{} = uri} -> {:ok, uri} {:error, reason} -> {:error, reason} end end defp add_http_adapter(config) do http_adapter = Application.get_env( :ash_authentication, :http_adapter, {Finch, supervisor: AshAuthentication.Finch} ) {:ok, Map.put(config, :http_adapter, http_adapter)} end end