Skip to content

Commit

Permalink
feat: switch from Cognito to Oidcc
Browse files Browse the repository at this point in the history
  • Loading branch information
paulswartz committed Nov 24, 2023
1 parent 7c10add commit bf32351
Show file tree
Hide file tree
Showing 26 changed files with 412 additions and 112 deletions.
10 changes: 8 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# is restricted to this project.

# General application configuration
use Mix.Config
import Config

config :arrow,
ecto_repos: [Arrow.Repo],
Expand All @@ -20,7 +20,10 @@ config :arrow,
# map cognito groups to roles
"arrow-admin" => "admin"
},
ueberauth_provider: :cognito,
api_login_module: ArrowWeb.TryApiTokenAuth.Cognito,
required_roles: %{
view_disruption: ["read-only", "admin"],
create_disruption: ["admin"],
update_disruption: ["admin"],
delete_disruption: ["admin"],
Expand Down Expand Up @@ -58,7 +61,10 @@ config :arrow, ArrowWeb.AuthManager, issuer: "arrow"

config :ueberauth, Ueberauth,
providers: [
cognito: {Ueberauth.Strategy.Cognito, []}
cognito: {Ueberauth.Strategy.Cognito, []},
keycloak:
{Ueberauth.Strategy.Oidcc,
issuer: :keycloak_issuer, userinfo: true, uid_field: "email", scopes: ~w"openid email"}
]

config :ueberauth, Ueberauth.Strategy.Cognito,
Expand Down
2 changes: 1 addition & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

# Configure your database
config :arrow, Arrow.Repo,
Expand Down
2 changes: 1 addition & 1 deletion config/prod.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information
Expand Down
45 changes: 45 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
import Config

is_test? = config_env() == :test

keycloak_issuer =
case System.get_env() do
%{"KEYCLOAK_ISSUER" => issuer} when issuer != "" ->
issuer

%{"KEYCLOAK_DISCOVERY_URI" => well_known} when well_known != "" ->
String.replace_trailing(well_known, "/.well-known/openid-configuration", "")

_ ->
nil
end

if is_binary(keycloak_issuer) and not is_test? do
config :arrow,
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 = [
client_id: System.fetch_env!("KEYCLOAK_CLIENT_ID"),
client_secret: System.fetch_env!("KEYCLOAK_CLIENT_SECRET")
]

keycloak_opts =
if keycloak_idp = System.get_env("KEYCLOAK_IDP_HINT") do
Keyword.put(keycloak_opts, :authorization_params, %{kc_idp_hint: keycloak_idp})
else
keycloak_opts
end

config :ueberauth_oidcc,
issuers: [
%{
name: :keycloak_issuer,
issuer: keycloak_issuer
}
],
strategies: [
keycloak: keycloak_opts
]
end

if config_env() == :prod do
sentry_env = System.get_env("SENTRY_ENV")

Expand Down
4 changes: 2 additions & 2 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

# Configure your database
config :arrow, Arrow.Repo,
Expand All @@ -25,7 +25,7 @@ config :arrow,
http_client: Arrow.HTTPMock

# Print only warnings and errors during test
config :logger, level: :warn
config :logger, level: :warning

config :arrow, env: :test

Expand Down
2 changes: 1 addition & 1 deletion lib/arrow/adjustment_fetcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ defmodule Arrow.AdjustmentFetcher do
_ =
case fetch() do
:ok -> Logger.debug("adjustment_fetch_complete")
{:error, reason} -> Logger.warn("adjustment_fetch_failed: #{inspect(reason)}")
{:error, reason} -> Logger.warning("adjustment_fetch_failed: #{inspect(reason)}")
end

Process.send_after(self(), :fetch, interval)
Expand Down
3 changes: 2 additions & 1 deletion lib/arrow/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ defmodule Arrow.Permissions do
@required_roles Application.compile_env!(:arrow, :required_roles)

@type action() ::
:create_disruption
:view_disruption
| :create_disruption
| :update_disruption
| :delete_disruption
| :use_api
Expand Down
3 changes: 2 additions & 1 deletion lib/arrow_web/auth_manager/error_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule ArrowWeb.AuthManager.ErrorHandler do

@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {_type, _reason}, _opts) do
Controller.redirect(conn, to: Routes.auth_path(conn, :request, "cognito"))
provider = Application.get_env(:arrow, :ueberauth_provider)
Controller.redirect(conn, to: Routes.auth_path(conn, :request, "#{provider}"))
end
end
57 changes: 50 additions & 7 deletions lib/arrow_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
defmodule ArrowWeb.AuthController do
use ArrowWeb, :controller
plug Ueberauth
plug(Ueberauth)

@spec logout(Plug.Conn.t(), map()) :: Plug.Conn.t()
def logout(conn, _params) do
logout_url = Map.get(Guardian.Plug.current_claims(conn), "logout_url")
conn = clear_session(conn)

if logout_url do
redirect(conn, external: logout_url)
else
redirect(conn, to: "/")
end
end

@cognito_groups Application.compile_env!(:arrow, :cognito_groups)

