Skip to content

Commit

Permalink
feat: authenticate users via API key
Browse files Browse the repository at this point in the history
  • Loading branch information
paulswartz committed Nov 14, 2023
1 parent e5c50d7 commit ed9a745
Show file tree
Hide file tree
Showing 12 changed files with 164 additions and 60 deletions.
1 change: 1 addition & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ config :arrow,
"arrow-admin" => "admin"
},
ueberauth_provider: :cognito,
api_login_module: ArrowWeb.TryApiTokenAuth.Cognito,
required_roles: %{
view_disruption: ["read-only", "admin"],
create_disruption: ["admin"],
Expand Down
5 changes: 4 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ is_test? = config_env() == :test
case System.fetch_env("KEYCLOAK_DISCOVERY_URI") do
{:ok, keycloak_uri} when keycloak_uri != "" and not is_test? ->
config :arrow,
ueberauth_provider: :keycloak
ueberauth_provider: :keycloak,
api_login_module: ArrowWeb.TryApiTokenAuth.Keycloak,
keycloak_client_uuid: System.fetch_env!("KEYCLOAK_CLIENT_UUID"),
keycloak_api_base: System.fetch_env!("KEYCLOAK_API_BASE")

keycloak_opts = [
discovery_document_uri: keycloak_uri,
Expand Down
2 changes: 0 additions & 2 deletions lib/arrow_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ defmodule ArrowWeb.AuthController do
},
ttl: {expiration - current_time, :seconds}
)
|> put_session(:arrow_username, username)
|> redirect(to: Routes.disruption_path(conn, :index))
end

Expand All @@ -64,7 +63,6 @@ defmodule ArrowWeb.AuthController do
},
ttl: {expiration - current_time, :seconds}
)
|> put_session(:arrow_username, username)
|> redirect(to: Routes.disruption_path(conn, :index))
end

Expand Down
2 changes: 1 addition & 1 deletion lib/arrow_web/controllers/my_token_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule ArrowWeb.MyTokenController do

@spec show(Plug.Conn.t(), Plug.Conn.params()) :: Plug.Conn.t()
def show(conn, _params) do
token = conn |> get_session(:arrow_username) |> AuthToken.get_or_create_token_for_user()
token = conn |> Guardian.Plug.current_resource() |> AuthToken.get_or_create_token_for_user()

render(conn, "index.html", token: token)
end
Expand Down
18 changes: 17 additions & 1 deletion lib/arrow_web/plug/assign_user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,23 @@ defmodule ArrowWeb.Plug.AssignUser do

@spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t()
def call(conn, _opts) do
%{"sub" => user_id, "roles" => roles} = Guardian.Plug.current_claims(conn)
%{"sub" => user_id, "roles" => roles} =
case Guardian.Plug.current_claims(conn) do
%{"roles" => _} = claims ->
claims

%{"groups" => groups} = claims ->
# need to map old groups to new roles
mapping = Application.get_env(:arrow, :cognito_groups)

roles =
for group <- groups,
{:ok, role} <- [Map.fetch(mapping, group)] do
role
end

Map.put(claims, "roles", roles)
end

