Repository: phoenixframework/phoenix
Pull request #6162 (2025-04-04 02:36:27)

Reissue new tokens periodically in phx.gen.auth code.
The auth code currently generated by mix phx.gen.auth generates tokens with a fixed expiration of 60 days. Unless a user logs in again, they will never get a new token. Once that token expires, they are forced to log in again. This could happen in the middle of a session, such as while they are filling out a form, causing them to lose their submission when the POST redirects them to log in again.

This PR fixes this by issuing a new token automatically to valid sessions that are using a token more than 7 days old.

With this new refresh mechanism, tokens now expired after 14 days instead of the old default of 60. Shorter lived tokens decrease the window of possible abuse if the token is exfiltrated somehow, such as from a backup of a browser cookie store.

Both the token life and reissue age can be easily changed via module attributes in `UserToken` and `UserAuth`.

Tokens now persist an `authenticated_at` field so that sudo checks are based on the time of the original login, and new tokens will carry forward that same timestamp, so that new tokens do not incorrectly offer a chance for sudo mode access.

This also fixes a small bug with setting `:user_remember_me` in the session after restoring a token from the remember me cookie, causing this preference to not persist after sudo mode forces a new login. This new code also relies on that being set in the session to properly reissue cookies after new tokens are created.

This PR supersedes #6151 after much work with the tireless help of @josevalim to fine-tune this mechanism.

