From 9f5feedc7d7c9f94e298c8c9e8a8f6d2890213ea Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Thu, 8 Aug 2024 20:03:48 -0400 Subject: [PATCH] feat: Dynamic Router + compile time dependency fixes (#487) * improvement: create a new dynamic router, and avoid other compile time dependencies * chore: "fix" credo --- .credo.exs | 217 ++++++++++++++++++ .formatter.exs | 3 + config/test.exs | 8 + dev/dev_web/live/home_page_live.ex | 4 +- dev/dev_web/router.ex | 12 +- documentation/tutorials/get-started.md | 37 ++- .../components/helpers.ex | 26 +++ .../components/magic_link.ex | 13 +- .../components/oauth2.ex | 13 +- .../components/password.ex | 4 + .../components/password/register_form.ex | 21 +- .../components/password/reset_form.ex | 11 +- .../components/password/sign_in_form.ex | 23 +- .../components/reset.ex | 2 + .../components/reset/form.ex | 13 +- .../components/sign_in.ex | 4 + .../live_session.ex | 12 + lib/ash_authentication_phoenix/router.ex | 80 ++++++- .../router/console_formatter.ex | 103 +++++++++ .../router/on_live_view_mount.ex | 2 +- .../sign_in_live.ex | 2 + .../strategy_router.ex | 81 +++++++ .../ash_authentication.phoenix.routes.ex | 143 ++++++++++++ mix.exs | 3 +- mix.lock | 1 + test/router_test.exs | 26 +++ test/sign_in_test.exs | 18 ++ test/support/auth_controller.ex | 32 +++ test/support/phoenix.ex | 105 +++++++++ test/test_helper.exs | 2 + 30 files changed, 936 insertions(+), 85 deletions(-) create mode 100644 .credo.exs create mode 100644 lib/ash_authentication_phoenix/router/console_formatter.ex create mode 100644 lib/ash_authentication_phoenix/strategy_router.ex create mode 100644 lib/mix/tasks/ash_authentication.phoenix.routes.ex create mode 100644 test/router_test.exs create mode 100644 test/sign_in_test.exs create mode 100644 test/support/auth_controller.ex create mode 100644 test/support/phoenix.ex diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..8cab9d0 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,217 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 15]}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, [max_complexity: 14]}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.formatter.exs b/.formatter.exs index 65cee3b..07ae189 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -9,6 +9,9 @@ locals_without_parens = [ auth_routes_for: 1, auth_routes_for: 2, auth_routes_for: 3, + auth_routes: 1, + auth_routes: 2, + auth_routes: 3, reset_route: 1, set: 2, ash_authentication_live_session: 1, diff --git a/config/test.exs b/config/test.exs index f02a645..6d462e5 100644 --- a/config/test.exs +++ b/config/test.exs @@ -13,3 +13,11 @@ config :ash_authentication, AshAuthentication.Jwt, signing_secret: "Marty McFly in the past with the Delorean" config :phoenix, :json_library, Jason + +config :ash_authentication_phoenix, AshAuthentication.Phoenix.Test.Endpoint, + server: false, + debug_errors: true, + live_view: [signing_salt: "aaaaaaaa"], + secret_key_base: String.duplicate("a", 64) + +config :logger, level: :error diff --git a/dev/dev_web/live/home_page_live.ex b/dev/dev_web/live/home_page_live.ex index bbf948b..f2f2919 100644 --- a/dev/dev_web/live/home_page_live.ex +++ b/dev/dev_web/live/home_page_live.ex @@ -14,11 +14,11 @@ defmodule DevWeb.HomePageLive do <%= if @current_user do %>

Current user: <%= @current_user.email %>

- <.link navigate={Routes.auth_path(@socket, :sign_out)}>Sign out + <.link navigate="/sign-out">Sign out <% else %>

Please sign in

- <.link navigate={Routes.auth_path(@socket, :sign_in)}>Standard sign in + <.link navigate="/auth/sign-in">Standard sign in
<.link navigate={Routes.live_path(@socket, DevWeb.CustomSignInLive)}>Custom sign in <% end %> diff --git a/dev/dev_web/router.ex b/dev/dev_web/router.ex index fa24686..2c2e928 100644 --- a/dev/dev_web/router.ex +++ b/dev/dev_web/router.ex @@ -27,11 +27,17 @@ defmodule DevWeb.Router do end end - scope "/", DevWeb do + scope "/auth", DevWeb do pipe_through :browser - auth_routes_for(Example.Accounts.User, to: AuthController, path: "/auth") - sign_in_route(overrides: [DevWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default]) + sign_out_route(AuthController, "/sign-out") reset_route() + + sign_in_route( + path: "/sign-in", + overrides: [DevWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default] + ) + + auth_routes(AuthController, Example.Accounts.User) end end diff --git a/documentation/tutorials/get-started.md b/documentation/tutorials/get-started.md index 931434f..7a893ae 100644 --- a/documentation/tutorials/get-started.md +++ b/documentation/tutorials/get-started.md @@ -73,21 +73,6 @@ We can make our life easier and the code more consistent by adding formatters to ] ``` -### Phoenix 1.7 compatibility - -For Phoenix 1.7 we need to change `helpers: false` to `helpers: true` in the router section: - -**lib/example_web.ex** - -```elixir -defmodule ExampleWeb do -# ... - def router do - quote do - use Phoenix.Router, helpers: true # <-- Change this line - # ... -``` - ### Tailwind If you plan on using our default [Tailwind](https://tailwindcss.com/)-based @@ -120,25 +105,28 @@ module.exports = { plugins: [ require("@tailwindcss/forms"), plugin(({ addVariant }) => - addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"]) + addVariant("phx-no-feedback", [ + ".phx-no-feedback&", + ".phx-no-feedback &", + ]), ), plugin(({ addVariant }) => addVariant("phx-click-loading", [ ".phx-click-loading&", ".phx-click-loading &", - ]) + ]), ), plugin(({ addVariant }) => addVariant("phx-submit-loading", [ ".phx-submit-loading&", ".phx-submit-loading &", - ]) + ]), ), plugin(({ addVariant }) => addVariant("phx-change-loading", [ ".phx-change-loading&", ".phx-change-loading &", - ]) + ]), ), ], }; @@ -380,12 +368,17 @@ defmodule ExampleWeb.Router do get "/", PageController, :home # add these lines --> + + # Standard controller-backed routes + auth_routes AuthController, Example.Accounts.User, path: "/auth" + sign_out_route AuthController + + # Prebuilt LiveViews for signing in, registration, resetting, etc. # Leave out `register_path` and `reset_path` if you don't want to support # user registration and/or password resets respectively. - sign_in_route(register_path: "/register", reset_path: "/reset") - sign_out_route AuthController - auth_routes_for Example.Accounts.User, to: AuthController + sign_in_route(register_path: "/register", reset_path: "/reset", auth_routes_prefix: "/auth") reset_route [] + # <-- add these lines end diff --git a/lib/ash_authentication_phoenix/components/helpers.ex b/lib/ash_authentication_phoenix/components/helpers.ex index 0da23b9..e71f1b2 100644 --- a/lib/ash_authentication_phoenix/components/helpers.ex +++ b/lib/ash_authentication_phoenix/components/helpers.ex @@ -18,6 +18,32 @@ defmodule AshAuthentication.Phoenix.Components.Helpers do |> socket.endpoint.config() end + def auth_path(socket, subject_name, auth_routes_prefix, strategy, phase, params \\ %{}) do + if auth_routes_prefix do + strategy + |> AshAuthentication.Strategy.routes() + |> Enum.find(&(elem(&1, 1) == phase)) + |> elem(0) + |> URI.parse() + |> Map.put(:query, URI.encode_query(params)) + |> Map.update!(:path, &Path.join(auth_routes_prefix, &1)) + |> URI.to_string() + else + route_helpers = route_helpers(socket) + + if Code.ensure_loaded?(route_helpers) do + route_helpers.auth_path( + socket.endpoint, + {subject_name, AshAuthentication.Strategy.name(strategy), phase} + ) + else + raise """ + Must configure the `auth_routes_prefix`, or enable router helpers. + """ + end + end + end + @doc """ The LiveView `Socket` contains a refererence to the Phoenix router, and from there we can generate the name of the route helpers module. diff --git a/lib/ash_authentication_phoenix/components/magic_link.ex b/lib/ash_authentication_phoenix/components/magic_link.ex index a9a038d..029fe14 100644 --- a/lib/ash_authentication_phoenix/components/magic_link.ex +++ b/lib/ash_authentication_phoenix/components/magic_link.ex @@ -33,13 +33,14 @@ defmodule AshAuthentication.Phoenix.Components.MagicLink do alias AshAuthentication.{Info, Phoenix.Components.Password.Input, Strategy} alias AshPhoenix.Form alias Phoenix.LiveView.{Rendered, Socket} - import AshAuthentication.Phoenix.Components.Helpers, only: [route_helpers: 1] + import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5] import Slug @type props :: %{ required(:strategy) => AshAuthentication.Strategy.t(), optional(:overrides) => [module], - optional(:current_tenant) => String.t() + optional(:current_tenant) => String.t(), + optional(:auth_routes_prefix) => String.t() } @doc false @@ -58,6 +59,7 @@ defmodule AshAuthentication.Phoenix.Components.MagicLink do |> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end) |> assign_new(:label, fn -> nil end) |> assign_new(:current_tenant, fn -> nil end) + |> assign_new(:auth_routes_prefix, fn -> nil end) {:ok, socket} end @@ -79,12 +81,7 @@ defmodule AshAuthentication.Phoenix.Components.MagicLink do phx-submit="submit" phx-trigger-action={@trigger_action} phx-target={@myself} - action={ - route_helpers(@socket).auth_path( - @socket.endpoint, - {@subject_name, Strategy.name(@strategy), :request} - ) - } + action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :request)} method="POST" class={override_for(@overrides, :form_class)} > diff --git a/lib/ash_authentication_phoenix/components/oauth2.ex b/lib/ash_authentication_phoenix/components/oauth2.ex index 269eeec..06b0707 100644 --- a/lib/ash_authentication_phoenix/components/oauth2.ex +++ b/lib/ash_authentication_phoenix/components/oauth2.ex @@ -24,13 +24,14 @@ defmodule AshAuthentication.Phoenix.Components.OAuth2 do use AshAuthentication.Phoenix.Web, :live_component alias AshAuthentication.{Info, Strategy} alias Phoenix.LiveView.Rendered - import AshAuthentication.Phoenix.Components.Helpers, only: [route_helpers: 1] + import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5] import Phoenix.HTML, only: [raw: 1] import PhoenixHTMLHelpers.Form, only: [humanize: 1] @type props :: %{ required(:strategy) => AshAuthentication.Strategy.t(), - optional(:overrides) => [module] + optional(:overrides) => [module], + optional(:auth_routes_prefix) => String.t() } @doc false @@ -41,16 +42,12 @@ defmodule AshAuthentication.Phoenix.Components.OAuth2 do assigns |> assign(:subject_name, Info.authentication_subject_name!(assigns.strategy.resource)) |> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end) + |> assign_new(:auth_routes_prefix, fn -> nil end) ~H"""
<.icon icon={@strategy.icon} overrides={@overrides} /> diff --git a/lib/ash_authentication_phoenix/components/password.ex b/lib/ash_authentication_phoenix/components/password.ex index 587a578..1c0ef77 100644 --- a/lib/ash_authentication_phoenix/components/password.ex +++ b/lib/ash_authentication_phoenix/components/password.ex @@ -143,6 +143,7 @@ defmodule AshAuthentication.Phoenix.Components.Password do |> assign_new(:reset_path, fn -> nil end) |> assign_new(:register_path, fn -> nil end) |> assign_new(:current_tenant, fn -> nil end) + |> assign_new(:auth_routes_prefix, fn -> nil end) show = if assigns[:live_action] == :sign_in && is_nil(assigns[:reset_path]) && @@ -160,6 +161,7 @@ defmodule AshAuthentication.Phoenix.Components.Password do <.live_component :let={form} module={Password.SignInForm} + auth_routes_prefix={@auth_routes_prefix} id={@sign_in_id} strategy={@strategy} label={false} @@ -204,6 +206,7 @@ defmodule AshAuthentication.Phoenix.Components.Password do <.live_component :let={form} module={Password.RegisterForm} + auth_routes_prefix={@auth_routes_prefix} id={@register_id} strategy={@strategy} label={false} @@ -245,6 +248,7 @@ defmodule AshAuthentication.Phoenix.Components.Password do <.live_component :let={form} module={Password.ResetForm} + auth_routes_prefix={@auth_routes_prefix} id={@reset_id} strategy={@strategy} label={false} diff --git a/lib/ash_authentication_phoenix/components/password/register_form.ex b/lib/ash_authentication_phoenix/components/password/register_form.ex index b6a30f2..cc33d74 100644 --- a/lib/ash_authentication_phoenix/components/password/register_form.ex +++ b/lib/ash_authentication_phoenix/components/password/register_form.ex @@ -44,7 +44,8 @@ defmodule AshAuthentication.Phoenix.Components.Password.RegisterForm do required(:strategy) => AshAuthentication.Strategy.t(), optional(:overrides) => [module], optional(:live_action) => :sign_in | :register, - optional(:current_tenant) => String.t() + optional(:current_tenant) => String.t(), + optional(:auth_routes_prefix) => String.t() } @doc false @@ -79,6 +80,7 @@ defmodule AshAuthentication.Phoenix.Components.Password.RegisterForm do |> assign_new(:inner_block, fn -> nil end) |> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end) |> assign_new(:current_tenant, fn -> nil end) + |> assign_new(:auth_routes_prefix, fn -> nil end) {:ok, socket} end @@ -102,12 +104,7 @@ defmodule AshAuthentication.Phoenix.Components.Password.RegisterForm do phx-submit="submit" phx-trigger-action={@trigger_action} phx-target={@myself} - action={ - route_helpers(@socket).auth_path( - @socket.endpoint, - {@subject_name, Strategy.name(@strategy), :register} - ) - } + action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :register)} method="POST" class={override_for(@overrides, :form_class)} > @@ -166,10 +163,12 @@ defmodule AshAuthentication.Phoenix.Components.Password.RegisterForm do ) do {:ok, user} -> validate_sign_in_token_path = - route_helpers(socket).auth_path( - socket.endpoint, - {socket.assigns.subject_name, Strategy.name(socket.assigns.strategy), - :sign_in_with_token}, + auth_path( + socket, + socket.assigns.subject_name, + socket.assigns.auth_routes_prefix, + socket.assigns.strategy, + :sign_in_with_token, token: user.__metadata__.token ) diff --git a/lib/ash_authentication_phoenix/components/password/reset_form.ex b/lib/ash_authentication_phoenix/components/password/reset_form.ex index ca3ddf8..f18585d 100644 --- a/lib/ash_authentication_phoenix/components/password/reset_form.ex +++ b/lib/ash_authentication_phoenix/components/password/reset_form.ex @@ -45,7 +45,8 @@ defmodule AshAuthentication.Phoenix.Components.Password.ResetForm do required(:strategy) => AshAuthentication.Strategy.t(), optional(:label) => String.t() | false, optional(:overrides) => [module], - optional(:current_tenant) => String.t() + optional(:current_tenant) => String.t(), + optional(:auth_routes_prefix) => String.t() } @doc false @@ -63,6 +64,7 @@ defmodule AshAuthentication.Phoenix.Components.Password.ResetForm do |> assign_new(:inner_block, fn -> nil end) |> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end) |> assign_new(:current_tenant, fn -> nil end) + |> assign_new(:auth_routes_prefix, fn -> nil end) {:ok, socket} end @@ -85,12 +87,7 @@ defmodule AshAuthentication.Phoenix.Components.Password.ResetForm do phx-submit="submit" phx-change="change" phx-target={@myself} - action={ - route_helpers(@socket).auth_path( - @socket.endpoint, - {@subject_name, Strategy.name(@strategy), :reset_request} - ) - } + action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :reset_request)} method="POST" class={override_for(@overrides, :form_class)} > diff --git a/lib/ash_authentication_phoenix/components/password/sign_in_form.ex b/lib/ash_authentication_phoenix/components/password/sign_in_form.ex index ea6bd2e..f8599ca 100644 --- a/lib/ash_authentication_phoenix/components/password/sign_in_form.ex +++ b/lib/ash_authentication_phoenix/components/password/sign_in_form.ex @@ -37,7 +37,7 @@ defmodule AshAuthentication.Phoenix.Components.Password.SignInForm do alias Phoenix.LiveView.{Rendered, Socket} import AshAuthentication.Phoenix.Components.Helpers, - only: [route_helpers: 1] + only: [auth_path: 5, auth_path: 6] import PhoenixHTMLHelpers.Form import Slug @@ -46,7 +46,8 @@ defmodule AshAuthentication.Phoenix.Components.Password.SignInForm do required(:strategy) => AshAuthentication.Strategy.t(), optional(:label) => String.t() | false, optional(:overrides) => [module], - optional(:current_tenant) => String.t() + optional(:current_tenant) => String.t(), + optional(:auth_routes_prefix) => String.t() } @doc false @@ -77,6 +78,7 @@ defmodule AshAuthentication.Phoenix.Components.Password.SignInForm do |> assign_new(:inner_block, fn -> nil end) |> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end) |> assign_new(:current_tenant, fn -> nil end) + |> assign_new(:auth_routes_prefix, fn -> nil end) {:ok, socket} end @@ -98,12 +100,7 @@ defmodule AshAuthentication.Phoenix.Components.Password.SignInForm do phx-submit="submit" phx-trigger-action={@trigger_action} phx-target={@myself} - action={ - route_helpers(@socket).auth_path( - @socket.endpoint, - {@subject_name, Strategy.name(@strategy), :sign_in} - ) - } + action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :sign_in)} method="POST" class={override_for(@overrides, :form_class)} > @@ -158,10 +155,12 @@ defmodule AshAuthentication.Phoenix.Components.Password.SignInForm do ) do {:ok, user} -> validate_sign_in_token_path = - route_helpers(socket).auth_path( - socket.endpoint, - {socket.assigns.subject_name, Strategy.name(socket.assigns.strategy), - :sign_in_with_token}, + auth_path( + socket, + socket.assigns.subject_name, + socket.assigns.auth_routes_prefix, + socket.assigns.strategy, + :sign_in_with_token, token: user.__metadata__.token ) diff --git a/lib/ash_authentication_phoenix/components/reset.ex b/lib/ash_authentication_phoenix/components/reset.ex index 011ee04..626cb59 100644 --- a/lib/ash_authentication_phoenix/components/reset.ex +++ b/lib/ash_authentication_phoenix/components/reset.ex @@ -53,6 +53,7 @@ defmodule AshAuthentication.Phoenix.Components.Reset do socket |> assign(strategies: strategies) |> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end) + |> assign_new(:auth_routes_prefix, fn -> nil end) {:ok, socket} end @@ -71,6 +72,7 @@ defmodule AshAuthentication.Phoenix.Components.Reset do
<.live_component module={Components.Reset.Form} + auth_routes_prefix={@auth_routes_prefix} strategy={strategy} token={@token} id="reset-form" diff --git a/lib/ash_authentication_phoenix/components/reset/form.ex b/lib/ash_authentication_phoenix/components/reset/form.ex index 4a2a0ff..760d5fc 100644 --- a/lib/ash_authentication_phoenix/components/reset/form.ex +++ b/lib/ash_authentication_phoenix/components/reset/form.ex @@ -38,7 +38,7 @@ defmodule AshAuthentication.Phoenix.Components.Reset.Form do alias AshAuthentication.{Info, Phoenix.Components.Password.Input, Strategy} alias AshPhoenix.Form alias Phoenix.LiveView.{Rendered, Socket} - import AshAuthentication.Phoenix.Components.Helpers, only: [route_helpers: 1] + import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5] import PhoenixHTMLHelpers.Form import Slug @@ -47,7 +47,8 @@ defmodule AshAuthentication.Phoenix.Components.Reset.Form do required(:strategy) => AshAuthentication.Strategy.t(), required(:token) => String.t(), optional(:label) => String.t() | false, - optional(:overrides) => [module] + optional(:overrides) => [module], + optional(:auth_routes_prefix) => String.t() } @doc false @@ -82,6 +83,7 @@ defmodule AshAuthentication.Phoenix.Components.Reset.Form do ) |> assign_new(:label, fn -> humanize(resettable.password_reset_action_name) end) |> assign_new(:overrides, fn -> [AshAuthentication.Phoenix.Overrides.Default] end) + |> assign_new(:auth_routes_prefix, fn -> nil end) {:ok, socket} end @@ -103,12 +105,7 @@ defmodule AshAuthentication.Phoenix.Components.Reset.Form do phx-submit="submit" phx-trigger-action={@trigger_action} phx-target={@myself} - action={ - route_helpers(@socket).auth_path( - @socket.endpoint, - {@subject_name, Strategy.name(@strategy), :reset} - ) - } + action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :reset)} method="POST" class={override_for(@overrides, :form_class)} > diff --git a/lib/ash_authentication_phoenix/components/sign_in.ex b/lib/ash_authentication_phoenix/components/sign_in.ex index 3333088..43cbaca 100644 --- a/lib/ash_authentication_phoenix/components/sign_in.ex +++ b/lib/ash_authentication_phoenix/components/sign_in.ex @@ -81,6 +81,7 @@ defmodule AshAuthentication.Phoenix.Components.SignIn do |> assign_new(:reset_path, fn -> nil end) |> assign_new(:register_path, fn -> nil end) |> assign_new(:current_tenant, fn -> nil end) + |> assign_new(:auth_routes_prefix, fn -> nil end) {:ok, socket} end @@ -103,6 +104,7 @@ defmodule AshAuthentication.Phoenix.Components.SignIn do live_action={@live_action} strategy={strategy} path={@path} + auth_routes_prefix={@auth_routes_prefix} reset_path={@reset_path} register_path={@register_path} overrides={@overrides} @@ -125,6 +127,7 @@ defmodule AshAuthentication.Phoenix.Components.SignIn do component={component_for_strategy(strategy)} live_action={@live_action} strategy={strategy} + auth_routes_prefix={@auth_routes_prefix} path={@path} reset_path={@reset_path} register_path={@register_path} @@ -145,6 +148,7 @@ defmodule AshAuthentication.Phoenix.Components.SignIn do module={@component} id={strategy_id(@strategy)} strategy={@strategy} + auth_routes_prefix={@auth_routes_prefix} path={@path} reset_path={@reset_path} register_path={@register_path} diff --git a/lib/ash_authentication_phoenix/live_session.ex b/lib/ash_authentication_phoenix/live_session.ex index 2bf8cdb..1e8ff20 100644 --- a/lib/ash_authentication_phoenix/live_session.ex +++ b/lib/ash_authentication_phoenix/live_session.ex @@ -37,6 +37,13 @@ defmodule AshAuthentication.Phoenix.LiveSession do defmacro ash_authentication_live_session(session_name \\ :ash_authentication, opts \\ [], do: block ) do + opts = + if Macro.quoted_literal?(opts) do + Macro.prewalk(opts, &expand_alias(&1, __CALLER__)) + else + opts + end + quote do on_mount = [LiveSession] @@ -64,6 +71,11 @@ defmodule AshAuthentication.Phoenix.LiveSession do end end + defp expand_alias({:__aliases__, _, _} = alias, env), + do: Macro.expand(alias, %{env | function: {:mount, 3}}) + + defp expand_alias(other, _env), do: other + @doc """ Inspects the incoming session for any subject_name -> subject values and loads them into the socket's assigns. diff --git a/lib/ash_authentication_phoenix/router.ex b/lib/ash_authentication_phoenix/router.ex index 8824abc..6522443 100644 --- a/lib/ash_authentication_phoenix/router.ex +++ b/lib/ash_authentication_phoenix/router.ex @@ -119,6 +119,60 @@ defmodule AshAuthentication.Phoenix.Router do end end + @doc """ + Generates the routes needed for the various strategies for a given + AshAuthentication resource. + + This matches *all* routes at the provided `path`, which defaults to `/auth`. This means that + if you have any other routes that begin with `/auth`, you will need to make sure this + appears after them. + + ## Upgrading from `auth_routes_for/2` + + If you are using route helpers anywhere in your application, typically looks like `Routes.auth_path/3` + or `Helpers.auth_path/3` you will need to update them to use verified routes. To see what routes are + available to you, use `mix ash_authentication.phoenix.routes`. + + If you are using any of the components provided by `AshAuthenticationPhoenix`, you will need to supply + them with the `auth_routes_prefix` assign, set to the `path` you provide here (set to `/auth` by default). + + ## Options + + * `path` - the path to mount auth routes at. Defaults to `/auth`. If changed, you will also want + to change the `auth_routes_prefix` option in `sign_in_route` to match. + routes. + * `not_found_plug` - a plug to call if no route is found. By default, it renders a simple JSON + response with a 404 status code. + * `as` - the alias to use for the generated scope. Defaults to `:auth`. + """ + @spec auth_routes( + auth_controller :: module(), + Ash.Resource.t() | list(Ash.Resource.t()), + auth_route_options + ) :: Macro.t() + defmacro auth_routes(auth_controller, resource_or_resources, opts \\ []) when is_list(opts) do + resource_or_resources = + resource_or_resources + |> List.wrap() + |> Enum.map(&Macro.expand_once(&1, %{__CALLER__ | function: {:auth_routes, 2}})) + + quote location: :keep do + opts = unquote(opts) + path = Keyword.get(opts, :path, "/auth") + not_found_plug = Keyword.get(opts, :not_found_plug) + controller = Phoenix.Router.scoped_alias(__MODULE__, unquote(auth_controller)) + + scope "/", alias: false do + forward path, AshAuthentication.Phoenix.StrategyRouter, + path: Phoenix.Router.scoped_path(__MODULE__, path), + as: opts[:as] || :auth, + controller: controller, + not_found_plug: not_found_plug, + resources: List.wrap(unquote(resource_or_resources)) + end + end + end + @doc """ Generates a generic, white-label sign-in page using LiveView and the components in `AshAuthentication.Phoenix.Components`. @@ -128,12 +182,15 @@ defmodule AshAuthentication.Phoenix.Router do Available options are: * `path` the path under which to mount the sign-in live-view. Defaults to `"/sign-in"`. + * `auth_routes_prefix` if set, this will be used instead of route helpers when determining routes. + Allows disabling `helpers: true`. * `register_path` - the path under which to mount the password strategy's registration live-view. If not set, and registration is supported, registration will use a dynamic toggle and will not be routeable to. * `reset_path` - the path under which to mount the password strategy's password reset live-view. If not set, and password reset is supported, password reset will use a dynamic toggle and will not be routeable to. * `live_view` the name of the live view to render. Defaults to `AshAuthentication.Phoenix.SignInLive`. + * `auth_routes_prefix` the prefix to use for the auth routes. Defaults to `"/auth"`. * `as` which is passed to the generated `live` route. Defaults to `:auth`. * `otp_app` the otp app or apps to find authentication resources in. Pulls from the socket by default. * `overrides` specify any override modules for customisation. See @@ -160,6 +217,7 @@ defmodule AshAuthentication.Phoenix.Router do {on_mount, opts} = Keyword.pop(opts, :on_mount) {reset_path, opts} = Keyword.pop(opts, :reset_path) {register_path, opts} = Keyword.pop(opts, :register_path) + {auth_routes_prefix, opts} = Keyword.pop(opts, :auth_routes_prefix) {overrides, opts} = Keyword.pop(opts, :overrides, [AshAuthentication.Phoenix.Overrides.Default]) @@ -174,7 +232,7 @@ defmodule AshAuthentication.Phoenix.Router do on_mount = [ - AshAuthenticationPhoenix.Router.OnLiveViewMount, + AshAuthentication.Phoenix.Router.OnLiveViewMount, AshAuthentication.Phoenix.LiveSession | unquote(on_mount || []) ] |> Enum.uniq_by(fn @@ -182,12 +240,30 @@ defmodule AshAuthentication.Phoenix.Router do mod -> mod end) + register_path = + case unquote(register_path) do + nil -> nil + {:unscoped, value} -> value + value -> Phoenix.Router.scoped_path(__MODULE__, value) + end + + reset_path = + case unquote(reset_path) do + nil -> nil + {:unscoped, value} -> value + value -> Phoenix.Router.scoped_path(__MODULE__, value) + end + + unquote(register_path) && + Phoenix.Router.scoped_path(__MODULE__, unquote(register_path)) + live_session_opts = [ session: {AshAuthentication.Phoenix.Router, :generate_session, [ %{ "overrides" => unquote(overrides), + "auth_routes_prefix" => unquote(auth_routes_prefix), "otp_app" => unquote(otp_app), "path" => Phoenix.Router.scoped_path(__MODULE__, unquote(path)), "reset_path" => @@ -294,7 +370,7 @@ defmodule AshAuthentication.Phoenix.Router do on_mount = [ - AshAuthenticationPhoenix.Router.OnLiveViewMount, + AshAuthentication.Phoenix.Router.OnLiveViewMount, AshAuthentication.Phoenix.LiveSession | unquote(on_mount || []) ] |> Enum.uniq_by(fn diff --git a/lib/ash_authentication_phoenix/router/console_formatter.ex b/lib/ash_authentication_phoenix/router/console_formatter.ex new file mode 100644 index 0000000..f890688 --- /dev/null +++ b/lib/ash_authentication_phoenix/router/console_formatter.ex @@ -0,0 +1,103 @@ +defmodule AshAuthentication.Phoenix.Router.ConsoleFormatter do + @moduledoc false + + @doc """ + Format the routes for printing. + + This was copied from Phoenix and adapted for our case. + """ + def format(router, endpoint \\ nil) do + routes = Phoenix.Router.routes(router) + column_widths = calculate_column_widths(router, routes, endpoint) + + routes + |> Enum.map(&format_route(&1, router, column_widths)) + |> Enum.filter(& &1) + |> Enum.join("") + end + + defp calculate_column_widths(router, routes, endpoint) do + sockets = (endpoint && endpoint.__sockets__()) || [] + + widths = + Enum.reduce(routes, {0, 0, 0}, fn route, acc -> + %{verb: verb, path: path, helper: helper} = route + verb = verb_name(verb) + {verb_len, path_len, route_name_len} = acc + route_name = route_name(router, helper) + + {max(verb_len, String.length(verb)), max(path_len, String.length(path)), + max(route_name_len, String.length(route_name))} + end) + + Enum.reduce(sockets, widths, fn {path, _mod, _opts}, acc -> + {verb_len, path_len, route_name_len} = acc + prefix = if router.__helpers__(), do: "websocket", else: "" + + {verb_len, max(path_len, String.length(path <> "/websocket")), + max(route_name_len, String.length(prefix))} + end) + end + + defp format_route( + %{ + verb: :*, + plug: AshAuthentication.Phoenix.StrategyRouter, + path: plug_path, + plug_opts: plug_opts, + helper: helper + }, + router, + column_widths + ) do + plug_opts[:resources] + |> List.wrap() + |> resource_routes() + |> Enum.map(fn {strategy, path, phase} -> + verb = verb_name(AshAuthentication.Strategy.method_for_phase(strategy, phase)) + route_name = route_name(router, helper) + {verb_len, path_len, route_name_len} = column_widths + log_module = strategy.__struct__ + path = Path.join(plug_path || "/", path) + + String.pad_leading(route_name, route_name_len) <> + " " <> + String.pad_trailing(verb, verb_len) <> + " " <> + String.pad_trailing(path, path_len) <> + " " <> + "#{inspect(log_module)}\n" + end) + end + + defp format_route(_, _, _), do: nil + + defp resource_routes(resources) do + Stream.flat_map(resources, fn resource -> + resource + |> AshAuthentication.Info.authentication_add_ons() + |> Enum.concat(AshAuthentication.Info.authentication_strategies(resource)) + |> strategy_routes() + end) + end + + defp strategy_routes(strategies) do + Stream.flat_map(strategies, fn strategy -> + strategy + |> AshAuthentication.Strategy.routes() + |> Stream.map(fn {path, phase} -> {strategy, path, phase} end) + end) + end + + defp route_name(_router, nil), do: "" + + defp route_name(router, name) do + if router.__helpers__() do + name <> "_path" + else + "" + end + end + + defp verb_name(verb), do: verb |> to_string() |> String.upcase() +end diff --git a/lib/ash_authentication_phoenix/router/on_live_view_mount.ex b/lib/ash_authentication_phoenix/router/on_live_view_mount.ex index 7ea913f..10be432 100644 --- a/lib/ash_authentication_phoenix/router/on_live_view_mount.ex +++ b/lib/ash_authentication_phoenix/router/on_live_view_mount.ex @@ -1,4 +1,4 @@ -defmodule AshAuthenticationPhoenix.Router.OnLiveViewMount do +defmodule AshAuthentication.Phoenix.Router.OnLiveViewMount do @moduledoc false import Phoenix.Component diff --git a/lib/ash_authentication_phoenix/sign_in_live.ex b/lib/ash_authentication_phoenix/sign_in_live.ex index 28dd1ec..1a4c865 100644 --- a/lib/ash_authentication_phoenix/sign_in_live.ex +++ b/lib/ash_authentication_phoenix/sign_in_live.ex @@ -37,6 +37,7 @@ defmodule AshAuthentication.Phoenix.SignInLive do |> assign(:reset_path, session["reset_path"]) |> assign(:register_path, session["register_path"]) |> assign(:current_tenant, session["tenant"]) + |> assign(:auth_routes_prefix, session["auth_routes_prefix"]) {:ok, socket} end @@ -57,6 +58,7 @@ defmodule AshAuthentication.Phoenix.SignInLive do otp_app={@otp_app} live_action={@live_action} path={@path} + auth_routes_prefix={@auth_routes_prefix} reset_path={@reset_path} register_path={@register_path} id={override_for(@overrides, :sign_in_id, "sign-in")} diff --git a/lib/ash_authentication_phoenix/strategy_router.ex b/lib/ash_authentication_phoenix/strategy_router.ex new file mode 100644 index 0000000..3af0676 --- /dev/null +++ b/lib/ash_authentication_phoenix/strategy_router.ex @@ -0,0 +1,81 @@ +defmodule AshAuthentication.Phoenix.StrategyRouter do + @moduledoc false + @behaviour Plug + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, opts) do + opts + |> routes() + |> Enum.reduce_while( + {:not_found, conn}, + fn {resource, strategy, path, phase}, {:not_found, conn} -> + strategy_path_split = Path.split(String.trim_leading(path, "/")) + + if paths_match?(strategy_path_split, conn.path_info) do + {:halt, {:found, resource, strategy, path, phase}} + else + {:cont, {:not_found, conn}} + end + end + ) + |> case do + {:found, resource, strategy, _path, phase} -> + subject_name = AshAuthentication.Info.authentication_subject_name!(resource) + + conn + |> Plug.Conn.put_private(:strategy, strategy) + |> opts[:controller].call( + {subject_name, AshAuthentication.Strategy.name(strategy), phase} + ) + + {:not_found, conn} -> + not_found(conn, opts) + end + end + + defp not_found(conn, opts) do + if plug = opts[:not_found_plug] do + plug.call(conn, opts) + else + conn + |> Plug.Conn.put_status(:not_found) + |> Phoenix.Controller.json(%{error: "Not Found"}) + |> Plug.Conn.halt() + end + end + + defp paths_match?([], []), do: true + + defp paths_match?([":" <> _ | strategy_rest], [_ | actual_rest]) do + paths_match?(strategy_rest, actual_rest) + end + + defp paths_match?([":*"], _), do: true + + defp paths_match?([item | strategy_rest], [item | actual_rest]) do + paths_match?(strategy_rest, actual_rest) + end + + defp paths_match?(_, _), do: false + + defp routes(opts) do + opts[:resources] + |> Stream.flat_map(fn resource -> + resource + |> AshAuthentication.Info.authentication_add_ons() + |> Enum.concat(AshAuthentication.Info.authentication_strategies(resource)) + |> Stream.flat_map(&strategy_routes(resource, &1)) + end) + end + + defp strategy_routes(resource, strategy) do + strategy + |> AshAuthentication.Strategy.routes() + |> Stream.map(fn {path, phase} -> + {resource, strategy, path, phase} + end) + end +end diff --git a/lib/mix/tasks/ash_authentication.phoenix.routes.ex b/lib/mix/tasks/ash_authentication.phoenix.routes.ex new file mode 100644 index 0000000..8b6b8b3 --- /dev/null +++ b/lib/mix/tasks/ash_authentication.phoenix.routes.ex @@ -0,0 +1,143 @@ +defmodule Mix.Tasks.AshAuthentication.Phoenix.Routes do + use Mix.Task + + alias AshAuthentication.Phoenix.Router.ConsoleFormatter + + @moduledoc """ + Prints all routes pertaining to AshAuthenticationPhoenix for the default or a given router. + + This task can be called directly, accepting the same options as `mix phx.routes`, except for `--info`. + + Alternatively, you can modify your aliases task to run them back to back it. + + ```elixir + aliases: ["phx.routes": ["do", "phx.routes,", "ash_authentication.phx.routes"]] + ``` + """ + + @shortdoc "Prints all routes generated by AshAuthentication Phoenix" + @impl true + def run(args, base \\ Mix.Phoenix.base()) do + Mix.Task.run("compile", args) + Mix.Task.reenable("ash_authentication.phoenix.routes") + + {opts, args, _} = + OptionParser.parse(args, switches: [endpoint: :string, router: :string, info: :string]) + + {router_mod, endpoint_mod} = + case args do + [passed_router] -> {router(passed_router, base), opts[:endpoint]} + [] -> {router(opts[:router], base), endpoint(opts[:endpoint], base)} + end + + case Keyword.fetch(opts, :info) do + {:ok, url} -> + get_url_info(url, {router_mod, opts}) + + :error -> + router_mod + |> ConsoleFormatter.format(endpoint_mod) + |> Mix.shell().info() + end + end + + defp router(nil, base) do + if Mix.Project.umbrella?() do + Mix.raise(""" + umbrella applications require an explicit router to be given to phx.routes, for example: + + $ mix ash_authentication.phoenix.routes MyAppWeb.Router + + An alias can be added to mix.exs aliases to automate this: + + "ash_authentication.phoenix.routes": "ash_authentication.phoenix.routes MyAppWeb.Router" + + """) + end + + web_router = web_mod(base, "Router") + old_router = app_mod(base, "Router") + + loaded(web_router) || loaded(old_router) || + Mix.raise(""" + no router found at #{inspect(web_router)} or #{inspect(old_router)}. + An explicit router module may be given to ash_authentication.phoenix.routes, for example: + + $ mix ash_authentication.phoenix.routes MyAppWeb.Router + + An alias can be added to mix.exs aliases to automate this: + + "ash_authentication.phoenix.routes": "ash_authentication.phoenix.routes MyAppWeb.Router" + + """) + end + + defp router(router_name, _base) do + arg_router = Module.concat([router_name]) + loaded(arg_router) || Mix.raise("the provided router, #{inspect(arg_router)}, does not exist") + end + + defp endpoint(nil, base) do + loaded(web_mod(base, "Endpoint")) + end + + defp endpoint(module, _base) do + loaded(Module.concat([module])) + end + + defp app_mod(base, name), do: Module.concat([base, name]) + + defp web_mod(base, name), do: Module.concat(["#{base}Web", name]) + + defp loaded(module) do + if Code.ensure_loaded?(module), do: module + end + + def get_url_info(url, {router_mod, _opts}) do + %{path: path} = URI.parse(url) + + meta = Phoenix.Router.route_info(router_mod, "GET", path, "") + %{plug: plug, plug_opts: plug_opts} = meta + + {module, func_name} = + if log_mod = meta[:log_module] do + {log_mod, meta[:log_function]} + else + {plug, plug_opts} + end + + Mix.shell().info("Module: #{inspect(module)}") + if func_name, do: Mix.shell().info("Function: #{inspect(func_name)}") + + file_path = get_file_path(module) + + if line = get_line_number(module, func_name) do + Mix.shell().info("#{file_path}:#{line}") + else + Mix.shell().info("#{file_path}") + end + end + + defp get_file_path(module_name) do + [compile_infos] = Keyword.get_values(module_name.module_info(), :compile) + [source] = Keyword.get_values(compile_infos, :source) + source + end + + defp get_line_number(_, nil), do: nil + + defp get_line_number(module, function_name) do + {_, _, _, _, _, _, functions_list} = Code.fetch_docs(module) + + function_infos = + functions_list + |> Enum.find(fn {{type, name, _}, _, _, _, _} -> + type == :function and name == function_name + end) + + case function_infos do + {_, line, _, _, _} -> line + nil -> nil + end + end +end diff --git a/mix.exs b/mix.exs index 78c730a..f501989 100644 --- a/mix.exs +++ b/mix.exs @@ -132,7 +132,8 @@ defmodule AshAuthentication.Phoenix.MixProject do {:mimic, "~> 1.7", only: [:dev, :test]}, {:mix_audit, "~> 2.1", only: [:dev, :test]}, {:plug_cowboy, "~> 2.5", only: [:dev, :test]}, - {:sobelow, "~> 0.13", only: [:dev, :test]} + {:sobelow, "~> 0.13", only: [:dev, :test]}, + {:floki, ">= 0.30.0", only: :test} ] end diff --git a/mix.lock b/mix.lock index d0a433d..cda0e86 100644 --- a/mix.lock +++ b/mix.lock @@ -25,6 +25,7 @@ "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, + "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"}, "glob_ex": {:hex, :glob_ex, "0.1.8", "f7ef872877ca2ae7a792ab1f9ff73d9c16bf46ecb028603a8a3c5283016adc07", [:mix], [], "hexpm", "9e39d01729419a60a937c9260a43981440c43aa4cadd1fa6672fecd58241c464"}, diff --git a/test/router_test.exs b/test/router_test.exs new file mode 100644 index 0000000..eeb073e --- /dev/null +++ b/test/router_test.exs @@ -0,0 +1,26 @@ +defmodule AshAuthentication.Phoenix.RouterTest do + @moduledoc false + use ExUnit.Case + + test "sign_in_routes adds a route according to its scope" do + route = + AshAuthentication.Phoenix.Test.Router + |> Phoenix.Router.routes() + |> Enum.find(&(&1.path == "/sign-in")) + + {_, _, _, %{extra: %{session: session}}} = route.metadata.phoenix_live_view + + assert session == + {AshAuthentication.Phoenix.Router, :generate_session, + [ + %{ + "auth_routes_prefix" => "/auth", + "otp_app" => nil, + "overrides" => [AshAuthentication.Phoenix.Overrides.Default], + "path" => "/sign-in", + "register_path" => "/register", + "reset_path" => "/reset" + } + ]} + end +end diff --git a/test/sign_in_test.exs b/test/sign_in_test.exs new file mode 100644 index 0000000..7f7207e --- /dev/null +++ b/test/sign_in_test.exs @@ -0,0 +1,18 @@ +defmodule AshAuthentication.Phoenix.SignInTest do + @moduledoc false + use ExUnit.Case + import Phoenix.ConnTest + import Phoenix.LiveViewTest + @endpoint AshAuthentication.Phoenix.Test.Endpoint + + setup do + # foo + {:ok, conn: Phoenix.ConnTest.build_conn()} + end + + test "sign_in routes liveview renders the sign in page", %{conn: conn} do + conn = get(conn, "/sign-in") + assert {:ok, _view, html} = live(conn) + assert html =~ "Sign in" + end +end diff --git a/test/support/auth_controller.ex b/test/support/auth_controller.ex new file mode 100644 index 0000000..18c138f --- /dev/null +++ b/test/support/auth_controller.ex @@ -0,0 +1,32 @@ +defmodule AshAuthentication.Phoenix.Test.AuthController do + @moduledoc false + + use DevWeb, :controller + use AshAuthentication.Phoenix.Controller + + @doc false + @impl true + def success(conn, _activity, user, _token) do + conn + |> store_in_session(user) + |> assign(:current_user, user) + |> put_status(200) + |> render("success.html") + end + + @doc false + @impl true + def failure(conn, _activity, reason) do + conn + |> assign(:failure_reason, reason) + |> redirect(to: "/sign-in") + end + + @doc false + @impl true + def sign_out(conn, _params) do + conn + |> clear_session() + |> render("sign_out.html") + end +end diff --git a/test/support/phoenix.ex b/test/support/phoenix.ex new file mode 100644 index 0000000..5dcdd8c --- /dev/null +++ b/test/support/phoenix.ex @@ -0,0 +1,105 @@ +defmodule AshAuthentication.Phoenix.Test.ErrorView do + @moduledoc false + @doc false + def render(template, _), do: Phoenix.Controller.status_message_from_template(template) +end + +defmodule AshAuthentication.Phoenix.Test.HomeLive do + @moduledoc false + use Phoenix.LiveView, layout: {__MODULE__, :live} + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + defp phx_vsn, do: Application.spec(:phoenix, :vsn) + defp lv_vsn, do: Application.spec(:phoenix_live_view, :vsn) + + @doc false + def render("live.html", assigns) do + ~H""" + + + + + <%= @inner_content %> + """ + end + + @impl true + def render(assigns) do + ~H""" + <%= @count %> + + + """ + end + + @impl true + def handle_event("inc", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_event("dec", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count - 1)} + end +end + +defmodule AshAuthentication.Phoenix.Test.Router do + @moduledoc false + use Phoenix.Router + import Phoenix.LiveView.Router + use AshAuthentication.Phoenix.Router + + pipeline :browser do + plug(:accepts, ["html"]) + plug :fetch_session + plug :fetch_live_flash + plug :protect_from_forgery + plug :put_secure_browser_headers + + plug :load_from_session + end + + scope "/", AshAuthentication.Phoenix.Test do + pipe_through :browser + + sign_in_route register_path: "/register", reset_path: "/reset", auth_routes_prefix: "/auth" + sign_out_route AuthController + reset_route [] + auth_routes AuthController, Example.Accounts.User, path: "/auth" + end + + scope "/", AshAuthentication.Phoenix.Test do + pipe_through(:browser) + + live("/", HomeLive, :index) + end +end + +defmodule AshAuthentication.Phoenix.Test.Endpoint do + @moduledoc false + use Phoenix.Endpoint, otp_app: :ash_authentication_phoenix + + @session_options [ + store: :cookie, + key: "_webuilt_key", + signing_salt: "c911QDW5", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + plug Plug.Session, @session_options + plug(AshAuthentication.Phoenix.Test.Router) +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..e84a7ca 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,3 @@ ExUnit.start() + +AshAuthentication.Phoenix.Test.Endpoint.start_link()