Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: logout with ueberauth_oidcc #950

Merged
merged 1 commit into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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
}
],
providers: [
keycloak: keycloak_opts
]
end

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

Expand Down
18 changes: 17 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 48 additions & 1 deletion lib/arrow_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions 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 All @@ -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)
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
13 changes: 12 additions & 1 deletion lib/arrow_web/try_api_token_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
104 changes: 104 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,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}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this provide a session that never expires?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opposite; it expires immediately so that if it does happen to make it outside of the API (which it shouldn't) it won't be usable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be a session that expires immediately, but I will double-check that's how Guardian treats it (or if there's another way to implement this).

The issue I ran into (and I think this is true of the existing implementation) is that the API returned a session cookie that you can use instead of the API key, and I didn't think that was the normal approach for an API. We disable that now, but also set a 0-second TTL to ensure that if the token did leak out, it wouldn't be usable.

)
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
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
]
Expand Down
7 changes: 5 additions & 2 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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"},
Expand Down
Loading
Loading