This is mostly the same change for both `--live` and `--no-live` authentication, with just some small changes in a couple tests between the two to make sure sudo mode is working correctly. Diffs from the original output of `mix phx.gen.auth` and the output after this patch is applied can be easily seen via:
- [`mix phx.gen.auth --live Accounts User users`](https://github.com/nshafer/myapp18/compare/auth_live_orig2..auth_live_new2)
- [`mix phx.gen.auth --no-live Accounts User users`](https://github.com/nshafer/myapp18/compare/auth_nolive_orig2..auth_nolive_new2)

@josevalim - feel free to make any further tweaks you want. Also review the 14 day token expiration and 7 day renewal, tweak if you like. I'd be fine with that default in my projects, but you'll have a better idea of what is good as a global default.

Thanks again for all your tireless work on Elixir and Phoenix, and for helping me get this PR whipped into shape!

nshafer: The CI tests located an issue with mixed `DateTime` vs `NaiveDateTime` usage that I'm not properly handling. The current code assumes `config :myapp18,  generators: [timestamp_type: :utc_datetime]` and throws errors when `timestamp_type: :naive_datetime` in the projects config. Good catch with the extra integration tests! I'll fix it up tomorrow or this weekend.

Patch:
diff --git a/guides/authn_authz/scopes.md b/guides/authn_authz/scopes.md
index 4e7ae366c..0d5e4940e 100644
--- a/guides/authn_authz/scopes.md
+++ b/guides/authn_authz/scopes.md
@@ -390,6 +390,21 @@ end
 
 For LiveViews, we'll also need to add a new `:on_mount` hook and add it to `live_session`'s `on_mount` option in the router:
 
+```diff
+  # router.ex
+  scope "/", MyAppWeb do
+    pipe_through [:browser]
+
+    live_session :current_user,
+      on_mount: [
+        {MyAppWeb.UserAuth, :mount_current_scope},
++       {MyAppWeb.UserAuth, :assign_org_to_scope}
+      ] do
+      ...
+    end
+  end
+```
+
 ```elixir
 # user_auth.ex
 def on_mount(:assign_org_to_scope, %{"org" => slug}, _session, socket) do
diff --git a/guides/controllers.md b/guides/controllers.md
index b5d60987c..97195e290 100644
--- a/guides/controllers.md
+++ b/guides/controllers.md
@@ -15,7 +15,7 @@ defmodule HelloWeb.PageController do
   use HelloWeb, :controller
 
   def home(conn, _params) do
-    render(conn, :home, layout: false)
+    render(conn, :home)
   end
 end
 ```
@@ -171,7 +171,7 @@ As an example, let's take `PageController`'s `home` action from a newly generate
 
 ```elixir
 def home(conn, _params) do
-  render(conn, :home, layout: false)
+  render(conn, :home)
 end
 ```
 
@@ -282,7 +282,7 @@ Let's change the status in our `PageController` `home` action.
 def home(conn, _params) do
   conn
   |> put_status(202)
-  |> render(:home, layout: false)
+  |> render(:home)
 end
 ```
 
@@ -328,7 +328,7 @@ Finally, let's define in the same file the action we redirect to, which simply r
 
 ```elixir
 def redirect_test(conn, _params) do
-  render(conn, :home, layout: false)
+  render(conn, :home)
 end
 ```
 
@@ -358,7 +358,7 @@ defmodule HelloWeb.PageController do
   def home(conn, _params) do
     conn
     |> put_flash(:error, "Let's pretend we have an error.")
-    |> render(:home, layout: false)
+    |> render(:home)
   end
 end
 ```
diff --git a/guides/data_modelling/cross_context_boundaries.md b/guides/data_modelling/cross_context_boundaries.md
index 2fe25fe7c..221d1cfb7 100644
--- a/guides/data_modelling/cross_context_boundaries.md
+++ b/guides/data_modelling/cross_context_boundaries.md
@@ -18,7 +18,7 @@ From the description, it's clear we need a `Cart` resource for storing the user'
 Most of the cart functionality is tied to a specific user. Therefore, in order to allow each user to manage their own cart (and only their own carts), we must be able to authenticate users. To do so, we will use Phoenix's built-in `mix phx.gen.auth` generator to scaffold a solution for us:
 
 ```console
-mix phx.gen.auth Accounts User user
+mix phx.gen.auth Accounts User users
 
 An authentication system can be created in two different ways:
 - Using Phoenix.LiveView (default)
@@ -39,7 +39,7 @@ Remember to update your repository by running migrations:
 
     $ mix ecto.migrate
 
-Once you are ready, visit "/user/register"
+Once you are ready, visit "/users/register"
 to create your account and then access "/dev/mailbox" to
 see the account confirmation email.
 ```
diff --git a/installer/recreate_default_css.exs b/installer/recreate_default_css.exs
index c5c32592b..50c72760f 100644
--- a/installer/recreate_default_css.exs
+++ b/installer/recreate_default_css.exs
@@ -22,10 +22,6 @@ File.cd!("installer", fn ->
   shell_in_daisy!.("mix phx.gen.auth Accounts User users --live")
   shell_in_daisy!.("mix deps.get")
   shell_in_daisy!.("mix phx.gen.live Blog Post posts title:string body:text")
-
-  css = File.read!("dayzee/assets/css/app.css")
-  File.write!("dayzee/assets/css/app.css", String.replace(css, "themes: all;", "themes: light --default, dark --prefersdark;"))
-
   shell_in_daisy!.("mix tailwind dayzee")
 
   content = File.read!("dayzee/priv/static/assets/css/app.css")
diff --git a/installer/templates/phx_web/components/core_components.ex b/installer/templates/phx_web/components/core_components.ex
index f96bfc423..3e4cc9a18 100644
--- a/installer/templates/phx_web/components/core_components.ex
+++ b/installer/templates/phx_web/components/core_components.ex
@@ -88,7 +88,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
       <.button phx-click="go" variant="primary">Send!</.button>
       <.button navigate={~p"/"}>Home</.button>
   """
-  attr :rest, :global, include: ~w(href navigate patch)
+  attr :rest, :global, include: ~w(href navigate patch method)
   attr :variant, :string, values: ~w(primary)
   slot :inner_block, required: true
 
diff --git a/installer/templates/phx_web/controllers/page_controller.ex b/installer/templates/phx_web/controllers/page_controller.ex
index 43660462c..b3a89f4ee 100644
--- a/installer/templates/phx_web/controllers/page_controller.ex
+++ b/installer/templates/phx_web/controllers/page_controller.ex
@@ -2,8 +2,6 @@ defmodule <%= @web_namespace %>.PageController do
   use <%= @web_namespace %>, :controller
 
   def home(conn, _params) do
-    # The home page is often custom made,
-    # so skip the default app layout.
-    render(conn, :home, layout: false)
+    render(conn, :home)
   end
 end
diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex
index 9db43ccdb..c06de7c05 100644
--- a/lib/mix/tasks/phx.gen.auth.ex
+++ b/lib/mix/tasks/phx.gen.auth.ex
@@ -211,6 +211,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth do
       test_case_options: test_case_options(ecto_adapter),
       live?: Keyword.fetch!(context.opts, :live),
       datetime_module: datetime_module(schema),
+      datetime_now: datetime_now(schema),
       scope_config: scope_config(context, opts[:scope])
     ]
 
@@ -1029,6 +1030,10 @@ defmodule Mix.Tasks.Phx.Gen.Auth do
   defp datetime_module(%{timestamp_type: :utc_datetime}), do: DateTime
   defp datetime_module(%{timestamp_type: :utc_datetime_usec}), do: DateTime
 
+  defp datetime_now(%{timestamp_type: :naive_datetime}), do: "NaiveDateTime.utc_now(:second)"
+  defp datetime_now(%{timestamp_type: :utc_datetime}), do: "DateTime.utc_now(:second)"
+  defp datetime_now(%{timestamp_type: :utc_datetime_usec}), do: "DateTime.utc_now()"
+
   defp put_live_option(schema) do
     opts =
       case Keyword.fetch(schema.opts, :live) do
diff --git a/lib/phoenix/digester.ex b/lib/phoenix/digester.ex
index 69b1b4fa8..f5222bf3d 100644
--- a/lib/phoenix/digester.ex
+++ b/lib/phoenix/digester.ex
@@ -1,5 +1,4 @@
 defmodule Phoenix.Digester do
-  @digested_file_regex ~r/(-[a-fA-F\d]{32})/
   @manifest_version 1
   @empty_manifest %{
     "version" => @manifest_version,
@@ -156,7 +155,9 @@ defmodule Phoenix.Digester do
   defp manifest_join(path, filename), do: Path.join(path, filename)
 
   defp compiled_file?(file_path) do
-    Regex.match?(@digested_file_regex, Path.basename(file_path)) ||
+    digested_file_regex = ~r/(-[a-fA-F\d]{32})/
+
+    Regex.match?(digested_file_regex, Path.basename(file_path)) ||
       Path.extname(file_path) in compressed_extensions() ||
       Path.basename(file_path) == "cache_manifest.json"
   end
@@ -240,12 +241,12 @@ defmodule Phoenix.Digester do
     %{file | digested_content: digested_content}
   end
 
-  @stylesheet_url_regex ~r{(url\(\s*)(\S+?)(\s*\))}
-  @quoted_text_regex ~r{\A(['"])(.+)\1\z}
-
   defp digest_stylesheet_asset_references(file, latest, with_vsn?) do
-    Regex.replace(@stylesheet_url_regex, file.content, fn _, open, url, close ->
-      case Regex.run(@quoted_text_regex, url) do
+    stylesheet_url_regex = ~r{(url\(\s*)(\S+?)(\s*\))}
+    quoted_text_regex = ~r{\A(['"])(.+)\1\z}
+
+    Regex.replace(stylesheet_url_regex, file.content, fn _, open, url, close ->
+      case Regex.run(quoted_text_regex, url) do
         [_, quote_symbol, url] ->
           open <>
             quote_symbol <> digested_url(url, file, latest, with_vsn?) <> quote_symbol <> close
@@ -256,18 +257,18 @@ defmodule Phoenix.Digester do
     end)
   end
 
-  @javascript_source_map_regex ~r{(//#\s*sourceMappingURL=\s*)(\S+)}
-
   defp digest_javascript_asset_references(file, latest) do
-    Regex.replace(@javascript_source_map_regex, file.content, fn _, source_map_text, url ->
+    javascript_source_map_regex = ~r{(//#\s*sourceMappingURL=\s*)(\S+)}
+
+    Regex.replace(javascript_source_map_regex, file.content, fn _, source_map_text, url ->
       source_map_text <> digested_url(url, file, latest, false)
     end)
   end
 
-  @javascript_map_file_regex ~r{(['"]file['"]:['"])([^,"']+)(['"])}
-
   defp digest_javascript_map_asset_references(file, latest) do
-    Regex.replace(@javascript_map_file_regex, file.content, fn _, open_text, url, close_text ->
+    javascript_map_file_regex = ~r{(['"]file['"]:['"])([^,"']+)(['"])}
+
+    Regex.replace(javascript_map_file_regex, file.content, fn _, open_text, url, close_text ->
       open_text <> digested_url(url, file, latest, false) <> close_text
     end)
   end
diff --git a/priv/templates/phx.gen.auth/auth.ex b/priv/templates/phx.gen.auth/auth.ex
index e0b139ec2..32b9603f5 100644
--- a/priv/templates/phx.gen.auth/auth.ex
+++ b/priv/templates/phx.gen.auth/auth.ex
@@ -7,12 +7,24 @@ defmodule <%= inspect auth_module %> do
   alias <%= inspect context.module %>
   alias <%= inspect scope_config.scope.module %>
 
-  # Make the remember me cookie valid for 60 days.
-  # If you want bump or reduce this value, also change
-  # the token expiry itself in <%= inspect schema.alias %>Token.
-  @max_age 60 * 60 * 24 * 60
+  # Make the remember me cookie valid for 14 days. This should match
+  # the session validity setting in <%= inspect schema.alias %>Token.
+  @max_cookie_age_in_days 14
   @remember_me_cookie "_<%= web_app_name %>_<%= schema.singular %>_remember_me"
-  @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
+  @remember_me_options [
+    sign: true,
+    max_age: @max_cookie_age_in_days * 24 * 60 * 60,
+    same_site: "Lax"
+  ]
+
+  # How old the session token should be before a new one is issued. When a request is made
+  # with a session token older than this value, then a new session token will be created
+  # and the session and remember-me cookies (if set) will be updated with the new token.
+  # Lowering this value will result in more tokens being created by active users. Increasing
+  # it will result in less time before a session token expires for a user to get issued a new
+  # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
+  # the reissuing of tokens completely.
+  @session_reissue_age_in_days 7
 
   @doc """
   Logs the <%= schema.singular %> in.
@@ -35,7 +47,7 @@ defmodule <%= inspect auth_module %> do
     remember_me = get_session(conn, :<%= schema.singular %>_remember_me)
 
     conn
-    |> renew_session()
+    |> renew_session(<%= schema.singular %>)
     |> put_token_in_session(token)
     |> maybe_write_remember_me_cookie(token, params, remember_me)
     |> redirect(to: <%= schema.singular %>_return_to || signed_in_path(conn))
@@ -55,13 +67,20 @@ defmodule <%= inspect auth_module %> do
     |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
   end
 
+  # Do not renew session if the <%= schema.singular %> is already logged in (sudo mode reauthentication)
+  # to prevent CSRF errors for tabs that are still open
+  defp renew_session(conn, <%= schema.singular %>) when conn.assigns.current_scope.<%= schema.singular %>.id == <%= schema.singular %>.id do
+    conn
+  end
+
   # This function renews the session ID and erases the whole
   # session to avoid fixation attacks. If there is any data
   # in the session you may want to preserve after log in/log out,
   # you must explicitly fetch the session data before clearing
   # and then immediately set it after clearing, for example:
   #
-  #     defp renew_session(conn) do
+  #     defp renew_session(conn, _<%= schema.singular %>) do
+  #       delete_csrf_token()
   #       preferred_locale = get_session(conn, :preferred_locale)
   #
   #       conn
@@ -70,7 +89,7 @@ defmodule <%= inspect auth_module %> do
   #       |> put_session(:preferred_locale, preferred_locale)
   #     end
   #
-  defp renew_session(conn) do
+  defp renew_session(conn, _<%= schema.singular %>) do
     delete_csrf_token()
 
     conn
@@ -92,19 +111,25 @@ defmodule <%= inspect auth_module %> do
     end
 
     conn
-    |> renew_session()
+    |> renew_session(nil)
     |> delete_resp_cookie(@remember_me_cookie)
     |> redirect(to: ~p"/")
   end
 
   @doc """
-  Authenticates the <%= schema.singular %> by looking into the session
-  and remember me token.
+  Authenticates the <%= schema.singular %> by looking into the session and remember me token.
+
+  Will reissue the session token if it is older than the configured age.
   """
   def fetch_current_scope_for_<%= schema.singular %>(conn, _opts) do
-    {<%= schema.singular %>_token, conn} = ensure_<%= schema.singular %>_token(conn)
-    <%= schema.singular %> = <%= schema.singular %>_token && <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token)
-    assign(conn, :current_scope, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>))
+    with {token, conn} <- ensure_<%= schema.singular %>_token(conn),
+         {<%= schema.singular %>, token_inserted_at} <- <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) do
+      conn
+      |> assign(:current_scope, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>))
+      |> maybe_reissue_<%= schema.singular %>_session_token(<%= schema.singular %>, token_inserted_at)
+    else
+      nil -> assign(conn, :current_scope, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(nil))
+    end
   end
 
   defp ensure_<%= schema.singular %>_token(conn) do
