diff --git a/lib/hexpm/web_auth.ex b/lib/hexpm/web_auth.ex new file mode 100644 index 000000000..27ed87272 --- /dev/null +++ b/lib/hexpm/web_auth.ex @@ -0,0 +1,123 @@ +defmodule Hexpm.WebAuth do + use GenServer + + @moduledoc false + + # A pool for storing and validating web auth requests. + + alias HexpmWeb.Router.Helpers, as: Routes + import Phoenix.ConnTest, only: [build_conn: 0] + + @name __MODULE__ + + # `device_code` refers to the code assigned to a client to identify it + # `user_code` refers to the code the user enters to authorize a client + # `verification_uri` refers to the url opened in the browser + # `access_token_uri` refers to the url the client polls + # `verification_expires_in` refers to the time a web auth request is stored in seconds + # `token_access_expires_in` refers to the time an access token in stored in seconds + # `access_token` refers to a key that the user/organization can use + + @verification_uri "https://hex.pm" <> Routes.web_auth_path(build_conn(), :show) + @access_token_uri "https://hex.pm" <> Routes.web_auth_path(build_conn(), :access_token) + @verification_expires_in 900 + @token_access_expires_in 900 + + # Client interface + + @doc """ + Starts the GenServer from a Supervison tree + + ## Options + + - `:name` - The name the Web Auth pool is locally registered as. The default is `Hexpm.WebAuth` + - `:verification_uri` - The URI the user enters the user code. By default, it is taken from the Router. + - `:access_token_uri` - The URI the client polls for the access token. By default, it is taken from the Router. + - `:verification_expires_in` - The time a web auth request is stored in memory. The default is 15 minutes (900 secs). + - `:token_access_expires_in` - The time an access token is stored in memory. The default is 15 minutes (900 secs). + """ + def start_link(opts) do + name = opts[:name] || @name + verification_uri = opts[:verification_uri] || @verification_uri + access_token_uri = opts[:access_token_uri] || @access_token_uri + verification_expires_in = opts[:verification_expires_in] || @verification_expires_in + token_access_expires_in = opts[:token_access_expires_in] || @token_access_expires_in + + GenServer.start_link( + __MODULE__, + %{ + verification_uri: verification_uri, + access_token_uri: access_token_uri, + verification_expires_in: verification_expires_in, + token_access_expires_in: token_access_expires_in + }, + name: name + ) + end + + @doc """ + Adds a web auth request to the pool and returns the response. + + ## Params + + - `server` - The PID or locally registered name of the GenServer + - `scope` - The permission of the final access token + """ + def get_code(server \\ @name, scope) do + GenServer.call(server, {:get_code, scope, server}) + end + + # Server side code + + @impl GenServer + def init(opts) do + {:ok, + %{ + verification_uri: opts.verification_uri, + access_token_uri: opts.access_token_uri, + verification_expires_in: opts.verification_expires_in, + token_access_expires_in: opts.token_access_expires_in, + requests: [], + access_tokens: [] + }} + end + + @impl GenServer + def handle_call({:get_code, scope, server}, _, state) do + {response, new_state} = code(scope, server, state) + {:reply, response, new_state} + end + + # Helper functions + + def code(scope, server, state) do + device_code = "foo" + user_code = "bar" + + response = %{ + device_code: device_code, + user_code: user_code, + verification_uri: state.verification_uri, + access_token_uri: state.access_token_uri, + verification_expires_in: state.verification_expires_in, + token_access_expires_in: state.token_access_expires_in + } + + request = %{device_code: device_code, user_code: user_code, scope: scope} + + case state.verification_expires_in do + 0 -> + send(server, {:delete_request, device_code}) + + t -> + _ = + Process.send_after( + server, + {:delete_request, device_code}, + t + ) + end + + {response, %{state | requests: [request | state.requests]}} + end +end diff --git a/lib/hexpm_web/controllers/web_auth_controller.ex b/lib/hexpm_web/controllers/web_auth_controller.ex new file mode 100644 index 000000000..a3bd88887 --- /dev/null +++ b/lib/hexpm_web/controllers/web_auth_controller.ex @@ -0,0 +1,36 @@ +defmodule HexpmWeb.WebAuthController do + use HexpmWeb, :controller + @moduledoc false + + # Controller for Web Auth, a mode of authenticating the cli from the website + + @scopes ["write", "read"] + + # step one of device flow + def code(conn, params) do + case params do + %{"scope" => scope} when scope in @scopes -> + conn + |> put_status(:ok) + |> json(Hexpm.WebAuth.get_code(scope)) + + %{"scope" => _} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{"error" => "invalid scope"}) + + _ -> + conn + |> put_status(:bad_request) + |> json(%{"error" => "invalid parameters"}) + end + end + + def show(conn, params) do + json(conn, params) + end + + def access_token(conn, params) do + json(conn, params) + end +end diff --git a/lib/hexpm_web/router.ex b/lib/hexpm_web/router.ex index e29e9e1c3..2f3282476 100644 --- a/lib/hexpm_web/router.ex +++ b/lib/hexpm_web/router.ex @@ -63,6 +63,8 @@ defmodule HexpmWeb.Router do post "/login", LoginController, :create post "/logout", LoginController, :delete + get "/login/web_auth", WebAuthController, :show + get "/two_factor_auth", TFAAuthController, :show post "/two_factor_auth", TFAAuthController, :create @@ -202,6 +204,13 @@ defmodule HexpmWeb.Router do get "/feeds/blog.xml", FeedsController, :blog end + scope "/login/web_auth", HexpmWeb do + pipe_through :api + + post "/code", WebAuthController, :code + post "/access_token", WebAuthController, :access_token + end + scope "/api", HexpmWeb.API, as: :api do pipe_through :upload diff --git a/test/hexpm/web_auth_test.exs b/test/hexpm/web_auth_test.exs new file mode 100644 index 000000000..799342d92 --- /dev/null +++ b/test/hexpm/web_auth_test.exs @@ -0,0 +1,31 @@ +defmodule Hexpm.WebAuthTest do + use ExUnit.Case, async: true + + alias Hexpm.WebAuth + + describe "start_link/1" do + test "correctly starts a registered GenServer", config do + start_supervised!({WebAuth, name: config.test}) + + # Verify Process is running + assert Process.whereis(config.test) + end + end + + describe "get_code/2" do + test "returns a valid response on valid scope", config do + start_supervised!({WebAuth, name: config.test}) + + for scope <- ["write", "read"] do + response = WebAuth.get_code(config.test, scope) + + assert response.device_code + assert response.user_code + assert response.verification_uri + assert response.access_token_uri + assert response.verification_expires_in + assert response.token_access_expires_in + end + end + end +end diff --git a/test/hexpm_web/controllers/web_auth_controller.exs b/test/hexpm_web/controllers/web_auth_controller.exs new file mode 100644 index 000000000..d3ec807b3 --- /dev/null +++ b/test/hexpm_web/controllers/web_auth_controller.exs @@ -0,0 +1,67 @@ +defmodule HexpmWeb.WebAuthControllerTest do + use HexpmWeb.ConnCase, async: true + + @test %{scope: "write"} + + setup_all do + _ = start_supervised(Hexpm.WebAuth) + :ok + end + + setup do + conn = + build_conn() + |> put_req_header("accept", "application/json") + + %{conn: conn} + end + + describe "POST /web_auth/code" do + test "returns a valid response", %{conn: conn} do + response = + post(conn, Routes.web_auth_path(conn, :code, @test)) + |> json_response(200) + + assert response["device_code"] + assert response["user_code"] + assert response["verification_uri"] + assert response["access_token_uri"] + assert response["verification_expires_in"] + assert response["token_access_expires_in"] + end + + test "returns a verification_uri that is an endpoint", %{conn: conn} do + {:ok, verification_uri} = + post(conn, Routes.web_auth_path(conn, :code, @test)) + |> json_response(200) + |> Map.fetch("verification_uri") + + assert verification_uri =~ Routes.web_auth_path(conn, :show) + end + + test "returns a access_token_uri that is an endpoint", %{conn: conn} do + {:ok, access_token_uri} = + post(conn, Routes.web_auth_path(conn, :code, @test)) + |> json_response(200) + |> Map.fetch("access_token_uri") + + assert access_token_uri =~ Routes.web_auth_path(conn, :access_token) + end + + test "returns an error on invalid scope", %{conn: conn} do + response = + post(conn, Routes.web_auth_path(conn, :code, %{"scope" => "foo"})) + |> json_response(422) + + assert response == %{"error" => "invalid scope"} + end + + test "returns an error on invalid parameters", %{conn: conn} do + response = + post(conn, Routes.web_auth_path(conn, :code, %{"foo" => "bar"})) + |> json_response(400) + + assert response == %{"error" => "invalid parameters"} + end + end +end