Skip to content

Commit

Permalink
Merge pull request #15 from handnot2/idp-first
Browse files Browse the repository at this point in the history
Support for IDP initiated login
  • Loading branch information
handnot2 authored Feb 26, 2018
2 parents 725e3d6 + c3ec4cd commit f56cd68
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 51 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -15,7 +15,7 @@ plug enabled routes.
defp deps() do
[
# ...
{:samly, "~> 0.8"},
{:samly, "~> 0.9"},
]
end
```
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
}
]
```
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
42 changes: 13 additions & 29 deletions lib/samly/auth_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,40 +25,24 @@ defmodule Samly.AuthHandler do
</body>
"""

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
Expand Down
16 changes: 16 additions & 0 deletions lib/samly/idp_data.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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(),
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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
Expand Down
56 changes: 52 additions & 4 deletions lib/samly/sp_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -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
Expand Down
25 changes: 19 additions & 6 deletions lib/samly/subject.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,15 +23,17 @@ 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(),
name_qualifier: :undefined | String.t(),
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
Expand All @@ -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__{
Expand All @@ -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

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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}
Expand Down
14 changes: 8 additions & 6 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
}

0 comments on commit f56cd68

Please sign in to comment.