Why won't Phoenix/Plug/Guardian recognize this CSRF token in AJAX calls?

593 views Asked by At

Protected routes in my Phoenix API are sending 403 responses to requests. Debug logs show:

(Plug.CSRFProtection.InvalidCSRFTokenError) invalid CSRF (Cross Site Request Forgery) token, please make sure that:

  * The session cookie is being sent and session is loaded
  * The request include a valid '_csrf_token' param or 'x-csrf-token' header

My router looks like this:

defmodule MyWeb.Router do
  use MyWeb, :router
  alias MyWeb.Auth.GuardianPipeline

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

  pipeline :protected do
    plug Guardian.Plug.EnsureAuthenticated
    plug :protect_from_forgery
  end

  scope "/api/v1", MyWeb.API.V1, as: :api_v1 do
    pipe_through :api

    post "/login", SessionController, :login
  end

  scope "/api/v1", MyWeb.API.V1, as: :api_v1 do
    pipe_through [:api, :protected]

    delete "/logout", SessionController, :logout
  end
end

My session controller login action looks like this:

defp login_reply({:ok, user}, conn) do
  conn
  |> Guardian.Plug.sign_in(user)
  |> put_session(:_csrf_token, csrf_token)
  |> json(%{csrf_token: get_csrf_token()})
end

On the frontend, my request to the protected "logout" path looks like this:

const logout = async () => {
  const method = 'DELETE'
  const headers = {
    'Content-Type': 'application/json',
    'x-csrf-token': csrfToken.value
  }
  await fetch('http://localhost:4000/api/v1/logout', { method, headers, credentials: 'include' })
  1. The CSRF error says I should make sure the session is sent and loaded I see the session cookie loaded in my browser, so I know it's sent. The session is loaded by the :fetch_session plug. So this first condition should be satisfied.

  2. The x-csrf_token header is being set in the fetch, and I have verified that the csrfToken.value matches what was created by get_csrf_token(). So the second condition should be satisfied too.

What am I doing wrong? Why isn't the csrf token validated?

--

Here is the entire JS component

<script lang="ts">
import { defineComponent, ref, onMounted } from '@nuxtjs/composition-api'

export default defineComponent({
  setup (_props) {
    const csrfToken = ref('')
    const email = '[email protected]'
    const error = ref('')
    const submitting = false

    const isAuthenticated = () => !!csrfToken.value

    const setAuthenticated = () => {
      return isAuthenticated()
    }

    const logout = async () => {
      const method = 'DELETE'
      const headers = {
        'Content-Type': 'application/json',
        'x-csrf-token': csrfToken.value
      }
      await fetch('http://localhost:4000/api/v1/logout', { method, headers, credentials: 'include' })
        .then((response) => {
          if (response.status === 401) {
            error.value = 'No account found with that email address'
          } else if (!response.ok) {
            error.value = 'Something went wrong'
          }
          return response
        })
        .then(response => response.json())
        .catch((_err) => {
          error.value = 'network error'
        })

      if (error.value) {
        throw new Error(error.value)
      }

      csrfToken.value = ''
      return true
    }

    const onLogout = () => {
      error.value = ''
      return logout().catch(_e => '')
    }

    const login = async () => {
      const method = 'POST'
      const body = JSON.stringify({ user: { email } })
      const headers = {
        'Content-Type': 'application/json'
      }
      const resp = await fetch('http://localhost:4000/api/v1/login', { method, headers, body, credentials: 'include' })
        .then((response) => {
          if (response.status === 401) {
            error.value = 'No account found with that email address'
          } else if (!response.ok) {
            error.value = 'Something went wrong'
          }
          return response
        })
        .then(response => response.json())
        .catch((_err) => {
          error.value = 'network error'
        })

      if (error.value) {
        throw new Error(error.value)
      }

      console.log(resp)
      csrfToken.value = resp.csrf_token
      return true
    }

    const onSubmit = () => {
      error.value = ''
      return login().catch(_e => '')
    }

    onMounted(setAuthenticated)

    return {
      csrfToken,
      email,
      error,
      submitting,
      isAuthenticated,
      setAuthenticated,
      onLogout,
      onSubmit
    }
  }
})
</script>

The entire Phoenix Session Controller

defmodule MyWeb.API.V1.SessionController do
  use MyWeb, :controller
  alias MyWeb.Auth.Guardian
  alias MyWeb.API.V1.Auth

  def login(conn, %{"user" => %{"email" => email}}) do
    Auth.authenticate_user(%{email: email})
    |> login_reply(conn)
  end

  def logout(conn, _params) do
    conn
    |> Guardian.Plug.sign_out(clear_remember_me: true)
    |> configure_session(drop: true)
    |> send_resp(:ok, "")
  end

  defp login_reply({:ok, user}, conn) do
    conn
    |> Guardian.Plug.sign_in(user)
    # |> Guardian.Plug.remember_me(user)
    |> put_session(:_csrf_token, csrf_token)
    |> json(%{csrf_token: csrf_token})
  end

  defp login_reply({:error, reason}, conn) do
    conn
    |> put_status(:unauthorized)
    |> json(%{reason: reason})
  end
end

I created this custom plug for my router's protected pipeline:

  defp inspect_plug(conn, _opts) do
    IO.inspect(Plug.Conn.get_session(conn), label: :session)

    session_token = Plug.Conn.get_session(conn, "_csrf_token")
    IO.inspect(session_token, label: :session_token)
    IO.inspect(byte_size(session_token), label: :session_token_size)
    csrf_token = Plug.CSRFProtection.dump_state_from_session(session_token)

    IO.inspect(csrf_token, label: :csrf_token)

    conn
  end

Here's an example of what it logs on "logout" requests:

session: %{
  "_csrf_token" => "HSwrOF5AOQ5nKyIWHH9RFFg0HVMWDRA_dFIVlulMQipLw39PmPzkPXIf",
  "guardian_default_token" => "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRvbWVldHVwIiwiZXhwIjoxNjI2NDU1MDQ4LCJpYXQiOjE2MjQwMzU4NDgsImlzcyI6ImF1dG9tZWV0dXAiLCJqdGkiOiJkMDczYTMwZi00ZTMzLTRkZjItODBhMi04YzRkZDNiYzAzMzAiLCJuYmYiOjE2MjQwMzU4NDcsInN1YiI6IjEiLCJ0eXAiOiJhY2Nlc3MifQ.KF-rv-65G_0NUMsFa9tYedWKE0gRTd9hvx_pOyBHFV5DTGFPtsfJnaz3WOk63ARKu8-bEbjAZGXRRPAZ0E3LMw"
}
session_token: "HSwrOF5AOQ5nKyIWHH9RFFg0HVMWDRA_dFIVlulMQipLw39PmPzkPXIf"
session_token_size: 56
csrf_token: nil

I'm wondering why Plug.CSRFProtection.dump_state_from_session is returning nil from my _csrf_token session cookie.

https://github.com/elixir-plug/plug/blob/fa579592ebf53306e3a4b13e2414a40997add1f7/lib/plug/csrf_protection.ex#L195

0

There are 0 answers