Elixir / Phoenix: How to implement session timeout / expiration

This is our production solution (view in a Gist):

sliding_session_timeout.ex
defmodule Auth.SlidingSessionTimeout do
  import Plug.Conn

  def init(opts \\ []) do
    Keyword.merge([timeout_after_seconds: 3600], opts)
  end

  def call(conn, opts) do
    timeout_at = get_session(conn, :session_timeout_at)
    if timeout_at && now() > timeout_at do
      logout_user(conn)
    else
      put_session(conn, :session_timeout_at, new_session_timeout_at(opts[:timeout_after_seconds]))
    end
  end

  defp logout_user(conn) do
    conn
    |> clear_session()
    |> configure_session([:renew])
    |> assign(:session_timeout, true)
  end

  defp now do
    DateTime.utc_now() |> DateTime.to_unix
  end

  defp new_session_timeout_at(timeout_after_seconds) do
    now() + timeout_after_seconds
  end
end

How to use it

Plug it at the end of your :browser pipeline in your Phoenix app’s router.ex.

Please note that authentication (getting user_id from the session, loading the user from the DB) and authorization are concerns for other plugs, further in the pipeline. So make sure it is plugged before your session-based authentication and authorization Plugs.

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :put_secure_browser_headers
  plug Auth.SlidingSessionTimeout, timeout_after_seconds: 3600    # <=
end

I first looked for cookie expiration options in the Plug library, then realized that an easier (and more secure) approach is to simply set an expiration datetime in the session along with the user_id. The session is tamper-proof, so when I receive each request, I can compare the datetime to now; if the session hasn't yet expired, I set current_user as normal. Otherwise I call logout! to delete the expired session.

An implementation would look something like this (requires the Timex library):

# Assign current_user to the conn, if a user is logged in
def load_current_user(conn, _opts) do
  cond do
    no_login_session?(conn) ->
      conn # No user_id was found; make no changes
    current_user_already_set?(conn) ->
      conn
    session_expired?(conn) ->
      logout!(conn)
    user = load_user_from_session(conn) ->
      conn
        |> put_session(:expires_at, new_expiration_datetime_string)
        |> assign(:current_user, user)
  end
end

defp session_expired?(conn) do
  expires_at = get_session(conn, :expires_at) |> Timex.parse!("{ISO:Extended}")
  Timex.after?(Timex.now, expires_at)
end

# ... more ...

# Start a logged-in session for an (already authenticated) user
def login!(conn, user) do
  conn
    |> assign(:current_user, user)
    |> put_session(:user_id, user.id)
    |> put_session(:expires_at, new_expiration_datetime_string)
    |> configure_session(renew: true)
end

defp new_expiration_datetime_string do
  Timex.now |> Timex.shift(hours: +2) |> Timex.format("{ISO:Extended}")
end

# ... more ...

The Plug.Sessions module has a built-in option to set the expiration of a cookie using the max_age key. For example, extending your endpoint.ex snippet would look like:

plug Plug.Session,
  store: :cookie,
  key: "_zb_key",
  signing_salt: "RANDOM HEX",
  max_age: 24*60*60*37       # 37 days

Credit: https://teamgaslight.com/blog/til-how-to-explicitly-set-session-expiration-in-phoenix

Documentation: https://hexdocs.pm/plug/Plug.Session.html#module-options