assign(conn, :current_user, %User{
id: user_id,
Expand Down
51 changes: 2 additions & 49 deletions lib/arrow_web/try_api_token_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ defmodule ArrowWeb.TryApiTokenAuth do
import Plug.Conn
require Logger

@aws_cognito_target "AWSCognitoIdentityProviderService"
@cognito_groups Application.compile_env!(:arrow, :cognito_groups)

def init(options), do: options

def call(conn, _opts) do
Expand All @@ -25,53 +22,9 @@ defmodule ArrowWeb.TryApiTokenAuth do
if is_nil(auth_token) do
conn |> send_resp(401, "unauthenticated") |> halt()
else
user_pool_id =
:ueberauth
|> Application.get_env(Ueberauth.Strategy.Cognito)
|> Keyword.get(:user_pool_id)
|> config_value

data = %{
"Username" => auth_token.username,
"UserPoolId" => user_pool_id
}

headers = [
{"x-amz-target", "#{@aws_cognito_target}.AdminListGroupsForUser"},
{"content-type", "application/x-amz-json-1.1"}
]

operation = ExAws.Operation.JSON.new(:"cognito-idp", data: data, headers: headers)

{module, function} = Application.get_env(:arrow, :ex_aws_requester)

roles =
case apply(module, function, [operation]) do
{:ok, %{"Groups" => groups}} ->
Enum.flat_map(groups, fn %{"GroupName" => group} ->
case @cognito_groups[group] do
role when is_binary(role) -> [role]
_ -> []
end
end)

response ->
:ok = Logger.warn("unexpected_aws_api_response: #{inspect(response)}")
[]
end

conn
|> Guardian.Plug.sign_in(
ArrowWeb.AuthManager,
auth_token.username,
%{roles: roles}
)
|> put_session(:arrow_username, auth_token.username)
api_login_module = Application.get_env(:arrow, :api_login_module)
api_login_module.sign_in(conn, auth_token)
end
end
end

@spec config_value(binary() | {module(), atom(), [any()]}) :: any()
defp config_value(value) when is_binary(value), do: value
defp config_value({m, f, a}), do: apply(m, f, a)
end
56 changes: 56 additions & 0 deletions lib/arrow_web/try_api_token_auth/cognito.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
defmodule ArrowWeb.TryApiTokenAuth.Cognito do
@moduledoc """
Signs in an API client via Cognito.
"""

require Logger

@aws_cognito_target "AWSCognitoIdentityProviderService"
@cognito_groups Application.compile_env!(:arrow, :cognito_groups)

def sign_in(conn, auth_token) do
user_pool_id =
:ueberauth
|> Application.get_env(Ueberauth.Strategy.Cognito)
|> Keyword.get(:user_pool_id)
|> config_value

data = %{
"Username" => auth_token.username,
"UserPoolId" => user_pool_id
}

headers = [
{"x-amz-target", "#{@aws_cognito_target}.AdminListGroupsForUser"},
{"content-type", "application/x-amz-json-1.1"}
]

operation = ExAws.Operation.JSON.new(:"cognito-idp", data: data, headers: headers)

{module, function} = Application.get_env(:arrow, :ex_aws_requester)

roles =
case apply(module, function, [operation]) do
{:ok, %{"Groups" => groups}} ->
for %{"GroupName" => group} <- groups,
{:ok, role} <- [Map.fetch(@cognito_groups, group)] do
role
end

response ->
:ok = Logger.warn("unexpected_aws_api_response: #{inspect(response)}")
[]
end

conn
|> Guardian.Plug.sign_in(
ArrowWeb.AuthManager,
auth_token.username,
%{roles: roles}
)
end

@spec config_value(binary() | {module(), atom(), [any()]}) :: any()
defp config_value(value) when is_binary(value), do: value
defp config_value({m, f, a}), do: apply(m, f, a)
end
77 changes: 77 additions & 0 deletions lib/arrow_web/try_api_token_auth/keycloak.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
defmodule ArrowWeb.TryApiTokenAuth.Keycloak do
@moduledoc """
Signs in an API client via Keycloak.
"""

require Logger

def sign_in(conn, auth_token) do
with {:ok, user_id} <- lookup_user_id(auth_token.username),
{:ok, roles} <- lookup_user_roles(user_id) do
conn
|> Guardian.Plug.sign_in(
ArrowWeb.AuthManager,
auth_token.username,
%{roles: roles}
)
else
other ->
Logger.warn(
"unexpected response when logging #{auth_token.username} in via Keycloak API: #{inspect(other)}"
)

conn
end
end

defp lookup_user_id(email) do
case keycloak_api("users", %{
max: 1,
email: String.downcase(email),
exact: true,
briefRepresentation: true
}) do
{:ok, [%{"id" => user_id}]} ->
{:ok, user_id}

{:ok, []} ->
{:error, :no_users}

{:ok, [_, _ | _]} ->
{:error, :multiple_users}

e ->
e
end
end

defp lookup_user_roles(user_id) do
client_uuid = Application.get_env(:arrow, :keycloak_client_uuid)
url = "users/#{user_id}/role-mappings/clients/#{client_uuid}/composite"

case keycloak_api(url) do
{:ok, response} ->
roles = for r <- response, do: r["name"]
{:ok, roles}

e ->
e
end
end

defp keycloak_api(url, params \\ %{}) do
base_url = Application.get_env(:arrow, :keycloak_api_base)
opts = Map.new(Application.get_env(:ueberauth, Ueberauth.Strategy.OIDC)[:keycloak])

with {:ok, tokens} <-
OpenIDConnect.fetch_tokens(opts, %{grant_type: "client_credentials", scope: "openid"}),
headers = [{"authorization", "#{tokens["token_type"]} #{tokens["access_token"]}"}],
{:ok, %{status_code: 200} = response} <-
HTTPoison.get("#{base_url}#{url}", headers, params: params) do
Jason.decode(response.body)
else
{:ok, %{status_code: _} = response} -> {:error, response}
e -> e
end
end
end
4 changes: 2 additions & 2 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
"oauth2": {:hex, :oauth2, "2.0.1", "70729503e05378697b958919bb2d65b002ba6b28c8112328063648a9348aaa3f", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "c64e20d4d105bcdbcbe03170fb530d0eddc3a3e6b135a87528a22c8aecf74c52"},
"openid_connect": {:git, "https://github.com/DockYard/openid_connect.git", "ddafedc3c81a5bc91919d13630b6cb5ea2595ffe", [ref: "ddafedc3c81a5bc91919d13630b6cb5ea2595ffe"]},
"openid_connect": {:git, "https://github.com/firezone/openid_connect.git", "13320ed8b0d347330d07e1375a9661f3089b9c03", [ref: "13320ed8b0d347330d07e1375a9661f3089b9c03"]},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.6.9", "648e660040cdc758c5401972e0f592ce622d4ce9cd16d2d9c33dda32d0c9f7fa", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be2fe497597d6bf297dcbf9f4416b4929dbfbdcc25edc1acf6d4dcaecbe898a6"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
Expand All @@ -67,7 +67,7 @@
"ueberauth": {:hex, :ueberauth, "0.10.5", "806adb703df87e55b5615cf365e809f84c20c68aa8c08ff8a416a5a6644c4b02", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3efd1f31d490a125c7ed453b926f7c31d78b97b8a854c755f5c40064bf3ac9e1"},
"ueberauth_cognito": {:hex, :ueberauth_cognito, "0.4.0", "62daa3f675298c2b03002d2e1b7e5a30cbc513400e5732a264864a26847e71ac", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.0", [hex: :jose, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "62378f4f34c8569cd95cc4e7463c56e9981c8afc83fdc516922065f0e1302a35"},
"ueberauth_keycloak_strategy": {:hex, :ueberauth_keycloak_strategy, "0.4.0", "51e975874564ef4a6eb0044b9f0c6a08be4ba6086e62e41d385e7dd52fe9568b", [:mix], [{:oauth2, "~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "c03027937bddcbd9ff499e457f9bb05f79018fa321abf79ebcfed2af0007211b"},
"ueberauth_oidc": {:git, "https://github.com/mbta/ueberauth_oidc.git", "22857f8eeb03dac47c2fbaebe4a169e45af9656f", []},
"ueberauth_oidc": {:git, "https://github.com/mbta/ueberauth_oidc.git", "6216cb2a93bf075b76bcd97db89579233c44314f", []},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"wallaby": {:hex, :wallaby, "0.28.1", "0487ac4e76a5ffcc9b0ac3ddc35b931b0c2f4cac87b30b029a0f4e7e5ee20ff3", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.1.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "618538448e21dc8b0a02f6810472eb48a05badf14db4a0441dc562a1eac896e9"},
"web_driver_client": {:hex, :web_driver_client, "0.1.0", "19466a989c76b7ec803c796cec0fec4611a64f445fd5120ce50c9e3817e09c2c", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "c9c031ca915e8fc75b5e24ac93503244f3cc406dd7f53047087a45aa62d60e9e"},
Expand Down
4 changes: 2 additions & 2 deletions test/arrow_web/controllers/auth_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ defmodule ArrowWeb.Controllers.AuthControllerTest do

assert response =~ Routes.disruption_path(conn, :index)
assert Enum.sort(Guardian.Plug.current_claims(conn)["roles"]) == ["admin", "read-only"]
assert get_session(conn, :arrow_username) == "foo@mbta.com"
assert Guardian.Plug.current_resource(conn) == "foo@mbta.com"
end

test "redirects on success (keycloak)", %{conn: conn} do
Expand Down Expand Up @@ -54,7 +54,7 @@ defmodule ArrowWeb.Controllers.AuthControllerTest do

assert response =~ Routes.disruption_path(conn, :index)
assert Guardian.Plug.current_claims(conn)["roles"] == ["admin"]
assert get_session(conn, :arrow_username) == "foo@mbta.com"
assert Guardian.Plug.current_resource(conn) == "foo@mbta.com"
end

test "handles missing roles (keycloak)", %{conn: conn} do
Expand Down
2 changes: 1 addition & 1 deletion test/arrow_web/try_api_token_auth_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ defmodule ArrowWeb.TryApiTokenAuthTest do
assert claims["sub"] == "foo@mbta.com"
assert claims["typ"] == "access"
assert claims["roles"] == ["admin"]
assert get_session(conn, :arrow_username) == "foo@mbta.com"
assert Guardian.Plug.current_resource(conn) == "foo@mbta.com"
end

test "handles unexpected response from Cognito API", %{conn: conn} do
Expand Down
2 changes: 1 addition & 1 deletion test/support/conn_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ defmodule ArrowWeb.ConnCase do
defp build_conn(user, roles) do
Phoenix.ConnTest.build_conn()
|> Plug.Conn.put_req_header("x-forwarded-proto", "https")
|> init_test_session(%{arrow_username: user})
|> init_test_session(%{})
|> Guardian.Plug.sign_in(ArrowWeb.AuthManager, user, %{roles: roles})
end
end

0 comments on commit ed9a745

Please sign in to comment.