@@ -114,13 +139,37 @@ defmodule <%= inspect auth_module %> do
       conn = fetch_cookies(conn, signed: [@remember_me_cookie])
 
       if token = conn.cookies[@remember_me_cookie] do
-        {token, put_token_in_session(conn, token)}
+        {token, conn |> put_token_in_session(token) |> put_session(:<%= schema.singular %>_remember_me, true)}
       else
-        {nil, conn}
+        nil
       end
     end
   end
 
+  # Reissue the session token if it is older than the configured reissue age.
+  defp maybe_reissue_<%= schema.singular %>_session_token(conn, <%= schema.singular %>, token_inserted_at) do
+    token_age = <%= inspect datetime_module %>.diff(<%= datetime_now %>, token_inserted_at, :day)
+
+    if token_age >= @session_reissue_age_in_days do
+      new_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>)
+
+      conn
+      |> put_token_in_session(new_token)
+      |> maybe_refresh_remember_me_cookie(new_token)
+    else
+      conn
+    end
+  end
+
+  # Refresh the remember me cookie with the new token and new expiration date.
+  defp maybe_refresh_remember_me_cookie(conn, new_token) do
+    if get_session(conn, :<%= schema.singular %>_remember_me) do
+      put_resp_cookie(conn, @remember_me_cookie, new_token, @remember_me_options)
+    else
+      conn
+    end
+  end
+
   <%= if live? do %>@doc """
   Handles mounting and authenticating the current_scope in LiveViews.
 
@@ -189,10 +238,10 @@ defmodule <%= inspect auth_module %> do
 
   defp mount_current_scope(socket, session) do
     Phoenix.Component.assign_new(socket, :current_scope, fn ->
-      <%= schema.singular %> =
+      {<%= schema.singular %>, _} =
         if <%= schema.singular %>_token = session["<%= schema.singular %>_token"] do
           <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token)
-        end
+        end || {nil, nil}
 
       <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>)
     end)
diff --git a/priv/templates/phx.gen.auth/conn_case.exs b/priv/templates/phx.gen.auth/conn_case.exs
index d8d2a151f..de10dd72b 100644
--- a/priv/templates/phx.gen.auth/conn_case.exs
+++ b/priv/templates/phx.gen.auth/conn_case.exs
@@ -13,7 +13,7 @@
 
     opts =
       context
-      |> Map.take([:token_inserted_at])
+      |> Map.take([:token_authenticated_at])
       |> Enum.into([])
 
     %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>, opts), <%= schema.singular %>: <%= schema.singular %>, scope: scope}
@@ -27,15 +27,15 @@
   def log_in_<%= schema.singular %>(conn, <%= schema.singular %>, opts \\ []) do
     token = <%= inspect context.module %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>)
 
-    maybe_set_token_inserted_at(token, opts[:token_inserted_at])
+    maybe_set_token_authenticated_at(token, opts[:token_authenticated_at])
 
     conn
     |> Phoenix.ConnTest.init_test_session(%{})
     |> Plug.Conn.put_session(:<%= schema.singular %>_token, token)
   end
 
-  defp maybe_set_token_inserted_at(_token, nil), do: nil
+  defp maybe_set_token_authenticated_at(_token, nil), do: nil
 
-  defp maybe_set_token_inserted_at(token, inserted_at) do
-    <%= inspect context.module %>Fixtures.override_token_inserted_at(token, inserted_at)
+  defp maybe_set_token_authenticated_at(token, authenticated_at) do
+    <%= inspect context.module %>Fixtures.override_token_authenticated_at(token, authenticated_at)
   end
diff --git a/priv/templates/phx.gen.auth/context_fixtures_functions.ex b/priv/templates/phx.gen.auth/context_fixtures_functions.ex
index e0249ccc0..376730f7d 100644
--- a/priv/templates/phx.gen.auth/context_fixtures_functions.ex
+++ b/priv/templates/phx.gen.auth/context_fixtures_functions.ex
@@ -56,12 +56,12 @@
     token
   end
 
-  def override_token_inserted_at(token, inserted_at) when is_binary(token) do
+  def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
     <%= inspect schema.repo %>.update_all(
       from(t in <%= inspect context.alias %>.<%= inspect schema.alias %>Token,
         where: t.token == ^token
       ),
-      set: [inserted_at: inserted_at]
+      set: [authenticated_at: authenticated_at]
     )
   end
 
@@ -70,3 +70,12 @@
     <%= inspect schema.repo %>.insert!(<%= schema.singular %>_token)
     {encoded_token, <%= schema.singular %>_token.token}
   end
+
+  def offset_<%= schema.singular %>_token(token, amount_to_add, unit) do
+    dt = <%= inspect datetime_module %>.add(<%= datetime_now %>, amount_to_add, unit)
+
+    <%= inspect schema.repo %>.update_all(
+      from(ut in <%= inspect context.alias %>.<%= inspect schema.alias %>Token, where: ut.token == ^token),
+      set: [inserted_at: dt, authenticated_at: dt]
+    )
+  end
diff --git a/priv/templates/phx.gen.auth/context_functions.ex b/priv/templates/phx.gen.auth/context_functions.ex
index 51988d129..9b1808749 100644
--- a/priv/templates/phx.gen.auth/context_functions.ex
+++ b/priv/templates/phx.gen.auth/context_functions.ex
@@ -180,6 +180,8 @@
 
   @doc """
   Gets the <%= schema.singular %> with the given signed token.
