diff --git a/config/config.exs b/config/config.exs index 6cb560ef1..acffd98b6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -61,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, diff --git a/config/runtime.exs b/config/runtime.exs index f03a4d3ec..3ed025b1e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 + } + ], + providers: [ + keycloak: keycloak_opts + ] +end + if config_env() == :prod do sentry_env = System.get_env("SENTRY_ENV") diff --git a/config/test.exs b/config/test.exs index 791957c21..402bb2918 100644 --- a/config/test.exs +++ b/config/test.exs @@ -17,7 +17,23 @@ config :arrow, ArrowWeb.AuthManager, secret_key: "test key" config :ueberauth, Ueberauth, providers: [ - cognito: {Arrow.Ueberauth.Strategy.Fake, []} + cognito: {Arrow.Ueberauth.Strategy.Fake, []}, + keycloak: {Ueberauth.Strategy.Oidcc, []} + ] + +# Configure Keycloak +config :arrow, + keycloak_api_base: "https://keycloak.example/auth/realm/", + keycloak_client_uuid: "UUID" + +config :ueberauth_oidcc, + providers: [ + keycloak: [ + issuer: :fake_issuer, + client_id: "fake_client", + client_secret: "fake_client_secret", + module: Arrow.FakeOidcc + ] ] config :arrow, diff --git a/lib/arrow_web/controllers/auth_controller.ex b/lib/arrow_web/controllers/auth_controller.ex index a75c6a42d..89cda04fd 100644 --- a/lib/arrow_web/controllers/auth_controller.ex +++ b/lib/arrow_web/controllers/auth_controller.ex @@ -1,7 +1,21 @@ defmodule ArrowWeb.AuthController do use ArrowWeb, :controller + require Logger + 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 = configure_session(conn, drop: true) + + 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() @@ -33,10 +47,43 @@ defmodule ArrowWeb.AuthController do |> 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} + ) + |> redirect(to: Routes.disruption_path(conn, :index)) + end + def callback( - %{assigns: %{ueberauth_failure: %Ueberauth.Failure{}}} = conn, + %{assigns: %{ueberauth_failure: %Ueberauth.Failure{errors: errors}}} = conn, _params ) do + Logger.warning("failed to authenticate errors=#{inspect(errors)}") + send_resp(conn, 401, "unauthenticated") end end diff --git a/lib/arrow_web/router.ex b/lib/arrow_web/router.ex index 195ce9f3e..73a979a02 100644 --- a/lib/arrow_web/router.ex +++ b/lib/arrow_web/router.ex @@ -11,7 +11,6 @@ defmodule ArrowWeb.Router do pipeline :json_api do plug(:accepts, ["json-api"]) - plug(:fetch_session) plug(JaSerializer.ContentTypeNegotiation) end @@ -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) @@ -56,7 +56,7 @@ defmodule ArrowWeb.Router do end scope "/auth", ArrowWeb do - pipe_through([:browser]) + pipe_through([:redirect_prod_http, :browser]) get("/:provider", AuthController, :request) get("/:provider/callback", AuthController, :callback) diff --git a/lib/arrow_web/templates/layout/app.html.heex b/lib/arrow_web/templates/layout/app.html.heex index 71a619263..e189b5c33 100644 --- a/lib/arrow_web/templates/layout/app.html.heex +++ b/lib/arrow_web/templates/layout/app.html.heex @@ -27,6 +27,11 @@
diff --git a/lib/arrow_web/try_api_token_auth.ex b/lib/arrow_web/try_api_token_auth.ex index 8238b15e9..55af18122 100644 --- a/lib/arrow_web/try_api_token_auth.ex +++ b/lib/arrow_web/try_api_token_auth.ex @@ -15,7 +15,7 @@ defmodule ArrowWeb.TryApiTokenAuth do 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), + api_login_module = api_login_module_for_token(auth_token), conn = api_login_module.sign_in(conn, auth_token), true <- Guardian.Plug.authenticated?(conn) do conn @@ -32,4 +32,15 @@ defmodule ArrowWeb.TryApiTokenAuth do conn |> send_resp(401, "unauthenticated") |> halt() end end + + defp api_login_module_for_token(auth_token) + + defp api_login_module_for_token(%Arrow.AuthToken{username: "ActiveDirectory" <> _}) do + # These users are always from Cognito + ArrowWeb.TryApiTokenAuth.Cognito + end + + defp api_login_module_for_token(_token) do + Application.get_env(:arrow, :api_login_module) + end end diff --git a/lib/arrow_web/try_api_token_auth/keycloak.ex b/lib/arrow_web/try_api_token_auth/keycloak.ex new file mode 100644 index 000000000..8f3d8de14 --- /dev/null +++ b/lib/arrow_web/try_api_token_auth/keycloak.ex @@ -0,0 +1,104 @@ +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}, + ttl: {0, :second} + ) + 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, :missing_user} + + 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 = + String.replace_suffix( + Application.get_env(:arrow, :keycloak_api_base), + "/", + "" + ) + + {_, base_opts} = Application.get_env(:ueberauth, Ueberauth)[:providers][:keycloak] + runtime_opts = Application.get_env(:ueberauth_oidcc, :providers)[:keycloak] + + opts = + base_opts + |> Keyword.merge(runtime_opts) + |> Map.new() + + http_module = Application.get_env(:arrow, :http_client) + oidcc_module = Map.get(opts, :module, Oidcc) + + with {:ok, token} <- + oidcc_module.client_credentials_token( + opts.issuer, + opts.client_id, + opts.client_secret, + %{} + ), + headers = [{"authorization", "Bearer #{token.access.token}"}], + {:ok, %{status_code: 200} = response} <- + http_module.get("#{base_url}#{url}", headers, + params: params, + hackney: [ + ssl_options: + Keyword.merge( + :httpc.ssl_verify_host_options(true), + versions: [:"tlsv1.3", :"tlsv1.2"] + ) + ] + ) do + Jason.decode(response.body) + else + {:ok, %{status_code: _} = response} -> {:error, response} + e -> e + end + end +end diff --git a/mix.exs b/mix.exs index eba7bd629..384c36859 100644 --- a/mix.exs +++ b/mix.exs @@ -75,7 +75,8 @@ defmodule Arrow.MixProject do {:react_phoenix, "1.2.0"}, {:tzdata, "~> 1.1"}, {:ueberauth_cognito, "0.4.0"}, - {:ueberauth, "~> 0.9"}, + {:ueberauth_oidcc, "~> 0.2.0"}, + {:ueberauth, "~> 0.10"}, {:wallaby, "~> 0.30.6", runtime: false, only: :test}, {:sentry, "~> 8.0"} ] diff --git a/mix.lock b/mix.lock index c8b747b9b..528d4f6a9 100644 --- a/mix.lock +++ b/mix.lock @@ -29,12 +29,13 @@ "inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm", "7b5ccb9b720c26516f5962dc4565fc26f083ca107b0f6c167048506a125d2df3"}, "ja_serializer": {:git, "https://github.com/mbta/ja_serializer.git", "efb1d4489809e31e4b54b4af9e85f0b3ceeb650b", [branch: "master"]}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, - "jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"}, + "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, "lcov_ex": {:hex, :lcov_ex, "0.2.2", "2dd1ef86ed510ac9c3fe3f11b82cbfd09c92b91b7d6565298932c560f72bd584", [:mix], [], "hexpm", "5e412eddb6a384d2e66cff94eec048ba5c9ed30d6cd9fb5a331456b210de0801"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mox": {:hex, :mox, "1.0.1", "b651bf0113265cda0ba3a827fcb691f848b683c373b77e7d7439910a8d754d6e", [:mix], [], "hexpm", "35bc0dea5499d18db4ef7fe4360067a59b06c74376eb6ab3bd67e6295b133469"}, + "oidcc": {:hex, :oidcc, "3.1.0", "0d290c56e050ad7f94e043d3f71de86b7d81e6ff84bdc536b8e061c8f71b739b", [:mix, :rebar3], [{:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "a4a3f527d90158d61f741ac4f16381fbc5dc83897e0e8cc0cdc1f1d3d6ced0bb"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.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", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, @@ -56,10 +57,12 @@ "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.1", "14a3319a7d9027bdbff7ebcacf1a438f5f5c903057b93aee484cca26f05bdcba", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d0ca77b691cf854ed074b459a93b87f4c7f5512f8f7743c635ca83da81f939e"}, "tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"}, "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, - "ueberauth": {:hex, :ueberauth, "0.9.0", "9f2dc8f6158fc09d048da0c1a548a4b2f9326bf01a35acdcaa94f4bc5b936c9a", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6d6e0c6f7191b8d25153ae3596b3d98b5c06f9bb887d1e2d7b98b74eff3d189b"}, + "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_oidcc": {:hex, :ueberauth_oidcc, "0.2.0", "a271984271b4361f0cfb1135c34abd87b04d99f0a1bf2b6bbad80e3579d5e49c", [:mix], [{:oidcc, "~> 3.1.0", [hex: :oidcc, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10.5", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "4b09b376573da608fd702ecad1fa915c3da04fe43f6ab5715d3393fe73be510d"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "wallaby": {:hex, :wallaby, "0.30.6", "7dc4c1213f3b52c4152581d126632bc7e06892336d3a0f582853efeeabd45a71", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.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.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "50950c1d968549b54c20e16175c68c7fc0824138e2bb93feb11ef6add8eb23d4"}, "web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"}, diff --git a/test/arrow_web/controllers/auth_controller_test.exs b/test/arrow_web/controllers/auth_controller_test.exs index 5ff590e20..1187f6389 100644 --- a/test/arrow_web/controllers/auth_controller_test.exs +++ b/test/arrow_web/controllers/auth_controller_test.exs @@ -22,10 +22,72 @@ defmodule ArrowWeb.Controllers.AuthControllerTest do response = html_response(conn, 302) assert response =~ Routes.disruption_path(conn, :index) + assert Enum.sort(Guardian.Plug.current_claims(conn)["roles"]) == ["admin", "read-only"] assert Guardian.Plug.current_resource(conn) == "foo@mbta.com" end + test "redirects on success (keycloak)", %{conn: conn} do + current_time = System.system_time(:second) + + auth = %Ueberauth.Auth{ + uid: "foo@mbta.com", + provider: :keycloak, + credentials: %Ueberauth.Auth.Credentials{ + expires_at: current_time + 1_000, + other: %{id_token: "id_token"} + }, + extra: %{ + raw_info: %{ + userinfo: %{ + "roles" => ["admin"] + } + } + } + } + + conn = + conn + |> assign(:ueberauth_auth, auth) + |> get(Routes.auth_path(conn, :callback, "keycloak")) + + response = html_response(conn, 302) + + assert response =~ Routes.disruption_path(conn, :index) + assert Guardian.Plug.current_claims(conn)["roles"] == ["admin"] + assert Guardian.Plug.current_resource(conn) == "foo@mbta.com" + end + + test "handles missing roles (keycloak)", %{conn: conn} do + current_time = System.system_time(:second) + + auth = %Ueberauth.Auth{ + uid: "foo@mbta.com", + provider: :keycloak, + credentials: %Ueberauth.Auth.Credentials{ + expires_at: current_time + 1_000, + other: %{id_token: "id_token"} + }, + extra: %{ + raw_info: %{ + userinfo: %{} + } + } + } + + conn = + conn + |> assign(:ueberauth_auth, auth) + |> get(Routes.auth_path(conn, :callback, "keycloak")) + + response = html_response(conn, 302) + + assert response =~ Routes.disruption_path(conn, :index) + assert Guardian.Plug.current_claims(conn)["roles"] == [] + assert Guardian.Plug.current_resource(conn) == "foo@mbta.com" + end + + @tag :capture_log test "handles generic failure", %{conn: conn} do conn = conn diff --git a/test/arrow_web/try_api_token_auth/keycloak_test.exs b/test/arrow_web/try_api_token_auth/keycloak_test.exs new file mode 100644 index 000000000..968ba2de2 --- /dev/null +++ b/test/arrow_web/try_api_token_auth/keycloak_test.exs @@ -0,0 +1,110 @@ +defmodule ArrowWeb.TryApiTokenAuth.KeycloakTest do + use ArrowWeb.ConnCase + import ExUnit.CaptureLog + + alias Arrow.HTTPMock + alias ArrowWeb.TryApiTokenAuth.Keycloak + + import Mox + + setup :verify_on_exit! + + describe "sign_in/2" do + test "signs in an admin", %{conn: conn} do + auth_token = auth_token_for("arrow-admin@example.com") + + expect(HTTPMock, :get, fn url, headers, opts -> + assert url == "https://keycloak.example/auth/realm/users" + assert {_, "Bearer fake_access_token"} = List.keyfind(headers, "authorization", 0) + + assert %{ + email: "arrow-admin@example.com" + } = opts[:params] + + {:ok, + %HTTPoison.Response{ + status_code: 200, + body: Jason.encode!([%{id: "admin_user_id"}]) + }} + end) + + expect(HTTPMock, :get, fn _url, _headers, _opts -> + {:ok, + %HTTPoison.Response{ + status_code: 200, + body: Jason.encode!([%{name: "read-only"}, %{name: "admin"}]) + }} + end) + + conn = Keycloak.sign_in(conn, auth_token) + + assert Guardian.Plug.authenticated?(conn) + claims = Guardian.Plug.current_claims(conn) + + assert claims["sub"] == "arrow-admin@example.com" + assert claims["typ"] == "access" + assert claims["roles"] == ["read-only", "admin"] + assert Guardian.Plug.current_resource(conn) == "arrow-admin@example.com" + end + + test "signs in a read-only user", %{conn: conn} do + auth_token = auth_token_for("arrow-read-only@example.com") + + expect(HTTPMock, :get, fn _url, _headers, opts -> + assert %{ + email: "arrow-read-only@example.com" + } = opts[:params] + + {:ok, + %HTTPoison.Response{ + status_code: 200, + body: Jason.encode!([%{id: "read_only_user_id"}]) + }} + end) + + expect(HTTPMock, :get, fn url, _headers, _opts -> + assert url == + "https://keycloak.example/auth/realm/users/read_only_user_id/role-mappings/clients/UUID/composite" + + {:ok, + %HTTPoison.Response{ + status_code: 200, + body: Jason.encode!([%{name: "read-only"}]) + }} + end) + + conn = Keycloak.sign_in(conn, auth_token) + + assert Guardian.Plug.authenticated?(conn) + claims = Guardian.Plug.current_claims(conn) + + assert claims["sub"] == "arrow-read-only@example.com" + assert claims["typ"] == "access" + assert claims["roles"] == ["read-only"] + assert Guardian.Plug.current_resource(conn) == "arrow-read-only@example.com" + end + + test "does not sign in a user who does not exist in Keycloak", %{conn: conn} do + auth_token = auth_token_for("arrow-missing@example.com") + + expect(HTTPMock, :get, fn _url, _headers, _opts -> + {:ok, + %HTTPoison.Response{ + status_code: 200, + body: Jason.encode!([]) + }} + end) + + {conn, log} = with_log(fn -> Keycloak.sign_in(conn, auth_token) end) + + refute Guardian.Plug.authenticated?(conn) + + assert log =~ "{:error, :missing_user}" + end + end + + defp auth_token_for(email) do + token = Arrow.AuthToken.get_or_create_token_for_user(email) + Arrow.Repo.get_by(Arrow.AuthToken, token: token) + end +end diff --git a/test/arrow_web/try_api_token_auth_test.exs b/test/arrow_web/try_api_token_auth_test.exs index 2ce1bef58..56442a812 100644 --- a/test/arrow_web/try_api_token_auth_test.exs +++ b/test/arrow_web/try_api_token_auth_test.exs @@ -44,7 +44,6 @@ defmodule ArrowWeb.TryApiTokenAuthTest do conn = conn - |> init_test_session([]) |> put_req_header("x-api-key", token) |> ArrowWeb.TryApiTokenAuth.call([]) @@ -65,7 +64,6 @@ defmodule ArrowWeb.TryApiTokenAuthTest do capture_log([level: :warn], fn -> conn = conn - |> init_test_session([]) |> put_req_header("x-api-key", token) |> ArrowWeb.TryApiTokenAuth.call([]) diff --git a/test/support/fake_oidcc.ex b/test/support/fake_oidcc.ex new file mode 100644 index 000000000..cfa22e7eb --- /dev/null +++ b/test/support/fake_oidcc.ex @@ -0,0 +1,12 @@ +defmodule Arrow.FakeOidcc do + @moduledoc false + + def client_credentials_token(:fake_issuer, "fake_client", "fake_client_secret", _opts) do + {:ok, + %Oidcc.Token{ + access: %Oidcc.Token.Access{ + token: "fake_access_token" + } + }} + end +end