Rolling deploys and persistent Plug.Session

Hi folks,

I am on the edge of launching my new project (stay tuned… :eyes:) but there is one last thing that annoys me, but first a bit of context:

  • My app is a mix (:smile:) of phoenix (landing, signup, sign in) and phoenix liveview (the app itself)
  • I have deployed on gigalixir, my stack is very simple: Postgres, no other dependencies.
  • I am using Plug.Session with cookie store to handle authentication: I have a plug that loads the current user for all of my routes and another one that requires it to be loaded for some protected routes.

Now the problem I am facing is that, when I am deploying (using rolling update from gigalixir), the session cache goes away and my users have to login again, even if they still have a session cookie (max_age ~ 1month).

I have read other posts where people suggest to use a third party storage, like mnesia, redis, or even postgres storage but I am a bit unsure about that: I don’t want to add more infra (so no redis), I don’t think this info should live in my main DB (no postgres) and because gigalixir doesn’t offer persistent disk, mnesia doesn’t seem an option.

My questions are:

  1. Is there something existing out there to facilitate that? I’d like to keep my stack as simple as possible and not introduce yet another library (eg. pow, guardian, etc.)
  2. Is there a way to use a bearer token for the session (type: Phoenix.Token) instead of the token that comes from Plug.Session? If so, could that mess with liveview receiving the session on mount?
  3. Would it be acceptable to store 2 cookies for each user session: one for the Plug.Session and another one being a bearer token containing the user_id. When my plug that loads the current user kicks in, it can fallback on the bearer token to retrieve the user, and populate the session with it (this should only happen after a deploy).

Thanks in advance :pray:

1 Like

mnesia is included in your release anyway so not sure what’s the problem using it? If Gigalixir doesn’t allow you to create files on the app node then yeah, that can be a problem.

That’s basically the issue with mnesia + gigalixir. My app is deployed by Gigalixir on pods that can rotated and don’t have access to a persistent disk. Deploying always create new pods and recycle old ones.

Forgot to mention in the post above: I could also look into hot upgrade (gigalixir supports it) but it has constraints I don’t want to have.

Hm, what auth do you use? I’d look into how Pow solves this for example.

I literally just use Plug.Session. When my user login I call put_session(conn, :user_id, ID). When a new request comes in, I check in my LoadCurrentUserPlug the session with get_session(conn, :user_id).

Haven’t used Gigalixir in at least two years but does it have a key/value store in its free tier somewhere? If not, I am afraid you’ll have to reconsider your hosting.

Hcae you considered simply using an ordinary cookie session without a server side cache?

Can you tell me more about it? Is it documented in Plug.Session?

What I am considering to do (inspired from POW who supports stateless token) is to use a bearer cookie to store the user_id (using Phoenix.Token), and use it to know if my user is authenticated and also populate the session (so that it works with liveview).

Is that what you had in mind?

Here is basically my auth module:

defmodule PortfollowWeb.AuthHelpers do
  import Plug.Conn

  alias Portfollow.Accounts

  # todo: retrieve it from prod.secret
  @bearer_key "user salt"
  @bearer_cookie_key "_portfollow_bearer"
  # a month, this could change if my app has a remember_me feature.
  @max_age 60 * 60 * 24 * 30

  @doc """
  Builds the user session from the bearer token.
  """
  def build_user_session(conn) do
    token = conn.req_cookies[@bearer_cookie_key]

    PortfollowWeb.Endpoint
    |> Phoenix.Token.verify(@bearer_key, token, max_age: @max_age)
    |> case do
      {:error, _} ->
        conn
        |> clear_session()

      {:ok, id} ->
        conn
        |> put_session(:user_id, id)
    end
  end

  @doc """
  Signs in a user.
  """
  def sign_in(conn, user) do
    conn
    |> put_bearer_cookie(user.id)
    |> put_session(:user_id, user.id)
    |> assign(:user, user)
  end

  defp put_bearer_cookie(conn, user_id) do
    token = Phoenix.Token.sign(PortfollowWeb.Endpoint, @bearer_key, user_id)

    conn
    |> put_resp_cookie(@bearer_cookie_key, token, max_age: @max_age, signed: true, http_only: true)
  end

  @doc """
  Signs out a user.
  """
  def sign_out(conn) do
    conn
    |> clear_bearer_cookie()
    |> clear_session()
    |> assign(:user, nil)
  end

  defp clear_bearer_cookie(conn) do
    conn
    |> delete_resp_cookie(@bearer_cookie_key)
  end

  @doc """
  Returns the current user.
  """
  def current_user(conn) do
    with user_id when not is_nil(user_id) <- get_session(conn, :user_id),
         user when not is_nil(user) <-
           Accounts.get_user(user_id) |> Accounts.preload_user_credential() do
      {:ok, user}
    else
      _ -> {:error, nil}
    end
  end
end

In my router file, I make sure to have a plug that calls build_user_session first. Then another plug that loads the current user later/if needed.

I quite like this approach and it seems to solve my issue. Can you see anything wrong with it?

You’re manually managing the cookie, which I don’t fully understand. Normally you just configure the Plug.Session to use cookies (which is the default), and then you just add and remove :user_id. There isn’t any need to use Phoenix.Token because the session itself is secure, you don’t need to add your own security.

and then you just add and remove :user_id

You add it to the session? Right?
That’s what I currently have configured in production. I have configured Plug.Session to use cookies,

But in that cases, back to my initial problem, if my app is deployed on a new pod, the session will be destroyed.
Clients will still have the cookie in their browser (which just old a reference to the server session, right?), but it will map to an empty session on the pod freshly started.

Rephrasing the main question :slight_smile: => how do I make sure sessions are persisted if my “app” goes through a complete restart? And is there something built-in in Plug.Session to support that?

Does that make sense?

Plug.Session with cookies should be working already. Something is misconfigured in your application which is breaking this somehow. Can you show your Plug.Session configuration?

That’s my session_options

  @session_options [
    store: :cookie,
    key: "_my_app_key",
    signing_salt: "something",
    max_age: 60 * 60 * 24 * 30
  ]

  plug Plug.Session, @session_options

Plug.Session with cookies should be working already

For my own understanding: how does that work if the server goes through a complete restart? Does that mean if I run put_session(conn, :user_id, 1) this will somehow be encoded in the cookie sent to the browser?
I thought this cookie was just a reference to a session living on the server. :thinking:

That is exactly what happens. It is encoded and sent in the response cookie. It is not a reference, the actual value is encrypted and sent to the client. That is what is meant by store: :cookie. It will actually store the value in the cookie.

I think something in you flow is clearing this out? I’m not sure. We use cookies to store user ids and session tokens all via the session api without touching the cookies directly and it works across deploys just fine.

1 Like

Thanks for your time. I appreciate it. It also clarified my understanding (which was pretty wrong :sweat_smile:)

That’s weird indeed. Couple of days ago I missed the max_age on the session options, but I think I have seen this bug again this morning.

I just tried to reproduce it now but I couldn’t :thinking:.

I’ll stop here and mark your post as solution. Thanks again. I’ll update if it happens again and I find the root cause!

1 Like