+
+  If the token is valid `{<%= schema.singular %>, token_inserted_at}` is returned, otherwise `nil` is returned.
   """
   def get_<%= schema.singular %>_by_session_token(token) do
     {:ok, query} = <%= inspect schema.alias %>Token.verify_session_token_query(token)
diff --git a/priv/templates/phx.gen.auth/migration.ex b/priv/templates/phx.gen.auth/migration.ex
index 4219a23cd..291d321e0 100644
--- a/priv/templates/phx.gen.auth/migration.ex
+++ b/priv/templates/phx.gen.auth/migration.ex
@@ -21,6 +21,7 @@ defmodule <%= inspect schema.repo %>.Migrations.Create<%= Macro.camelize(schema.
       <%= migration.column_definitions[:token] %>
       add :context, :string, null: false
       add :sent_to, :string
+      add :authenticated_at, <%= inspect schema.timestamp_type %>
 
       timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}, " %>updated_at: false)
     end
diff --git a/priv/templates/phx.gen.auth/schema_token.ex b/priv/templates/phx.gen.auth/schema_token.ex
index 3dcce14f2..beed7c2cb 100644
--- a/priv/templates/phx.gen.auth/schema_token.ex
+++ b/priv/templates/phx.gen.auth/schema_token.ex
@@ -10,7 +10,7 @@ defmodule <%= inspect schema.module %>Token do
   # since someone with access to the email may take over the account.
   @magic_link_validity_in_minutes 15
   @change_email_validity_in_days 7
-  @session_validity_in_days 60
+  @session_validity_in_days 14
 <%= if schema.binary_id do %>
   @primary_key {:id, :binary_id, autogenerate: true}
   @foreign_key_type :binary_id<% end %>
@@ -18,6 +18,7 @@ defmodule <%= inspect schema.module %>Token do
     field :token, :binary
     field :context, :string
     field :sent_to, :string
+    field :authenticated_at, <%= inspect schema.timestamp_type %>
     belongs_to :<%= schema.singular %>, <%= inspect schema.module %>
 
     timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}, " %>updated_at: false)
@@ -44,13 +45,14 @@ defmodule <%= inspect schema.module %>Token do
   """
   def build_session_token(<%= schema.singular %>) do
     token = :crypto.strong_rand_bytes(@rand_size)
