Skip to content

Commit 826d594

Browse files
committed
Plug Pipeline config changes
Computed attributes in Samly.Assertion Samly.Provider base_url handling changes
1 parent feeb98c commit 826d594

File tree

11 files changed

+406
-52
lines changed

11 files changed

+406
-52
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# CHANGELOG
2+
3+
### v0.6.0
4+
5+
+ Plug Pipeline config `:pre_session_create_pipeline`
6+
+ Computed attributes available in `Samly.Assertion`
7+
+ Updates to `Samly.Provider` `base_url` config handling

README.md

Lines changed: 290 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,292 @@
11
# Samly
22

3-
SAML plug ... WIP
3+
A Plug library to enable SAML 2.0 Single Sign On in a Plug/Phoenix application.
4+
5+
This library uses Erlang [`esaml`](https://github.com/handnot2/esaml) to provide
6+
plug enabled routes. So, it is constrained by `esaml` capabilities - only Service
7+
Provider initiated login is supported. The logout operation can be either IdP
8+
initiated or SP initiated.
9+
10+
## FAQ
11+
12+
#### How to setup a SAML 2.0 IdP for development purposes?
13+
14+
Docker based setup of [`SimpleSAMLPhp`](https://simplesamlphp.org) is made available
15+
at [`samly_simplesaml`](https://github.com/handnot2/samly_simplesaml) Git Repo.
16+
17+
```sh
18+
git clone https://github.com/handnot2/samly_simplesaml
19+
cd samly_simplesaml
20+
21+
# Ubuntu 16.04 based
22+
./build.sh
23+
24+
# Follow along README.md (skip SAML Service Provider registration part for now)
25+
# Edit setup/params/params.yml with appropriate information
26+
# Add the IDP host name to your /etc/hosts resolving to 127.0.0.1
27+
# 127.0.0.1 samly.idp
28+
# Compose exposes and binds to port 8082 by default.
29+
30+
docker-compose up -d
31+
docker-compose restart
32+
```
33+
34+
You should have a working SAML 2.0 IdP that you can work with.
35+
36+
#### Any sample Phoenix application that shows how to use Samly?
37+
38+
Clone the [`samly_howto`](https://github.com/handnot2/samly_howto) Git Repo.
39+
40+
```sh
41+
git clone https://github.com/handnot2/samly_howto
42+
43+
# Add the SP host name to your /etc/hosts resolving to 127.0.0.1
44+
# 127.0.0.1 samly.howto
45+
46+
cd samly_howto
47+
48+
# Use gencert.sh to create a self-signed certificate for the SAML Service Provider
49+
# embedded in your app (by `Samly`). We will register this and the `Samly` URLs
50+
# with IdP shortly. Take a look at this script and adjust the certificate subject
51+
# if needed.
52+
53+
./gencert.sh
54+
55+
# Fetch the IdP metadata XML. `Samly` needs this to make sure that it can
56+
# validate the request/responses to/from IdP.
57+
58+
wget http://samly.idp:8082/simplesaml/saml2/idp/metadata.php -O idp_metadata.xml
59+
60+
mix deps.get
61+
mix compile
62+
63+
HOST=samly.howto PORT=4003 iex -S mix phx.server
64+
```
65+
66+
> Important: Make sure that your have registered this application with
67+
> the IdP before you explore this application using a browser.
68+
69+
Open `http://samly.howto:4003` in your browser and check out the app.
70+
71+
#### How to register the service provider with IdP
72+
73+
Complte the setup by registering `samly_howto` as a Service Provider with the
74+
IdP.
75+
76+
```sh
77+
mkdir -p samly_simplesaml/setup/sp/samly_howto # use the correct path
78+
cp samly.crt samly_simplesaml/setup/sp/samly_howto/sp.crt
79+
cd samly_simplesaml
80+
docker-compose restart
81+
```
82+
83+
> The IdP related instructions are very specific to the docker based development
84+
> setup of SimpleSAMLphp IdP. But similar ideas work for your own IdP setup.
85+
86+
#### How do I enable Samly in my application?
87+
88+
The short of it is:
89+
90+
+ Add `Samly` to your `mix.exs`
91+
+ Include `Samly` in your supervision tree
92+
+ Include route forwarding to your `router.ex`
93+
+ Use `/sso/auth/signin` and `/soo/auth/signout` relative URIs in your UI
94+
with optional `target_url` query parameter
95+
+ Config changes in your config files or environment variable as appropriate
96+
+ Use `Samly.get_active_assertion` function to get authenticated user
97+
information
98+
+ Register this application with the IdP
99+
100+
That covers it for the basics. If you need to use different attribute names
101+
(from what the IdP provides), derive/compute new attributes or do Just-in-time
102+
user provisioning, create your own Plug Pipeline and make that available to
103+
`Samly` using a config setting. Check out the `SAML Assertion` section for
104+
specifics.
105+
106+
## Setup
107+
108+
```elixir
109+
# mix.exs
110+
111+
defp deps() do
112+
[
113+
# ...
114+
{:samly, "~> 0.6"},
115+
]
116+
end
117+
```
118+
119+
## Configuration
120+
121+
#### Router
122+
123+
Make the following change in your application router.
124+
125+
```elixir
126+
# router.ex
127+
128+
# Add the following scope in front of other routes
129+
scope "/sso" do
130+
forward "/", Samly.Router
131+
end
132+
```
133+
134+
#### Supervision Tree
135+
136+
Add `Samly.Provider` to your application supervision tree.
137+
138+
```elixir
139+
# application.ex
140+
141+
children = [
142+
# ...
143+
worker(Samly.Provider, []),
144+
]
145+
```
146+
#### Configuration Parameters
147+
148+
The configuration information needed for `Samly` can be specified in as shown here:
149+
150+
```elixir
151+
# config/dev.exs
152+
153+
config :samly, Samly.Provider,
154+
base_url: "http://samly.howto:4003/sso",
155+
#pre_session_create_pipeline: MySamlyPipeline,
156+
certfile: "path/to/service/provider/certificate/file",
157+
keyfile: "path/to/corresponding/private/key/file",
158+
idp_metadata_file: "path/to/idp/metadata/xml/file"
159+
```
160+
161+
If these are not specified in the config file, `Samly` relies on the environment
162+
variables described below.
163+
164+
#### Environment Variables
165+
166+
| Variable | Description |
167+
|:-------------------- |:-------------------- |
168+
| SAMLY_CERTFILE | Path to the X509 certificate file. Defaults to `samly.crt` |
169+
| SAMLY_KEYFILE | Path to the private key for the certificate. Defaults to `samly.pem` |
170+
| SAMLY_IDP_METADATA_FILE | Path to the SAML IDP metadata XML file. Defaults to `idp_metadata.xml` |
171+
| SAMLY_BASE_URL | Set this to the base URL for your application (include `/sso`) |
172+
173+
#### Generating Self-Signed Certificate and Key Files for Samly
174+
175+
Make sure `openssl` is available on your system. Use the `gencert.sh` script
176+
to generate the certificate and key files needed to send and recieve
177+
signed SAML requests. As mentioned in FAQ change certificate subject in the
178+
script if needed.
179+
180+
#### SAML IdP Metadata
181+
182+
This should be an XML file that contains information on the IdP
183+
`SingleSignOnService` and `SingleLogoutService` endpoints, IdP Certificate and
184+
other metadata information. When `Samly` is used to work with
185+
[`SimpleSAMLPhp`](https://simplesamlphp.org), the following command can be used to
186+
fetch the metadata:
187+
188+
```sh
189+
wget http://samly.idp:8082/simplesaml/saml2/idp/metadata.php -O idp_metadata.xml
190+
```
191+
192+
Make sure to use the host and port in the above IdP metadata URL.
193+
194+
It is possible to use the admin web console for `SimpleSAMLphp` to get this metadata.
195+
Use the browser to reach the admin web console (`http://samly.idp:8082/simplesaml`).
196+
Use the `SimpleSAMLphp` admin credentials to login. Go to the `Federation` tab.
197+
At the top there will be a section titled "SAML 2.0 IdP Metadata". Click on the
198+
`Show metadata` link. Copy the metadata XML from this page and create
199+
`idp_metadata.xml` file with that content.
200+
201+
## Sign in and Sign out
202+
203+
Use `Samly.get_active_assertion` API. This API will return `Samly.Assertion` structure
204+
if the user is authenticated. If not it return `nil`.
205+
206+
Use `/sso/auth/signin` and `/sso/auth/signout` as relative URIs in your UI login and
207+
logout links or buttons.
208+
209+
## SAML Assertion
210+
211+
Once authentication is completed successfully, IdP sends a "consume" SAML
212+
request to `Samly`. `Samly` in turn performs its own checks (including checking
213+
the integrity of the "consume" request). At this point, the SAML assertion
214+
with the authenticated user subject and attributes is available.
215+
216+
The subject in the SAML assertion is tracked by `Samly` so that subsequent
217+
logout/signout request, either service provider initiated or IdP initiated
218+
would result in proper removal of the corresponding SAML assertion.
219+
220+
Use the `Samly.get_active_assertion` function to get the SAML assertion
221+
for the currently authenticated user. This function will return `nil` if
222+
the user is not authenticated.
223+
224+
> Avoid using the subject in the SAML assertion in UI. Depending on how the
225+
> IdP is setup, this might be a randomly generated id.
226+
>
227+
> You should only rely on the user attributes in the assertion.
228+
> As an application working with an IdP, you should know which attributes
229+
> will be made available to your application and out of
230+
> those attributes which one should be treated as the logged in userid/name.
231+
> For example it could be "uid" or "email" depending on how the authentication
232+
> source is setup in the IdP.
233+
234+
## Customization
235+
236+
`Samly` allows you to specify a Plug Pipeline if you need more control over
237+
the authenticated user's attributes and/or do a Just-in-time user creation.
238+
The Plug Pipeline is invoked after the user has successfully authenticated
239+
with the IdP but before a session is created.
240+
241+
This is just a vanilla Plug Pipeline. The SAML assertion from
242+
the IdP is made available in the Plug connection as a "private".
243+
If you want to derive new attributes, create an Elixir map data (`%{}`)
244+
and update the `computed` field of the SAML assertion and put it back
245+
in the Plug connection private with `Conn.put_private` call.
246+
247+
Here is a sample pipeline that shows this:
248+
249+
```elixir
250+
defmodule MySamlyPipeline do
251+
use Plug.Builder
252+
alias Samly.{Assertion}
253+
254+
plug :compute_attributes
255+
plug :jit_provision_user
256+
257+
def compute_attributes(conn, _opts) do
258+
assertion = conn.private[:samly_assertion]
259+
260+
first_name = Map.get(assertion.attributes, :first_name)
261+
last_name = Map.get(assertion.attributes, :last_name)
262+
263+
computed = %{full_name: "#{first_name} #{last_name}"}
264+
265+
assertion = %Assertion{assertion | computed: computed}
266+
267+
conn
268+
|> put_private(:samly_assertion, assertion)
269+
270+
# If you have an error condition:
271+
# conn
272+
# |> send_resp(404, "attribute mapping failed")
273+
# |> halt()
274+
end
275+
276+
def jit_provision_user(conn, _opts) do
277+
# your user creation here ...
278+
conn
279+
end
280+
end
281+
```
282+
283+
Make this pipeline available in your config:
284+
285+
```elixir
286+
config :samly, Samly.Provider,
287+
pre_session_create_pipeline: MySamlyPipeline
288+
```
289+
290+
> Important: If you think you have a Plug Pipeline but don't find the computed
291+
> attributes in the assertion returned by `Samly.get_active_assertion`, make
292+
> sure the above config setting is specified.

lib/samly.ex

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
11
defmodule Samly do
2-
@moduledoc false
2+
alias Plug.Conn
3+
alias Samly.{Assertion, State}
4+
5+
@doc """
6+
Returns authenticated user SAML Assertion and any corresponding locally
7+
computed/derived attributes. Returns `nil` if the current Plug session
8+
is not authenticated.
9+
"""
10+
def get_active_assertion(conn) do
11+
nameid = conn |> Conn.get_session("samly_nameid")
12+
case State.get_by_nameid(nameid) do
13+
{^nameid, saml_assertion} -> saml_assertion
14+
_ -> nil
15+
end
16+
end
17+
18+
def get_attribute(nil, _name), do: nil
19+
def get_attribute(%Assertion{} = assertion, name) do
20+
computed = assertion.computed
21+
attributes = assertion.attributes
22+
Map.get(computed, name) || Map.get(attributes, name)
23+
end
324
end

lib/samly/assertion.ex

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ defmodule Samly.Assertion do
99
recipient: "",
1010
issuer: "",
1111
subject: %Subject{},
12-
conditions: [],
13-
attributes: [],
14-
authn: []
12+
conditions: %{},
13+
attributes: %{},
14+
authn: %{},
15+
computed: %{}
1516
]
1617

1718
@type t :: %__MODULE__{
@@ -20,9 +21,10 @@ defmodule Samly.Assertion do
2021
recipient: String.t,
2122
issuer: String.t,
2223
subject: Subject.t,
23-
conditions: Keyword.t,
24-
attributes: Keyword.t,
25-
authn: Keyword.t
24+
conditions: map,
25+
attributes: map,
26+
authn: map,
27+
computed: map
2628
}
2729

2830
def from_rec(assertion_rec) do

lib/samly/auth_handler.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,15 @@ defmodule Samly.AuthHandler do
6363
end
6464

6565
def send_signin_req(conn) do
66-
sp = Helper.get_sp()
66+
sp = Helper.get_sp() |> Helper.ensure_sp_uris_set(conn)
6767
idp_metadata = Helper.get_idp_metadata()
6868

6969
target_url = conn.params["target_url"] || "/"
7070
|> URI.decode_www_form()
7171

7272
nameid = get_session(conn, "samly_nameid")
7373
case State.get_by_nameid(nameid) do
74-
{^nameid, _assertions} ->
74+
{^nameid, _saml_assertion} ->
7575
conn
7676
|> redirect(302, target_url)
7777
_ ->
@@ -87,13 +87,13 @@ defmodule Samly.AuthHandler do
8787
end
8888

8989
def send_signout_req(conn) do
90-
sp = Helper.get_sp()
90+
sp = Helper.get_sp() |> Helper.ensure_sp_uris_set(conn)
9191
idp_metadata = Helper.get_idp_metadata()
9292
target_url = conn.params["target_url"] || "/"
9393
nameid = get_session(conn, "samly_nameid")
9494

9595
case State.get_by_nameid(nameid) do
96-
{^nameid, _assertions} ->
96+
{^nameid, _saml_assertion} ->
9797
{idp_signout_url, req_xml_frag} = Helper.gen_idp_signout_req(sp, idp_metadata, nameid)
9898

9999
State.delete(nameid)

0 commit comments

Comments
 (0)