From 826d594f21d41e4167b669c17b9bbc256b589d92 Mon Sep 17 00:00:00 2001 From: handnot2 Date: Sat, 9 Sep 2017 23:17:59 -0700 Subject: [PATCH] Plug Pipeline config changes Computed attributes in Samly.Assertion Samly.Provider base_url handling changes --- CHANGELOG.md | 7 + README.md | 291 +++++++++++++++++++++++++++++++++++++- lib/samly.ex | 23 ++- lib/samly/assertion.ex | 14 +- lib/samly/auth_handler.ex | 8 +- lib/samly/helper.ex | 31 ++++ lib/samly/provider.ex | 21 ++- lib/samly/sp_handler.ex | 33 +++-- lib/samly/state.ex | 6 +- mix.exs | 8 +- mix.lock | 16 +-- 11 files changed, 406 insertions(+), 52 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9fdafe6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# CHANGELOG + +### v0.6.0 + ++ Plug Pipeline config `:pre_session_create_pipeline` ++ Computed attributes available in `Samly.Assertion` ++ Updates to `Samly.Provider` `base_url` config handling diff --git a/README.md b/README.md index 752ab64..67f4427 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,292 @@ # Samly -SAML plug ... WIP +A Plug library to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. + +This library uses Erlang [`esaml`](https://github.com/handnot2/esaml) to provide +plug enabled routes. So, it is constrained by `esaml` capabilities - only Service +Provider initiated login is supported. The logout operation can be either IdP +initiated or SP initiated. + +## FAQ + +#### How to setup a SAML 2.0 IdP for development purposes? + +Docker based setup of [`SimpleSAMLPhp`](https://simplesamlphp.org) is made available +at [`samly_simplesaml`](https://github.com/handnot2/samly_simplesaml) Git Repo. + +```sh +git clone https://github.com/handnot2/samly_simplesaml +cd samly_simplesaml + +# Ubuntu 16.04 based +./build.sh + +# Follow along README.md (skip SAML Service Provider registration part for now) +# Edit setup/params/params.yml with appropriate information +# Add the IDP host name to your /etc/hosts resolving to 127.0.0.1 +# 127.0.0.1 samly.idp +# Compose exposes and binds to port 8082 by default. + +docker-compose up -d +docker-compose restart +``` + +You should have a working SAML 2.0 IdP that you can work with. + +#### Any sample Phoenix application that shows how to use Samly? + +Clone the [`samly_howto`](https://github.com/handnot2/samly_howto) Git Repo. + +```sh +git clone https://github.com/handnot2/samly_howto + +# Add the SP host name to your /etc/hosts resolving to 127.0.0.1 +# 127.0.0.1 samly.howto + +cd samly_howto + +# Use gencert.sh to create a self-signed certificate for the SAML Service Provider +# embedded in your app (by `Samly`). We will register this and the `Samly` URLs +# with IdP shortly. Take a look at this script and adjust the certificate subject +# if needed. + +./gencert.sh + +# Fetch the IdP metadata XML. `Samly` needs this to make sure that it can +# validate the request/responses to/from IdP. + +wget http://samly.idp:8082/simplesaml/saml2/idp/metadata.php -O idp_metadata.xml + +mix deps.get +mix compile + +HOST=samly.howto PORT=4003 iex -S mix phx.server +``` + +> Important: Make sure that your have registered this application with +> the IdP before you explore this application using a browser. + +Open `http://samly.howto:4003` in your browser and check out the app. + +#### How to register the service provider with IdP + +Complte the setup by registering `samly_howto` as a Service Provider with the +IdP. + +```sh +mkdir -p samly_simplesaml/setup/sp/samly_howto # use the correct path +cp samly.crt samly_simplesaml/setup/sp/samly_howto/sp.crt +cd samly_simplesaml +docker-compose restart +``` + +> The IdP related instructions are very specific to the docker based development +> setup of SimpleSAMLphp IdP. But similar ideas work for your own IdP setup. + +#### How do I enable Samly in my application? + +The short of it is: + ++ Add `Samly` to your `mix.exs` ++ Include `Samly` in your supervision tree ++ Include route forwarding to your `router.ex` ++ Use `/sso/auth/signin` and `/soo/auth/signout` relative URIs in your UI + with optional `target_url` query parameter ++ Config changes in your config files or environment variable as appropriate ++ Use `Samly.get_active_assertion` function to get authenticated user + information ++ Register this application with the IdP + +That covers it for the basics. If you need to use different attribute names +(from what the IdP provides), derive/compute new attributes or do Just-in-time +user provisioning, create your own Plug Pipeline and make that available to +`Samly` using a config setting. Check out the `SAML Assertion` section for +specifics. + +## Setup + +```elixir +# mix.exs + +defp deps() do + [ + # ... + {:samly, "~> 0.6"}, + ] +end +``` + +## Configuration + +#### Router + +Make the following change in your application router. + +```elixir +# router.ex + +# Add the following scope in front of other routes +scope "/sso" do + forward "/", Samly.Router +end +``` + +#### Supervision Tree + +Add `Samly.Provider` to your application supervision tree. + +```elixir +# application.ex + +children = [ + # ... + worker(Samly.Provider, []), +] +``` +#### Configuration Parameters + +The configuration information needed for `Samly` can be specified in as shown here: + +```elixir +# config/dev.exs + +config :samly, Samly.Provider, + base_url: "http://samly.howto:4003/sso", + #pre_session_create_pipeline: MySamlyPipeline, + certfile: "path/to/service/provider/certificate/file", + keyfile: "path/to/corresponding/private/key/file", + idp_metadata_file: "path/to/idp/metadata/xml/file" +``` + +If these are not specified in the config file, `Samly` relies on the environment +variables described below. + +#### Environment Variables + +| Variable | Description | +|:-------------------- |:-------------------- | +| SAMLY_CERTFILE | Path to the X509 certificate file. Defaults to `samly.crt` | +| SAMLY_KEYFILE | Path to the private key for the certificate. Defaults to `samly.pem` | +| SAMLY_IDP_METADATA_FILE | Path to the SAML IDP metadata XML file. Defaults to `idp_metadata.xml` | +| SAMLY_BASE_URL | Set this to the base URL for your application (include `/sso`) | + +#### Generating Self-Signed Certificate and Key Files for Samly + +Make sure `openssl` is available on your system. Use the `gencert.sh` script +to generate the certificate and key files needed to send and recieve +signed SAML requests. As mentioned in FAQ change certificate subject in the +script if needed. + +#### SAML IdP Metadata + +This should be an XML file that contains information on the IdP +`SingleSignOnService` and `SingleLogoutService` endpoints, IdP Certificate and +other metadata information. When `Samly` is used to work with +[`SimpleSAMLPhp`](https://simplesamlphp.org), the following command can be used to +fetch the metadata: + +```sh +wget http://samly.idp:8082/simplesaml/saml2/idp/metadata.php -O idp_metadata.xml +``` + +Make sure to use the host and port in the above IdP metadata URL. + +It is possible to use the admin web console for `SimpleSAMLphp` to get this metadata. +Use the browser to reach the admin web console (`http://samly.idp:8082/simplesaml`). +Use the `SimpleSAMLphp` admin credentials to login. Go to the `Federation` tab. +At the top there will be a section titled "SAML 2.0 IdP Metadata". Click on the +`Show metadata` link. Copy the metadata XML from this page and create +`idp_metadata.xml` file with that content. + +## Sign in and Sign out + +Use `Samly.get_active_assertion` API. This API will return `Samly.Assertion` structure +if the user is authenticated. If not it return `nil`. + +Use `/sso/auth/signin` and `/sso/auth/signout` as relative URIs in your UI login and +logout links or buttons. + +## SAML Assertion + +Once authentication is completed successfully, IdP sends a "consume" SAML +request to `Samly`. `Samly` in turn performs its own checks (including checking +the integrity of the "consume" request). At this point, the SAML assertion +with the authenticated user subject and attributes is available. + +The subject in the SAML assertion is tracked by `Samly` so that subsequent +logout/signout request, either service provider initiated or IdP initiated +would result in proper removal of the corresponding SAML assertion. + +Use the `Samly.get_active_assertion` function to get the SAML assertion +for the currently authenticated user. This function will return `nil` if +the user is not authenticated. + +> Avoid using the subject in the SAML assertion in UI. Depending on how the +> IdP is setup, this might be a randomly generated id. +> +> You should only rely on the user attributes in the assertion. +> As an application working with an IdP, you should know which attributes +> will be made available to your application and out of +> those attributes which one should be treated as the logged in userid/name. +> For example it could be "uid" or "email" depending on how the authentication +> source is setup in the IdP. + +## Customization + +`Samly` allows you to specify a Plug Pipeline if you need more control over +the authenticated user's attributes and/or do a Just-in-time user creation. +The Plug Pipeline is invoked after the user has successfully authenticated +with the IdP but before a session is created. + +This is just a vanilla Plug Pipeline. The SAML assertion from +the IdP is made available in the Plug connection as a "private". +If you want to derive new attributes, create an Elixir map data (`%{}`) +and update the `computed` field of the SAML assertion and put it back +in the Plug connection private with `Conn.put_private` call. + +Here is a sample pipeline that shows this: + +```elixir +defmodule MySamlyPipeline do + use Plug.Builder + alias Samly.{Assertion} + + plug :compute_attributes + plug :jit_provision_user + + def compute_attributes(conn, _opts) do + assertion = conn.private[:samly_assertion] + + first_name = Map.get(assertion.attributes, :first_name) + last_name = Map.get(assertion.attributes, :last_name) + + computed = %{full_name: "#{first_name} #{last_name}"} + + assertion = %Assertion{assertion | computed: computed} + + conn + |> put_private(:samly_assertion, assertion) + + # If you have an error condition: + # conn + # |> send_resp(404, "attribute mapping failed") + # |> halt() + end + + def jit_provision_user(conn, _opts) do + # your user creation here ... + conn + end +end +``` + +Make this pipeline available in your config: + +```elixir +config :samly, Samly.Provider, + pre_session_create_pipeline: MySamlyPipeline +``` + +> Important: If you think you have a Plug Pipeline but don't find the computed +> attributes in the assertion returned by `Samly.get_active_assertion`, make +> sure the above config setting is specified. diff --git a/lib/samly.ex b/lib/samly.ex index b2303c9..4d48dfb 100644 --- a/lib/samly.ex +++ b/lib/samly.ex @@ -1,3 +1,24 @@ defmodule Samly do - @moduledoc false + alias Plug.Conn + alias Samly.{Assertion, State} + + @doc """ + Returns authenticated user SAML Assertion and any corresponding locally + computed/derived attributes. Returns `nil` if the current Plug session + is not authenticated. + """ + def get_active_assertion(conn) do + nameid = conn |> Conn.get_session("samly_nameid") + case State.get_by_nameid(nameid) do + {^nameid, saml_assertion} -> saml_assertion + _ -> nil + end + end + + def get_attribute(nil, _name), do: nil + def get_attribute(%Assertion{} = assertion, name) do + computed = assertion.computed + attributes = assertion.attributes + Map.get(computed, name) || Map.get(attributes, name) + end end diff --git a/lib/samly/assertion.ex b/lib/samly/assertion.ex index 62fc392..5d3ee1d 100644 --- a/lib/samly/assertion.ex +++ b/lib/samly/assertion.ex @@ -9,9 +9,10 @@ defmodule Samly.Assertion do recipient: "", issuer: "", subject: %Subject{}, - conditions: [], - attributes: [], - authn: [] + conditions: %{}, + attributes: %{}, + authn: %{}, + computed: %{} ] @type t :: %__MODULE__{ @@ -20,9 +21,10 @@ defmodule Samly.Assertion do recipient: String.t, issuer: String.t, subject: Subject.t, - conditions: Keyword.t, - attributes: Keyword.t, - authn: Keyword.t + conditions: map, + attributes: map, + authn: map, + computed: map } def from_rec(assertion_rec) do diff --git a/lib/samly/auth_handler.ex b/lib/samly/auth_handler.ex index 7ed3cef..e1df6a1 100644 --- a/lib/samly/auth_handler.ex +++ b/lib/samly/auth_handler.ex @@ -63,7 +63,7 @@ defmodule Samly.AuthHandler do end def send_signin_req(conn) do - sp = Helper.get_sp() + sp = Helper.get_sp() |> Helper.ensure_sp_uris_set(conn) idp_metadata = Helper.get_idp_metadata() target_url = conn.params["target_url"] || "/" @@ -71,7 +71,7 @@ defmodule Samly.AuthHandler do nameid = get_session(conn, "samly_nameid") case State.get_by_nameid(nameid) do - {^nameid, _assertions} -> + {^nameid, _saml_assertion} -> conn |> redirect(302, target_url) _ -> @@ -87,13 +87,13 @@ defmodule Samly.AuthHandler do end def send_signout_req(conn) do - sp = Helper.get_sp() + sp = Helper.get_sp() |> Helper.ensure_sp_uris_set(conn) idp_metadata = Helper.get_idp_metadata() target_url = conn.params["target_url"] || "/" nameid = get_session(conn, "samly_nameid") case State.get_by_nameid(nameid) do - {^nameid, _assertions} -> + {^nameid, _saml_assertion} -> {idp_signout_url, req_xml_frag} = Helper.gen_idp_signout_req(sp, idp_metadata, nameid) State.delete(nameid) diff --git a/lib/samly/helper.ex b/lib/samly/helper.ex index b1eadaa..0c984dc 100644 --- a/lib/samly/helper.ex +++ b/lib/samly/helper.ex @@ -6,10 +6,41 @@ defmodule Samly.Helper do require Samly.Esaml alias Samly.{Assertion, Esaml} + def get_metadata_uri(sp_base_url) when is_list(sp_base_url) do + sp_base_url ++ '/sp/metadata' + end + + def get_consume_uri(sp_base_url) when is_list(sp_base_url) do + sp_base_url ++ '/sp/consume' + end + + def get_logout_uri(sp_base_url) when is_list(sp_base_url) do + sp_base_url ++ '/sp/logout' + end + def get_sp() do get_env(:samly, :sp) end + def ensure_sp_uris_set(sp, conn) do + case Esaml.esaml_sp(sp, :metadata_uri) do + [?/ | _] -> + uri = %URI{ + scheme: Atom.to_string(conn.scheme), + host: conn.host, + port: conn.port, + path: "/sso" + } + base_url = URI.to_string(uri) |> String.to_charlist() + Esaml.esaml_sp(sp, + metadata_uri: get_metadata_uri(base_url), + consume_uri: get_consume_uri(base_url), + logout_uri: get_logout_uri(base_url) + ) + _ -> sp + end + end + def get_idp_metadata() do get_env(:samly, :idp_metadata) end diff --git a/lib/samly/provider.ex b/lib/samly/provider.ex index 0d00d20..292f574 100644 --- a/lib/samly/provider.ex +++ b/lib/samly/provider.ex @@ -4,9 +4,8 @@ defmodule Samly.Provider do use GenServer require Logger - alias Samly.State require Samly.Esaml - alias Samly.Esaml + alias Samly.{Esaml, Helper, State} def start_link(gs_opts \\ []) do GenServer.start_link(__MODULE__, [], gs_opts) @@ -14,13 +13,12 @@ defmodule Samly.Provider do def init([]) do opts = Application.get_env(:samly, Samly.Provider, []) - pipeline = opts[:assertion_pipeline] + pipeline = opts[:pre_session_create_pipeline] sp_certfile = get_opt(opts, :certfile, "SAMLY_CERTFILE", "samly.crt") sp_keyfile = get_opt(opts, :keyfile, "SAMLY_KEYFILE", "samly.pem") idp_metadata_file = get_opt(opts, :idp_metadata_file, "SAMLY_IDP_METADATA_FILE", "idp_metadata.xml") - sp_base_url = get_opt(opts, :base_url, - "SAMLY_BASE_URL", "http://localhost:4000/sso") + sp_base_url = get_opt(opts, :base_url, "SAMLY_BASE_URL", "") State.init() @@ -34,7 +32,7 @@ defmodule Samly.Provider do Application.put_env(:samly, :sp, sp) Application.put_env(:samly, :idp_metadata, idp_metadata) if pipeline do - Application.put_env(:samly, :assertion_pipeline, pipeline) + Application.put_env(:samly, :pre_session_create_pipeline, pipeline) end else {:idp_metadata_file, {:error, reason}} -> @@ -80,9 +78,9 @@ defmodule Samly.Provider do sp_sign_requests: true, sp_sign_metadata: true, trusted_fingerprints: trusted_fingerprints, - consume_uri: sp_base_url ++ '/sp/consume', - metadata_uri: sp_base_url ++ '/sp/metadata', - logout_uri: sp_base_url ++ '/sp/logout', + metadata_uri: Helper.get_metadata_uri(sp_base_url), + consume_uri: Helper.get_consume_uri(sp_base_url), + logout_uri: Helper.get_logout_uri(sp_base_url), # TODO: get this from config org: Esaml.esaml_org( name: 'Samly SP', @@ -95,7 +93,7 @@ defmodule Samly.Provider do ) ) - {:ok, sp_rec |> :esaml_sp.setup()} + {:ok, sp_rec} end defp idp_metadata_from_xml(metadata_xml) when is_binary(metadata_xml) do @@ -114,7 +112,8 @@ defmodule Samly.Provider do |> Esaml.esaml_idp_metadata(:certificate) |> cert_fingerprint() |> String.to_charlist() - [fingerprint] + + [fingerprint] |> :esaml_util.convert_fingerprints() end defp cert_fingerprint(dercert) do diff --git a/lib/samly/sp_handler.ex b/lib/samly/sp_handler.ex index cfae478..91a2c2e 100644 --- a/lib/samly/sp_handler.ex +++ b/lib/samly/sp_handler.ex @@ -3,39 +3,44 @@ defmodule Samly.SPHandler do import Plug.Conn alias Plug.Conn - alias Samly.{Helper, State} + require Samly.Esaml + alias Samly.{Assertion, Esaml, Helper, State} import Samly.RouterUtil, only: [send_saml_request: 4, redirect: 3] - require Samly.Esaml - alias Samly.Esaml - def send_metadata(conn) do - metadata = Helper.get_sp() |> Helper.sp_metadata() + metadata = Helper.get_sp() + |> Helper.ensure_sp_uris_set(conn) + |> Helper.sp_metadata() + conn |> put_resp_header("Content-Type", "text/xml") |> send_resp(200, metadata) end def consume_signin_response(conn) do - sp = Helper.get_sp() + sp = Helper.get_sp() |> Helper.ensure_sp_uris_set(conn) saml_encoding = conn.body_params["SAMLEncoding"] saml_response = conn.body_params["SAMLResponse"] relay_state = conn.body_params["RelayState"] - assertion_pipeline = Application.get_env(:samly, :assertion_pipeline) + pipeline = Application.get_env(:samly, :pre_session_create_pipeline) with ^relay_state when relay_state != nil <- get_session(conn, "relay_state"), target_url when target_url != nil <- get_session(conn, "target_url"), {:ok, assertion} <- Helper.decode_idp_auth_resp(sp, saml_encoding, saml_response), conn = conn |> put_private(:samly_assertion, assertion), {:halted, %Conn{halted: false} = conn} <- - {:halted, pipethrough(conn, assertion_pipeline)} + {:halted, pipethrough(conn, pipeline)} do - assertion = conn.private[:samly_assertion] + updated_assertion = conn.private[:samly_assertion] + computed = updated_assertion.computed + assertion = %Assertion{assertion | computed: computed} + nameid = assertion.subject.name State.put(nameid, assertion) + conn |> configure_session(renew: true) |> put_session("samly_nameid", nameid) @@ -52,12 +57,12 @@ defmodule Samly.SPHandler do end defp pipethrough(conn, nil), do: conn - defp pipethrough(conn, assertion_pipeline) do - assertion_pipeline.call(conn, []) + defp pipethrough(conn, pipeline) do + pipeline.call(conn, []) end def handle_logout_response(conn) do - sp = Helper.get_sp() + sp = Helper.get_sp() |> Helper.ensure_sp_uris_set(conn) saml_encoding = conn.body_params["SAMLEncoding"] saml_response = conn.body_params["SAMLResponse"] @@ -79,7 +84,7 @@ defmodule Samly.SPHandler do # non-ui logout request from IDP def handle_logout_request(conn) do - sp = Helper.get_sp() + sp = Helper.get_sp() |> Helper.ensure_sp_uris_set(conn) idp_metadata = Helper.get_idp_metadata() saml_encoding = conn.body_params["SAMLEncoding"] @@ -90,7 +95,7 @@ defmodule Samly.SPHandler do do nameid = Esaml.esaml_logoutreq(payload, :name) case State.get_by_nameid(nameid) do - {^nameid, _assertions} -> + {^nameid, _saml_assertion} -> State.delete(nameid) _ -> :ok end diff --git a/lib/samly/state.ex b/lib/samly/state.ex index 7189be9..fc0cc74 100644 --- a/lib/samly/state.ex +++ b/lib/samly/state.ex @@ -13,13 +13,13 @@ defmodule Samly.State do def get_by_nameid(nameid) do case :ets.lookup(:esaml_nameids, nameid) do - [{nameid, assertions}] -> {nameid, assertions} + [{_nameid, _saml_assertion} = rec] -> rec _ -> nil end end - def put(nameid, assertions) do - :ets.insert(:esaml_nameids, {nameid, assertions}) + def put(nameid, saml_assertion) do + :ets.insert(:esaml_nameids, {nameid, saml_assertion}) end def delete(nameid) do diff --git a/mix.exs b/mix.exs index 50ed8cf..5622d66 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Samly.Mixfile do use Mix.Project - @version "0.5.0" + @version "0.6.0" @description "SAML plug" @source_url "https://github.com/handnot2/samly" @@ -36,10 +36,10 @@ defmodule Samly.Mixfile do defp docs() do [ - #extras: ["README.md"], - #main: "readme", + extras: ["README.md"], + main: "readme", source_ref: "v#{@version}", - source_url: @source_url, + source_url: @source_url ] end diff --git a/mix.lock b/mix.lock index 03e7cc1..477adc9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,8 @@ -%{"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, - "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, - "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm"}, - "esaml": {:hex, :esaml, "3.0.1", "fea1bf280438f1c247a4fa45d87bf7df3ce1cbee504ae423c4d0f3f292e786aa", [:rebar3], [{:cowboy, "1.1.2", [hex: :cowboy, repo: "hexpm", optional: false]}], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.16.3", "cd2a4cfe5d26e37502d3ec776702c72efa1adfa24ed9ce723bb565f4c30bd31a", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, - "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], [], "hexpm"}, - "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, - "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}} +%{"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [], [], "hexpm"}, + "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [], [], "hexpm"}, + "esaml": {:hex, :esaml, "3.0.1", "fea1bf280438f1c247a4fa45d87bf7df3ce1cbee504ae423c4d0f3f292e786aa", [], [{:cowboy, "1.1.2", [hex: :cowboy, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.16.3", "cd2a4cfe5d26e37502d3ec776702c72efa1adfa24ed9ce723bb565f4c30bd31a", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [], [], "hexpm"}, + "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [], [], "hexpm"}}