defmodule Mix.Tasks.Skua.Gen.Auth do
@shortdoc "Scaffold authentication on top of phx.gen.auth (magic-link / OTP / password+OTP / custom)"
@moduledoc """
Generates an authentication system by running `mix phx.gen.auth` and then
applying Skua's flow-specific changes. The generated code is yours to edit.
mix skua.gen.auth --auth magic_link # default — Phoenix's magic-link flow
mix skua.gen.auth --auth otp # 6-digit one-time-code login
mix skua.gen.auth --auth password_otp # password + OTP, both on the login screen
mix skua.gen.auth --auth custom # stock phx.gen.auth, nothing extra
Flags:
* `--otp-length` (otp/password_otp) — code length, default 6 (8 recommended
for high-value apps).
* `--otp-expiry` — code validity in minutes, default 10.
Idempotent: re-running detects an existing `Accounts` context and skips the
base generation.
"""
use Mix.Task
@flows ~w(magic_link otp password_otp custom)
@switches [auth: :string, otp_length: :integer, otp_expiry: :integer, yes: :boolean]
@impl Mix.Task
def run(args) do
{opts, _, _} = OptionParser.parse(args, switches: @switches)
flow = opts[:auth] || "magic_link"
cond do
flow not in @flows ->
Mix.raise("Unknown --auth #{inspect(flow)}. Use one of: #{Enum.join(@flows, ", ")}.")
Mix.Project.umbrella?() ->
Mix.raise("Run `mix skua.gen.auth` from inside your *_web app, not the umbrella root.")
true ->
Mix.shell().info([:cyan, "\n skua.gen.auth — #{flow}\n", :reset])
ensure_phx_gen_auth!()
apply_flow(flow, opts)
end
end
# Run phx.gen.auth once (idempotent: skip if the Accounts context already exists).
defp ensure_phx_gen_auth! do
app = Mix.Project.config()[:app]
if File.exists?(Path.join(["lib", "#{app}", "accounts.ex"])) do
Mix.shell().info([:faint, " • Accounts context exists — skipping phx.gen.auth", :reset])
else
run_phx_gen_auth()
end
end
defp run_phx_gen_auth do
Mix.shell().info(" running `mix phx.gen.auth Accounts User users`…")
previous = Mix.shell()
Mix.shell(Skua.Auth.YesShell)
try do
Mix.Task.run("phx.gen.auth", ~w(Accounts User users))
after
Mix.shell(previous)
end
end
# --- per-flow ------------------------------------------------------------
alias Skua.Auth.Patches, as: AP
defp apply_flow("custom", _opts) do
print_results(dev_setup_steps(app_context()))
done("""
Stock phx.gen.auth generated — your closest-to-Phoenix starting point.
Add Sign in with Google / other providers yourself from here.
In dev, the DB is created + migrated automatically on `mix phx.server`
(skua_auto_setup in config/dev.exs); test/prod are unaffected.
Next: mix deps.get && mix ecto.migrate
""")
end
defp apply_flow("magic_link", _opts) do
# Stock phx.gen.auth IS the magic-link flow. We only add a production email
# provider (Resend) so the magic links actually deliver off-box, while dev
# and test keep Swoosh's local mailbox untouched.
ctx = app_context()
print_results(mailer_steps(ctx) ++ dev_setup_steps(ctx))
done("""
Magic-link auth generated (Phoenix default) with a Resend production mailer.
Dev and test still use Swoosh's local mailbox; prod reads RESEND_API_KEY
(see .env.example) at runtime, so dev/test boot with no key required.
Next: mix deps.get && mix ecto.migrate
""")
end
defp apply_flow("otp", opts), do: gen_otp(opts, :otp)
defp apply_flow("password_otp", opts), do: gen_otp(opts, :password_otp)
# Resolve the app's module/path naming once.
defp app_context do
app = to_string(Mix.Project.config()[:app])
app_mod = Macro.camelize(app)
%{
app: app,
app_mod: app_mod,
web: app_mod <> "Web",
accounts_mod: app_mod <> ".Accounts",
repo: app_mod <> ".Repo",
app_atom: ":" <> app,
acc: Path.join("lib", app),
web_dir: Path.join("lib", "#{app}_web"),
live: Path.join(["lib", "#{app}_web", "live", "user_live"]),
test_live: Path.join(["test", "#{app}_web", "live", "user_live"])
}
end
# Wire a production email provider (Resend) without touching dev/test, which
# keep Swoosh's local mailbox. Shared by every flow that delivers login email
# (magic_link sends the link; otp/password_otp send the code). Idempotent.
defp mailer_steps(ctx) do
[
file_patch("config/runtime.exs", &AP.mailer_resend_runtime(&1, ctx.app_atom, ctx.app_mod)),
write_env_example(".env.example")
]
end
# Dev-only automatic DB setup: patch application.ex to create + migrate the DB
# on boot, gated on a flag that only config/dev.exs sets — a guaranteed no-op
# in test/prod. A context-level concern, so every flow (otp, password_otp,
# magic_link, custom) gets it. Idempotent.
defp dev_setup_steps(ctx) do
[
file_patch(
Path.join(ctx.acc, "application.ex"),
&AP.application_auto_setup(&1, ctx.app_atom, ctx.repo)
),
file_patch("config/dev.exs", &AP.config_dev_auto_setup(&1, ctx.app_atom))
]
end
defp gen_otp(opts, variant) do
digits = opts[:otp_length] || 6
expiry = opts[:otp_expiry] || 10
ctx = app_context()
context = [
file_patch(
Path.join(ctx.acc, "accounts/user_token.ex"),
&AP.user_token_otp(&1, digits, expiry)
),
file_patch(Path.join(ctx.acc, "accounts.ex"), &AP.accounts_otp/1),
file_patch(Path.join(ctx.acc, "accounts/user_notifier.ex"), &AP.notifier_otp(&1, expiry))
]
web_layer = web_layer(variant, ctx)
print_results(context ++ web_layer ++ mailer_steps(ctx) ++ dev_setup_steps(ctx))
done("""
#{variant_label(variant)} wired (#{digits}-digit, #{expiry}-min) — request a code → verify it,
single-use + constant-time + session-fixation guard + Hammer rate limiting
(request + verify, tunable in config/config.exs). Codes deliver via Swoosh's
local mailbox in dev; prod uses Resend (set RESEND_API_KEY — see .env.example).
Next: mix deps.get && mix ecto.migrate, then visit /users/log-in.
""")
end
defp variant_label(:otp), do: "OTP login"
defp variant_label(:password_otp), do: "Password + OTP login"
# The OTP request/verify routes, the verify screen, the secure session-controller
# clause, the Hammer rate limiter, and the obsolete-magic-link-route cleanup are
# identical for both variants — only the login screen and the stale-test handling
# differ. The shared rate-limiter pieces are extracted into rate_limit_steps/1.
defp web_layer(:otp, ctx) do
[
write_file(
Path.join(ctx.live, "login.ex"),
AP.login_otp_view(ctx.web, ctx.app_mod, ctx.app_atom),
:overwrite
)
| otp_verify_steps(ctx) ++
rate_limit_steps(ctx) ++ otp_stale_tests(ctx) ++ submit_button_steps(ctx)
]
end
defp web_layer(:password_otp, ctx) do
[
write_file(
Path.join(ctx.live, "login.ex"),
AP.login_password_otp_view(ctx.web, ctx.app_mod, ctx.app_atom),
:overwrite
)
| otp_verify_steps(ctx) ++
rate_limit_steps(ctx) ++ password_otp_stale_tests(ctx) ++ submit_button_steps(ctx)
]
end
# Skua's <.button> defaults to type="button" (never submits a form by
# accident); the auth views rely on the button submitting. Add type="submit"
# to the typeless <.button>s in every generated auth LiveView. Idempotent.
defp submit_button_steps(ctx) do
ctx.live
|> Path.join("*.ex")
|> Path.wildcard()
|> Enum.map(fn path -> file_patch(path, &AP.submit_buttons/1) end)
end
# Verify route + screen + secure controller clause + registration → verify.
# Shared by both variants (registration stays email-only; the magic-link route
# it used to link to is removed by router_otp, so it now issues an OTP instead).
defp otp_verify_steps(ctx) do
[
write_file(
Path.join(ctx.live, "otp_verify.ex"),
AP.otp_verify_view(ctx.web, ctx.app_mod, ctx.app_atom),
:create
),
file_patch(Path.join(ctx.live, "registration.ex"), &AP.registration_otp/1),
file_patch(Path.join(ctx.web_dir, "router.ex"), &AP.router_otp/1),
file_patch(
Path.join([ctx.web_dir, "controllers", "user_session_controller.ex"]),
&AP.session_controller_otp/1
),
rm_file(Path.join(ctx.live, "confirmation.ex"))
]
end
# Hammer rate limiter: module + dep + supervision + config. Both variants need
# it — the verify-side limit is the brute-force gate on the OTP code space.
defp rate_limit_steps(ctx) do
[
write_file(
Path.join(ctx.acc, "accounts/rate_limit.ex"),
AP.rate_limit_view(ctx.app_atom, ctx.accounts_mod),
:create
),
file_patch("mix.exs", &AP.mix_exs_hammer/1),
file_patch(
Path.join(ctx.acc, "application.ex"),
&AP.application_supervision(&1, ctx.accounts_mod)
),
file_patch("config/config.exs", &AP.config_rate_limit(&1, ctx.app_atom, ctx.accounts_mod))
]
end
# otp: registration is passwordless and confirmation is gone, so all three
# stock magic-link LiveView tests are obsolete; replace with a context OTP test.
defp otp_stale_tests(ctx) do
[
rm_file(Path.join(ctx.test_live, "confirmation_test.exs")),
rm_file(Path.join(ctx.test_live, "login_test.exs")),
rm_file(Path.join(ctx.test_live, "registration_test.exs")),
write_file(
Path.join(["test", ctx.app, "accounts_otp_test.exs"]),
AP.otp_test_view(ctx.app_mod),
:create
)
]
end
# password_otp: the confirmation screen + its test are gone and the login screen
# is rewritten (no magic-link form), so swap the stock login test for one that
# covers BOTH the kept password path and the new OTP-request path. The stock
# registration test stays valid (it now redirects to the verify screen) and the
# password/session/accounts tests are untouched, so we keep them. We still add
# the context OTP test to lock down the secure single-use path.
defp password_otp_stale_tests(ctx) do
[
rm_file(Path.join(ctx.test_live, "confirmation_test.exs")),
rm_file(Path.join(ctx.test_live, "registration_test.exs")),
write_file(
Path.join(ctx.test_live, "login_test.exs"),
AP.login_password_otp_test_view(ctx.web, ctx.app_mod),
:overwrite
),
write_file(
Path.join(["test", ctx.app, "accounts_otp_test.exs"]),
AP.otp_test_view(ctx.app_mod),
:create
)
]
end
# --- helpers --------------------------------------------------------------
defp file_patch(path, transform) do
cond do
not File.exists?(path) ->
{:manual, path, "not found — apply this step manually"}
true ->
case transform.(File.read!(path)) do
:skip -> {:skip, path}
{:ok, new} -> File.write!(path, new) && {:ok, path}
{:manual, msg} -> {:manual, path, msg}
end
end
end
# mode :create skips if the file exists; :overwrite always writes.
defp write_file(path, contents, mode) do
if mode == :create and File.exists?(path) do
{:skip, path}
else
File.mkdir_p!(Path.dirname(path))
File.write!(path, contents)
{:ok, path}
end
end
defp rm_file(path) do
if File.exists?(path) do
File.rm!(path)
{:ok, path <> " (removed)"}
else
{:skip, path}
end
end
# Idempotent + non-destructive .env.example: create it if absent, otherwise
# append the RESEND_API_KEY line only if it isn't already present (preserving
# any keys the app already documents).
defp write_env_example(path) do
cond do
not File.exists?(path) ->
File.write!(path, AP.env_example_view())
{:ok, path}
String.contains?(File.read!(path), "RESEND_API_KEY") ->
{:skip, path}
true ->
File.write!(path, ensure_trailing_newline(File.read!(path)) <> AP.env_example_view())
{:ok, path}
end
end
defp ensure_trailing_newline(content) do
if content == "" or String.ends_with?(content, "\n"), do: content, else: content <> "\n"
end
defp print_results(results) do
Enum.each(results, fn
{:ok, path} -> Mix.shell().info([:green, " ✓ ", :reset, "patched — ", path])
{:skip, path} -> Mix.shell().info([:faint, " • already done — ", path, :reset])
{:manual, path, msg} -> Mix.shell().info([:yellow, " ! ", :reset, path, ": ", msg])
end)
end
defp done(msg), do: Mix.shell().info([:green, "\n ✓ ", :reset, String.trim(msg), "\n"])
end