-    {token, %<%= inspect schema.alias %>Token{token: token, context: "session", <%= schema.singular %>_id: <%= schema.singular %>.id}}
+    dt = <%= schema.singular %>.authenticated_at || <%= datetime_now %>
+    {token, %<%= inspect schema.alias %>Token{token: token, context: "session", <%= schema.singular %>_id: <%= schema.singular %>.id, authenticated_at: dt}}
   end
 
   @doc """
   Checks if the token is valid and returns its underlying lookup query.
 
-  The query returns the <%= schema.singular %> found by the token, if any.
+  The query returns the <%= schema.singular %> found by the token, if any, along with the token's creation time.
 
   The token is valid if it matches the value in the database and it has
   not expired (after @session_validity_in_days).
@@ -60,8 +62,7 @@ defmodule <%= inspect schema.module %>Token do
       from token in by_token_and_context_query(token, "session"),
         join: <%= schema.singular %> in assoc(token, :<%= schema.singular %>),
         where: token.inserted_at > ago(@session_validity_in_days, "day"),
-        select: <%= schema.singular %>,
-        select_merge: %{authenticated_at: token.inserted_at}
+        select: {%{<%= schema.singular %> | authenticated_at: token.authenticated_at}, token.inserted_at}
 
     {:ok, query}
   end
diff --git a/priv/templates/phx.gen.auth/scope.ex b/priv/templates/phx.gen.auth/scope.ex
index 8c31a2e02..2829aa251 100644
--- a/priv/templates/phx.gen.auth/scope.ex
+++ b/priv/templates/phx.gen.auth/scope.ex
@@ -2,7 +2,7 @@ defmodule <%= inspect scope_config.scope.module %> do
   @moduledoc """
   Defines the scope of the caller to be used throughout the app.
 
