3

I'm using Absinthe and have a sign in mutation. When users send over valid credentials, I'd like to set a session cookie in the response via put_session.

The problem I'm facing is that I'm not able to access the conn from within a resolver function. That tells me that I'm not supposed to update the connection's properties from within a resolver.

Is it possible to do this with Absinthe? What are some alternative solutions?

Raphael Rafatpanah
  • 19,082
  • 25
  • 92
  • 158

2 Answers2

5

It looks like one solution is:

  1. In the resolver, resolve either an {:ok, _} or an {:error, _} as normal
  2. Add middleware after the resolver to pattern match that resolution.value returned from step 1 and update the GraphQL context
  3. Use the before_send feature of Absinthe (which has access to both the GraphQL context and the connection to put_session before sending a response

Code Example

Mutation:

mutation do
  @desc "Authenticate a user."
  field :login, :user do
    arg(:email, non_null(:string))
    arg(:password, non_null(:string))
    resolve(&Resolvers.Accounts.signin/3)

    middleware(fn resolution, _ ->
      case resolution.value do
        %{user: user, auth_token: auth_token} ->
          Map.update!(
            resolution,
            :context,
            &Map.merge(&1, %{auth_token: auth_token, user: user})
          )

        _ ->
          resolution
      end
    end)
  end
end

Resolver:

defmodule AppWeb.Resolvers.Accounts do
  alias App.Accounts

  def signin(_, %{email: email, password: password}, _) do
    if user = Accounts.get_user_by_email_and_password(email, password) do
      auth_token = Accounts.generate_user_session_token(user)
      {:ok, %{user: user, auth_token: auth_token}}
    else
      {:error, "Invalid credentials."}
    end
  end
end

Router:

defmodule AppWeb.Router do
  use AppWeb, :router

  pipeline :api do
    plug(:accepts, ["json"])
    plug(:fetch_session)
  end

  scope "/" do
    pipe_through(:api)

    forward("/api", Absinthe.Plug,
      schema: AppWeb.Schema,
      before_send: {__MODULE__, :absinthe_before_send}
    )

    forward("/graphiql", Absinthe.Plug.GraphiQL,
      schema: AppWeb.Schema,
      before_send: {__MODULE__, :absinthe_before_send}
    )
  end

  def absinthe_before_send(conn, %Absinthe.Blueprint{} = blueprint) do
    if auth_token = blueprint.execution.context[:auth_token] do
      put_session(conn, :auth_token, auth_token)
    else
      conn
    end
  end

  def absinthe_before_send(conn, _) do
    conn
  end
end
Raphael Rafatpanah
  • 19,082
  • 25
  • 92
  • 158
1

Not sure why you want to use a session, can't this be solved using a bearer?

Please disregard the interfaces. :-)

Mutation.

  object :user_token_payload do
    field(:user, :user)
    field(:token, :string)
  end

  object :login_user_mutation_response, is_type_of: :login_user do
    interface(:straw_hat_mutation_response)

    field(:errors, list_of(:straw_hat_error))
    field(:successful, non_null(:boolean))
    field(:payload, :user_token_payload)
  end

Resolver.

  def authenticate_user(args, _) do
    case Accounts.authenticate_user(args) do
      {:ok, user, token} -> MutationResponse.succeeded(%{user: user, token: token})
      {:error, message} -> MutationResponse.failed(StrawHat.Error.new(message))
    end
  end

Now the client can pass along that token with the Authorization header, and pick it up with a plug.

defmodule MyAppWeb.Plugs.Context do
  import Plug.Conn
  alias MyApp.Admission

  def init(opts), do: opts

  def call(conn, _) do
    case build_context(conn) do
      {:ok, context} -> put_private(conn, :absinthe, %{context: context})
      _ -> put_private(conn, :absinthe, %{context: %{}})
    end
  end

  @doc """
  Return the current user context based on the authorization header
  """
  def build_context(conn) do
    auth_header =
      get_req_header(conn, "authorization")
      |> List.first()

    if auth_header do
      "Bearer " <> token = auth_header

      case Admission.get_token_by_hash(token) do
        nil -> :error
        token -> {:ok, %{current_user: token.user}}
      end
    else
      :error
    end
  end
end

Then add the plug to your pipeline

plug(MyApp.Plugs.Context)

Then you can pick up the current user in your resolvers like so.

  def create_note(%{input: input}, %{context: %{current_user: user}}) do
  end
MartinElvar
  • 5,695
  • 6
  • 39
  • 56
  • 2
    Thanks for sharing this alternative solution using Bearer tokens. As I understand, using Bearer tokens requires clients to store them in local storage, which could open up the application to more XSS attacks. Going with the HTTP only cookie approach, that problem goes away (and introduces CSRF attacks, which [this document helps solve](https://dev.to/rtfeldman/defense-against-the-dark-arts-csrf-attacks)). – Raphael Rafatpanah Aug 27 '20 at 17:02