@spec callback(Plug.Conn.t(), map()) :: Plug.Conn.t()
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
def callback(%{assigns: %{ueberauth_auth: %{provider: :cognito} = auth}} = conn, _params) do
username = auth.uid
expiration = auth.credentials.expires_at
credentials = conn.assigns.ueberauth_auth.credentials

current_time = System.system_time(:second)

groups = credentials.other[:groups] || []
groups = Map.get(auth.credentials.other, :groups, [])

roles =
Enum.flat_map(groups, fn group ->
Expand All @@ -26,10 +36,43 @@ defmodule ArrowWeb.AuthController do
|> Guardian.Plug.sign_in(
ArrowWeb.AuthManager,
username,
%{roles: roles},
%{
# all cognito users have read-only access
roles: roles ++ ["read-only"]
},
ttl: {expiration - current_time, :seconds}
)
|> redirect(to: Routes.disruption_path(conn, :index))
end

def callback(%{assigns: %{ueberauth_auth: %{provider: :keycloak} = auth}} = conn, _params) do
username = auth.uid
expiration = auth.credentials.expires_at
current_time = System.system_time(:second)

roles = auth.extra.raw_info.userinfo["roles"] || []

logout_url =
case UeberauthOidcc.initiate_logout_url(auth, %{
post_logout_redirect_uri: "https://www.mbta.com/"
}) do
{:ok, url} ->
url

_ ->
nil
end

conn
|> Guardian.Plug.sign_in(
ArrowWeb.AuthManager,
username,
%{
roles: roles,
logout_url: logout_url
},
ttl: {expiration - current_time, :seconds}
)
|> put_session(:arrow_username, username)
|> redirect(to: Routes.disruption_path(conn, :index))
end

Expand Down
1 change: 1 addition & 0 deletions lib/arrow_web/controllers/disruption_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule ArrowWeb.DisruptionController do
alias Ecto.Changeset
alias Plug.Conn

plug(Authorize, :view_disruption when action in [:index, :show])
plug(Authorize, :create_disruption when action in [:new, :create])
plug(Authorize, :update_disruption when action in [:edit, :update, :update_row_status])
plug(Authorize, :delete_disruption when action in [:delete])
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
3 changes: 3 additions & 0 deletions lib/arrow_web/controllers/unauthorized_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ defmodule ArrowWeb.UnauthorizedController do

@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
def index(conn, _params) do
roles = Guardian.Plug.current_claims(conn)["roles"] || []

conn
|> assign(:roles, roles)
|> put_status(403)
|> render("index.html")
end
Expand Down
2 changes: 1 addition & 1 deletion lib/arrow_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ defmodule ArrowWeb.Router do

pipeline :json_api do
plug(:accepts, ["json-api"])
plug(:fetch_session)
plug(JaSerializer.ContentTypeNegotiation)
end

Expand Down Expand Up @@ -42,6 +41,7 @@ defmodule ArrowWeb.Router do
scope "/", ArrowWeb do
pipe_through([:redirect_prod_http, :browser, :authenticate])

get("/logout", AuthController, :logout)
get("/unauthorized", UnauthorizedController, :index)
get("/feed", FeedController, :index)
get("/mytoken", MyTokenController, :show)
Expand Down
5 changes: 5 additions & 0 deletions lib/arrow_web/templates/layout/app.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@

<div class="m-header__long-name navbar-text justify-content-end navbar-collapse collapse">
Adjustments to the Regular Right of Way
<%= if Map.has_key?(Guardian.Plug.current_claims(@conn), "logout_url"), do:
link("logout",
to: Routes.auth_path(@conn, :logout),
class: "ml-2 btn btn-outline-danger"
) %>
</div>
</nav>
</header>
Expand Down
2 changes: 2 additions & 0 deletions lib/arrow_web/templates/unauthorized/index.html.heex
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<section class="d-flex flex-column justify-content-center align-items-center h-100">
<h4 class="text-danger text-start">Whoops! You are not authorized to access this page.</h4>
<%= if "read-only" in @roles or "admin" in @roles do %>
<span class="text-start">
Were you looking for a <%= link("list of disruptions", to: Routes.disruption_path(@conn, :index)) %>
or a <%= link("calendar schedule", to: Routes.disruption_path(@conn, :index, view: "calendar")) %>?
</span>
<% end %>
<span class="text-start">To request access to this page, please contact <%= link("[email protected]", to: "mailto:[email protected]") %>.</span>
</section>
68 changes: 11 additions & 57 deletions lib/arrow_web/try_api_token_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,72 +6,26 @@ 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
api_key_values = get_req_header(conn, "x-api-key")

if api_key_values == [] do
with [token | _] <- api_key_values,
token = String.downcase(token),
auth_token = %Arrow.AuthToken{} <-
Arrow.Repo.get_by(Arrow.AuthToken, token: token),
api_login_module = Application.get_env(:arrow, :api_login_module),
conn = api_login_module.sign_in(conn, auth_token),
true <- Guardian.Plug.authenticated?(conn) do
conn
else
[token | _] = api_key_values
token = String.downcase(token)

auth_token = Arrow.Repo.get_by(Arrow.AuthToken, token: token)
[] ->
# no API key present, pass on through
conn

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)
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
Loading

0 comments on commit bf32351

Please sign in to comment.