-  The `<%= inspect context.module %>.<%= inspect schema.alias %>Scope` allows public interfaces to receive
+  The `<%= inspect scope_config.scope.module %>` allows public interfaces to receive
   information about the caller, such as if the call is initiated from an
   end-user, and if so, which user. Additionally, such a scope can carry fields
   such as "super user" or other privileges for use as authorization, or to


Test patch:
diff --git a/priv/templates/phx.gen.auth/auth_test.exs b/priv/templates/phx.gen.auth/auth_test.exs
index ac3e4e1bc..f895f93f3 100644
--- a/priv/templates/phx.gen.auth/auth_test.exs
+++ b/priv/templates/phx.gen.auth/auth_test.exs
@@ -9,6 +9,7 @@ defmodule <%= inspect auth_module %>Test do
   import <%= inspect context.module %>Fixtures
 
   @remember_me_cookie "_<%= web_app_name %>_<%= schema.singular %>_remember_me"
+  @remember_me_cookie_max_age 60 * 60 * 24 * 14
 
   setup %{conn: conn} do
     conn =
@@ -16,7 +17,7 @@ defmodule <%= inspect auth_module %>Test do
       |> Map.replace!(:secret_key_base, <%= inspect endpoint_module %>.config(:secret_key_base))
       |> init_test_session(%{})
 
-    %{<%= schema.singular %>: %{<%= schema.singular %>_fixture() | authenticated_at: <%= inspect datetime_module %>.utc_now()}, conn: conn}
+    %{<%= schema.singular %>: %{<%= schema.singular %>_fixture() | authenticated_at: <%= datetime_now %>}, conn: conn}
   end
 
   describe "log_in_<%= schema.singular %>/3" do
@@ -33,6 +34,31 @@ defmodule <%= inspect auth_module %>Test do
       refute get_session(conn, :to_be_removed)
     end
 
+    test "keeps session when re-authenticating", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
+      conn =
+        conn
+        |> assign(:current_scope, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>))
+        |> put_session(:to_be_removed, "value")
+        |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>)
+
+      assert get_session(conn, :to_be_removed)
+    end
+
+    test "clears session when <%= schema.singular %> does not match when re-authenticating", %{
+      conn: conn,
+      <%= schema.singular %>: <%= schema.singular %>
+    } do
+      other_<%= schema.singular %> = <%= schema.singular %>_fixture()
+
+      conn =
+        conn
+        |> assign(:current_scope, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(other_<%= schema.singular %>))
+        |> put_session(:to_be_removed, "value")
+        |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>)
+
+      refute get_session(conn, :to_be_removed)
+    end
+
     test "redirects to the configured path", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
       conn = conn |> put_session(:<%= schema.singular %>_return_to, "/hello") |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>)
       assert redirected_to(conn) == "/hello"
@@ -45,7 +71,7 @@ defmodule <%= inspect auth_module %>Test do
 
       assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
       assert signed_token != get_session(conn, :<%= schema.singular %>_token)
-      assert max_age == 5_184_000
+      assert max_age == @remember_me_cookie_max_age
     end<%= if live? do %>
 
     test "redirects to settings when <%= schema.singular %> is already logged in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
@@ -69,13 +95,13 @@ defmodule <%= inspect auth_module %>Test do
         |> fetch_cookies()
         |> init_test_session(%{<%= schema.singular %>_remember_me: true})
 
