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' })
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.The
x-csrf_token
header is being set in the fetch, and I have verified that thecsrfToken.value
matches what was created byget_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.