short_doc = "Generate an auth controller for your OpenID provider" example = """ mix oidcc_plug.gen.controller \\ --name MyApp.AuthController \\ --provider MyApp.OpenIDProvider \\ --base-url /auth \\ --issuer https://account.google.com \\ --client-id client-id\ """ case Code.ensure_loaded(Igniter.Mix.Task) do {:module, Igniter.Mix.Task} -> defmodule Mix.Tasks.OidccPlug.Gen.Controller do @shortdoc short_doc @moduledoc """ #{@shortdoc} Generates an auth controller that starts the OpenID Connect flow and handles the result. Additionally, it will add the routes to your router. ## Example ```bash #{example} ``` ## Options * `--name` or `-n` - Name of the controller * `--provider` or `-p` - Name of the OpenID Provider * `--base-url` or `-b` - Base URL for the controller * `--issuer` or `-i` - Issuer URL of the OpenID Provider * `--client-id` - Client ID for the OpenID Provider * `--client-secret` - Client Secret for the OpenID Provider """ use Igniter.Mix.Task alias Igniter.Libs.Phoenix alias Igniter.Project.Config alias Igniter.Project.IgniterConfig alias Igniter.Project.Module @example example @impl Igniter.Mix.Task def info(_argv, _composing_task) do %Igniter.Mix.Task.Info{ # dependencies to add adds_deps: [oidcc: "~> 3.5"], # dependencies to add and call their associated installers, if they exist installs: [], # An example invocation example: @example, # Accept additional arguments that are not in your schema # Does not guarantee that, when composed, the only options you get are the ones you define extra_args?: false, # A list of environments that this should be installed in, only relevant if this is an installer. only: nil, # a list of positional arguments, i.e `[:file]` positional: [], # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv # This ensures your option schema includes options from nested tasks composes: [], # `OptionParser` schema schema: [ name: :string, provider: :string, base_url: :string, issuer: :string, client_id: :string ], # CLI aliases aliases: [n: :name, p: :provider, b: :base_url, i: :issuer] } end @impl Igniter.Mix.Task def igniter(igniter) do options = setup_options(igniter) igniter |> IgniterConfig.setup() |> setup_provider(options) |> setup_config(options) |> generate_controller(options) |> add_routes(options) end defp setup_options(igniter) do igniter.args.options |> Keyword.update( :name, Phoenix.web_module_name(igniter, "AuthController"), &Module.parse/1 ) |> Keyword.update( :provider, Module.module_name(igniter, "OpenIDProvider"), &Module.parse/1 ) |> Keyword.put_new(:base_url, "/auth") |> Keyword.put(:app_name, Igniter.Project.Application.app_name(igniter)) end defp setup_provider(igniter, options) do Igniter.compose_task(igniter, "oidcc.gen.provider_configuration_worker", [ "--name", inspect(options[:provider]), "--issuer", options[:issuer] ]) end defp setup_config(igniter, options) do env_prefix = options[:provider] |> Macro.underscore() |> String.upcase() |> String.replace("/", "_") client_id_config = case Keyword.fetch(options, :client_id) do {:ok, client_id} -> quote do System.get_env(unquote("#{env_prefix}_CLIENT_ID"), unquote(client_id)) end :error -> quote do System.fetch_env!(unquote("#{env_prefix}_CLIENT_ID")) end end client_secret_config = quote do System.fetch_env!(unquote("#{env_prefix}_CLIENT_SECRET")) end config = quote do [client_id: unquote(client_id_config), client_secret: unquote(client_secret_config)] end igniter |> Config.configure_new( "config.exs", options[:app_name], [options[:name], :provider], options[:provider] ) |> Config.configure_new( "runtime.exs", options[:app_name], [options[:name]], {:code, config} ) |> Igniter.add_notice(""" Use the environment variables #{env_prefix}_ISSUER, #{env_prefix}_CLIENT_ID and #{env_prefix}_CLIENT_SECRET to set the issuer, client ID and secret for the OpenID provider. If you prefer to load the configuration from a different location then env variables, you can do so by modifying the `config/runtime.exs` file. """) end defp generate_controller(igniter, options) do web_module = Phoenix.web_module(igniter) html_module_name = options[:name] |> inspect() |> String.trim_trailing("Controller") |> Kernel.<>("HTML") |> then(&Module.module_name(igniter, &1)) html_path = html_module_name |> inspect() |> String.split(".") |> List.last() |> Macro.underscore() html_template_path = Path.join([ igniter |> Igniter.Project.Module.proper_location(web_module) |> Path.rootname(".ex"), "controllers", html_path ]) page_html_template_path = Path.join([ igniter |> Igniter.Project.Module.proper_location(web_module) |> Path.rootname(".ex"), "controllers", "page_html" ]) igniter |> Module.create_module( options[:name], Sourceror.to_string( quote do use unquote(web_module), :controller alias Oidcc.Plug.AuthorizationCallback plug( Oidcc.Plug.Authorize, [ provider: Application.compile_env(unquote(options[:app_name]), [__MODULE__, :provider]), client_id: &__MODULE__.client_id/0, client_secret: &__MODULE__.client_secret/0, redirect_uri: &__MODULE__.callback_uri/0 ] when action in [:authorize] ) plug( AuthorizationCallback, [ provider: Application.compile_env(unquote(options[:app_name]), [__MODULE__, :provider]), client_id: &__MODULE__.client_id/0, client_secret: &__MODULE__.client_secret/0, redirect_uri: &__MODULE__.callback_uri/0 ] when action in [:callback] ) def authorize(conn, _params), do: conn def callback( %Plug.Conn{ private: %{AuthorizationCallback => {:ok, {_token, userinfo}}} } = conn, params ) do conn |> put_session("oidcc_claims", userinfo) |> redirect( to: case params[:state] do nil -> "/" state -> state end ) end def callback( %Plug.Conn{private: %{AuthorizationCallback => {:error, reason}}} = conn, _params ) do conn |> put_status(400) |> render(:error, reason: reason) end @doc false def client_id, do: Application.fetch_env!(unquote(options[:app_name]), __MODULE__)[:client_id] @doc false def client_secret, do: Application.fetch_env!(unquote(options[:app_name]), __MODULE__)[:client_secret] @doc false def callback_uri, do: url(unquote({:sigil_p, [delimiter: "\""], [{:<<>>, [], ["#{options[:base_url]}/callback"]}, []]})) end ) ) |> Module.create_module( html_module_name, Sourceror.to_string( quote do defmodule unquote(html_module_name) do use unquote(web_module), :html embed_templates(unquote("#{html_path}/*")) end end ) ) |> Igniter.create_new_file(Path.join(html_template_path, "error.html.heex"), """

error:

<%= inspect(@reason, pretty: true) %>
""") |> Igniter.update_file(Path.join(page_html_template_path, "home.html.heex"), fn current -> Rewrite.Source.update(current, :content, """ #{Rewrite.Source.get(current, :content)}
<%= case Plug.Conn.get_session(@conn, "oidcc_claims") do %> <% nil -> %> Log In <% %{"sub" => sub} -> %> Logged in as <%= sub %> <% end %>
""") end) end defp add_routes(igniter, options) do case Phoenix.select_router(igniter) do {igniter, nil} -> Igniter.add_warning(igniter, """ No Phoenix router found, skipping Route installation. See the Getting Started guide for instructions on installing AshJsonApi with `plug`. If you have yet to set up Phoenix, you'll have to do that manually and then rerun this installer. """) {igniter, router} -> web_module = Phoenix.web_module(igniter) controler_name = String.replace(inspect(options[:name]), ~r/^#{inspect(web_module)}\./, "") Phoenix.add_scope( igniter, options[:base_url], """ pipe_through [:browser] get "/authorize", #{controler_name}, :authorize get "/callback", #{controler_name}, :callback post "/callback", #{controler_name}, :callback """, router: router, arg2: web_module ) end end end _ -> defmodule Mix.Tasks.OidccPlug.Gen.Controller do @shortdoc "#{short_doc} | Install `igniter` to use" @moduledoc @shortdoc use Mix.Task @impl Mix.Task def run(_argv) do Mix.shell().error(""" The task 'oidcc.gen.controller' requires igniter to be run. Please install igniter and try again. For more information, see: https://hexdocs.pm/igniter """) exit({:shutdown, 1}) end end end