-      # the conn is already logged in and has the remeber_me cookie set,
+      # the conn is already logged in and has the remember_me cookie set,
       # now we log in again and even without explicitly setting remember_me,
       # the cookie should be set again
       conn = conn |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{})
       assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
       assert signed_token != get_session(conn, :<%= schema.singular %>_token)
-      assert max_age == 5_184_000
+      assert max_age == @remember_me_cookie_max_age
       assert get_session(conn, :<%= schema.singular %>_remember_me) == true
     end
   end
@@ -125,6 +151,8 @@ defmodule <%= inspect auth_module %>Test do
         conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> <%= inspect schema.alias %>Auth.fetch_current_scope_for_<%= schema.singular %>([])
 
       assert conn.assigns.current_scope.<%= schema.singular %>.id == <%= schema.singular %>.id
+      assert conn.assigns.current_scope.<%= schema.singular %>.authenticated_at == <%= schema.singular %>.authenticated_at
+      assert get_session(conn, :<%= schema.singular %>_token) == <%= schema.singular %>_token
     end
 
     test "authenticates <%= schema.singular %> from cookies", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
@@ -140,7 +168,9 @@ defmodule <%= inspect auth_module %>Test do
         |> <%= inspect schema.alias %>Auth.fetch_current_scope_for_<%= schema.singular %>([])
 
       assert conn.assigns.current_scope.<%= schema.singular %>.id == <%= schema.singular %>.id
-      assert get_session(conn, :<%= schema.singular %>_token) == <%= schema.singular %>_token<%= if live? do %>
+      assert conn.assigns.current_scope.<%= schema.singular %>.authenticated_at == <%= schema.singular %>.authenticated_at
+      assert get_session(conn, :<%= schema.singular %>_token) == <%= schema.singular %>_token
+      assert get_session(conn, :<%= schema.singular %>_remember_me)<%= if live? do %>
 
       assert get_session(conn, :live_socket_id) ==
                "<%= schema.plural %>_sessions:#{Base.url_encode64(<%= schema.singular %>_token)}"<% end %>
@@ -152,6 +182,32 @@ defmodule <%= inspect auth_module %>Test do
       refute get_session(conn, :<%= schema.singular %>_token)
       refute conn.assigns.current_scope
     end
+
+    test "reissues a new token after a few days and refreshes cookie", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
+      logged_in_conn =
+        conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{"remember_me" => "true"})
+
+      token = logged_in_conn.cookies[@remember_me_cookie]
+      %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
+
+      offset_<%= schema.singular %>_token(token, -10, :day)
+      {<%= schema.singular %>, _} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token)
+
+      conn =
+        conn
+        |> put_session(:<%= schema.singular %>_token, token)
+        |> put_session(:<%= schema.singular %>_remember_me, true)
+        |> put_req_cookie(@remember_me_cookie, signed_token)
+        |> <%= inspect schema.alias %>Auth.fetch_current_scope_for_<%= schema.singular %>([])
+
+      assert conn.assigns.current_scope.<%= schema.singular %>.id == <%= schema.singular %>.id
+      assert conn.assigns.current_scope.<%= schema.singular %>.authenticated_at == <%= schema.singular %>.authenticated_at
+      assert new_token = get_session(conn, :<%= schema.singular %>_token)
+      assert new_token != token
+      assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
+      assert new_signed_token != signed_token
+      assert max_age == @remember_me_cookie_max_age
+    end
   end
 
   <%= if live? do %>describe "on_mount :mount_current_scope" do
@@ -240,20 +296,21 @@ defmodule <%= inspect auth_module %>Test do
                <%= inspect schema.alias %>Auth.on_mount(:require_sudo_mode, %{}, session, socket)
     end
 
-    test "redirects when authentication is too old", %{<%= schema.singular %>: <%= schema.singular %>} do
-      eleven_minutes_ago = DateTime.utc_now() |> DateTime.add(-11, :minute)
+    test "redirects when authentication is too old", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
+      eleven_minutes_ago = <%= datetime_now %> |> <%= inspect datetime_module %>.add(-11, :minute)
+      <%= schema.singular %> = %{<%= schema.singular %> | authenticated_at: eleven_minutes_ago}
+      <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>)
+      {<%= schema.singular %>, token_inserted_at} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token)
+      assert <%= inspect datetime_module %>.compare(token_inserted_at, <%= schema.singular %>.authenticated_at) == :gt
+      session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session()
 
       socket = %LiveView.Socket{
-        endpoint: AuthAppWeb.Endpoint,
-        assigns: %{
-          __changed__: %{},
-          flash: %{},
-          current_scope: <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(%{<%= schema.singular %> | authenticated_at: eleven_minutes_ago})
-        }
+        endpoint: <%= inspect context.web_module %>.Endpoint,
+        assigns: %{__changed__: %{}, flash: %{}}
       }
 
       assert {:halt, _updated_socket} =
-               <%= inspect schema.alias %>Auth.on_mount(:require_sudo_mode, %{}, %{}, socket)
+               <%= inspect schema.alias %>Auth.on_mount(:require_sudo_mode, %{}, session, socket)
     end
   end<% else %>describe "require_sudo_mode/2" do
     test "allows <%= schema.plural %> that have authenticated in the last 10 minutes", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
