Skip to content

Commit

Permalink
WebAuth: Add support for step of OAuth device flow
Browse files Browse the repository at this point in the history
The Hexpm team is looking for a means of authenticating a hex client
from the browser. The plan is to default to this new means of
authentication and deprecate the Username/Password authentication.

The github cli (https://github.com/cli/cli) had a similar problem. What
they did was implement the device OAuth
flow (https://datatracker.ietf.org/doc/html/rfc8628). I decided that a
flow of that sort would work very well for Hexpm.

However, Hexpm has no support for OAuth. So, I can only "mock" the
device flow. This commit does the following:

- Adds all the endpoints at the router for such a flow
- Creates a controller
- Add support for step 1 of the device flow
- Adds tests for the above functionality

See also: hexpm#1024, erlef/build-and-packaging-wg#21
  • Loading branch information
Benjamin-Philip committed May 4, 2022
1 parent 20b15a6 commit a4a013e
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 0 deletions.
123 changes: 123 additions & 0 deletions lib/hexpm/web_auth.ex
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions lib/hexpm_web/controllers/web_auth_controller.ex
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions lib/hexpm_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions test/hexpm/web_auth_test.exs
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions test/hexpm_web/controllers/web_auth_controller.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a4a013e

Please sign in to comment.