Skip to content

Commit f56cd68

Browse files
authored
Merge pull request #15 from handnot2/idp-first
Support for IDP initiated login
2 parents 725e3d6 + c3ec4cd commit f56cd68

File tree

8 files changed

+131
-51
lines changed

8 files changed

+131
-51
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/auth_handler.ex

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,40 +25,24 @@ defmodule Samly.AuthHandler do
2525
</body>
2626
"""
2727

28-
def valid_referer?(conn) do
29-
referer =
30-
case conn |> get_req_header("referer") do
31-
[uri] -> URI.parse(uri)
32-
_ -> %URI{}
33-
end
34-
35-
[request_authority] = conn |> get_req_header("host")
36-
request_authority == referer.authority && referer.scheme == Atom.to_string(conn.scheme)
37-
end
38-
3928
def initiate_sso_req(conn) do
4029
import Plug.CSRFProtection, only: [get_csrf_token: 0]
4130

42-
with true <- valid_referer?(conn), target_url = conn.params["target_url"] do
43-
target_url = if target_url, do: URI.decode_www_form(target_url), else: nil
31+
target_url =
32+
case conn.params["target_url"] do
33+
nil -> nil
34+
url -> URI.decode_www_form(url)
35+
end
4436

45-
opts = [
46-
action: conn.request_path,
47-
target_url: target_url,
48-
csrf_token: get_csrf_token()
49-
]
37+
opts = [
38+
action: conn.request_path,
39+
target_url: target_url,
40+
csrf_token: get_csrf_token()
41+
]
5042

51-
conn
52-
|> put_resp_header("Content-Type", "text/html")
53-
|> send_resp(200, EEx.eval_string(@sso_init_resp_template, opts))
54-
else
55-
_ -> conn |> send_resp(403, "invalid_request")
56-
end
57-
58-
# rescue
59-
# error ->
60-
# Logger.error("#{inspect error}")
61-
# conn |> send_resp(500, "request_failed")
43+
conn
44+
|> put_resp_header("Content-Type", "text/html")
45+
|> send_resp(200, EEx.eval_string(@sso_init_resp_template, opts))
6246
end
6347

6448
def send_signin_req(conn) do

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

lib/samly/subject.ex

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ defmodule Samly.Subject do
33
The subject in a SAML 2.0 Assertion.
44
55
This is part of the `Samly.Assertion` struct. The `name` field in this struct should not
6-
be used any UI directly. It might be a temporary randomly generated
6+
be used in any UI directly. It might be a temporary randomly generated
77
ID from IdP. `Samly` internally uses this to deal with IdP initiated logout requests.
8+
9+
If an authentication request was sent from `Samly` (SP initiated), the SAML response
10+
is expected to include the original request ID. This ID is made available in
11+
`Samly.Subject.in_response_to`.
12+
13+
If the authentication request originated from the IDP (IDP initiated), there won't
14+
be a `Samly` request ID associated with it. The `Samly.Subject.in_response_to`
15+
will be an empty string in that case.
816
"""
917

1018
require Samly.Esaml
@@ -15,15 +23,17 @@ defmodule Samly.Subject do
1523
sp_name_qualifier: :undefined,
1624
name_format: :undefined,
1725
confirmation_method: :bearer,
18-
notonorafter: ""
26+
notonorafter: "",
27+
in_response_to: ""
1928

2029
@type t :: %__MODULE__{
2130
name: String.t(),
2231
name_qualifier: :undefined | String.t(),
2332
sp_name_qualifier: :undefined | String.t(),
2433
name_format: :undefined | String.t(),
2534
confirmation_method: atom,
26-
notonorafter: String.t()
35+
notonorafter: String.t(),
36+
in_response_to: String.t()
2737
}
2838

2939
@doc false
@@ -34,7 +44,8 @@ defmodule Samly.Subject do
3444
sp_name_qualifier: sp_name_qualifier,
3545
name_format: name_format,
3646
confirmation_method: confirmation_method,
37-
notonorafter: notonorafter
47+
notonorafter: notonorafter,
48+
in_response_to: in_response_to
3849
) = subject_rec
3950

4051
%__MODULE__{
@@ -43,7 +54,8 @@ defmodule Samly.Subject do
4354
sp_name_qualifier: to_string_or_undefined(sp_name_qualifier),
4455
name_format: to_string_or_undefined(name_format),
4556
confirmation_method: confirmation_method,
46-
notonorafter: notonorafter |> List.to_string()
57+
notonorafter: notonorafter |> List.to_string(),
58+
in_response_to: in_response_to |> List.to_string()
4759
}
4860
end
4961

@@ -55,7 +67,8 @@ defmodule Samly.Subject do
5567
sp_name_qualifier: from_string_or_undefined(subject.sp_name_qualifier),
5668
name_format: from_string_or_undefined(subject.name_format),
5769
confirmation_method: subject.confirmation_method,
58-
notonorafter: String.to_charlist(subject.notonorafter)
70+
notonorafter: String.to_charlist(subject.notonorafter),
71+
in_response_to: String.to_charlist(subject.in_response_to)
5972
)
6073
end
6174

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)