forked from hexpm/hexpm
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
WebAuth: Add support for step of OAuth device flow
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
1 parent
20b15a6
commit a4a013e
Showing
5 changed files
with
266 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |