Skip to content

Commit c3ec4cd

Browse files
committed
Support for IDP initiated SSO login. Fixes #12
1 parent ccad951 commit c3ec4cd

File tree

6 files changed

+99
-16
lines changed

6 files changed

+99
-16
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# CHANGELOG
22

3+
### v0.9.0
4+
5+
+ Issue: #12. Support for IDP initiated SSO flow.
6+
7+
+ Original auth request ID when returned in auth response is made available
8+
in the assertion subject (SP initiated SSO flows). For IDP initiated
9+
SSO flows, this will be an empty string.
10+
11+
+ Issue: #14. Remove built-in referer check.
12+
Not specific to `Samly`. It is better handled by the consuming application.
13+
314
### v0.8.4
415

516
+ Shibboleth Single Logout session match related fix. Uptake `esaml v3.3.0`.

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Samly
22

3-
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.
3+
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.
44

55
[![Inline docs](http://inch-ci.org/github/handnot2/samly.svg)](http://inch-ci.org/github/handnot2/samly)
66

@@ -15,7 +15,7 @@ plug enabled routes.
1515
defp deps() do
1616
[
1717
# ...
18-
{:samly, "~> 0.8"},
18+
{:samly, "~> 0.9"},
1919
]
2020
end
2121
```
@@ -79,7 +79,7 @@ tab. At the top there will be a section titled "SAML 2.0 IdP Metadata". Click
7979
on the `Show metadata` link. Copy the metadata XML from this page and save it
8080
in a local file (`idp_metadata.xml` for example).
8181

82-
Make sure to save this XML file and provide the path to the saveed file in
82+
Make sure to save this XML file and provide the path to the saved file in
8383
`Samly` configuration.
8484

8585
## Identity Provider ID in Samly
@@ -166,7 +166,9 @@ config :samly, Samly.Provider,
166166
#sign_requests: true,
167167
#sign_metadata: true,
168168
#signed_assertion_in_resp: true,
169-
#signed_envelopes_in_resp: true
169+
#signed_envelopes_in_resp: true,
170+
#allow_idp_initiated_flow: false,
171+
#allowed_target_urls: ["http://do-good.org"]
170172
}
171173
]
172174
```
@@ -193,6 +195,8 @@ config :samly, Samly.Provider,
193195
| `use_redirect_for_req` | _(optional)_ Default is `false`. When this is `false`, `Samly` will POST to the IdP SAML endpoints. |
194196
| `signed_requests`, `signed_metadata` | _(optional)_ Default is `true`. |
195197
| `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. |
198+
| `allow_idp_initiated_flow` | _(optional)_ Default is `false`. IDP initiated SSO is allowed only when this is set to `true`. |
199+
| `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. |
196200

197201
## SAML Assertion
198202

@@ -285,6 +289,8 @@ config :samly, Samly.Provider,
285289

286290
+ `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`).
287291
+ Besides the `RelayState`, the request and response `idp_id`s must match. Reponse is rejected if they don't.
292+
+ `Samly` makes the original request ID that an auth response corresponds to
293+
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.
288294
+ OOTB SAML requests and responses are signed.
289295
+ Signature digest method supported: `SHA256`.
290296
> Some Identity Providers may be using `SHA1` by default.

lib/samly/idp_data.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ defmodule Samly.IdpData do
2020
sign_metadata: true,
2121
signed_assertion_in_resp: true,
2222
signed_envelopes_in_resp: true,
23+
allow_idp_initiated_flow: false,
24+
allowed_target_urls: [],
2325
entity_id: "",
2426
signed_requests: "",
2527
certs: [],
@@ -44,6 +46,8 @@ defmodule Samly.IdpData do
4446
sign_metadata: boolean(),
4547
signed_assertion_in_resp: boolean(),
4648
signed_envelopes_in_resp: boolean(),
49+
allow_idp_initiated_flow: boolean(),
50+
allowed_target_urls: nil | [binary()],
4751
entity_id: binary(),
4852
signed_requests: binary(),
4953
certs: certs(),
@@ -105,11 +109,13 @@ defmodule Samly.IdpData do
105109
%IdpData{idp_data | id: id, sp_id: sp_id, base_url: Map.get(opts_map, :base_url)}
106110
|> set_metadata_file(opts_map)
107111
|> set_pipeline(opts_map)
112+
|> set_allowed_target_urls(opts_map)
108113
|> set_boolean_attr(opts_map, :use_redirect_for_req)
109114
|> set_boolean_attr(opts_map, :sign_requests)
110115
|> set_boolean_attr(opts_map, :sign_metadata)
111116
|> set_boolean_attr(opts_map, :signed_assertion_in_resp)
112117
|> set_boolean_attr(opts_map, :signed_envelopes_in_resp)
118+
|> set_boolean_attr(opts_map, :allow_idp_initiated_flow)
113119
end
114120

115121
@spec load_metadata(%IdpData{}, map()) :: %IdpData{}
@@ -155,6 +161,16 @@ defmodule Samly.IdpData do
155161
%IdpData{idp_data | pre_session_create_pipeline: pipeline}
156162
end
157163

164+
defp set_allowed_target_urls(%IdpData{} = idp_data, %{} = opts_map) do
165+
target_urls =
166+
case Map.get(opts_map, :allowed_target_urls, nil) do
167+
nil -> nil
168+
urls when is_list(urls) -> Enum.filter(urls, &is_binary/1)
169+
end
170+
171+
%IdpData{idp_data | allowed_target_urls: target_urls}
172+
end
173+
158174
@spec set_boolean_attr(%IdpData{}, map(), atom()) :: %IdpData{}
159175
defp set_boolean_attr(%IdpData{} = idp_data, %{} = opts_map, attr_name)
160176
when is_atom(attr_name) do

lib/samly/sp_handler.ex

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,8 @@ defmodule Samly.SPHandler do
3434
saml_response = conn.body_params["SAMLResponse"]
3535
relay_state = conn.body_params["RelayState"] |> URI.decode_www_form()
3636

37-
with ^relay_state when relay_state != nil <- get_session(conn, "relay_state"),
38-
^idp_id <- get_session(conn, "idp_id"),
39-
target_url when target_url != nil <- get_session(conn, "target_url"),
40-
{:ok, assertion} <- Helper.decode_idp_auth_resp(sp, saml_encoding, saml_response),
37+
with {:ok, assertion} <- Helper.decode_idp_auth_resp(sp, saml_encoding, saml_response),
38+
:ok <- validate_authresp(conn, assertion, relay_state),
4139
conn = conn |> put_private(:samly_assertion, assertion),
4240
{:halted, %Conn{halted: false} = conn} <- {:halted, pipethrough(conn, pipeline)} do
4341
updated_assertion = conn.private[:samly_assertion]
@@ -47,6 +45,7 @@ defmodule Samly.SPHandler do
4745
# TODO: use idp_id + nameid
4846
nameid = assertion.subject.name
4947
State.put(nameid, assertion)
48+
target_url = auth_target_url(conn, assertion, relay_state)
5049

5150
conn
5251
|> configure_session(renew: true)
@@ -64,12 +63,61 @@ defmodule Samly.SPHandler do
6463
# conn |> send_resp(500, "request_failed")
6564
end
6665

66+
# IDP-initiated flow auth response
67+
@spec validate_authresp(Conn.t(), Assertion.t(), binary) :: :ok | {:error, atom}
68+
defp validate_authresp(conn, %{subject: %{in_response_to: ""}}, relay_state) do
69+
idp_data = conn.private[:samly_idp]
70+
71+
if idp_data.allow_idp_initiated_flow do
72+
if idp_data.allowed_target_urls do
73+
if relay_state in idp_data.allowed_target_urls do
74+
:ok
75+
else
76+
{:error, :invalid_target_url}
77+
end
78+
else
79+
:ok
80+
end
81+
else
82+
{:error, :idp_first_flow_not_allowed}
83+
end
84+
end
85+
86+
# SP-initiated flow auth response
87+
defp validate_authresp(conn, _assertion, relay_state) do
88+
%IdpData{id: idp_id} = conn.private[:samly_idp]
89+
rs_in_session = get_session(conn, "relay_state")
90+
idp_id_in_session = get_session(conn, "idp_id")
91+
url_in_session = get_session(conn, "target_url")
92+
93+
cond do
94+
rs_in_session == nil || rs_in_session != relay_state ->
95+
{:error, :invalid_relay_state}
96+
97+
idp_id_in_session == nil || idp_id_in_session != idp_id ->
98+
{:error, :invalid_idp_id}
99+
100+
url_in_session == nil ->
101+
{:error, :invalid_target_url}
102+
103+
true ->
104+
:ok
105+
end
106+
end
107+
67108
defp pipethrough(conn, nil), do: conn
68109

69110
defp pipethrough(conn, pipeline) do
70111
pipeline.call(conn, [])
71112
end
72113

114+
defp auth_target_url(_conn, %{subject: %{in_response_to: ""}}, ""), do: "/"
115+
defp auth_target_url(_conn, %{subject: %{in_response_to: ""}}, url), do: url
116+
117+
defp auth_target_url(conn, _assertion, _relay_state) do
118+
get_session(conn, "target_url") || "/"
119+
end
120+
73121
def handle_logout_response(conn) do
74122
%IdpData{id: idp_id} = idp = conn.private[:samly_idp]
75123
%IdpData{esaml_idp_rec: _idp_rec, esaml_sp_rec: sp_rec} = idp

mix.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Samly.Mixfile do
22
use Mix.Project
33

4-
@version "0.8.4"
4+
@version "0.9.0"
55
@description "SAML SP SSO made easy"
66
@source_url "https://github.com/handnot2/samly"
77

@@ -29,7 +29,7 @@ defmodule Samly.Mixfile do
2929
defp deps() do
3030
[
3131
{:plug, "~> 1.4"},
32-
{:esaml, "~> 3.3"},
32+
{:esaml, "~> 3.4"},
3333
{:sweet_xml, "~> 0.6"},
3434
{:ex_doc, "~> 0.18", only: :dev},
3535
{:inch_ex, "~> 0.5", only: :docs}

mix.lock

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
%{"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"},
1+
%{
2+
"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"},
23
"cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"},
34
"earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"},
4-
"esaml": {:hex, :esaml, "3.3.0", "9b675c1201ef2d60e53cf5603a20560e1a688acc128bf0de476812919e4d2c52", [:rebar3], [{:cowboy, "1.1.2", [hex: :cowboy, repo: "hexpm", optional: false]}], "hexpm"},
5-
"ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"},
5+
"esaml": {:hex, :esaml, "3.4.0", "4950639c1fb700e8b6a00bd9776e791372263d360db882c0654183e082b390d8", [:rebar3], [{:cowboy, "1.1.2", [hex: :cowboy, repo: "hexpm", optional: false]}], "hexpm"},
6+
"ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"},
67
"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"},
7-
"mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], [], "hexpm"},
8-
"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"},
8+
"mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"},
9+
"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"},
910
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
1011
"ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"},
11-
"sweet_xml": {:hex, :sweet_xml, "0.6.5", "dd9cde443212b505d1b5f9758feb2000e66a14d3c449f04c572f3048c66e6697", [:mix], [], "hexpm"}}
12+
"sweet_xml": {:hex, :sweet_xml, "0.6.5", "dd9cde443212b505d1b5f9758feb2000e66a14d3c449f04c572f3048c66e6697", [:mix], [], "hexpm"},
13+
}

0 commit comments

Comments
 (0)