@@ -268,15 +325,16 @@ defmodule <%= inspect auth_module %>Test do
     end
 
     test "redirects when authentication is too old", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
-      eleven_minutes_ago = DateTime.utc_now() |> DateTime.add(-11, :minute)
+      eleven_minutes_ago = <%= datetime_now %> |> <%= inspect datetime_module %>.add(-11, :minute)
+      <%= schema.singular %> = %{<%= schema.singular %> | authenticated_at: eleven_minutes_ago}
+      <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>)
+      {<%= schema.singular %>, token_inserted_at} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token)
+      assert <%= inspect datetime_module %>.compare(token_inserted_at, <%= schema.singular %>.authenticated_at) == :gt
 
       conn =
         conn
         |> fetch_flash()
-        |> assign(
-          :current_scope,
-          <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(%{<%= schema.singular %> | authenticated_at: eleven_minutes_ago})
-        )
+        |> assign(:current_scope, Scope.for_<%= schema.singular %>(<%= schema.singular %>))
         |> <%= inspect schema.alias %>Auth.require_sudo_mode([])
 
       assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in"
diff --git a/priv/templates/phx.gen.auth/settings_controller_test.exs b/priv/templates/phx.gen.auth/settings_controller_test.exs
index c2705f144..783185501 100644
--- a/priv/templates/phx.gen.auth/settings_controller_test.exs
+++ b/priv/templates/phx.gen.auth/settings_controller_test.exs
@@ -19,7 +19,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
       assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in"
     end
 
-    @tag token_inserted_at: <%= inspect datetime_module %>.add(<%= inspect datetime_module %>.utc_now(), -11, :minute)
+    @tag token_authenticated_at: <%= inspect datetime_module %>.add(<%= datetime_now %>, -11, :minute)
     test "redirects if <%= schema.singular %> is not in sudo mode", %{conn: conn} do
       conn = get(conn, ~p"<%= schema.route_prefix %>/settings")
       assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in"
diff --git a/priv/templates/phx.gen.auth/settings_live_test.exs b/priv/templates/phx.gen.auth/settings_live_test.exs
index 6642b2c94..a025eecc9 100644
--- a/priv/templates/phx.gen.auth/settings_live_test.exs
+++ b/priv/templates/phx.gen.auth/settings_live_test.exs
@@ -28,7 +28,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
       {:ok, conn} =
         conn
         |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture(),
-          token_inserted_at: <%= inspect datetime_module %>.add(<%= inspect datetime_module %>.utc_now(), -11, :minute)
+          token_authenticated_at: <%= inspect datetime_module %>.add(<%= datetime_now %>, -11, :minute)
         )
         |> live(~p"<%= schema.route_prefix %>/settings")
         |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in")
diff --git a/priv/templates/phx.gen.auth/test_cases.exs b/priv/templates/phx.gen.auth/test_cases.exs
index 98632c46a..1ecb959f5 100644
--- a/priv/templates/phx.gen.auth/test_cases.exs
+++ b/priv/templates/phx.gen.auth/test_cases.exs
@@ -249,6 +249,7 @@
       token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>)
       assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: token)
       assert <%= schema.singular %>_token.context == "session"
+      assert <%= schema.singular %>_token.authenticated_at != nil
 
       # Creating the same token for another <%= schema.singular %> should fail
       assert_raise Ecto.ConstraintError, fn ->
@@ -259,6 +260,14 @@
         })
       end
     end
+
+    test "duplicates the authenticated_at of given <%= schema.singular %> in new token", %{<%= schema.singular %>: <%= schema.singular %>} do
+      <%= schema.singular %> = %{<%= schema.singular %> | authenticated_at: <%= inspect datetime_module %>.add(<%= datetime_now %>, -3600)}
+      token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>)
+      assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: token)
+      assert <%= schema.singular %>_token.authenticated_at == <%= schema.singular %>.authenticated_at
+      assert <%= inspect datetime_module %>.compare(<%= schema.singular %>_token.inserted_at, <%= schema.singular %>.authenticated_at) == :gt
+    end
   end
 
   describe "get_<%= schema.singular %>_by_session_token/1" do
@@ -269,8 +278,10 @@
     end
 
     test "returns <%= schema.singular %> by token", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do
-      assert session_<%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token)
+      assert {session_<%= schema.singular %>, token_inserted_at} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token)
       assert session_<%= schema.singular %>.id == <%= schema.singular %>.id
+      assert session_<%= schema.singular %>.authenticated_at != nil
+      assert token_inserted_at != nil
     end
 
     test "does not return <%= schema.singular %> for invalid token" do
@@ -278,7 +289,8 @@
     end
 
     test "does not return <%= schema.singular %> for expired token", %{token: token} do
-      {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+      dt = ~N[2020-01-01 00:00:00]
+      {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: dt, authenticated_at: dt])
       refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token)
     end
   end