diff --git a/CHANGELOG.md b/CHANGELOG.md index aeed291..7501fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +### v0.9.0 + ++ Issue: #12. Support for IDP initiated SSO flow. + ++ Original auth request ID when returned in auth response is made available + in the assertion subject (SP initiated SSO flows). For IDP initiated + SSO flows, this will be an empty string. + ++ Issue: #14. Remove built-in referer check. + Not specific to `Samly`. It is better handled by the consuming application. + ### v0.8.4 + Shibboleth Single Logout session match related fix. Uptake `esaml v3.3.0`. diff --git a/README.md b/README.md index d9e8d05..ccf9d49 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Samly -SAML 2.0 SP SSO made easy. This is a Plug library that can be used to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. +SAML 2.0 SP SSO made easy. This is a Plug library that can be used to enable SAML 2.0 Single Sign On authentication in a Plug/Phoenix application. [![Inline docs](http://inch-ci.org/github/handnot2/samly.svg)](http://inch-ci.org/github/handnot2/samly) @@ -15,7 +15,7 @@ plug enabled routes. defp deps() do [ # ... - {:samly, "~> 0.8"}, + {:samly, "~> 0.9"}, ] end ``` @@ -79,7 +79,7 @@ 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 save it in a local file (`idp_metadata.xml` for example). -Make sure to save this XML file and provide the path to the saveed file in +Make sure to save this XML file and provide the path to the saved file in `Samly` configuration. ## Identity Provider ID in Samly @@ -166,7 +166,9 @@ config :samly, Samly.Provider, #sign_requests: true, #sign_metadata: true, #signed_assertion_in_resp: true, - #signed_envelopes_in_resp: true + #signed_envelopes_in_resp: true, + #allow_idp_initiated_flow: false, + #allowed_target_urls: ["http://do-good.org"] } ] ``` @@ -193,6 +195,8 @@ config :samly, Samly.Provider, | `use_redirect_for_req` | _(optional)_ Default is `false`. When this is `false`, `Samly` will POST to the IdP SAML endpoints. | | `signed_requests`, `signed_metadata` | _(optional)_ Default is `true`. | | `signed_assertion_in_resp`, `signed_envelopes_in_resp` | _(optional)_ Default is `true`. When `true`, `Samly` expects the requests and responses from IdP to be signed. | +| `allow_idp_initiated_flow` | _(optional)_ Default is `false`. IDP initiated SSO is allowed only when this is set to `true`. | +| `allowed_target_urls` | _(optional)_ Default is `[]`. `Samly` uses this **only** when `allow_idp_initiated_flow` parameter is set to `true`. Make sure to set this to one or more exact URLs you want to allow (whitelist). The URL to redirect the user after completing the SSO flow is sent from IDP in auth response as `relay_state`. This `relay_state` target URL is matched against this URL list. Set the value to `nil` if you do not want this whitelist capability. | ## SAML Assertion @@ -285,6 +289,8 @@ config :samly, Samly.Provider, + `Samly` initiated sign-in/sign-out requests send `RelayState` to IdP and expect to get that back. Mismatched or missing `RelayState` in IdP responses to SP initiated requests will fail (with HTTP `403 access_denied`). + Besides the `RelayState`, the request and response `idp_id`s must match. Reponse is rejected if they don't. ++ `Samly` makes the original request ID that an auth response corresponds to +in `Samly.Subject.in_response_to` field. It is the responsibility of the consuming application to use this information along with the validity period in the assertion to check for **replay attacks**. The consuming application should use the `pre_session_create_pipeline` to perform this check. You may need a database or a distributed cache such as memcache in a clustered setup to keep track of these request IDs for their validity period to perform this check. Be aware that `in_response_to` field is **not** set when IDP initialized authorization flow is used. + OOTB SAML requests and responses are signed. + Signature digest method supported: `SHA256`. > Some Identity Providers may be using `SHA1` by default. diff --git a/lib/samly/auth_handler.ex b/lib/samly/auth_handler.ex index 532f1f8..9def400 100644 --- a/lib/samly/auth_handler.ex +++ b/lib/samly/auth_handler.ex @@ -25,40 +25,24 @@ defmodule Samly.AuthHandler do """ - def valid_referer?(conn) do - referer = - case conn |> get_req_header("referer") do - [uri] -> URI.parse(uri) - _ -> %URI{} - end - - [request_authority] = conn |> get_req_header("host") - request_authority == referer.authority && referer.scheme == Atom.to_string(conn.scheme) - end - def initiate_sso_req(conn) do import Plug.CSRFProtection, only: [get_csrf_token: 0] - with true <- valid_referer?(conn), target_url = conn.params["target_url"] do - target_url = if target_url, do: URI.decode_www_form(target_url), else: nil + target_url = + case conn.params["target_url"] do + nil -> nil + url -> URI.decode_www_form(url) + end - opts = [ - action: conn.request_path, - target_url: target_url, - csrf_token: get_csrf_token() - ] + opts = [ + action: conn.request_path, + target_url: target_url, + csrf_token: get_csrf_token() + ] - conn - |> put_resp_header("Content-Type", "text/html") - |> send_resp(200, EEx.eval_string(@sso_init_resp_template, opts)) - else - _ -> conn |> send_resp(403, "invalid_request") - end - - # rescue - # error -> - # Logger.error("#{inspect error}") - # conn |> send_resp(500, "request_failed") + conn + |> put_resp_header("Content-Type", "text/html") + |> send_resp(200, EEx.eval_string(@sso_init_resp_template, opts)) end def send_signin_req(conn) do diff --git a/lib/samly/idp_data.ex b/lib/samly/idp_data.ex index 3052bf3..5795568 100644 --- a/lib/samly/idp_data.ex +++ b/lib/samly/idp_data.ex @@ -20,6 +20,8 @@ defmodule Samly.IdpData do sign_metadata: true, signed_assertion_in_resp: true, signed_envelopes_in_resp: true, + allow_idp_initiated_flow: false, + allowed_target_urls: [], entity_id: "", signed_requests: "", certs: [], @@ -44,6 +46,8 @@ defmodule Samly.IdpData do sign_metadata: boolean(), signed_assertion_in_resp: boolean(), signed_envelopes_in_resp: boolean(), + allow_idp_initiated_flow: boolean(), + allowed_target_urls: nil | [binary()], entity_id: binary(), signed_requests: binary(), certs: certs(), @@ -105,11 +109,13 @@ defmodule Samly.IdpData do %IdpData{idp_data | id: id, sp_id: sp_id, base_url: Map.get(opts_map, :base_url)} |> set_metadata_file(opts_map) |> set_pipeline(opts_map) + |> set_allowed_target_urls(opts_map) |> set_boolean_attr(opts_map, :use_redirect_for_req) |> set_boolean_attr(opts_map, :sign_requests) |> set_boolean_attr(opts_map, :sign_metadata) |> set_boolean_attr(opts_map, :signed_assertion_in_resp) |> set_boolean_attr(opts_map, :signed_envelopes_in_resp) + |> set_boolean_attr(opts_map, :allow_idp_initiated_flow) end @spec load_metadata(%IdpData{}, map()) :: %IdpData{} @@ -155,6 +161,16 @@ defmodule Samly.IdpData do %IdpData{idp_data | pre_session_create_pipeline: pipeline} end + defp set_allowed_target_urls(%IdpData{} = idp_data, %{} = opts_map) do + target_urls = + case Map.get(opts_map, :allowed_target_urls, nil) do + nil -> nil + urls when is_list(urls) -> Enum.filter(urls, &is_binary/1) + end + + %IdpData{idp_data | allowed_target_urls: target_urls} + end + @spec set_boolean_attr(%IdpData{}, map(), atom()) :: %IdpData{} defp set_boolean_attr(%IdpData{} = idp_data, %{} = opts_map, attr_name) when is_atom(attr_name) do diff --git a/lib/samly/sp_handler.ex b/lib/samly/sp_handler.ex index dc1e9f6..02ad1ee 100644 --- a/lib/samly/sp_handler.ex +++ b/lib/samly/sp_handler.ex @@ -34,10 +34,8 @@ defmodule Samly.SPHandler do saml_response = conn.body_params["SAMLResponse"] relay_state = conn.body_params["RelayState"] |> URI.decode_www_form() - with ^relay_state when relay_state != nil <- get_session(conn, "relay_state"), - ^idp_id <- get_session(conn, "idp_id"), - target_url when target_url != nil <- get_session(conn, "target_url"), - {:ok, assertion} <- Helper.decode_idp_auth_resp(sp, saml_encoding, saml_response), + with {:ok, assertion} <- Helper.decode_idp_auth_resp(sp, saml_encoding, saml_response), + :ok <- validate_authresp(conn, assertion, relay_state), conn = conn |> put_private(:samly_assertion, assertion), {:halted, %Conn{halted: false} = conn} <- {:halted, pipethrough(conn, pipeline)} do updated_assertion = conn.private[:samly_assertion] @@ -47,6 +45,7 @@ defmodule Samly.SPHandler do # TODO: use idp_id + nameid nameid = assertion.subject.name State.put(nameid, assertion) + target_url = auth_target_url(conn, assertion, relay_state) conn |> configure_session(renew: true) @@ -64,12 +63,61 @@ defmodule Samly.SPHandler do # conn |> send_resp(500, "request_failed") end + # IDP-initiated flow auth response + @spec validate_authresp(Conn.t(), Assertion.t(), binary) :: :ok | {:error, atom} + defp validate_authresp(conn, %{subject: %{in_response_to: ""}}, relay_state) do + idp_data = conn.private[:samly_idp] + + if idp_data.allow_idp_initiated_flow do + if idp_data.allowed_target_urls do + if relay_state in idp_data.allowed_target_urls do + :ok + else + {:error, :invalid_target_url} + end + else + :ok + end + else + {:error, :idp_first_flow_not_allowed} + end + end + + # SP-initiated flow auth response + defp validate_authresp(conn, _assertion, relay_state) do + %IdpData{id: idp_id} = conn.private[:samly_idp] + rs_in_session = get_session(conn, "relay_state") + idp_id_in_session = get_session(conn, "idp_id") + url_in_session = get_session(conn, "target_url") + + cond do + rs_in_session == nil || rs_in_session != relay_state -> + {:error, :invalid_relay_state} + + idp_id_in_session == nil || idp_id_in_session != idp_id -> + {:error, :invalid_idp_id} + + url_in_session == nil -> + {:error, :invalid_target_url} + + true -> + :ok + end + end + defp pipethrough(conn, nil), do: conn defp pipethrough(conn, pipeline) do pipeline.call(conn, []) end + defp auth_target_url(_conn, %{subject: %{in_response_to: ""}}, ""), do: "/" + defp auth_target_url(_conn, %{subject: %{in_response_to: ""}}, url), do: url + + defp auth_target_url(conn, _assertion, _relay_state) do + get_session(conn, "target_url") || "/" + end + def handle_logout_response(conn) do %IdpData{id: idp_id} = idp = conn.private[:samly_idp] %IdpData{esaml_idp_rec: _idp_rec, esaml_sp_rec: sp_rec} = idp diff --git a/lib/samly/subject.ex b/lib/samly/subject.ex index fcce2b0..b1bd1f9 100644 --- a/lib/samly/subject.ex +++ b/lib/samly/subject.ex @@ -3,8 +3,16 @@ defmodule Samly.Subject do The subject in a SAML 2.0 Assertion. This is part of the `Samly.Assertion` struct. The `name` field in this struct should not - be used any UI directly. It might be a temporary randomly generated + be used in any UI directly. It might be a temporary randomly generated ID from IdP. `Samly` internally uses this to deal with IdP initiated logout requests. + + If an authentication request was sent from `Samly` (SP initiated), the SAML response + is expected to include the original request ID. This ID is made available in + `Samly.Subject.in_response_to`. + + If the authentication request originated from the IDP (IDP initiated), there won't + be a `Samly` request ID associated with it. The `Samly.Subject.in_response_to` + will be an empty string in that case. """ require Samly.Esaml @@ -15,7 +23,8 @@ defmodule Samly.Subject do sp_name_qualifier: :undefined, name_format: :undefined, confirmation_method: :bearer, - notonorafter: "" + notonorafter: "", + in_response_to: "" @type t :: %__MODULE__{ name: String.t(), @@ -23,7 +32,8 @@ defmodule Samly.Subject do sp_name_qualifier: :undefined | String.t(), name_format: :undefined | String.t(), confirmation_method: atom, - notonorafter: String.t() + notonorafter: String.t(), + in_response_to: String.t() } @doc false @@ -34,7 +44,8 @@ defmodule Samly.Subject do sp_name_qualifier: sp_name_qualifier, name_format: name_format, confirmation_method: confirmation_method, - notonorafter: notonorafter + notonorafter: notonorafter, + in_response_to: in_response_to ) = subject_rec %__MODULE__{ @@ -43,7 +54,8 @@ defmodule Samly.Subject do sp_name_qualifier: to_string_or_undefined(sp_name_qualifier), name_format: to_string_or_undefined(name_format), confirmation_method: confirmation_method, - notonorafter: notonorafter |> List.to_string() + notonorafter: notonorafter |> List.to_string(), + in_response_to: in_response_to |> List.to_string() } end @@ -55,7 +67,8 @@ defmodule Samly.Subject do sp_name_qualifier: from_string_or_undefined(subject.sp_name_qualifier), name_format: from_string_or_undefined(subject.name_format), confirmation_method: subject.confirmation_method, - notonorafter: String.to_charlist(subject.notonorafter) + notonorafter: String.to_charlist(subject.notonorafter), + in_response_to: String.to_charlist(subject.in_response_to) ) end diff --git a/mix.exs b/mix.exs index be74f52..31d4f51 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Samly.Mixfile do use Mix.Project - @version "0.8.4" + @version "0.9.0" @description "SAML SP SSO made easy" @source_url "https://github.com/handnot2/samly" @@ -29,7 +29,7 @@ defmodule Samly.Mixfile do defp deps() do [ {:plug, "~> 1.4"}, - {:esaml, "~> 3.3"}, + {:esaml, "~> 3.4"}, {:sweet_xml, "~> 0.6"}, {:ex_doc, "~> 0.18", only: :dev}, {:inch_ex, "~> 0.5", only: :docs} diff --git a/mix.lock b/mix.lock index e5b86b4..7b8c223 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,13 @@ -%{"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"}, +%{ + "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.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, - "esaml": {:hex, :esaml, "3.3.0", "9b675c1201ef2d60e53cf5603a20560e1a688acc128bf0de476812919e4d2c52", [:rebar3], [{:cowboy, "1.1.2", [hex: :cowboy, repo: "hexpm", optional: false]}], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "esaml": {:hex, :esaml, "3.4.0", "4950639c1fb700e8b6a00bd9776e791372263d360db882c0654183e082b390d8", [:rebar3], [{:cowboy, "1.1.2", [hex: :cowboy, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, 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"}, + "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.4.5", "7b13869283fff6b8b21b84b8735326cc012c5eef8607095dc6ee24bd0a273d8e", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, - "sweet_xml": {:hex, :sweet_xml, "0.6.5", "dd9cde443212b505d1b5f9758feb2000e66a14d3c449f04c572f3048c66e6697", [:mix], [], "hexpm"}} + "sweet_xml": {:hex, :sweet_xml, "0.6.5", "dd9cde443212b505d1b5f9758feb2000e66a14d3c449f04c572f3048c66e6697", [:mix], [], "hexpm"}, +}