From 5b96bedb39c55a9ddf0c999eb35d1c64c8f08783 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:52:31 +0800 Subject: [PATCH 1/9] create auth flow for token exchange --- lib/cadet/code_exchange.ex | 56 +++++++++ lib/cadet_web/controllers/auth_controller.ex | 110 +++++++++++++++++- lib/cadet_web/router.ex | 2 + ...50317093922_create_code_exchange_table.exs | 13 +++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 lib/cadet/code_exchange.ex create mode 100644 priv/repo/migrations/20250317093922_create_code_exchange_table.exs diff --git a/lib/cadet/code_exchange.ex b/lib/cadet/code_exchange.ex new file mode 100644 index 000000000..94d8aa7f4 --- /dev/null +++ b/lib/cadet/code_exchange.ex @@ -0,0 +1,56 @@ +defmodule Cadet.CodeExchange do + @moduledoc """ + The CodeExchange entity stores short-lived codes to be exchanged for long-lived auth tokens. + """ + use Cadet, :model + + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Accounts.User + + schema "code_exchange" do + field(:code, :string) + field(:generated_at, :utc_datetime_usec) + field(:expires_at, :utc_datetime_usec) + + belongs_to(:user, User) + + timestamps() + end + + @required_fields ~w(code generated_at expires_at user_id)a + + def get_by_code(code) do + case Repo.get_by(__MODULE__, code: code) do + nil -> {:error, "Not found"} + struct -> + if Timex.before?(struct.expires_at, Timex.now()) do + {:error, "Expired"} + else + struct = Repo.preload(struct, :user) + Repo.delete(struct) + {:ok, struct} + end + end + end + + def delete_expired do + now = Timex.now() + from(c in __MODULE__, where: c.expires_at < ^now) + |> Repo.delete_all() + end + + def changeset(struct, attrs) do + struct + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + end + + def insert(attrs) do + changeset = %__MODULE__{} + |> changeset(attrs) + changeset + |> Repo.insert() + end +end diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index d51089785..20e841514 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -8,6 +8,7 @@ defmodule CadetWeb.AuthController do alias Cadet.Accounts alias Cadet.Accounts.User alias Cadet.Auth.{Guardian, Provider} + alias Cadet.CodeExchange @doc """ Receives a /login request with valid attributes. @@ -75,7 +76,6 @@ defmodule CadetWeb.AuthController do |> put_resp_header("location", URI.encode(client_redirect_url)) |> send_resp(302, "") |> halt() - conn -> conn end @@ -85,6 +85,107 @@ defmodule CadetWeb.AuthController do send_resp(conn, :bad_request, "Missing parameter") end + @doc """ + Exchanges a short-lived code for access and refresh tokens. + """ + def exchange( + conn, + %{ + "code" => code, + "provider" => provider + } + ) do + case CodeExchange.get_by_code(code) do + {:error, _message} -> + conn + |> put_status(:forbidden) + |> text("Invalid code") + + {:ok, struct} -> + tokens = generate_tokens(struct.user) + {_provider, %{client_post_exchange_redirect_url: client_post_exchange_redirect_url}} = + Application.get_env(:cadet, :identity_providers, %{})[provider] + + conn + |> put_resp_header("location", URI.encode(client_post_exchange_redirect_url <> "?access_token=" <> tokens.access_token <> "&refresh_token=" <> tokens.refresh_token)) + |> send_resp(302, "") + |> halt() + end + end + + @doc """ + Alternate callback URL which redirect to VSCode via deeplinking. + """ + def saml_redirect_vscode(conn, + %{ + "provider" => provider + } + ) do + code_ttl = 60 + case create_user(%{ + conn: conn, + provider_instance: provider, + code: nil, + client_id: nil, + redirect_uri: nil, + }) do + {:ok, user} -> + code = generate_code() + CodeExchange.insert(%{ + code: code, + generated_at: Timex.now(), + expires_at: Timex.add(Timex.now(), Timex.Duration.from_seconds(code_ttl)), + user_id: user.id + }) + + {_provider, %{vscode_redirect_url_prefix: vscode_redirect_url_prefix}} = + Application.get_env(:cadet, :identity_providers, %{})[provider] + conn + |> put_resp_header("location", vscode_redirect_url_prefix <> "?provider=" <> provider <> "&code=" <> code) + |> send_resp(302, "") + |> halt() + + conn -> + conn + end + end + + @spec create_user(Provider.authorise_params()) :: {:ok, User.t()} | Plug.Conn.t() + defp create_user( + params = %{ + conn: conn, + provider_instance: provider, + } + ) do + with {:authorise, {:ok, %{token: token, username: username}}} <- + {:authorise, Provider.authorise(params)}, + {:signin, {:ok, user}} <- {:signin, Accounts.sign_in(username, token, provider)} do + {:ok, user} + else + {:authorise, {:error, :upstream, reason}} -> + conn + |> put_status(:bad_request) + |> text("Unable to retrieve token from authentication provider: #{reason}") + + {:authorise, {:error, :invalid_credentials, reason}} -> + conn + |> put_status(:bad_request) + |> text("Unable to validate token: #{reason}") + + {:authorise, {:error, _, reason}} -> + conn + |> put_status(:internal_server_error) + |> text("Unknown error: #{reason}") + + {:signin, {:error, status, reason}} -> + # status can be :bad_request or :internal_server_error + conn + |> put_status(status) + |> text("Unable to retrieve user: #{reason}") + end + end + + @spec create_user_and_tokens(Provider.authorise_params()) :: {:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t() defp create_user_and_tokens( @@ -170,6 +271,13 @@ defmodule CadetWeb.AuthController do %{access_token: access_token, refresh_token: refresh_token} end + @spec generate_code() :: String.t() + defp generate_code() do + :crypto.strong_rand_bytes(16) + |> Base.url_encode64(padding: false) + |> String.slice(0, 22) + end + swagger_path :create do post("/auth/login") diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index 1ee304c01..edc51277f 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -51,6 +51,8 @@ defmodule CadetWeb.Router do post("/auth/login", AuthController, :create) post("/auth/logout", AuthController, :logout) get("/auth/saml_redirect", AuthController, :saml_redirect) + get("/auth/saml_redirect_vscode", AuthController, :saml_redirect_vscode) + get("/auth/exchange", AuthController, :exchange) end scope "/v2", CadetWeb do diff --git a/priv/repo/migrations/20250317093922_create_code_exchange_table.exs b/priv/repo/migrations/20250317093922_create_code_exchange_table.exs new file mode 100644 index 000000000..81463ebcf --- /dev/null +++ b/priv/repo/migrations/20250317093922_create_code_exchange_table.exs @@ -0,0 +1,13 @@ +defmodule Cadet.Repo.Migrations.CreateCodeExchangeTable do + use Ecto.Migration + + def change do + create table(:code_exchange) do + add(:code, :string, null: false) + add(:generated_at, :utc_datetime_usec, null: false) + add(:expires_at, :utc_datetime_usec, null: false) + add(:user_id, references(:users), null: false) + timestamps() + end + end +end From c90d7d399512d29633b57aa3d54fd918892a9ebe Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:57:10 +0800 Subject: [PATCH 2/9] clean up create_user_and_tokens --- lib/cadet_web/controllers/auth_controller.ex | 34 +++----------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index 20e841514..6607a14c7 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -188,37 +188,13 @@ defmodule CadetWeb.AuthController do @spec create_user_and_tokens(Provider.authorise_params()) :: {:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t() - defp create_user_and_tokens( - params = %{ - conn: conn, - provider_instance: provider - } - ) do - with {:authorise, {:ok, %{token: token, username: username}}} <- - {:authorise, Provider.authorise(params)}, - {:signin, {:ok, user}} <- {:signin, Accounts.sign_in(username, token, provider)} do - {:ok, generate_tokens(user)} - else - {:authorise, {:error, :upstream, reason}} -> - conn - |> put_status(:bad_request) - |> text("Unable to retrieve token from authentication provider: #{reason}") - - {:authorise, {:error, :invalid_credentials, reason}} -> - conn - |> put_status(:bad_request) - |> text("Unable to validate token: #{reason}") - - {:authorise, {:error, _, reason}} -> - conn - |> put_status(:internal_server_error) - |> text("Unknown error: #{reason}") + defp create_user_and_tokens(params) do + case create_user(params) do + {:ok, user} -> + {:ok, generate_tokens(user)} - {:signin, {:error, status, reason}} -> - # status can be :bad_request or :internal_server_error + conn -> conn - |> put_status(status) - |> text("Unable to retrieve user: #{reason}") end end From 45da0831389af7bc0cf46413155e677a82701d6d Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:04:23 +0800 Subject: [PATCH 3/9] update example env file --- config/dev.secrets.exs.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/dev.secrets.exs.example b/config/dev.secrets.exs.example index 42a87b674..131eb0503 100644 --- a/config/dev.secrets.exs.example +++ b/config/dev.secrets.exs.example @@ -36,6 +36,8 @@ config :cadet, # %{ # assertion_extractor: Cadet.Auth.Providers.NusstuAssertionExtractor, # client_redirect_url: "http://cadet.frontend:8000/login/callback" + # vscode_redirect_url_prefix: "vscode://source-academy.source-academy/sso", + # client_post_exchange_redirect_url: "http://cadet.frontend:8000/login/vscode_callback", # }}, "test" => From 3ab518a3153eeb5e5c74b62522cce18c38edd8eb Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:18:27 +0800 Subject: [PATCH 4/9] schedule deletion job --- config/config.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index c998adfe2..f86fafdda 100644 --- a/config/config.exs +++ b/config/config.exs @@ -25,7 +25,9 @@ config :cadet, Cadet.Jobs.Scheduler, # Compute rolling leaderboard every 2 hours {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}, # Collate contest entries that close in the previous day at 00:01 - {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} + {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}}, + # Clean up expired exchange tokens at 00:01 + {"1 0 * * *", {Cadet.CodeExchange, :delete_expired, []}} ] # Configures the endpoint From 20cf57d66d3908d967a31448ee32817dc41cdd32 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Mon, 24 Mar 2025 23:20:59 +0800 Subject: [PATCH 5/9] rename CodeExchange to TokenExchange --- config/config.exs | 2 +- lib/cadet/code_exchange.ex | 6 +++--- lib/cadet_web/controllers/auth_controller.ex | 6 +++--- ...e.exs => 20250317093922_create_token_exchange_table.exs} | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) rename priv/repo/migrations/{20250317093922_create_code_exchange_table.exs => 20250317093922_create_token_exchange_table.exs} (74%) diff --git a/config/config.exs b/config/config.exs index f86fafdda..beaa22daf 100644 --- a/config/config.exs +++ b/config/config.exs @@ -27,7 +27,7 @@ config :cadet, Cadet.Jobs.Scheduler, # Collate contest entries that close in the previous day at 00:01 {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}}, # Clean up expired exchange tokens at 00:01 - {"1 0 * * *", {Cadet.CodeExchange, :delete_expired, []}} + {"1 0 * * *", {Cadet.TokenExchange, :delete_expired, []}} ] # Configures the endpoint diff --git a/lib/cadet/code_exchange.ex b/lib/cadet/code_exchange.ex index 94d8aa7f4..0cc45bbbe 100644 --- a/lib/cadet/code_exchange.ex +++ b/lib/cadet/code_exchange.ex @@ -1,6 +1,6 @@ -defmodule Cadet.CodeExchange do +defmodule Cadet.TokenExchange do @moduledoc """ - The CodeExchange entity stores short-lived codes to be exchanged for long-lived auth tokens. + The TokenExchange entity stores short-lived codes to be exchanged for long-lived auth tokens. """ use Cadet, :model @@ -9,7 +9,7 @@ defmodule Cadet.CodeExchange do alias Cadet.Repo alias Cadet.Accounts.User - schema "code_exchange" do + schema "token_exchange" do field(:code, :string) field(:generated_at, :utc_datetime_usec) field(:expires_at, :utc_datetime_usec) diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index 6607a14c7..b7c2225ae 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -8,7 +8,7 @@ defmodule CadetWeb.AuthController do alias Cadet.Accounts alias Cadet.Accounts.User alias Cadet.Auth.{Guardian, Provider} - alias Cadet.CodeExchange + alias Cadet.TokenExchange @doc """ Receives a /login request with valid attributes. @@ -95,7 +95,7 @@ defmodule CadetWeb.AuthController do "provider" => provider } ) do - case CodeExchange.get_by_code(code) do + case TokenExchange.get_by_code(code) do {:error, _message} -> conn |> put_status(:forbidden) @@ -131,7 +131,7 @@ defmodule CadetWeb.AuthController do }) do {:ok, user} -> code = generate_code() - CodeExchange.insert(%{ + TokenExchange.insert(%{ code: code, generated_at: Timex.now(), expires_at: Timex.add(Timex.now(), Timex.Duration.from_seconds(code_ttl)), diff --git a/priv/repo/migrations/20250317093922_create_code_exchange_table.exs b/priv/repo/migrations/20250317093922_create_token_exchange_table.exs similarity index 74% rename from priv/repo/migrations/20250317093922_create_code_exchange_table.exs rename to priv/repo/migrations/20250317093922_create_token_exchange_table.exs index 81463ebcf..636029ffd 100644 --- a/priv/repo/migrations/20250317093922_create_code_exchange_table.exs +++ b/priv/repo/migrations/20250317093922_create_token_exchange_table.exs @@ -1,8 +1,8 @@ -defmodule Cadet.Repo.Migrations.CreateCodeExchangeTable do +defmodule Cadet.Repo.Migrations.CreateTokenExchangeTable do use Ecto.Migration def change do - create table(:code_exchange) do + create table(:token_exchange) do add(:code, :string, null: false) add(:generated_at, :utc_datetime_usec, null: false) add(:expires_at, :utc_datetime_usec, null: false) From fd1f9af6726f5e2016dacd927d9925ab6e6dd114 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Tue, 25 Mar 2025 00:13:13 +0800 Subject: [PATCH 6/9] fix formatting --- lib/cadet/code_exchange.ex | 11 +++- lib/cadet_web/controllers/auth_controller.ex | 68 ++++++++++++-------- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/lib/cadet/code_exchange.ex b/lib/cadet/code_exchange.ex index 0cc45bbbe..a0ac5e0c6 100644 --- a/lib/cadet/code_exchange.ex +++ b/lib/cadet/code_exchange.ex @@ -23,7 +23,9 @@ defmodule Cadet.TokenExchange do def get_by_code(code) do case Repo.get_by(__MODULE__, code: code) do - nil -> {:error, "Not found"} + nil -> + {:error, "Not found"} + struct -> if Timex.before?(struct.expires_at, Timex.now()) do {:error, "Expired"} @@ -37,6 +39,7 @@ defmodule Cadet.TokenExchange do def delete_expired do now = Timex.now() + from(c in __MODULE__, where: c.expires_at < ^now) |> Repo.delete_all() end @@ -48,8 +51,10 @@ defmodule Cadet.TokenExchange do end def insert(attrs) do - changeset = %__MODULE__{} - |> changeset(attrs) + changeset = + %__MODULE__{} + |> changeset(attrs) + changeset |> Repo.insert() end diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index b7c2225ae..c515bf543 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -76,6 +76,7 @@ defmodule CadetWeb.AuthController do |> put_resp_header("location", URI.encode(client_redirect_url)) |> send_resp(302, "") |> halt() + conn -> conn end @@ -89,12 +90,12 @@ defmodule CadetWeb.AuthController do Exchanges a short-lived code for access and refresh tokens. """ def exchange( - conn, - %{ - "code" => code, - "provider" => provider - } - ) do + conn, + %{ + "code" => code, + "provider" => provider + } + ) do case TokenExchange.get_by_code(code) do {:error, _message} -> conn @@ -103,34 +104,44 @@ defmodule CadetWeb.AuthController do {:ok, struct} -> tokens = generate_tokens(struct.user) + {_provider, %{client_post_exchange_redirect_url: client_post_exchange_redirect_url}} = Application.get_env(:cadet, :identity_providers, %{})[provider] - conn - |> put_resp_header("location", URI.encode(client_post_exchange_redirect_url <> "?access_token=" <> tokens.access_token <> "&refresh_token=" <> tokens.refresh_token)) - |> send_resp(302, "") - |> halt() + conn + |> put_resp_header( + "location", + URI.encode( + client_post_exchange_redirect_url <> + "?access_token=" <> tokens.access_token <> "&refresh_token=" <> tokens.refresh_token + ) + ) + |> send_resp(302, "") + |> halt() end end @doc """ Alternate callback URL which redirect to VSCode via deeplinking. """ - def saml_redirect_vscode(conn, + def saml_redirect_vscode( + conn, %{ "provider" => provider } ) do code_ttl = 60 + case create_user(%{ - conn: conn, - provider_instance: provider, - code: nil, - client_id: nil, - redirect_uri: nil, - }) do + conn: conn, + provider_instance: provider, + code: nil, + client_id: nil, + redirect_uri: nil + }) do {:ok, user} -> code = generate_code() + TokenExchange.insert(%{ code: code, generated_at: Timex.now(), @@ -140,8 +151,12 @@ defmodule CadetWeb.AuthController do {_provider, %{vscode_redirect_url_prefix: vscode_redirect_url_prefix}} = Application.get_env(:cadet, :identity_providers, %{})[provider] + conn - |> put_resp_header("location", vscode_redirect_url_prefix <> "?provider=" <> provider <> "&code=" <> code) + |> put_resp_header( + "location", + vscode_redirect_url_prefix <> "?provider=" <> provider <> "&code=" <> code + ) |> send_resp(302, "") |> halt() @@ -152,15 +167,15 @@ defmodule CadetWeb.AuthController do @spec create_user(Provider.authorise_params()) :: {:ok, User.t()} | Plug.Conn.t() defp create_user( - params = %{ - conn: conn, - provider_instance: provider, - } - ) do + params = %{ + conn: conn, + provider_instance: provider + } + ) do with {:authorise, {:ok, %{token: token, username: username}}} <- {:authorise, Provider.authorise(params)}, {:signin, {:ok, user}} <- {:signin, Accounts.sign_in(username, token, provider)} do - {:ok, user} + {:ok, user} else {:authorise, {:error, :upstream, reason}} -> conn @@ -185,7 +200,6 @@ defmodule CadetWeb.AuthController do end end - @spec create_user_and_tokens(Provider.authorise_params()) :: {:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t() defp create_user_and_tokens(params) do @@ -250,8 +264,8 @@ defmodule CadetWeb.AuthController do @spec generate_code() :: String.t() defp generate_code() do :crypto.strong_rand_bytes(16) - |> Base.url_encode64(padding: false) - |> String.slice(0, 22) + |> Base.url_encode64(padding: false) + |> String.slice(0, 22) end swagger_path :create do From 331b25fca55db6d99ef49019a6ff2482ea481422 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Tue, 25 Mar 2025 00:49:12 +0800 Subject: [PATCH 7/9] fix credo issues --- lib/cadet/code_exchange.ex | 3 +-- lib/cadet_web/controllers/auth_controller.ex | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/cadet/code_exchange.ex b/lib/cadet/code_exchange.ex index a0ac5e0c6..0b58a0001 100644 --- a/lib/cadet/code_exchange.ex +++ b/lib/cadet/code_exchange.ex @@ -40,8 +40,7 @@ defmodule Cadet.TokenExchange do def delete_expired do now = Timex.now() - from(c in __MODULE__, where: c.expires_at < ^now) - |> Repo.delete_all() + Repo.delete_all(from(c in __MODULE__, where: c.expires_at < ^now)) end def changeset(struct, attrs) do diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index c515bf543..2e5173b18 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -5,8 +5,7 @@ defmodule CadetWeb.AuthController do use CadetWeb, :controller use PhoenixSwagger - alias Cadet.Accounts - alias Cadet.Accounts.User + alias Cadet.{Accounts, Accounts.User} alias Cadet.Auth.{Guardian, Provider} alias Cadet.TokenExchange @@ -261,9 +260,10 @@ defmodule CadetWeb.AuthController do %{access_token: access_token, refresh_token: refresh_token} end - @spec generate_code() :: String.t() - defp generate_code() do - :crypto.strong_rand_bytes(16) + @spec generate_code :: String.t() + defp generate_code do + 16 + |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) |> String.slice(0, 22) end From 600b29043cdb5dac0c88656081e440c821c74643 Mon Sep 17 00:00:00 2001 From: heyzec <61238538+heyzec@users.noreply.github.com> Date: Mon, 14 Apr 2025 20:52:11 +0800 Subject: [PATCH 8/9] make "code" PK in token_exchange table --- lib/cadet/code_exchange.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cadet/code_exchange.ex b/lib/cadet/code_exchange.ex index 0b58a0001..000f25c7f 100644 --- a/lib/cadet/code_exchange.ex +++ b/lib/cadet/code_exchange.ex @@ -9,8 +9,8 @@ defmodule Cadet.TokenExchange do alias Cadet.Repo alias Cadet.Accounts.User + @primary_key {:code, :string, []} schema "token_exchange" do - field(:code, :string) field(:generated_at, :utc_datetime_usec) field(:expires_at, :utc_datetime_usec) From 495aa506026d5f57f8af5b4efaff88ba95183547 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:54:52 +0800 Subject: [PATCH 9/9] Rename migration file to maintain total order --- ...e_table.exs => 20250417093922_create_token_exchange_table.exs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename priv/repo/migrations/{20250317093922_create_token_exchange_table.exs => 20250417093922_create_token_exchange_table.exs} (100%) diff --git a/priv/repo/migrations/20250317093922_create_token_exchange_table.exs b/priv/repo/migrations/20250417093922_create_token_exchange_table.exs similarity index 100% rename from priv/repo/migrations/20250317093922_create_token_exchange_table.exs rename to priv/repo/migrations/20250417093922_create_token_exchange_table.exs