├── examples
├── sp_with_logout
│ ├── .gitignore
│ ├── rebar.config
│ ├── src
│ │ ├── sp_with_logout.app.src
│ │ ├── sp_sup.erl
│ │ ├── sp_app.erl
│ │ └── sp_handler.erl
│ ├── start.sh
│ └── priv
│ │ ├── test.crt
│ │ └── test.key
└── sp
│ ├── .gitignore
│ ├── rebar.config
│ ├── src
│ ├── sp.app.src
│ ├── sp_sup.erl
│ ├── sp_app.erl
│ └── sp_handler.erl
│ ├── start.sh
│ └── priv
│ ├── test.crt
│ └── test.key
├── test
├── bad_data.pem
├── selfsigned.pem
└── selfsigned_key.pem
├── .gitignore
├── rebar.config
├── CONTRIBUTORS.md
├── src
├── esaml.app.src
├── xmerl_xpath_macros.hrl
├── esaml_binding.erl
├── esaml_cowboy.erl
├── esaml_sp.erl
├── esaml_util.erl
├── xmerl_c14n.erl
├── xmerl_dsig.erl
└── esaml.erl
├── LICENSE
├── README.md
├── CHANGELOG.md
└── include
└── esaml.hrl
/examples/sp_with_logout/.gitignore:
--------------------------------------------------------------------------------
1 | _checkouts
2 |
--------------------------------------------------------------------------------
/examples/sp/.gitignore:
--------------------------------------------------------------------------------
1 | .swp
2 | deps
3 | _checkouts
4 |
--------------------------------------------------------------------------------
/test/bad_data.pem:
--------------------------------------------------------------------------------
1 | Not actually a PEM file. This is used to test error conditions.
2 |
--------------------------------------------------------------------------------
/examples/sp/rebar.config:
--------------------------------------------------------------------------------
1 | {deps, [
2 | {esaml, "4.0.0"},
3 | {cowboy, "2.6.0"}
4 | ]}.
5 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/rebar.config:
--------------------------------------------------------------------------------
1 | {deps, [
2 | {esaml, "4.0.0"},
3 | {cowboy, "2.6.0"}
4 | ]}.
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .rebar3
2 | .eunit/*
3 | _build
4 | doc
5 | ebin
6 | erl_crash.dump
7 | rebar.lock
8 | rebar3.crashdump
9 | test/*.beam
10 |
--------------------------------------------------------------------------------
/rebar.config:
--------------------------------------------------------------------------------
1 | {cover_enabled, true}.
2 | {cover_excl_mods, [jsonpath_lex, jpparse]}.
3 |
4 | {ct_cover, true}.
5 | {ct_verbose, true}.
6 |
7 | {deps, [
8 | {cowboy, "< 3.0.0"}
9 | ]}.
10 |
11 | {edoc_opts, [{preprocess, true}]}.
12 |
13 | {eunit_opts, [
14 | {skip_deps, true},
15 | verbose
16 | ]}.
17 |
--------------------------------------------------------------------------------
/examples/sp/src/sp.app.src:
--------------------------------------------------------------------------------
1 | {application, sp,
2 | [
3 | {description, "SAML SP for erlang"},
4 | {vsn, "2"},
5 | {registered, []},
6 | {included_applications, [
7 | ]},
8 | {applications, [
9 | kernel,
10 | stdlib,
11 | esaml,
12 | cowboy
13 | ]},
14 | {mod, { sp_app, []}}
15 | ]}.
16 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/src/sp_with_logout.app.src:
--------------------------------------------------------------------------------
1 | {application, sp_with_logout,
2 | [
3 | {description, "SAML SP for erlang - with logout"},
4 | {vsn, "2"},
5 | {registered, []},
6 | {included_applications, [
7 | ]},
8 | {applications, [
9 | kernel,
10 | stdlib,
11 | esaml,
12 | cowboy
13 | ]},
14 | {mod, { sp_app, []}}
15 | ]}.
16 |
--------------------------------------------------------------------------------
/CONTRIBUTORS.md:
--------------------------------------------------------------------------------
1 | # Contributors
2 |
3 | Contributions are welcome but they do require that you agree to Dropbox's _Contributor License Agreement_ found [here](https://opensource.dropbox.com/cla/).
4 |
5 | Fixes that enable compatibility with different IdP implementations are welcome if and only if they do not come at the expense of compatibility with already supported IdPs. `esaml` prefers to follow as closely to the SAML standards as possible.
6 |
7 | Bugs/issues opened without patches are also welcome, but might take a lot longer to be looked at.
8 |
--------------------------------------------------------------------------------
/examples/sp/src/sp_sup.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | -module(sp_sup).
10 | -behaviour(supervisor).
11 |
12 | -export([start_link/0, init/1]).
13 |
14 | start_link() ->
15 | supervisor:start_link({local, ?MODULE}, ?MODULE, []).
16 |
17 | init([]) ->
18 | Procs = [],
19 | {ok, {{one_for_one, 10, 10}, Procs}}.
20 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/src/sp_sup.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | -module(sp_sup).
10 | -behaviour(supervisor).
11 |
12 | -export([start_link/0, init/1]).
13 |
14 | start_link() ->
15 | supervisor:start_link({local, ?MODULE}, ?MODULE, []).
16 |
17 | init([]) ->
18 | Procs = [],
19 | ets:new(sp_cookies, [set, public, named_table]),
20 | ets:new(sp_nameids, [bag, public, named_table]),
21 | {ok, {{one_for_one, 10, 10}, Procs}}.
22 |
--------------------------------------------------------------------------------
/examples/sp/src/sp_app.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | -module(sp_app).
10 |
11 | -behaviour(application).
12 |
13 | -export([start/2]).
14 | -export([stop/1]).
15 |
16 | start(_Type, _Args) ->
17 | HostMatch = '_',
18 | PathMatch = "/saml/:operation",
19 | InitialState = #{},
20 | Dispatch = cowboy_router:compile([
21 | {HostMatch, [{PathMatch, sp_handler, InitialState}]}
22 | ]),
23 | {ok, _} = cowboy:start_clear(sp_http_listener, [{port, 8080}],
24 | #{env => #{dispatch => Dispatch}}),
25 | sp_sup:start_link().
26 |
27 | stop(_State) ->
28 | ok.
29 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/src/sp_app.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | -module(sp_app).
10 |
11 | -behaviour(application).
12 |
13 | -export([start/2]).
14 | -export([stop/1]).
15 |
16 | start(_Type, _Args) ->
17 | HostMatch = '_',
18 | PathMatch = "/saml/:operation",
19 | InitialState = #{},
20 | Dispatch = cowboy_router:compile([
21 | {HostMatch, [{PathMatch, sp_handler, InitialState}]}
22 | ]),
23 | {ok, _} = cowboy:start_clear(sp_with_logout_http_listener, [{port, 8080}],
24 | #{env => #{dispatch => Dispatch}}),
25 | sp_sup:start_link().
26 |
27 | stop(_State) ->
28 | ok.
29 |
--------------------------------------------------------------------------------
/src/esaml.app.src:
--------------------------------------------------------------------------------
1 | {application,esaml,
2 | [{description,"SAML Server Provider library for erlang"},
3 | {vsn,"4.6.0"},
4 | {modules,[]},
5 | {registered,[]},
6 | {applications,[kernel,inets,ssl,stdlib,xmerl,cowlib,cowboy,
7 | ranch]},
8 | {licenses,["BSD"]},
9 | {links,[{"Github","https://github.com/dropbox/esaml"},
10 | {"Forked From","https://github.com/handnot2/esaml"},
11 | {"Original Repo","https://github.com/arekinath/esaml"}]},
12 | {env,[{org_name,"SAML Service Provider"},
13 | {org_displayname,"SAML Service Provider @ Some Location"},
14 | {org_url,"http://sp.example.com"},
15 | {tech_contact,[{name,"SAML SP Support"},
16 | {email,"saml-support@sp.example.com"}]},
17 | {trusted_fingerprints,[]}]},
18 | {mod,{esaml,[]}}]}.
19 |
--------------------------------------------------------------------------------
/examples/sp/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | HOST="${host:=127.0.0.1}"
4 | APP_NAME="esaml_example_sp"
5 | COOKIE="${APP_NAME}"
6 | NODE_NAME="${APP_NAME}@${HOST}"
7 | UNAME_STR=`uname`
8 | if [[ "${UNAME_STR}" == 'Linux' || "${UNAME_STR}" == 'Darwin' ]]; then
9 | EXE_NAME=erl
10 | else
11 | EXE_NAME='start //MAX werl.exe'
12 | #exename='erl.exe'
13 | fi
14 |
15 | # Node name
16 | NODE_NAME_OPT="-name ${NODE_NAME}"
17 |
18 | # Cookie
19 | COOKIE_OPT="-setcookie ${COOKIE}"
20 |
21 | # PATHS
22 | PATHS_OPT="-pa"
23 | PATHS_OPT="${PATHS_OPT} _build/default/lib/*/ebin"
24 | PATHS_OPT="${PATHS_OPT} _checkouts/*/ebin"
25 |
26 | START_OPTS="${PATHS_OPT} ${COOKIE_OPT} ${NODE_NAME_OPT}"
27 |
28 | # DDERL start options
29 | echo "------------------------------------------"
30 | echo "Starting ESaml Example (SP)"
31 | echo "------------------------------------------"
32 | echo "Node Name : ${NODE_NAME}"
33 | echo "Cookie : ${COOKIE}"
34 | echo "EBIN Path : ${PATHS_OPT}"
35 | echo "------------------------------------------"
36 |
37 | # Starting dderl
38 | ${EXE_NAME} ${START_OPTS} -eval "application:ensure_all_started(sp)."
39 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | HOST="${host:=127.0.0.1}"
4 | APP_NAME="esaml_example_sp_with_logout"
5 | COOKIE="${APP_NAME}"
6 | NODE_NAME="${APP_NAME}@${HOST}"
7 | UNAME_STR=`uname`
8 | if [[ "${UNAME_STR}" == 'Linux' || "${UNAME_STR}" == 'Darwin' ]]; then
9 | EXE_NAME=erl
10 | else
11 | EXE_NAME='start //MAX werl.exe'
12 | #exename='erl.exe'
13 | fi
14 |
15 | # Node name
16 | NODE_NAME_OPT="-name ${NODE_NAME}"
17 |
18 | # Cookie
19 | COOKIE_OPT="-setcookie ${COOKIE}"
20 |
21 | # PATHS
22 | PATHS_OPT="-pa"
23 | PATHS_OPT="${PATHS_OPT} _build/default/lib/*/ebin"
24 | PATHS_OPT="${PATHS_OPT} _checkouts/*/ebin"
25 |
26 | START_OPTS="${PATHS_OPT} ${COOKIE_OPT} ${NODE_NAME_OPT}"
27 |
28 | # DDERL start options
29 | echo "------------------------------------------"
30 | echo "Starting ESaml Example (SP with logout)"
31 | echo "------------------------------------------"
32 | echo "Node Name : ${NODE_NAME}"
33 | echo "Cookie : ${COOKIE}"
34 | echo "EBIN Path : ${PATHS_OPT}"
35 | echo "------------------------------------------"
36 |
37 | # Starting dderl
38 | ${EXE_NAME} ${START_OPTS} -eval "application:ensure_all_started(sp_with_logout)."
39 |
--------------------------------------------------------------------------------
/examples/sp/priv/test.crt:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIC/TCCAeWgAwIBAgIJAOrjLRGkHGpYMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV
3 | BAMMCWxvY2FsaG9zdDAgFw0xNjA0MjYwNzU4MDVaGA8yMTE2MDQwMjA3NTgwNVow
4 | FDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
5 | CgKCAQEAzxk42NUZyenAl3JComTM03Z/MOzO8os5BZDjSOcK9JGSpCUzlFU/hGxG
6 | ZNCuykrX6QhCcBAdu9cM/BT+AO3QCoXyEKQHdMpu+EisAhTBJFu0mTOeeoATLJEa
7 | CrUL7Wgf/8UB2HQd+43S0HQnGDqSHBE5oHmzJmiTQ0pd1VWTvCOx/VYvBjkrnzfe
8 | 0C8DZMiZ6cNDMcnS/M10YvuD9/PZ0w9202mImfB2lVPYaMPYd3qjU++nuwpYHQUz
9 | MO8enJOVgMtRvV6xTlAV2Cu2yAXQjJWZw8ZA7AarF2GrjGicGcGCvnm8FTpHEqL7
10 | Wii0FzT6fOMPZmRw00sErWhQ5PxrgQIDAQABo1AwTjAdBgNVHQ4EFgQUTzIbssmF
11 | /pGgV7+D1A7kf+6Wn7swHwYDVR0jBBgwFoAUTzIbssmF/pGgV7+D1A7kf+6Wn7sw
12 | DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAzZy2R85R1pJ6dwgt12Yo
13 | 3iaXuH2D5jIzhFDDIdeUakrMOMKUq4At8OFToNl/F2WtZiBDyoCX4t+UVzybJl1A
14 | 8CRsw0lz/ayF6UjAbFlLxESFKRa6tgkhrcBc9gFq/tO8bLRGT+ZKGoysFMu2lgtS
15 | LjZbr1lknxql7yXRjtAd5Tcz6GYGfOHJeVXkJMtueZUgmWkKUc6h7C6SP0scfLMG
16 | a4ucPXkQWi/QyF8Bziq1owR0aRMQyBO97Ua9eARYKCqReUP1WRMM4KJ0DKW0du/v
17 | l7f7o63DOOIp5cO5OnGUCsRfnGk7/NgvPpe0k1wE/alJTx9vfQfk2MLXWrhfO7ZJ
18 | rA==
19 | -----END CERTIFICATE-----
20 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/priv/test.crt:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIC/TCCAeWgAwIBAgIJAOrjLRGkHGpYMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV
3 | BAMMCWxvY2FsaG9zdDAgFw0xNjA0MjYwNzU4MDVaGA8yMTE2MDQwMjA3NTgwNVow
4 | FDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
5 | CgKCAQEAzxk42NUZyenAl3JComTM03Z/MOzO8os5BZDjSOcK9JGSpCUzlFU/hGxG
6 | ZNCuykrX6QhCcBAdu9cM/BT+AO3QCoXyEKQHdMpu+EisAhTBJFu0mTOeeoATLJEa
7 | CrUL7Wgf/8UB2HQd+43S0HQnGDqSHBE5oHmzJmiTQ0pd1VWTvCOx/VYvBjkrnzfe
8 | 0C8DZMiZ6cNDMcnS/M10YvuD9/PZ0w9202mImfB2lVPYaMPYd3qjU++nuwpYHQUz
9 | MO8enJOVgMtRvV6xTlAV2Cu2yAXQjJWZw8ZA7AarF2GrjGicGcGCvnm8FTpHEqL7
10 | Wii0FzT6fOMPZmRw00sErWhQ5PxrgQIDAQABo1AwTjAdBgNVHQ4EFgQUTzIbssmF
11 | /pGgV7+D1A7kf+6Wn7swHwYDVR0jBBgwFoAUTzIbssmF/pGgV7+D1A7kf+6Wn7sw
12 | DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAzZy2R85R1pJ6dwgt12Yo
13 | 3iaXuH2D5jIzhFDDIdeUakrMOMKUq4At8OFToNl/F2WtZiBDyoCX4t+UVzybJl1A
14 | 8CRsw0lz/ayF6UjAbFlLxESFKRa6tgkhrcBc9gFq/tO8bLRGT+ZKGoysFMu2lgtS
15 | LjZbr1lknxql7yXRjtAd5Tcz6GYGfOHJeVXkJMtueZUgmWkKUc6h7C6SP0scfLMG
16 | a4ucPXkQWi/QyF8Bziq1owR0aRMQyBO97Ua9eARYKCqReUP1WRMM4KJ0DKW0du/v
17 | l7f7o63DOOIp5cO5OnGUCsRfnGk7/NgvPpe0k1wE/alJTx9vfQfk2MLXWrhfO7ZJ
18 | rA==
19 | -----END CERTIFICATE-----
20 |
--------------------------------------------------------------------------------
/test/selfsigned.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIDfTCCAmWgAwIBAgIJAMzck+zVxHdHMA0GCSqGSIb3DQEBCwUAMEMxGjAYBgNV
3 | BAoMEVBob2VuaXggRnJhbWV3b3JrMSUwIwYDVQQDDBxTZWxmLXNpZ25lZCB0ZXN0
4 | IGNlcnRpZmljYXRlMB4XDTIyMDIwNzAwMDAwMFoXDTIzMDIwNzAwMDAwMFowQzEa
5 | MBgGA1UECgwRUGhvZW5peCBGcmFtZXdvcmsxJTAjBgNVBAMMHFNlbGYtc2lnbmVk
6 | IHRlc3QgY2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
7 | AQDdr+JgxM0T8rP+3necZihGFgaYJJ9c6NrJENDvvCzDQEsNBprFT6AA0x/wrQWm
8 | hdEKh6NGlRBf7TIny/a1gKoiQSm9QSZlOIRgsnzPfA5h7z2iS+NNSbz3gojXw/W7
9 | eyoMd8oSGvDx+PgZFXhxhIh8++1WLRPAKPOJr1Kx02zHGOWu63SmhAGfhalzpuzu
10 | hUn7UHARWLwOq/ROqW/z9hHfIIjio+sun21DuCCeAZOIDDb8POzbIIEplpRITe4k
11 | KxoPsPsDI1f/XWK1FI/o6KIET+zIA3UsdHH0Uh9GLOuvhUPK7+PopEq0yGFGpoZS
12 | G3B+F14PzitX9FYE3TMEdD7lAgMBAAGjdDByMAwGA1UdEwEB/wQCMAAwDgYDVR0P
13 | AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNVHQ4E
14 | FgQUx/BPoIIuaY7cz6Gw9obm54XXKPwwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0G
15 | CSqGSIb3DQEBCwUAA4IBAQAjjBrDC3MmVbKeI5k0qI2UNdW48XBJxgumxXb08hhU
16 | inM3ZujXO0zEf5eCmKqWA9hGpZgPsXUqvkqqcdwDBisHtG6Kxg+oM/x2E/I74bwF
17 | AWBogWtY3/1jfcF4QKiN/7jK60WX0D2fRD4Ka4HaQl5WCV+eYnfgQkRFUbZC78Oo
18 | eY0D0w+UGeICUWgxAc8q9Wm54XsfELFTdcO33/JSqu/pNJrs/u9ZH1OAeIWE7XK+
19 | ezmdpN9CfR2daQ0QJtqGnquY0IAeezHWYX7s4+tIloI2uZpWqJsKZ+XGWKqsk//g
20 | 7F41ZiBKhduXwu09sNardXxbmGuhdZrmWV0Z6XYNxXX3
21 | -----END CERTIFICATE-----
22 |
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013, Alex Wilson and the University of Queensland
2 | Copyright (c) 2021 Dropbox, Inc.
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without modification,
6 | are permitted provided that the following conditions are met:
7 |
8 | * Redistributions of source code must retain the above copyright notice,
9 | this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation and/or
12 | other materials provided with the distribution.
13 |
14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
15 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
16 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
17 | SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
18 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
19 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
20 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
22 | WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23 |
--------------------------------------------------------------------------------
/test/selfsigned_key.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | MIIEowIBAAKCAQEA3a/iYMTNE/Kz/t53nGYoRhYGmCSfXOjayRDQ77wsw0BLDQaa
3 | xU+gANMf8K0FpoXRCoejRpUQX+0yJ8v2tYCqIkEpvUEmZTiEYLJ8z3wOYe89okvj
4 | TUm894KI18P1u3sqDHfKEhrw8fj4GRV4cYSIfPvtVi0TwCjzia9SsdNsxxjlrut0
5 | poQBn4Wpc6bs7oVJ+1BwEVi8Dqv0Tqlv8/YR3yCI4qPrLp9tQ7ggngGTiAw2/Dzs
6 | 2yCBKZaUSE3uJCsaD7D7AyNX/11itRSP6OiiBE/syAN1LHRx9FIfRizrr4VDyu/j
7 | 6KRKtMhhRqaGUhtwfhdeD84rV/RWBN0zBHQ+5QIDAQABAoIBAAJ4JRNi3c3tFCgw
8 | njB1ytkNAcHMFqJYTaeTsmAZPn5mRu+8NRkhi+y2bVKm+rsiHnP5ks4Edww6fiaH
9 | VRYseriq9SYQhbb27DKPimhdP2PD4HHgWoXP3nT7VT7iBeiytIMzCmMtPaXUWh8d
10 | aBwLl+GchMZC9kdbrWrJMyib7EhDb2yNX05g60EHrzeV2sfky7OJhEnfXydr+VEs
11 | 7xJdLsLS5HXD9LLagglBSSNrRkNXma0gnKsKcGkUAjdeNL9YOohSuGxgOfOM+AB0
12 | T3cM0/cqVOb9i/LroZHgmo3c1wgIiIoZdwzPn/t75+NN2n90qn6mCeFJBnrL0Y5P
13 | XOO+/kECgYEA849L05zGyfEkOmiW4a1vUCAS0RkUNba6OeFpQc0/bENvtEedzPrd
14 | tiWjC7I7Fb6+VDgEUJsgHhGZOfx6Zi+zk2tF+DFU43Q53LV6RkPEhWkHLFcyvTyO
15 | 6ShTQSoAT2L0L/2iOr9mqIrGOxFY9BfTqIThglJyGqh2UM2po2LyRnkCgYEA6QKW
16 | j5dWPrZ3yno0YwODiB4lIlcSXhZSQhbk6l1UwxA9WwmxynSPmyVVeES0XgIpwj+P
17 | puRvZV36y29ys5jyTwpg7hhqvveHRmBIQXPxQcgRUdyrN2wvtSfQbQak7sElkSAQ
18 | PQ2mj4oioe0nELdq3fTQrPwqWbDKWKIwVbrMUM0CgYAQx4d+zac4VF+dkoUKiStJ
19 | BtylAShORwdvY2MgAGblK4QvlFt/uqy5lsAz1xSQ+/Ia1T0e3IEK8UVwJD++eHzT
20 | pClO3v8tKF2wIeSJoLOSSVkQKfW56ckisP+DVsRss7GE+OFLUNJevCjJ+vj44AdZ
21 | 7cWnd8yan45/JJwSQIfWaQKBgC96kYdMxQweTiZ55DbQvnp7+gEXUOzPC4/f7mE7
22 | B0yAAKCORyYDvkdUwiexiDcnpa6pGPJe1bwH/FR7rxmdbrJgYQPjAc1LzsquT8rW
23 | fzByPeU6W8D9UHNPW477rZvgy3DY4bYvE+NnuEracf1cAnCbs/GrqE2CUpjg44x0
24 | dbF5AoGBAJt2N3cEpTS8gdrngB5Aitv+L2iluf1QhdOd7d6uwjQ7bsiom/t8m+qe
25 | ZlrgLn/MkflvMolX89lbW1vKJyr6gcZdoFgOB5PpE6wLS2xB725CeUz0HyqikUAI
26 | oC5yoTWdJ8oPuiqb3eG0zUC9Ffa3JExWHbWwyMTN/5cR8Wgg0KEZ
27 | -----END RSA PRIVATE KEY-----
28 |
29 |
--------------------------------------------------------------------------------
/examples/sp/priv/test.key:
--------------------------------------------------------------------------------
1 | -----BEGIN PRIVATE KEY-----
2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDPGTjY1RnJ6cCX
3 | ckKiZMzTdn8w7M7yizkFkONI5wr0kZKkJTOUVT+EbEZk0K7KStfpCEJwEB271wz8
4 | FP4A7dAKhfIQpAd0ym74SKwCFMEkW7SZM556gBMskRoKtQvtaB//xQHYdB37jdLQ
5 | dCcYOpIcETmgebMmaJNDSl3VVZO8I7H9Vi8GOSufN97QLwNkyJnpw0MxydL8zXRi
6 | +4P389nTD3bTaYiZ8HaVU9how9h3eqNT76e7ClgdBTMw7x6ck5WAy1G9XrFOUBXY
7 | K7bIBdCMlZnDxkDsBqsXYauMaJwZwYK+ebwVOkcSovtaKLQXNPp84w9mZHDTSwSt
8 | aFDk/GuBAgMBAAECggEAd42e5IW4mngnpwXd87NvDtAEQvEh0qCObWkj3C0MhP0Y
9 | g0u6h/HidgavaHmTvdIQ7ETJXbngAFT3+PoBW/XtOHX0tKiMaV6HSytgqN7kVKHg
10 | EuTaousWpo6pUu8LEKUge9114EfAGzzXK2EyRGljeXJ0KvC2fAC4qoreuk3puBx0
11 | J8EAuysXD5HGg4hRIjFHeSW2yCfdHcMkZxySX5llfSIZYOMcMl10131H66gP1skZ
12 | +XID9XW1QeCup9KG2ZjCWYPCmE9SBa7cEnVSlK9oJEve61QWhFQyRyb6ZDPqsQcc
13 | dOYa6+g94zMnoDiRWvdqDzK0buvzCxdki1vBehd5dQKBgQD8YFp/aVQ4ZwqiBbiF
14 | RCYZ+VmQAjdboMl5dnzmjG/16cHLvO1dNDplMdUXm863QYLhkSetee8htNT1XsOG
15 | 6awhqVMyjGfwxRuIwcup+d6bkCtbUohi1OMleySYlX7U2Mo8ALk9iK1wsAtcjGCm
16 | ZMdPakbN+xfvA2vkIoZAh3s8cwKBgQDSEnFxBVZzfTCuW+scNHHL0stXmCNzsA4B
17 | 0vG0Ut25COLENJmK7Las7rCJ8cLWUAUALcm1AjoLAI8GLQ0M6lvfMsiRp3zj+PaA
18 | 1LrdMFYYAboIsa4Ek2wmeEd78l7olhj78YF1RZ8rZANRgbQYT2TwbslL4ENrd+4f
19 | 07I5rtpPOwKBgQCXQmya9pcKov9lckZQYTLw2FjMjfd9zFVUniZny34DBlIneRlO
20 | hlIFeqN73d88SGBYLzZ3q6AeNJJ6aYyI3J5VInYB0tMtJAXHplcZje/UjsjdmA60
21 | JWHqge7CIL9+dFxpMAnWDofdBTYaBuyabcZjG5BKPhbvIr7UYbjTDiBXbwKBgAW5
22 | jvHfjV2UWdFGm/+mxjshwXzfnoe2kosmLoQVhglW3qcuL8kDbm8ECjeYKREiGSDK
23 | HqcaKm7GUx999s8VS++nOKQPhm3ICR+1rGn+uTnqQiGehfmF8vqRaJFOJ5v7Cy9C
24 | g56oiQ/rp9N+z2OiNkP/IOk6cVvqZsjjQgYkZ7qlAoGAe2Klq+uLIGsZqA7RJvht
25 | LNls+cCNOZolW3DOmS+LbxrwEKQMQwSdzXHyr9IJxPMTi5jnw8BC5DteKmSodfcf
26 | PKyU4IuBpxmtrtX44nPKG9xiqVNL2nPStAHdoGEsIOfSJziEuwtxBsYHTGeeIIIc
27 | wKkjWP52eN706LmibMT9uEk=
28 | -----END PRIVATE KEY-----
29 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/priv/test.key:
--------------------------------------------------------------------------------
1 | -----BEGIN PRIVATE KEY-----
2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDPGTjY1RnJ6cCX
3 | ckKiZMzTdn8w7M7yizkFkONI5wr0kZKkJTOUVT+EbEZk0K7KStfpCEJwEB271wz8
4 | FP4A7dAKhfIQpAd0ym74SKwCFMEkW7SZM556gBMskRoKtQvtaB//xQHYdB37jdLQ
5 | dCcYOpIcETmgebMmaJNDSl3VVZO8I7H9Vi8GOSufN97QLwNkyJnpw0MxydL8zXRi
6 | +4P389nTD3bTaYiZ8HaVU9how9h3eqNT76e7ClgdBTMw7x6ck5WAy1G9XrFOUBXY
7 | K7bIBdCMlZnDxkDsBqsXYauMaJwZwYK+ebwVOkcSovtaKLQXNPp84w9mZHDTSwSt
8 | aFDk/GuBAgMBAAECggEAd42e5IW4mngnpwXd87NvDtAEQvEh0qCObWkj3C0MhP0Y
9 | g0u6h/HidgavaHmTvdIQ7ETJXbngAFT3+PoBW/XtOHX0tKiMaV6HSytgqN7kVKHg
10 | EuTaousWpo6pUu8LEKUge9114EfAGzzXK2EyRGljeXJ0KvC2fAC4qoreuk3puBx0
11 | J8EAuysXD5HGg4hRIjFHeSW2yCfdHcMkZxySX5llfSIZYOMcMl10131H66gP1skZ
12 | +XID9XW1QeCup9KG2ZjCWYPCmE9SBa7cEnVSlK9oJEve61QWhFQyRyb6ZDPqsQcc
13 | dOYa6+g94zMnoDiRWvdqDzK0buvzCxdki1vBehd5dQKBgQD8YFp/aVQ4ZwqiBbiF
14 | RCYZ+VmQAjdboMl5dnzmjG/16cHLvO1dNDplMdUXm863QYLhkSetee8htNT1XsOG
15 | 6awhqVMyjGfwxRuIwcup+d6bkCtbUohi1OMleySYlX7U2Mo8ALk9iK1wsAtcjGCm
16 | ZMdPakbN+xfvA2vkIoZAh3s8cwKBgQDSEnFxBVZzfTCuW+scNHHL0stXmCNzsA4B
17 | 0vG0Ut25COLENJmK7Las7rCJ8cLWUAUALcm1AjoLAI8GLQ0M6lvfMsiRp3zj+PaA
18 | 1LrdMFYYAboIsa4Ek2wmeEd78l7olhj78YF1RZ8rZANRgbQYT2TwbslL4ENrd+4f
19 | 07I5rtpPOwKBgQCXQmya9pcKov9lckZQYTLw2FjMjfd9zFVUniZny34DBlIneRlO
20 | hlIFeqN73d88SGBYLzZ3q6AeNJJ6aYyI3J5VInYB0tMtJAXHplcZje/UjsjdmA60
21 | JWHqge7CIL9+dFxpMAnWDofdBTYaBuyabcZjG5BKPhbvIr7UYbjTDiBXbwKBgAW5
22 | jvHfjV2UWdFGm/+mxjshwXzfnoe2kosmLoQVhglW3qcuL8kDbm8ECjeYKREiGSDK
23 | HqcaKm7GUx999s8VS++nOKQPhm3ICR+1rGn+uTnqQiGehfmF8vqRaJFOJ5v7Cy9C
24 | g56oiQ/rp9N+z2OiNkP/IOk6cVvqZsjjQgYkZ7qlAoGAe2Klq+uLIGsZqA7RJvht
25 | LNls+cCNOZolW3DOmS+LbxrwEKQMQwSdzXHyr9IJxPMTi5jnw8BC5DteKmSodfcf
26 | PKyU4IuBpxmtrtX44nPKG9xiqVNL2nPStAHdoGEsIOfSJziEuwtxBsYHTGeeIIIc
27 | wKkjWP52eN706LmibMT9uEk=
28 | -----END PRIVATE KEY-----
29 |
--------------------------------------------------------------------------------
/src/xmerl_xpath_macros.hrl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | -define(xpath_generic(XPath, Record, Field, TransFun, TargetType, NotFoundRet),
10 | fun(Resp) ->
11 | case xmerl_xpath:string(XPath, Xml, [{namespace, Ns}]) of
12 | [#TargetType{value = V}] -> Resp#Record{Field = TransFun(V)};
13 | _ -> NotFoundRet
14 | end
15 | end).
16 |
17 | -define(xpath_generic(XPath, Record, Field, TargetType, NotFoundRet),
18 | fun(Resp) ->
19 | case xmerl_xpath:string(XPath, Xml, [{namespace, Ns}]) of
20 | [#TargetType{value = V}] -> Resp#Record{Field = V};
21 | _ -> NotFoundRet
22 | end
23 | end).
24 |
25 | -define(xpath_attr(XPath, Record, Field),
26 | ?xpath_generic(XPath, Record, Field, xmlAttribute, Resp)).
27 | -define(xpath_attr(XPath, Record, Field, TransFun),
28 | ?xpath_generic(XPath, Record, Field, TransFun, xmlAttribute, Resp)).
29 |
30 | -define(xpath_attr_required(XPath, Record, Field, Error),
31 | ?xpath_generic(XPath, Record, Field, xmlAttribute, {error, Error})).
32 | -define(xpath_attr_required(XPath, Record, Field, TransFun, Error),
33 | ?xpath_generic(XPath, Record, Field, TransFun, xmlAttribute, {error, Error})).
34 |
35 | -define(xpath_text(XPath, Record, Field),
36 | ?xpath_generic(XPath, Record, Field, xmlText, Resp)).
37 | -define(xpath_text(XPath, Record, Field, TransFun),
38 | ?xpath_generic(XPath, Record, Field, TransFun, xmlText, Resp)).
39 |
40 | -define(xpath_text_required(XPath, Record, Field, Error),
41 | ?xpath_generic(XPath, Record, Field, xmlText, {error, Error})).
42 | -define(xpath_text_required(XPath, Record, Field, TransFun, Error),
43 | ?xpath_generic(XPath, Record, Field, TransFun, xmlText, {error, Error})).
44 |
45 | -define(xpath_text_append(XPath, Record, Field, Sep),
46 | fun(Resp) ->
47 | case xmerl_xpath:string(XPath, Xml, [{namespace, Ns}]) of
48 | [#xmlText{value = V}] -> Resp#Record{Field = Resp#Record.Field ++ Sep ++ V};
49 | _ -> Resp
50 | end
51 | end).
52 |
53 | -define(xpath_recurse(XPath, Record, Field, F),
54 | fun(Resp) ->
55 | case xmerl_xpath:string(XPath, Xml, [{namespace, Ns}]) of
56 | [E = #xmlElement{}] ->
57 | case F(E) of
58 | {error, V} -> {error, V};
59 | {ok, V} -> Resp#Record{Field = V}
60 | end;
61 | _ -> Resp
62 | end
63 | end).
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | An implementation of the Security Assertion Markup Language (SAML) in Erlang. So far this supports enough of the standard to act as a Service Provider (SP) to perform authentication with SAML. It has been tested extensively against the SimpleSAMLphp IdP and can be used in production.
2 |
3 | Please read [this](CONTRIBUTORS.md) in order to make contributions.
4 |
5 | ### Supported protocols
6 |
7 | The SAML standard refers to a flow of request/responses that make up one concrete action as a "protocol". Currently all of the basic Single-Sign-On and Single-Logout protocols are supported. There is no support at present for the optional Artifact Resolution, NameID Management, or NameID Mapping protocols.
8 |
9 | Future work may add support for the Assertion Query protocol (which is useful to check if SSO is already available for a user without demanding they authenticate immediately).
10 |
11 | Single sign-on protocols:
12 |
13 | * SP: send AuthnRequest (REDIRECT or POST) -> receive Response + Assertion (POST)
14 |
15 | Single log-out protocols:
16 |
17 | * SP: send LogoutRequest (REDIRECT) -> receive LogoutResponse (REDIRECT or POST)
18 | * SP: receive LogoutRequest (REDIRECT OR POST) -> send LogoutResponse (REDIRECT)
19 |
20 | `esaml` supports RSA+SHA1/SHA256 signing of all SP payloads, and validates signatures on all IdP responses. Compatibility flags are available to disable verification where IdP implementations lack support (see the [esaml_sp record](http://arekinath.github.io/esaml/esaml.html#type-sp), and members such as `idp_signs_logout_requests`).
21 |
22 | ### Assertion Encryption
23 |
24 | The following algorithms are supported:
25 |
26 | | Encryption | Algorithms |
27 | |:---------- |:---------- |
28 | | Key Encryption | `http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p`
`http://www.w3.org/2001/04/xmlenc#rsa-1_5` |
29 | | Data Encryption | `http://www.w3.org/2009/xmlenc11#aes128-gcm`
`http://www.w3.org/2001/04/xmlenc#aes128-cbc`
`http://www.w3.org/2001/04/xmlenc#aes256-cbc` |
30 |
31 | ### API documentation
32 |
33 | Edoc documentation for the whole API is available at:
34 |
35 | https://hexdocs.pm/esaml
36 |
37 | ### Licensing
38 |
39 | 2-clause BSD
40 |
41 | ### Getting started
42 |
43 | The simplest way to use `esaml` in your app is with the `esaml_cowboy` module. There are two SAML Server Provider (SP) applications included in the repo under `examples` directory.
44 |
45 | The application in `examples/sp` directory shows how you can use `esaml` to enabled Single-Sign-On (SSO) in your application. This application enables an endpoint that supports Server Provider metadata request, SAML authentication request as well as the ability to consume the response from IdP.
46 |
47 | The second application in `example/sp_with_logout` shows how Single Logout can be enabled. It also shows how you can build a bridge from `esaml` to local application session storage, by generating session cookies for each user that logs in (and storing them in ETS).
48 |
49 | ### More advanced usage
50 |
51 | You can also tap straight into lower-level APIs in `esaml` if `esaml_cowboy` doesn't meet your needs. The `esaml_binding` and `esaml_sp` modules are the interface used by `esaml_cowboy` itself, and contain all the basic primitives to generate and parse SAML payloads.
52 |
53 | This is particularly useful if you want to implement SOAP endpoints using SAML.
54 |
55 | > The Elixir library `Samly` is one such implementation. It dose not use `esaml_cowboy`. Instead it relies on the lower-level APIs and uses Elixir `Plug` and `Cowboy` directly for endpoints/routing.
56 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Changelog
2 |
3 | All changes are in the `main` branch (`master` remains unchanged).
4 |
5 | ### v4.6.0
6 | + remove uri double encoding thanks to @DiaanEngelbrecht
7 |
8 | ### v4.5.0
9 | + Update minor version due to using non-deprecated functions which may break previously supported OTP versions, specifically `http_uri` to `uri_string` [thanks jamesvl](https://github.com/dropbox/esaml/pull/6)
10 |
11 | ### v4.4.0
12 | + New feature providing additional key and certificate management functions to handle inline cert/key configuration rather than relying on a file
13 | + Fixes a race condition in start_ets/0
14 |
15 | ### v4.3.0
16 | + Update minor version due to using non-deprecated functions which may break previously supported OTP versions
17 | + Update license copyright
18 | + Remove overly restrictive semantic versioning of deps for hex in elixir
19 | + Modify APIs to use non-deprecated functions for recent versions of erlang
20 |
21 | ### v4.2.0
22 |
23 | + Erlang 21.x compatibility fix - PR #15 from [zwilias](https://github.com/zwilias)
24 | + Nonce in auto form submission script - Issue #16
25 |
26 | ### v4.1.0
27 |
28 | + Support for Encrypted Assertions - PR #13 from [tcrossland](https://github.com/tcrossland)
29 | Includes support for `aes128-gcm`, `aes128-cbc` and `aes256-cbc` data encryption algorithms
30 | and `rsa-oaep-mgf1p` key encryption algorithm.
31 |
32 | ### v4.0.0
33 |
34 | + Fixed issue: #11 - Support for Cowboy 2
35 |
36 | ### v3.6.1
37 |
38 | + Fixed issue: #9 - HTTP-REDIRECT wrong case
39 | Corrected SP metadata XML generated by `esaml` - `HTTP-Redirect` instead of
40 | the full uppercase form. Reported by [mikegazdag](https://github.com/mikegazdag).
41 |
42 | ### v3.6.0
43 |
44 | + Fixed issued: #8 - LogoutRequest Validation Error
45 | Removed `ProtocolBinding` attribute from `LogoutRequest` and `LogoutResponse`.
46 | Made sure the `saml:Issuer` element is in proper sequence in the requests.
47 | Schema validation was failing for `LogoutRequest` and `LogoutResponse` without
48 | these fixes. Thanks to [mjcloutier](https://github.com/mjcloutier) for reporting
49 | this issue.
50 |
51 | ### v3.5.0
52 |
53 | + Erlang/OTP 21.0 support
54 | Removed tuple calls. Thanks to PR from [zwilias](https://github.com/zwilias).
55 |
56 | ### v3.4.0
57 |
58 | + Fixed issue: #4 - InResponseTo - make this available
59 | In case of SP initiated SSO, the auth response includes the original
60 | request ID. Make this available in the assertion subject esaml record.
61 | (as `in_response_to`). The IDP initiated requests don't include this.
62 | The `in_response_to` field is set to an empty string in that case.
63 |
64 | ### v3.3.0
65 |
66 | + `NameID` format can be passed as a parameter to `esaml_sp:generate_authn_request/3`.
67 | Deprecated `esaml_sp:generate_authn_request/2`. Pass in `undefined` as NameID format
68 | if you do not want to pass in `NameIDPolicy` in the authn request.
69 |
70 | + Passing `#esaml_subject{}` with the values returned in the authn response
71 | assertion subject. This is essential for sending appropriate `NameQualifier`,
72 | `SPNameQualifier` and `Format` values in the SLO logout request. Without these
73 | values, Shibboleth fails to match the SP session on the IdP side. Deprecated
74 | `esaml_sp:generate_logout_request/3`. It will be removed in a future release.
75 |
76 | ### v3.2.0
77 |
78 | + Generate SP Metadata XML that passes schema validation
79 |
80 | ### v3.1.0
81 |
82 | + Support for customizable SP entity_id
83 |
--------------------------------------------------------------------------------
/examples/sp/src/sp_handler.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | -module(sp_handler).
10 | -include_lib("esaml/include/esaml.hrl").
11 |
12 | -export([init/2, terminate/3]).
13 |
14 | init(Req, State = #{initialized := true}) ->
15 | Operation = cowboy_req:binding(operation, Req),
16 | Method = cowboy_req:method(Req),
17 | io:format("[Method] ~p~n", [Method]),
18 | io:format("[Operation] ~p~n", [Operation]),
19 | io:format("[State] ~p~n", [State]),
20 | handle(Method, Operation, Req, State);
21 |
22 | init(Req, State) ->
23 | % Load the certificate and private key for the SP
24 | PrivKey = esaml_util:load_private_key("priv/test.key"),
25 | Cert = esaml_util:load_certificate("priv/test.crt"),
26 | % We build all of our URLs (in metadata, and in requests) based on this
27 | Base = "http://some.hostname.com/saml",
28 | % Certificate fingerprints to accept from our IDP
29 | FPs = ["6b:d1:24:4b:38:cf:6c:1f:4e:53:56:c5:c8:90:63:68:55:5e:27:28"],
30 |
31 | SP = esaml_sp:setup(#esaml_sp{
32 | key = PrivKey,
33 | certificate = Cert,
34 | trusted_fingerprints = FPs,
35 | consume_uri = Base ++ "/consume",
36 | metadata_uri = Base ++ "/metadata",
37 | org = #esaml_org{
38 | name = "Foo Bar",
39 | displayname = "Foo Bar",
40 | url = "http://some.hostname.com"
41 | },
42 | tech = #esaml_contact{
43 | name = "Foo Bar",
44 | email = "foo@bar.com"
45 | }
46 | }),
47 | % Rather than copying the IDP's metadata into our code, we'll just fetch it
48 | % (this call will cache after the first time around, so it will be fast)
49 | IdpMeta = esaml_util:load_metadata("https://some.idp.com/idp/saml2/idp/metadata.php"),
50 |
51 | State1 = State#{sp => SP, idp => IdpMeta, initialized => true},
52 | init(Req, State1).
53 |
54 | % Return our SP metadata as signed XML
55 | handle(<<"GET">>, <<"metadata">>, Req, State = #{sp := SP}) ->
56 | Req2 = esaml_cowboy:reply_with_metadata(SP, Req),
57 | {ok, Req2, State};
58 |
59 | % Visit /saml/auth to start the authentication process -- we will make an AuthnRequest
60 | % and send it to our IDP
61 | handle(<<"GET">>, <<"auth">>, Req, State = #{sp := SP,
62 | idp := #esaml_idp_metadata{login_location = IDP}}) ->
63 | Req2 = esaml_cowboy:reply_with_authnreq(SP, IDP, <<"foo">>, Req),
64 | {ok, Req2, State};
65 |
66 | % Handles HTTP-POST bound assertions coming back from the IDP.
67 | handle(<<"POST">>, <<"consume">>, Req, State = #{sp := SP}) ->
68 | case esaml_cowboy:validate_assertion(SP, fun esaml_util:check_dupe_ets/2, Req) of
69 | {ok, Assertion, RelayState, Req2} ->
70 | Attrs = Assertion#esaml_assertion.attributes,
71 | Uid = proplists:get_value(uid, Attrs),
72 | Output = io_lib:format("
SAML SP demoHi there!
This is the esaml_sp_default demo SP callback module from eSAML.
| Your name: | \n~p\n |
| Your UID: | \n~p\n |
RelayState:
\n~p\n
The assertion I got was:
\n~p\n
", [Assertion#esaml_assertion.subject#esaml_subject.name, Uid, RelayState, Assertion]),
73 | Req3 = cowboy_req:reply(200, #{<<"Content-Type">> => <<"text/html">>}, Output, Req2),
74 | {ok, Req3, State};
75 |
76 | {error, Reason, Req2} ->
77 | Req3 = cowboy_req:reply(403, #{<<"content-type">> => <<"text/plain">>},
78 | ["Access denied, assertion failed validation:\n", io_lib:format("~p\n", [Reason])],
79 | Req2),
80 | {ok, Req3, State}
81 | end;
82 |
83 | handle(_, _, Req, State = #{}) ->
84 | Req2 = cowboy_req:reply(404, #{}, <<"Not found">>, Req),
85 | {ok, Req2, State}.
86 |
87 | terminate(_Reason, _Req, _State) -> ok.
88 |
--------------------------------------------------------------------------------
/include/esaml.hrl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | %% data types / message records
10 |
11 | -include_lib("public_key/include/public_key.hrl").
12 |
13 | -record(esaml_org, {
14 | name = "" :: esaml:localized_string(),
15 | displayname = "" :: esaml:localized_string(),
16 | url = "" :: esaml:localized_string()}).
17 |
18 | -record(esaml_contact, {
19 | name = "" :: string(),
20 | email = "" :: string()}).
21 |
22 | -record(esaml_sp_metadata, {
23 | org = #esaml_org{} :: esaml:org(),
24 | tech = #esaml_contact{} :: esaml:contact(),
25 | signed_requests = true :: boolean(),
26 | signed_assertions = true :: boolean(),
27 | certificate :: binary() | undefined,
28 | cert_chain = [] :: [binary()],
29 | entity_id = "" :: string(),
30 | consumer_location = "" :: string(),
31 | logout_location :: string() | undefined}).
32 |
33 | -record(esaml_idp_metadata, {
34 | org = #esaml_org{} :: esaml:org(),
35 | tech = #esaml_contact{} :: esaml:contact(),
36 | signed_requests = true :: boolean(),
37 | certificate :: binary() | undefined,
38 | entity_id = "" :: string(),
39 | login_location = "" :: string(),
40 | logout_location :: string() | undefined,
41 | name_format = unknown :: esaml:name_format()}).
42 |
43 | -record(esaml_authnreq, {
44 | version = "2.0" :: esaml:version(),
45 | issue_instant = "" :: esaml:datetime(),
46 | destination = "" :: string(),
47 | issuer = "" :: string(),
48 | name_format = undefined :: undefined | string(),
49 | consumer_location = "" :: string()}).
50 |
51 | -record(esaml_subject, {
52 | name = "" :: string(),
53 | name_qualifier = undefined :: undefined | string(),
54 | sp_name_qualifier = undefined :: undefined | string(),
55 | name_format = undefined :: undefined | string(),
56 | confirmation_method = bearer :: atom(),
57 | notonorafter = "" :: esaml:datetime(),
58 | in_response_to = "" :: string()}).
59 |
60 | -record(esaml_assertion, {
61 | version = "2.0" :: esaml:version(),
62 | issue_instant = "" :: esaml:datetime(),
63 | recipient = "" :: string(),
64 | issuer = "" :: string(),
65 | subject = #esaml_subject{} :: esaml:subject(),
66 | conditions = [] :: esaml:conditions(),
67 | attributes = [] :: proplists:proplist(),
68 | authn = [] :: proplists:proplist()}).
69 |
70 | -record(esaml_logoutreq, {
71 | version = "2.0" :: esaml:version(),
72 | issue_instant = "" :: esaml:datetime(),
73 | destination = "" :: string(),
74 | issuer = "" :: string(),
75 | name = "" :: string(),
76 | name_qualifier = undefined :: undefined | string(),
77 | sp_name_qualifier = undefined :: undefined | string(),
78 | name_format = undefined :: undefined | string(),
79 | session_index = "" :: string(),
80 | reason = user :: esaml:logout_reason()}).
81 |
82 | -record(esaml_logoutresp, {
83 | version = "2.0" :: esaml:version(),
84 | issue_instant = "" :: esaml:datetime(),
85 | destination = "" :: string(),
86 | issuer = "" :: string(),
87 | status = unknown :: esaml:status_code()}).
88 |
89 | -record(esaml_response, {
90 | version = "2.0" :: esaml:version(),
91 | issue_instant = "" :: esaml:datetime(),
92 | destination = "" :: string(),
93 | issuer = "" :: string(),
94 | status = unknown :: esaml:status_code(),
95 | assertion = #esaml_assertion{} :: esaml:assertion()}).
96 |
97 | %% state records
98 |
99 | -record(esaml_sp, {
100 | org = #esaml_org{} :: esaml:org(),
101 | tech = #esaml_contact{} :: esaml:contact(),
102 | key :: #'RSAPrivateKey'{} | undefined,
103 | certificate :: binary() | undefined,
104 | cert_chain = [] :: [binary()],
105 | sp_sign_requests = false :: boolean(),
106 | idp_signs_assertions = true :: boolean(),
107 | idp_signs_envelopes = true :: boolean(),
108 | idp_signs_logout_requests = true :: boolean(),
109 | sp_sign_metadata = false :: boolean(),
110 | trusted_fingerprints = [] :: [string() | binary()],
111 | metadata_uri = "" :: string(),
112 | consume_uri = "" :: string(),
113 | logout_uri :: string() | undefined,
114 | encrypt_mandatory = false :: boolean(),
115 | entity_id :: string() | undefined}).
116 |
--------------------------------------------------------------------------------
/src/esaml_binding.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | %% @doc SAML HTTP binding handlers
10 | -module(esaml_binding).
11 |
12 | -export([decode_response/2, encode_http_redirect/4, encode_http_post/3, encode_http_post/4]).
13 |
14 | -include_lib("xmerl/include/xmerl.hrl").
15 | -define(deflate, <<"urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE">>).
16 |
17 | -type uri() :: binary() | string().
18 | -type html_doc() :: binary().
19 | -type xml() :: #xmlElement{} | #xmlDocument{}.
20 |
21 | %% @private
22 | -spec xml_payload_type(xml()) -> binary().
23 | xml_payload_type(Xml) ->
24 | case Xml of
25 | #xmlDocument{content = [#xmlElement{name = Atom}]} ->
26 | case lists:suffix("Response", atom_to_list(Atom)) of
27 | true -> <<"SAMLResponse">>;
28 | _ -> <<"SAMLRequest">>
29 | end;
30 | #xmlElement{name = Atom} ->
31 | case lists:suffix("Response", atom_to_list(Atom)) of
32 | true -> <<"SAMLResponse">>;
33 | _ -> <<"SAMLRequest">>
34 | end;
35 | _ -> <<"SAMLRequest">>
36 | end.
37 |
38 | %% @doc Unpack and parse a SAMLResponse with given encoding
39 | -spec decode_response(SAMLEncoding :: binary(), SAMLResponse :: binary()) -> #xmlDocument{}.
40 | decode_response(?deflate, SAMLResponse) ->
41 | XmlData = binary_to_list(zlib:unzip(base64:decode(SAMLResponse))),
42 | {Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}]),
43 | Xml;
44 | decode_response(_, SAMLResponse) ->
45 | Data = base64:decode(SAMLResponse),
46 | XmlData = case (catch zlib:unzip(Data)) of
47 | {'EXIT', _} -> binary_to_list(Data);
48 | Bin -> binary_to_list(Bin)
49 | end,
50 | {Xml, _} = xmerl_scan:string(XmlData, [{namespace_conformant, true}]),
51 | Xml.
52 |
53 | %% @doc Encode a SAMLRequest (or SAMLResponse) as an HTTP-Redirect binding
54 | %%
55 | %% Returns the URI that should be the target of redirection.
56 | -spec encode_http_redirect(IDPTarget :: uri(), SignedXml :: xml(), Username :: undefined | string(), RelayState :: binary()) -> uri().
57 | encode_http_redirect(IdpTarget, SignedXml, Username, RelayState) ->
58 | Type = xml_payload_type(SignedXml),
59 | Req = lists:flatten(xmerl:export([SignedXml], xmerl_xml)),
60 |
61 | QueryList = [
62 | {"SAMLEncoding", ?deflate},
63 | {Type, base64:encode_to_string(zlib:zip(Req))},
64 | {"RelayState", uri_string:normalize(binary_to_list(RelayState))}
65 | ],
66 | QueryParamStr = uri_string:compose_query(QueryList),
67 | FirstParamDelimiter = case lists:member($?, IdpTarget) of true -> "&"; false -> "?" end,
68 | Username_Part = redirect_username_part(Username),
69 |
70 | iolist_to_binary([IdpTarget, FirstParamDelimiter, QueryParamStr | Username_Part]).
71 |
72 | redirect_username_part(Username) when is_binary(Username), size(Username) > 0 ->
73 | ["&", uri_string:compose_query([{"username", uri_string:normalize(binary_to_list(Username))}])];
74 | redirect_username_part(_Other) -> [].
75 |
76 | %% @doc Encode a SAMLRequest (or SAMLResponse) as an HTTP-POST binding
77 | %%
78 | %% Returns the HTML document to be sent to the browser, containing a
79 | %% form and javascript to automatically submit it.
80 | -spec encode_http_post(IDPTarget :: uri(), SignedXml :: xml(), RelayState :: binary()) -> html_doc().
81 | encode_http_post(IdpTarget, SignedXml, RelayState) ->
82 | encode_http_post(IdpTarget, SignedXml, RelayState, <<>>).
83 |
84 | -spec encode_http_post(IDPTarget :: uri(), SignedXml :: xml(), RelayState :: binary(), Nonce :: binary()) -> html_doc().
85 | encode_http_post(IdpTarget, SignedXml, RelayState, Nonce) when is_binary(Nonce) ->
86 | Type = xml_payload_type(SignedXml),
87 | Req = lists:flatten(xmerl:export([SignedXml], xmerl_xml)),
88 | generate_post_html(Type, IdpTarget, base64:encode(Req), RelayState, Nonce).
89 |
90 | generate_post_html(Type, Dest, Req, RelayState, Nonce) ->
91 | NonceFragment = case Nonce of
92 | <<>> -> <<>>;
93 | _ -> [<<"nonce=\"">>, Nonce, <<"\"">>]
94 | end,
95 | iolist_to_binary([<<"
96 |
97 |
98 |
99 | POST data
100 |
101 |
102 |
107 |
110 |
115 |
116 | ">>]).
117 |
118 | -ifdef(TEST).
119 | -include_lib("eunit/include/eunit.hrl").
120 |
121 | -endif.
122 |
--------------------------------------------------------------------------------
/examples/sp_with_logout/src/sp_handler.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | -module(sp_handler).
10 | -include_lib("esaml/include/esaml.hrl").
11 |
12 | -export([init/2,terminate/3]).
13 |
14 | init(Req, State = #{initialized := true}) ->
15 | Operation = cowboy_req:binding(operation, Req),
16 | Method = cowboy_req:method(Req),
17 | handle(Method, Operation, Req, State);
18 |
19 | init(Req, State) ->
20 | % Load the certificate and private key for the SP
21 | PrivKey = esaml_util:load_private_key("priv/test.key"),
22 | Cert = esaml_util:load_certificate("priv/test.crt"),
23 | % We build all of our URLs (in metadata, and in requests) based on this
24 | Base = "http://some.hostname.com/saml",
25 | % Certificate fingerprints to accept from our IDP
26 | FPs = ["6b:d1:24:4b:38:cf:6c:1f:4e:53:56:c5:c8:90:63:68:55:5e:27:28"],
27 |
28 | SP = esaml_sp:setup(#esaml_sp{
29 | key = PrivKey,
30 | certificate = Cert,
31 | trusted_fingerprints = FPs,
32 | consume_uri = Base ++ "/consume",
33 | metadata_uri = Base ++ "/metadata",
34 | logout_uri = Base ++ "/logout",
35 | org = #esaml_org{
36 | % example of multi-lingual data -- only works in #esaml_org{}
37 | name = [{en, "Foo Bar"}, {de, "Das Foo Bar"}],
38 | displayname = "Foo Bar",
39 | url = "http://some.hostname.com"
40 | },
41 | tech = #esaml_contact{
42 | name = "Foo Bar",
43 | email = "foo@bar.com"
44 | }
45 | }),
46 | % Rather than copying the IDP's metadata into our code, we'll just fetch it
47 | % (this call will cache after the first time around, so it will be fast)
48 | IdpMeta = esaml_util:load_metadata("https://some.idp.com/idp/saml2/idp/metadata.php"),
49 |
50 | State1 = State#{sp => SP, idp => IdpMeta, initialized => true},
51 | init(Req, State1).
52 |
53 | % Return our SP metadata as signed XML
54 | handle(<<"GET">>, <<"metadata">>, Req, State = #{sp := SP}) ->
55 | Req2 = esaml_cowboy:reply_with_metadata(SP, Req),
56 | {ok, Req2, State};
57 |
58 | % Visit /saml/auth to start the authentication process -- first check to see if
59 | % we are already logged in, otherwise we will make an AuthnRequest and send it to
60 | % our IDP
61 | handle(<<"GET">>, <<"auth">>, Req, State = #{sp := SP,
62 | idp := #esaml_idp_metadata{login_location = IDP}}) ->
63 | #{sp_cookie := CookieID} = cowboy_req:match_cookies(
64 | [{sp_cookie, [], undefined}], Req),
65 | case CookieID of
66 | undefined ->
67 | % no cookie set, send them to the IdP
68 | Req2 = esaml_cowboy:reply_with_authnreq(SP, IDP, <<"foo">>, Req),
69 | {ok, Req2, State};
70 |
71 | _ ->
72 | case ets:lookup(sp_cookies, CookieID) of
73 | [{CookieID, _NameID, Uid}] ->
74 | Output = io_lib:format("
75 |
76 | SAML SP demo
77 |
78 | Hi there!
79 | You're authenticated as ~s!
80 | Log out
81 |
82 | ", [Uid]),
83 | Req2 = cowboy_req:reply(200, #{<<"Content-Type">> => <<"text/html">>}, Output, Req),
84 | {ok, Req2, State};
85 |
86 | _ ->
87 | % cookie was invalid, send them to the IdP
88 | Req2 = esaml_cowboy:reply_with_authnreq(SP, IDP, <<"foo">>, Req),
89 | {ok, Req2, State}
90 | end
91 | end;
92 |
93 | % Handles HTTP-POST bound assertions coming back from the IDP.
94 | handle(<<"POST">>, <<"consume">>, Req, State = #{sp := SP}) ->
95 | case esaml_cowboy:validate_assertion(SP, fun esaml_util:check_dupe_ets/2, Req) of
96 | {ok, Assertion, RelayState, Req2} ->
97 | NameID = Assertion#esaml_assertion.subject#esaml_subject.name,
98 | Attrs = Assertion#esaml_assertion.attributes,
99 | Uid = proplists:get_value(uid, Attrs),
100 |
101 | CookieID = gen_cookie_id(),
102 | ets:insert(sp_cookies, {CookieID, NameID, Uid}),
103 | ets:insert(sp_nameids, {NameID, CookieID}),
104 |
105 | Output = io_lib:format("
106 |
107 | SAML SP demo
108 |
109 | Hi there!
110 | You're now authenticated as ~s!
111 |
RelayState:
\n~p\n
Assertion:
\n~p\n
112 | Log out
113 |
114 | ", [Uid, RelayState, Assertion]),
115 | Req3 = cowboy_req:set_resp_cookie(<<"sp_cookie">>,
116 | CookieID, Req2, #{path => "/"}),
117 | Req4 = cowboy_req:reply(200, #{<<"Content-Type">> => <<"text/html">>}, Output, Req3),
118 | {ok, Req4, State};
119 |
120 | {error, Reason, Req2} ->
121 | Req3 = cowboy_req:reply(403, #{<<"content-type">> => <<"text/plain">>},
122 | ["Access denied, assertion failed validation:\n", io_lib:format("~p\n", [Reason])],
123 | Req2),
124 | {ok, Req3, State}
125 | end;
126 |
127 | handle(<<"GET">>, <<"deauth">>, Req, State = #{sp := SP, idp := #esaml_idp_metadata{logout_location = IDP}}) ->
128 | #{sp_cookie := CookieID} = cowboy_req:match_cookies([{sp_cookie, [], undefined}], Req),
129 | case CookieID of
130 | undefined ->
131 | Req2 = cowboy_req:reply(403, #{<<"content-type">> => <<"text/plain">>},
132 | ["Access denied, can't read your sp_cookie cookie!"], Req),
133 | {ok, Req2, State};
134 |
135 | _ ->
136 | [{CookieID, NameID, _Uid}] = ets:lookup(sp_cookies, CookieID),
137 | ets:delete(sp_cookies, CookieID),
138 | ets:delete(sp_nameids, NameID),
139 | Req2 = esaml_cowboy:reply_with_logoutreq(SP, IDP, NameID, Req),
140 | {ok, Req2, State}
141 | end;
142 |
143 | handle(_Method, <<"logout">>, Req, State = #{sp := SP, idp := #esaml_idp_metadata{logout_location = IDP}}) ->
144 | case esaml_cowboy:validate_logout(SP, Req) of
145 | {request, #esaml_logoutreq{name = NameID}, RS, Req2} ->
146 | Cookies = [Cookie || {_, Cookie} <- ets:lookup(sp_nameids, NameID)],
147 | lists:foreach(fun(C) -> ets:delete(sp_cookies, C) end, Cookies),
148 | Req3 = esaml_cowboy:reply_with_logoutresp(SP, IDP, success, RS, Req2),
149 | {ok, Req3, State};
150 |
151 | {response, LR = #esaml_logoutresp{}, RS, Req2} ->
152 | Output = io_lib:format("
153 | SAML SP demo
154 |
155 | Logout finished
156 | Logout response:
157 | \n~p\n
158 | RelayState:
159 | \n~p\n
160 |
161 | ", [LR, RS]),
162 | Req3 = cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Output, Req2),
163 | {ok, Req3, State};
164 |
165 | {error, Reason, Req2} ->
166 | Req3 = cowboy_req:reply(500, #{<<"content-type">> => <<"text/plain">>},
167 | ["Logout failed validation:\n", io_lib:format("~p\n", [Reason])], Req2),
168 | {ok, Req3, State}
169 | end;
170 |
171 | handle(_, _, Req, State = #{}) ->
172 | Req2 = cowboy_req:reply(404, #{}, <<"Not found">>, Req),
173 | {ok, Req2, State}.
174 |
175 | terminate(_Reason, _Req, _State) -> ok.
176 |
177 | gen_cookie_id() ->
178 | Bytes = crypto:strong_rand_bytes(24),
179 | Base = base64:encode(Bytes),
180 | Base2 = binary:replace(Base, <<"/">>, <<"_">>, [global]),
181 | binary:replace(Base2, <<"+">>, <<"-">>, [global]).
182 |
--------------------------------------------------------------------------------
/src/esaml_cowboy.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | %% @doc Convenience functions for use with Cowboy handlers
10 | %%
11 | %% This module makes it easier to use esaml in your Cowboy-based web
12 | %% application, by providing easy wrappers around the functions in
13 | %% esaml_binding and esaml_sp.
14 | -module(esaml_cowboy).
15 |
16 | -include_lib("xmerl/include/xmerl.hrl").
17 | -include("esaml.hrl").
18 |
19 | -export([reply_with_authnreq/4, reply_with_authnreq/7, reply_with_metadata/2, reply_with_logoutreq/4, reply_with_logoutresp/5]).
20 | -export([validate_assertion/2, validate_assertion/3, validate_assertion/5, validate_logout/2]).
21 |
22 | -type uri() :: string().
23 |
24 | %% @doc Reply to a Cowboy request with an AuthnRequest payload
25 | %%
26 | %% RelayState is an arbitrary blob up to 80 bytes long that will
27 | %% be returned verbatim with any assertion that results from this
28 | %% AuthnRequest.
29 | -spec reply_with_authnreq(esaml:sp(), IdPSSOEndpoint :: uri(), RelayState :: binary(), Req) -> Req.
30 | reply_with_authnreq(SP, IDP, RelayState, Req) ->
31 | reply_with_authnreq(SP, IDP, RelayState, Req, undefined, undefined, undefined).
32 |
33 | %% @doc Reply to a Cowboy request with an AuthnRequest payload and calls the callback with the (signed?) XML
34 | %%
35 | %% Similar to reply_with_authnreq/4, but before replying - calls the callback with the (signed?) XML, allowing persistence and later validation.
36 | -type xml_callback_state() :: any().
37 | -type xml_callback_fun() :: fun((#xmlElement{}, xml_callback_state()) -> any()).
38 | -spec reply_with_authnreq(
39 | esaml:sp(),
40 | IdPSSOEndpoint :: uri(),
41 | RelayState :: binary(),
42 | Req,
43 | undefined | string(),
44 | undefined | xml_callback_fun(),
45 | undefined | xml_callback_state()) -> Req.
46 | reply_with_authnreq(SP, IDP, RelayState, Req, User_Name_Id, Xml_Callback, Xml_Callback_State) ->
47 | SignedXml = esaml_sp:generate_authn_request(IDP, SP),
48 | is_function(Xml_Callback, 2) andalso Xml_Callback(SignedXml, Xml_Callback_State),
49 | reply_with_req(IDP, SignedXml, User_Name_Id, RelayState, Req).
50 |
51 | %% @doc Reply to a Cowboy request with a LogoutRequest payload
52 | %%
53 | %% NameID should be the exact subject name from the assertion you
54 | %% wish to log out.
55 | -spec reply_with_logoutreq(esaml:sp(), IdPSLOEndpoint :: uri(), NameID :: string(), Req) -> Req.
56 | reply_with_logoutreq(SP, IDP, NameID, Req) ->
57 | SignedXml = esaml_sp:generate_logout_request(IDP, NameID, SP),
58 | reply_with_req(IDP, SignedXml, undefined, <<>>, Req).
59 |
60 | %% @doc Reply to a Cowboy request with a LogoutResponse payload
61 | %%
62 | %% Be sure to keep the RelayState from the original LogoutRequest that you
63 | %% received to allow the IdP to keep state.
64 | -spec reply_with_logoutresp(esaml:sp(), IdPSLOEndpoint :: uri(), esaml:status_code(), RelayState :: binary(), Req) -> Req.
65 | reply_with_logoutresp(SP, IDP, Status, RelayState, Req) ->
66 | SignedXml = esaml_sp:generate_logout_response(IDP, Status, SP),
67 | reply_with_req(IDP, SignedXml, undefined, RelayState, Req).
68 |
69 | %% @private
70 | reply_with_req(IDP, SignedXml, Username, RelayState, Req) ->
71 | Target = esaml_binding:encode_http_redirect(IDP, SignedXml, Username, RelayState),
72 | UA = cowboy_req:header(<<"user-agent">>, Req, <<"">>),
73 | IsIE = not (binary:match(UA, <<"MSIE">>) =:= nomatch),
74 | if IsIE andalso (byte_size(Target) > 2042) ->
75 | Html = esaml_binding:encode_http_post(IDP, SignedXml, RelayState),
76 | cowboy_req:reply(200, #{
77 | <<"Cache-Control">> => <<"no-cache">>,
78 | <<"Pragma">> => <<"no-cache">>
79 | }, Html, Req);
80 | true ->
81 | cowboy_req:reply(302, #{
82 | <<"Cache-Control">> => <<"no-cache">>,
83 | <<"Pragma">> => <<"no-cache">>,
84 | <<"Location">> => Target
85 | }, <<"Redirecting...">>, Req)
86 | end.
87 |
88 | %% @doc Validate and parse a LogoutRequest or LogoutResponse
89 | %%
90 | %% This function handles both REDIRECT and POST bindings.
91 | -spec validate_logout(esaml:sp(), Req) ->
92 | {request, esaml:logoutreq(), RelayState::binary(), Req} |
93 | {response, esaml:logoutresp(), RelayState::binary(), Req} |
94 | {error, Reason :: term(), Req}.
95 | validate_logout(SP, Req) ->
96 | Method = cowboy_req:method(Req),
97 | case Method of
98 | <<"POST">> ->
99 | {ok, PostVals, Req2} = cowboy_req:read_urlencoded_body(Req, #{length => 128000}),
100 | SAMLEncoding = proplists:get_value(<<"SAMLEncoding">>, PostVals),
101 | SAMLResponse = proplists:get_value(<<"SAMLResponse">>, PostVals,
102 | proplists:get_value(<<"SAMLRequest">>, PostVals)),
103 | RelayState = proplists:get_value(<<"RelayState">>, PostVals, <<>>),
104 | validate_logout(SP, SAMLEncoding, SAMLResponse, RelayState, Req2);
105 | <<"GET">> ->
106 | SAMLEncoding = cowboy_req:match_qs(['SAMLEncoding'], Req),
107 | SAMLResponse = cowboy_req:match_qs(
108 | [{'SAMLResponse', [], cowboy_req:match_qs(['SAMLRequest'], Req)}], Req),
109 | RelayState = cowboy_req:match_qs([{'RelayState', [], <<>>}], Req),
110 | validate_logout(SP, SAMLEncoding, SAMLResponse, RelayState, Req)
111 | end.
112 |
113 | %% @private
114 | validate_logout(SP, SAMLEncoding, SAMLResponse, RelayState, Req2) ->
115 | case (catch esaml_binding:decode_response(SAMLEncoding, SAMLResponse)) of
116 | {'EXIT', Reason} ->
117 | {error, {bad_decode, Reason}, Req2};
118 | Xml ->
119 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
120 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
121 | case xmerl_xpath:string("/samlp:LogoutRequest", Xml, [{namespace, Ns}]) of
122 | [#xmlElement{}] ->
123 | case esaml_sp:validate_logout_request(Xml, SP) of
124 | {ok, Reqq} -> {request, Reqq, RelayState, Req2};
125 | Err -> Err
126 | end;
127 | _ ->
128 | case esaml_sp:validate_logout_response(Xml, SP) of
129 | {ok, Resp} -> {response, Resp, RelayState, Req2};
130 | Err -> Err
131 | end
132 | end
133 | end.
134 |
135 | %% @doc Reply to a Cowboy request with a Metadata payload
136 | -spec reply_with_metadata(esaml:sp(), Req) -> Req.
137 | reply_with_metadata(SP, Req) ->
138 | SignedXml = esaml_sp:generate_metadata(SP),
139 | Metadata = xmerl:export([SignedXml], xmerl_xml),
140 | cowboy_req:reply(200, #{<<"Content-Type">> => <<"text/xml">>}, Metadata, Req).
141 |
142 | %% @doc Validate and parse an Assertion inside a SAMLResponse
143 | %%
144 | %% This function handles only POST bindings.
145 | -spec validate_assertion(esaml:sp(), Req) ->
146 | {ok, esaml:assertion(), RelayState :: binary(), Req} |
147 | {error, Reason :: term(), Req}.
148 | validate_assertion(SP, Req) ->
149 | validate_assertion(SP, fun(_A, _Digest) -> ok end, Req).
150 |
151 | -spec validate_assertion(esaml:sp(), esaml_sp:dupe_fun(), Req) ->
152 | {ok, esaml:assertion(), RelayState :: binary(), Req} |
153 | {error, Reason :: term(), Req}.
154 | validate_assertion(SP, DuplicateFun, Req) ->
155 | validate_assertion(SP, DuplicateFun, undefined, undefined, Req).
156 |
157 | %% @doc Validate and parse an Assertion with duplicate detection
158 | %%
159 | %% This function handles only POST bindings.
160 | %%
161 | %% For the signature of DuplicateFun, see esaml_sp:validate_assertion/3
162 | -type custom_security_callback() :: fun((#xmlElement{}, esaml:assertion(), custom_security_callback_state()) -> ok | {error, any()}).
163 | -type custom_security_callback_state() :: any().
164 |
165 | -spec validate_assertion(
166 | esaml:sp(),
167 | esaml_sp:dupe_fun(),
168 | undefined | custom_security_callback(),
169 | undefined | custom_security_callback_state(),
170 | Req) ->
171 | {ok, esaml:assertion(), RelayState :: binary(), Req} |
172 | {error, Reason :: term(), Req}.
173 | validate_assertion(SP, DuplicateFun, Custom_Response_Security_Callback, Callback_State, Req) ->
174 | {ok, PostVals, Req2} = cowboy_req:read_urlencoded_body(Req, #{length => 128000}),
175 | SAMLEncoding = proplists:get_value(<<"SAMLEncoding">>, PostVals),
176 | SAMLResponse = proplists:get_value(<<"SAMLResponse">>, PostVals),
177 | RelayState = proplists:get_value(<<"RelayState">>, PostVals),
178 |
179 | case (catch esaml_binding:decode_response(SAMLEncoding, SAMLResponse)) of
180 | {'EXIT', Reason} ->
181 | {error, {bad_decode, Reason}, Req2};
182 | Xml ->
183 | case esaml_sp:validate_assertion(Xml, DuplicateFun, SP) of
184 | {ok, A} -> perform_extra_security_if_applicable(Custom_Response_Security_Callback, Callback_State, Xml, A, RelayState, Req2);
185 | {error, E} -> {error, E, Req2}
186 | end
187 | end.
188 |
189 | perform_extra_security_if_applicable(undefined, _Callback_State, _Xml, Assertion, RelayState, Req) ->
190 | {ok, Assertion, RelayState, Req};
191 | perform_extra_security_if_applicable(Callback, Callback_State, Xml, Assertion, RelayState, Req) when is_function(Callback, 3) ->
192 | case Callback(Xml, Assertion, Callback_State) of
193 | ok -> {ok, Assertion, RelayState, Req};
194 | {error, E} -> {error, E, Req}
195 | end.
196 |
197 | -ifdef(TEST).
198 | -include_lib("eunit/include/eunit.hrl").
199 |
200 | -endif.
201 |
--------------------------------------------------------------------------------
/src/esaml_sp.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | %% @doc SAML Service Provider (SP) routines
10 | -module(esaml_sp).
11 |
12 | -include("esaml.hrl").
13 | -include_lib("xmerl/include/xmerl.hrl").
14 |
15 | -export([setup/1, generate_authn_request/2, generate_authn_request/3, generate_metadata/1]).
16 | -export([validate_assertion/2, validate_assertion/3]).
17 | -export([generate_logout_request/3, generate_logout_request/4, generate_logout_response/3]).
18 | -export([validate_logout_request/2, validate_logout_response/2]).
19 |
20 | -type xml() :: #xmlElement{} | #xmlDocument{}.
21 | -type dupe_fun() :: fun((esaml:assertion(), Digest :: binary()) -> ok | term()).
22 | -type nameid_format() :: undefined | string().
23 | -export_type([dupe_fun/0]).
24 |
25 | %% @private
26 | -spec add_xml_id(xml()) -> xml().
27 | add_xml_id(Xml) ->
28 | Xml#xmlElement{attributes = Xml#xmlElement.attributes ++ [
29 | #xmlAttribute{name = 'ID',
30 | value = esaml_util:unique_id(),
31 | namespace = #xmlNamespace{}}
32 | ]}.
33 |
34 | %% @private
35 | -spec get_entity_id(esaml:sp()) -> string().
36 | get_entity_id(#esaml_sp{entity_id = EntityID, metadata_uri = MetaURI}) ->
37 | if (EntityID =:= undefined) ->
38 | MetaURI;
39 | true ->
40 | EntityID
41 | end.
42 |
43 | %% @private
44 | -spec reorder_issuer(xml()) -> xml().
45 | reorder_issuer(Elem) ->
46 | case lists:partition(fun(#xmlElement{name = N}) -> N == 'saml:Issuer' end, Elem#xmlElement.content) of
47 | {[Issuer], Other} -> Elem#xmlElement{content = [Issuer | Other]};
48 | _ -> Elem
49 | end.
50 |
51 | %% @doc Return an AuthnRequest as an XML element
52 | %% @deprecated Use generate_authn_request/3
53 | -spec generate_authn_request(IdpURL :: string(), esaml:sp()) -> #xmlElement{}.
54 | generate_authn_request(IdpURL, SP = #esaml_sp{}) ->
55 | generate_authn_request(IdpURL, SP, undefined).
56 |
57 | %% @doc Return an AuthnRequest as an XML element
58 | -spec generate_authn_request(IdpURL :: string(), esaml:sp(), Format :: nameid_format()) -> #xmlElement{}.
59 | generate_authn_request(IdpURL,
60 | SP = #esaml_sp{metadata_uri = _MetaURI, consume_uri = ConsumeURI},
61 | Format) ->
62 | Now = erlang:localtime_to_universaltime(erlang:localtime()),
63 | Stamp = esaml_util:datetime_to_saml(Now),
64 | Issuer = get_entity_id(SP),
65 |
66 | Xml = esaml:to_xml(#esaml_authnreq{issue_instant = Stamp,
67 | destination = IdpURL,
68 | issuer = Issuer,
69 | name_format = Format,
70 | consumer_location = ConsumeURI}),
71 | if SP#esaml_sp.sp_sign_requests ->
72 | reorder_issuer(xmerl_dsig:sign(Xml, SP#esaml_sp.key, SP#esaml_sp.certificate));
73 | true ->
74 | add_xml_id(Xml)
75 | end.
76 |
77 | %% @doc Return a LogoutRequest as an XML element
78 | %% @deprecated Use generate_logout_request/4
79 | -spec generate_logout_request(IdpURL :: string(), NameID :: string(), esaml:sp()) -> #xmlElement{}.
80 | generate_logout_request(IdpURL, NameID, SP = #esaml_sp{}) ->
81 | SessionIndex = "",
82 | Subject = #esaml_subject{name = NameID},
83 | generate_logout_request(IdpURL, SessionIndex, Subject, SP).
84 |
85 | %% @doc Return a LogoutRequest as an XML element
86 | -spec generate_logout_request(IdpURL :: string(), SessionIndex :: string(), esaml:subject(), esaml:sp()) -> #xmlElement{}.
87 | generate_logout_request(IdpURL, SessionIndex, Subject = #esaml_subject{}, SP = #esaml_sp{metadata_uri = _MetaURI})
88 | when is_record(Subject, esaml_subject) ->
89 | Now = erlang:localtime_to_universaltime(erlang:localtime()),
90 | Stamp = esaml_util:datetime_to_saml(Now),
91 | Issuer = get_entity_id(SP),
92 |
93 | Xml = esaml:to_xml(#esaml_logoutreq{issue_instant = Stamp,
94 | destination = IdpURL,
95 | issuer = Issuer,
96 | name = Subject#esaml_subject.name,
97 | name_qualifier = Subject#esaml_subject.name_qualifier,
98 | sp_name_qualifier = Subject#esaml_subject.sp_name_qualifier,
99 | name_format = Subject#esaml_subject.name_format,
100 | session_index = SessionIndex,
101 | reason = user}),
102 | if SP#esaml_sp.sp_sign_requests ->
103 | reorder_issuer(xmerl_dsig:sign(Xml, SP#esaml_sp.key, SP#esaml_sp.certificate));
104 | true ->
105 | add_xml_id(Xml)
106 | end.
107 |
108 | %% @doc Return a LogoutResponse as an XML element
109 | -spec generate_logout_response(IdpURL :: string(), esaml:status_code(), esaml:sp()) -> #xmlElement{}.
110 | generate_logout_response(IdpURL, Status, SP = #esaml_sp{metadata_uri = _MetaURI}) ->
111 | Now = erlang:localtime_to_universaltime(erlang:localtime()),
112 | Stamp = esaml_util:datetime_to_saml(Now),
113 | Issuer = get_entity_id(SP),
114 |
115 | Xml = esaml:to_xml(#esaml_logoutresp{issue_instant = Stamp,
116 | destination = IdpURL,
117 | issuer = Issuer,
118 | status = Status}),
119 | if SP#esaml_sp.sp_sign_requests ->
120 | reorder_issuer(xmerl_dsig:sign(Xml, SP#esaml_sp.key, SP#esaml_sp.certificate));
121 | true ->
122 | add_xml_id(Xml)
123 | end.
124 |
125 | %% @doc Return the SP metadata as an XML element
126 | -spec generate_metadata(esaml:sp()) -> #xmlElement{}.
127 | generate_metadata(SP = #esaml_sp{org = Org, tech = Tech}) ->
128 | EntityID = get_entity_id(SP),
129 | Xml = esaml:to_xml(#esaml_sp_metadata{
130 | org = Org,
131 | tech = Tech,
132 | signed_requests = SP#esaml_sp.sp_sign_requests,
133 | signed_assertions = SP#esaml_sp.idp_signs_assertions or SP#esaml_sp.idp_signs_envelopes,
134 | certificate = SP#esaml_sp.certificate,
135 | cert_chain = SP#esaml_sp.cert_chain,
136 | consumer_location = SP#esaml_sp.consume_uri,
137 | logout_location = SP#esaml_sp.logout_uri,
138 | entity_id = EntityID}),
139 | if SP#esaml_sp.sp_sign_metadata ->
140 | xmerl_dsig:sign(Xml, SP#esaml_sp.key, SP#esaml_sp.certificate);
141 | true ->
142 | add_xml_id(Xml)
143 | end.
144 |
145 | %% @doc Initialize and validate an esaml_sp record
146 | -spec setup(esaml:sp()) -> esaml:sp().
147 | setup(SP = #esaml_sp{trusted_fingerprints = FPs, metadata_uri = MetaURI,
148 | consume_uri = ConsumeURI}) ->
149 | Fingerprints = esaml_util:convert_fingerprints(FPs),
150 | case MetaURI of "" -> error("must specify metadata URI"); _ -> ok end,
151 | case ConsumeURI of "" -> error("must specify consume URI"); _ -> ok end,
152 | if (SP#esaml_sp.key =:= undefined) andalso (SP#esaml_sp.sp_sign_requests) ->
153 | error("must specify a key to sign requests");
154 | true -> ok
155 | end,
156 | if (not (SP#esaml_sp.key =:= undefined)) and (not (SP#esaml_sp.certificate =:= undefined)) ->
157 | SP#esaml_sp{sp_sign_requests = true, sp_sign_metadata = true, trusted_fingerprints = Fingerprints};
158 | true ->
159 | SP#esaml_sp{trusted_fingerprints = Fingerprints}
160 | end.
161 |
162 | %% @doc Validate and parse a LogoutRequest element
163 | -spec validate_logout_request(xml(), esaml:sp()) ->
164 | {ok, esaml:logoutreq()} | {error, Reason :: term()}.
165 | validate_logout_request(Xml, SP = #esaml_sp{}) ->
166 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
167 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
168 | esaml_util:threaduntil([
169 | fun(X) ->
170 | case xmerl_xpath:string("/samlp:LogoutRequest", X, [{namespace, Ns}]) of
171 | [#xmlElement{}] -> X;
172 | _ -> {error, bad_assertion}
173 | end
174 | end,
175 | fun(X) ->
176 | if SP#esaml_sp.idp_signs_logout_requests ->
177 | case xmerl_dsig:verify(X, SP#esaml_sp.trusted_fingerprints) of
178 | ok -> X;
179 | OuterError -> {error, OuterError}
180 | end;
181 | true -> X
182 | end
183 | end,
184 | fun(X) ->
185 | case (catch esaml:decode_logout_request(X)) of
186 | {ok, LR} -> LR;
187 | {'EXIT', Reason} -> {error, Reason};
188 | Err -> Err
189 | end
190 | end
191 | ], Xml).
192 |
193 | %% @doc Validate and parse a LogoutResponse element
194 | -spec validate_logout_response(xml(), esaml:sp()) ->
195 | {ok, esaml:logoutresp()} | {error, Reason :: term()}.
196 | validate_logout_response(Xml, SP = #esaml_sp{}) ->
197 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
198 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'},
199 | {"ds", 'http://www.w3.org/2000/09/xmldsig#'}],
200 | esaml_util:threaduntil([
201 | fun(X) ->
202 | case xmerl_xpath:string("/samlp:LogoutResponse", X, [{namespace, Ns}]) of
203 | [#xmlElement{}] -> X;
204 | _ -> {error, bad_assertion}
205 | end
206 | end,
207 | fun(X) ->
208 | % Signature is optional on the logout_response. Verify it if we have it.
209 | case xmerl_xpath:string("/samlp:LogoutResponse/ds:Signature", X, [{namespace, Ns}]) of
210 | [#xmlElement{}] ->
211 | case xmerl_dsig:verify(X, SP#esaml_sp.trusted_fingerprints) of
212 | ok -> X;
213 | OuterError -> {error, OuterError}
214 | end;
215 | _ -> X
216 | end
217 | end,
218 | fun(X) ->
219 | case (catch esaml:decode_logout_response(X)) of
220 | {ok, LR} -> LR;
221 | {'EXIT', Reason} -> {error, Reason};
222 | Err -> Err
223 | end
224 | end,
225 | fun(LR = #esaml_logoutresp{status = success}) -> LR;
226 | (#esaml_logoutresp{status = S}) -> {error, S} end
227 | ], Xml).
228 |
229 | %% @doc Validate and decode an assertion envelope in parsed XML
230 | -spec validate_assertion(xml(), esaml:sp()) ->
231 | {ok, esaml:assertion()} | {error, Reason :: term()}.
232 | validate_assertion(Xml, SP = #esaml_sp{}) ->
233 | validate_assertion(Xml, fun(_A, _Digest) -> ok end, SP).
234 |
235 | %% @doc Validate and decode an assertion envelope in parsed XML
236 | %%
237 | %% The dupe_fun argument is intended to detect duplicate assertions
238 | %% in the case of a replay attack.
239 | -spec validate_assertion(xml(), dupe_fun(), esaml:sp()) ->
240 | {ok, esaml:assertion()} | {error, Reason :: term()}.
241 | validate_assertion(Xml, DuplicateFun, SP = #esaml_sp{}) ->
242 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
243 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
244 | SuccessStatus = "urn:oasis:names:tc:SAML:2.0:status:Success",
245 | esaml_util:threaduntil([
246 | fun(X) ->
247 | case xmerl_xpath:string("/samlp:Response/samlp:Status/samlp:StatusCode/@Value", X, [{namespace, Ns}]) of
248 | [StatusCode] ->
249 | case StatusCode#xmlAttribute.value of
250 | SuccessStatus -> X;
251 | ErrorStatus ->
252 | ErrorMessage = case xmerl_xpath:string("/samlp:Response/samlp:Status/samlp:StatusMessage/text()", X, [{namespace, Ns}]) of
253 | [] -> undefined;
254 | [A] -> lists:flatten(xmerl_xs:value_of(A));
255 | _ -> malformed
256 | end,
257 | {error, {saml_error, ErrorStatus, ErrorMessage}}
258 | end;
259 | _ -> {error, bad_saml}
260 | end
261 | end,
262 | fun(X) ->
263 | case xmerl_xpath:string("/samlp:Response/saml:EncryptedAssertion", X, [{namespace, Ns}]) of
264 | [A1] ->
265 | try
266 | #xmlElement{} = DecryptedAssertion = decrypt_assertion(A1, SP),
267 | xmerl_xpath:string("/saml:Assertion", DecryptedAssertion, [{namespace, Ns}]) of
268 | [A2] -> A2
269 | catch
270 | _Error:_Reason -> {error, bad_assertion}
271 | end;
272 | _ ->
273 | case xmerl_xpath:string("/samlp:Response/saml:Assertion", X, [{namespace, Ns}]) of
274 | [A3] -> A3;
275 | _ -> {error, bad_assertion}
276 | end
277 | end
278 | end,
279 | fun(A) ->
280 | if
281 | SP#esaml_sp.idp_signs_envelopes ->
282 | case xmerl_dsig:verify(Xml, SP#esaml_sp.trusted_fingerprints) of
283 | ok -> A;
284 | OuterError -> {error, {envelope, OuterError}}
285 | end;
286 | true -> A
287 | end
288 | end,
289 | fun(A) ->
290 | if SP#esaml_sp.idp_signs_assertions ->
291 | case xmerl_dsig:verify(A, SP#esaml_sp.trusted_fingerprints) of
292 | ok -> A;
293 | InnerError -> {error, {assertion, InnerError}}
294 | end;
295 | true -> A
296 | end
297 | end,
298 | fun(A) ->
299 | case esaml:validate_assertion(A, SP#esaml_sp.consume_uri, get_entity_id(SP)) of
300 | {ok, AR} -> AR;
301 | {error, Reason} -> {error, Reason}
302 | end
303 | end,
304 | fun(AR) ->
305 | case DuplicateFun(AR, xmerl_dsig:digest(Xml)) of
306 | ok -> AR;
307 | _ -> {error, duplicate}
308 | end
309 | end
310 | ], Xml).
311 |
312 |
313 | %% @doc Decrypts an encrypted assertion element.
314 | decrypt_assertion(Xml, #esaml_sp{key = PrivateKey}) ->
315 | XencNs = [{"xenc", 'http://www.w3.org/2001/04/xmlenc#'}],
316 | [EncryptedData] = xmerl_xpath:string("./xenc:EncryptedData", Xml, [{namespace, XencNs}]),
317 | [#xmlText{value = CipherValue64}] = xmerl_xpath:string("xenc:CipherData/xenc:CipherValue/text()", EncryptedData, [{namespace, XencNs}]),
318 | CipherValue = base64:decode(CipherValue64),
319 | SymmetricKey = decrypt_key_info(EncryptedData, PrivateKey),
320 | [#xmlAttribute{value = Algorithm}] = xmerl_xpath:string("./xenc:EncryptionMethod/@Algorithm", EncryptedData, [{namespace, XencNs}]),
321 | AssertionXml = block_decrypt(Algorithm, SymmetricKey, CipherValue),
322 | {Assertion, _} = xmerl_scan:string(AssertionXml, [{namespace_conformant, true}]),
323 | Assertion.
324 |
325 |
326 | decrypt_key_info(EncryptedData, Key) ->
327 | DsNs = [{"ds", 'http://www.w3.org/2000/09/xmldsig#'}],
328 | XencNs = [{"xenc", 'http://www.w3.org/2001/04/xmlenc#'}],
329 | [KeyInfo] = xmerl_xpath:string("./ds:KeyInfo", EncryptedData, [{namespace, DsNs}]),
330 | [#xmlAttribute{value = Algorithm}] = xmerl_xpath:string("./xenc:EncryptedKey/xenc:EncryptionMethod/@Algorithm", KeyInfo, [{namespace, XencNs}]),
331 | [#xmlText{value = CipherValue64}] = xmerl_xpath:string("./xenc:EncryptedKey/xenc:CipherData/xenc:CipherValue/text()", KeyInfo, [{namespace, XencNs}]),
332 | CipherValue = base64:decode(CipherValue64),
333 | decrypt(CipherValue, Algorithm, Key).
334 |
335 | decrypt(CipherValue, "http://www.w3.org/2001/04/xmlenc#rsa-1_5", Key) ->
336 | Opts = [
337 | {rsa_padding, rsa_pkcs1_padding},
338 | {rsa_pad, rsa_pkcs1_padding}
339 | ],
340 | public_key:decrypt_private(CipherValue, Key, Opts);
341 |
342 | decrypt(CipherValue, "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p", Key) ->
343 | Opts = [
344 | {rsa_padding, rsa_pkcs1_oaep_padding},
345 | {rsa_pad, rsa_pkcs1_oaep_padding}
346 | ],
347 | public_key:decrypt_private(CipherValue, Key, Opts).
348 |
349 |
350 | block_decrypt("http://www.w3.org/2009/xmlenc11#aes128-gcm", SymmetricKey, CipherValue) ->
351 | %% IV: 12 bytes and Tag data: 16 bytes
352 | EncryptedDataSize = byte_size(CipherValue) - 12 - 16,
353 | <> = CipherValue,
354 | DecryptedData = crypto:crypto_one_time_aead(aes_128_gcm, SymmetricKey, IV, EncryptedData, <<>>, Tag, false),
355 | binary_to_list(DecryptedData);
356 |
357 | block_decrypt("http://www.w3.org/2001/04/xmlenc#aes128-cbc", SymmetricKey, CipherValue) ->
358 | <> = CipherValue,
359 | DecryptedData = crypto:crypto_one_time(aes_128_cbc, SymmetricKey, IV, EncryptedData, false),
360 | IsPadding = fun(X) -> X < 16 end,
361 | lists:reverse(lists:dropwhile(IsPadding, lists:reverse(binary_to_list(DecryptedData))));
362 |
363 | block_decrypt("http://www.w3.org/2001/04/xmlenc#aes256-cbc", SymmetricKey, CipherValue) ->
364 | <> = CipherValue,
365 | DecryptedData = crypto:crypto_one_time(aes_256_cbc, SymmetricKey, IV, EncryptedData, false),
366 | IsPadding = fun(X) -> X < 16 end,
367 | lists:reverse(lists:dropwhile(IsPadding, lists:reverse(binary_to_list(DecryptedData)))).
368 |
369 |
370 | -ifdef(TEST).
371 | -include_lib("eunit/include/eunit.hrl").
372 |
373 | -endif.
374 |
--------------------------------------------------------------------------------
/src/esaml_util.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | %% @doc Utility functions
10 | -module(esaml_util).
11 |
12 | -include_lib("xmerl/include/xmerl.hrl").
13 | -include_lib("public_key/include/public_key.hrl").
14 | -include("esaml.hrl").
15 |
16 | -export([datetime_to_saml/1, saml_to_datetime/1]).
17 | -export([start_ets/0, check_dupe_ets/2]).
18 | -export([folduntil/3, thread/2, threaduntil/2]).
19 | -export([build_nsinfo/2]).
20 | -export([load_private_key/1, import_private_key/2]).
21 | -export([load_certificate_chain/1, import_certificate_chain/2]).
22 | -export([load_certificate/1, import_certificate/2]).
23 | -export([load_metadata/2, load_metadata/1]).
24 | -export([convert_fingerprints/1]).
25 | -export([unique_id/0]).
26 |
27 | %% @doc Converts various ascii hex/base64 fingerprint formats to binary
28 | -spec convert_fingerprints([string() | binary()]) -> [binary()].
29 | convert_fingerprints(FPs) ->
30 | FPSources = FPs ++ esaml:config(trusted_fingerprints, []),
31 | lists:map(fun(Print) ->
32 | if is_list(Print) ->
33 | case string:tokens(Print, ":") of
34 | [Type, Base64] ->
35 | Hash = base64:decode(Base64),
36 | case string:to_lower(Type) of
37 | "sha" -> {sha, Hash};
38 | "sha1" -> {sha, Hash};
39 | "md5" -> {md5, Hash};
40 | "sha256" -> {sha256, Hash};
41 | "sha384" -> {sha384, Hash};
42 | "sha512" -> {sha512, Hash}
43 | end;
44 | [_] -> error("unknown fingerprint format");
45 | HexParts ->
46 | list_to_binary([list_to_integer(P, 16) || P <- HexParts])
47 | end;
48 | is_binary(Print) ->
49 | Print;
50 | true ->
51 | error("unknown fingerprint format")
52 | end
53 | end, FPSources).
54 |
55 | %% @doc Converts a calendar:datetime() into SAML time string
56 | -spec datetime_to_saml(calendar:datetime()) -> esaml:datetime().
57 | datetime_to_saml(Time) ->
58 | {{Y,Mo,D}, {H, Mi, S}} = Time,
59 | lists:flatten(io_lib:format("~4.10.0B-~2.10.0B-~2.10.0BT~2.10.0B:~2.10.0B:~2.10.0BZ", [Y, Mo, D, H, Mi, S])).
60 |
61 | %% @doc Converts a SAML time string into a calendar:datetime()
62 | %%
63 | %% Inverse of datetime_to_saml/1
64 | -spec saml_to_datetime(esaml:datetime()) -> calendar:datetime().
65 | saml_to_datetime(Stamp) ->
66 | StampBin = if is_list(Stamp) -> list_to_binary(Stamp); true -> Stamp end,
67 | <> = StampBin,
69 | %% check that time in UTC timezone because we don't handle another timezones properly
70 | $Z = binary:last(Rest),
71 | F = fun(B) -> list_to_integer(binary_to_list(B)) end,
72 | {{F(YBin), F(MoBin), F(DBin)}, {F(HBin), F(MiBin), F(SBin)}}.
73 |
74 | %% @private
75 | -spec folduntil(F :: fun(), Acc :: term(), List :: []) -> AccOut :: term().
76 | folduntil(_F, Acc, []) -> Acc;
77 | folduntil(F, Acc, [Next | Rest]) ->
78 | case F(Next, Acc) of
79 | {stop, AccOut} -> AccOut;
80 | NextAcc -> folduntil(F, NextAcc, Rest)
81 | end.
82 |
83 | %% @private
84 | thread([], Acc) -> Acc;
85 | thread([F | Rest], Acc) ->
86 | thread(Rest, F(Acc)).
87 |
88 | %% @private
89 | -spec threaduntil([fun((Acc :: term()) -> {error, term()} | {stop, term()} | term())], InitAcc::term()) -> {ok, term()} | {error, term()}.
90 | threaduntil([], Acc) -> {ok, Acc};
91 | threaduntil([F | Rest], Acc) ->
92 | case (catch F(Acc)) of
93 | {'EXIT', Reason} ->
94 | {error, Reason};
95 | {error, Reason} ->
96 | {error, Reason};
97 | {stop, LastAcc} ->
98 | {ok, LastAcc};
99 | NextAcc ->
100 | threaduntil(Rest, NextAcc)
101 | end.
102 |
103 | %% @private
104 | -spec build_nsinfo(#xmlNamespace{}, #xmlElement{}) -> #xmlElement{}.
105 | build_nsinfo(Ns, Attr = #xmlAttribute{name = Name}) ->
106 | case string:tokens(atom_to_list(Name), ":") of
107 | [NsPrefix, Rest] -> Attr#xmlAttribute{namespace = Ns, nsinfo = {NsPrefix, Rest}};
108 | _ -> Attr#xmlAttribute{namespace = Ns}
109 | end;
110 | build_nsinfo(Ns, Elem = #xmlElement{name = Name, content = Kids, attributes = Attrs}) ->
111 | Elem2 = case string:tokens(atom_to_list(Name), ":") of
112 | [NsPrefix, Rest] -> Elem#xmlElement{namespace = Ns, nsinfo = {NsPrefix, Rest}};
113 | _ -> Elem#xmlElement{namespace = Ns}
114 | end,
115 | Elem2#xmlElement{attributes = [build_nsinfo(Ns, Attr) || Attr <- Attrs],
116 | content = [build_nsinfo(Ns, Kid) || Kid <- Kids]};
117 | build_nsinfo(_Ns, Other) -> Other.
118 |
119 | %% @private
120 | start_ets() ->
121 | case erlang:whereis(esaml_ets_table_owner) of
122 | undefined ->
123 | create_tables();
124 | Pid ->
125 | Pid ! {self(), check_ready},
126 | receive
127 | {Pid, ready} -> {ok, Pid}
128 | end
129 | end.
130 |
131 | %% @private
132 | create_tables() ->
133 | Caller = self(),
134 | Pid = spawn_link(fun() ->
135 | register(esaml_ets_table_owner, self()),
136 | ets:new(esaml_assertion_seen, [set, public, named_table]),
137 | ets:new(esaml_privkey_cache, [set, public, named_table]),
138 | ets:new(esaml_certbin_cache, [set, public, named_table]),
139 | ets:new(esaml_idp_meta_cache, [set, public, named_table]),
140 | Caller ! {self(), ready},
141 | ets_table_owner()
142 | end),
143 |
144 | receive
145 | {Pid, ready} -> ok
146 | end,
147 |
148 | {ok, Pid}.
149 |
150 | %% @private
151 | ets_table_owner() ->
152 | receive
153 | stop ->
154 | ok;
155 | {Caller, check_ready} ->
156 | Caller ! {self(), ready},
157 | ets_table_owner();
158 | _ ->
159 | ets_table_owner()
160 | end.
161 |
162 | %% @doc Loads a private key from a file on disk (or ETS memory cache)
163 | -spec load_private_key(Path :: string()) -> #'RSAPrivateKey'{}.
164 | load_private_key(Path) ->
165 | case ets:lookup(esaml_privkey_cache, Path) of
166 | [{_, Key}] ->
167 | Key;
168 | _ ->
169 | {ok, KeyFile} = file:read_file(Path),
170 | do_import_private_key(KeyFile, Path)
171 | end.
172 |
173 | -spec import_private_key(EncodedKey :: string(), Identifier :: term()) -> #'RSAPrivateKey'{}.
174 | import_private_key(EncodedKey, Identifier) ->
175 | case ets:lookup(esaml_privkey_cache, Identifier) of
176 | [{_, Key}] -> Key;
177 | _ -> do_import_private_key(EncodedKey, Identifier)
178 | end.
179 |
180 | do_import_private_key(EncodedKey, Identifier) ->
181 | [KeyEntry] = public_key:pem_decode(EncodedKey),
182 | Key = case public_key:pem_entry_decode(KeyEntry) of
183 | #'PrivateKeyInfo'{privateKey = KeyData} ->
184 | KeyDataBin = if is_list(KeyData) -> list_to_binary(KeyData);
185 | true -> KeyData
186 | end,
187 | public_key:der_decode('RSAPrivateKey', KeyDataBin);
188 | Other -> Other
189 | end,
190 | ets:insert(esaml_privkey_cache, {Identifier, Key}),
191 | Key.
192 |
193 | -spec load_certificate(Path :: string()) -> binary().
194 | load_certificate(Path) ->
195 | [CertBin] = load_certificate_chain(Path),
196 | CertBin.
197 |
198 | -spec import_certificate(EncodedCert :: string(), Identifier :: term()) -> binary().
199 | import_certificate(EncodedCert, Identifier) ->
200 | [CertBin] = import_certificate_chain(EncodedCert, Identifier),
201 | CertBin.
202 |
203 | %% @doc Loads certificate chain from a file on disk (or ETS memory cache)
204 | -spec load_certificate_chain(Path :: string()) -> [binary()].
205 | load_certificate_chain(Path) ->
206 | case ets:lookup(esaml_certbin_cache, Path) of
207 | [{_, CertChain}] ->
208 | CertChain;
209 | _ ->
210 | {ok, EncodedCert} = file:read_file(Path),
211 | do_import_certificate_chain(EncodedCert, Path)
212 | end.
213 |
214 | %% @doc Loads certificate chain from a file on disk (or ETS memory cache)
215 | -spec import_certificate_chain(EncodedCerts :: string(), Identifier :: string()) -> [binary()].
216 | import_certificate_chain(EncodedCerts, Identifier) ->
217 | case ets:lookup(esaml_certbin_cache, Identifier) of
218 | [{_, CertChain}] ->
219 | CertChain;
220 | _ ->
221 | do_import_certificate_chain(EncodedCerts, Identifier)
222 | end.
223 |
224 | do_import_certificate_chain(EncodedCerts, Identifier) ->
225 | CertChain = [CertBin || {'Certificate', CertBin, not_encrypted} <- public_key:pem_decode(EncodedCerts)],
226 | ets:insert(esaml_certbin_cache, {Identifier, CertChain}),
227 | CertChain.
228 |
229 | %% @doc Reads IDP metadata from a URL (or ETS memory cache) and validates the signature
230 | -spec load_metadata(Url :: string(), Fingerprints :: [string() | binary()]) -> esaml:idp_metadata().
231 | load_metadata(Url, FPs) ->
232 | Fingerprints = convert_fingerprints(FPs),
233 | case ets:lookup(esaml_idp_meta_cache, Url) of
234 | [{Url, Meta}] -> Meta;
235 | _ ->
236 | {ok, {{_Ver, 200, _}, _Headers, Body}} = httpc:request(get, {Url, []}, [{autoredirect, true}, {timeout, 3000}], []),
237 | {Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}]),
238 | case xmerl_dsig:verify(Xml, Fingerprints) of
239 | ok -> ok;
240 | Err -> error(Err)
241 | end,
242 | {ok, Meta = #esaml_idp_metadata{}} = esaml:decode_idp_metadata(Xml),
243 | ets:insert(esaml_idp_meta_cache, {Url, Meta}),
244 | Meta
245 | end.
246 |
247 | %% @doc Reads IDP metadata from a URL (or ETS memory cache)
248 | -spec load_metadata(Url :: string()) -> esaml:idp_metadata().
249 | load_metadata(Url) ->
250 | case ets:lookup(esaml_idp_meta_cache, Url) of
251 | [{Url, Meta}] -> Meta;
252 | _ ->
253 | Timeout = application:get_env(esaml, load_metadata_timeout, 15000),
254 | {ok, {{_Ver, 200, _}, _Headers, Body}} = httpc:request(get, {Url, []}, [{autoredirect, true}, {timeout, Timeout}], []),
255 | {Xml, _} = xmerl_scan:string(Body, [{namespace_conformant, true}]),
256 | {ok, Meta = #esaml_idp_metadata{}} = esaml:decode_idp_metadata(Xml),
257 | ets:insert(esaml_idp_meta_cache, {Url, Meta}),
258 | Meta
259 | end.
260 |
261 | %% @doc Checks for a duplicate assertion using ETS tables in memory on all available nodes.
262 | %%
263 | %% This is a helper to be used as a DuplicateFun with esaml_sp:validate_assertion/3.
264 | %% If you aren't using standard erlang distribution for your app, you probably don't
265 | %% want to use this.
266 | -spec check_dupe_ets(esaml:assertion(), Digest :: binary()) -> ok | {error, duplicate_assertion}.
267 | check_dupe_ets(A, Digest) ->
268 | Now = erlang:localtime_to_universaltime(erlang:localtime()),
269 | NowSecs = calendar:datetime_to_gregorian_seconds(Now),
270 | DeathSecs = esaml:stale_time(A),
271 | {ResL, _BadNodes} = rpc:multicall(erlang, apply, [fun() ->
272 | case (catch ets:lookup(esaml_assertion_seen, Digest)) of
273 | [{Digest, seen} | _] -> seen;
274 | _ -> ok
275 | end
276 | end, []]),
277 | case lists:member(seen, ResL) of
278 | true ->
279 | {error, duplicate_assertion};
280 | _ ->
281 | Until = DeathSecs - NowSecs + 1,
282 | rpc:multicall(erlang, apply, [fun() ->
283 | case ets:info(esaml_assertion_seen) of
284 | undefined ->
285 | Me = self(),
286 | Pid = spawn(fun() ->
287 | register(esaml_ets_table_owner, self()),
288 | ets:new(esaml_assertion_seen, [set, public, named_table]),
289 | ets:new(esaml_privkey_cache, [set, public, named_table]),
290 | ets:new(esaml_certbin_cache, [set, public, named_table]),
291 | ets:insert(esaml_assertion_seen, {Digest, seen}),
292 | Me ! {self(), ping},
293 | ets_table_owner()
294 | end),
295 | receive
296 | {Pid, ping} -> ok
297 | end;
298 | _ ->
299 | ets:insert(esaml_assertion_seen, {Digest, seen})
300 | end,
301 | {ok, _} = timer:apply_after(Until * 1000, erlang, apply, [fun() ->
302 | ets:delete(esaml_assertion_seen, Digest)
303 | end, []])
304 | end, []]),
305 | ok
306 | end.
307 |
308 | % TODO: switch to uuid_erl hex pkg
309 | unique_id() ->
310 | "id"
311 | ++ integer_to_list(erlang:system_time())
312 | ++ integer_to_list(erlang:unique_integer([positive])).
313 |
314 | -ifdef(TEST).
315 | -include_lib("eunit/include/eunit.hrl").
316 |
317 | fingerprints_test() ->
318 | [<<0:128>>] = convert_fingerprints([<<0:128>>]),
319 | {'EXIT', _} = (catch convert_fingerprints(["testing"])),
320 | [<<0:32,1,10,3>>] = convert_fingerprints(["00:00:00:00:01:0a:03"]),
321 | Hash = crypto:hash(sha, <<"testing1234">>),
322 | [{sha,Hash}] = convert_fingerprints(["SHA:" ++ base64:encode_to_string(Hash)]),
323 | Sha256 = crypto:hash(sha256, <<"testing1234">>),
324 | [{sha256,Sha256},{md5,Hash}] = convert_fingerprints(["SHA256:" ++ base64:encode_to_string(Sha256), "md5:" ++ base64:encode_to_string(Hash)]),
325 | {'EXIT', _} = (catch convert_fingerprints(["SOMEALGO:AAAAA="])).
326 |
327 | datetime_test() ->
328 | "2013-05-02T17:26:53Z" = datetime_to_saml({{2013,5,2},{17,26,53}}),
329 | {{1990,11,23},{18,1,1}} = saml_to_datetime("1990-11-23T18:01:01Z").
330 |
331 | build_nsinfo_test() ->
332 | EmptyNs = #xmlNamespace{},
333 | FooNs = #xmlNamespace{nodes = [{"foo", 'urn:foo:'}]},
334 |
335 | E1 = #xmlElement{name = 'foo', content = [#xmlText{value = 'bar'}]},
336 | E1 = build_nsinfo(EmptyNs, E1),
337 |
338 | E2 = #xmlElement{name = 'foo:Blah', content = [#xmlText{value = 'bar'}]},
339 | E2Ns = E2#xmlElement{nsinfo = {"foo", "Blah"}, namespace = FooNs},
340 | E2Ns = build_nsinfo(FooNs, E2),
341 |
342 | E3 = #xmlElement{name = 'blah:George', content = [E2]},
343 | E3Ns = E3#xmlElement{nsinfo = {"blah", "George"}, namespace = FooNs, content = [E2Ns]},
344 | E3Ns = build_nsinfo(FooNs, E3).
345 |
346 | key_load_test() ->
347 | start_ets(),
348 | KeyPath = "../test/selfsigned_key.pem",
349 | Key = load_private_key(KeyPath),
350 | ?assertEqual([{KeyPath, Key}], ets:lookup(esaml_privkey_cache, KeyPath)).
351 |
352 | key_import_test() ->
353 | start_ets(),
354 | {ok, EncodedKey} = file:read_file("../test/selfsigned_key.pem"),
355 | Key = import_private_key(EncodedKey, my_key),
356 | ?assertEqual([{my_key, Key}], ets:lookup(esaml_privkey_cache, my_key)).
357 |
358 | bad_key_load_test() ->
359 | start_ets(),
360 | KeyPath = "../test/bad_data.pem",
361 | ?assertException(error, {badmatch, []}, load_private_key(KeyPath)),
362 | ?assertEqual([], ets:lookup(esaml_privkey_cache, KeyPath)).
363 |
364 | cert_load_test() ->
365 | start_ets(),
366 | CertPath = "../test/selfsigned.pem",
367 | Cert = load_certificate(CertPath),
368 | ?assertEqual([{CertPath, [Cert]}], ets:lookup(esaml_certbin_cache, CertPath)).
369 |
370 | cert_import_test() ->
371 | start_ets(),
372 | {ok, EncodedCert} = file:read_file("../test/selfsigned.pem"),
373 | Cert = import_certificate(EncodedCert, my_cert),
374 | ?assertEqual([{my_cert, [Cert]}], ets:lookup(esaml_certbin_cache, my_cert)).
375 |
376 | bad_cert_load_test() ->
377 | start_ets(),
378 | CertPath = "../test/bad_data.pem",
379 | ?assertException(error, {badmatch, []}, load_certificate(CertPath)),
380 | ?assertEqual([{CertPath, []}], ets:lookup(esaml_certbin_cache, CertPath)).
381 |
382 | -include("xmerl_xpath_macros.hrl").
383 |
384 | -record(b, {name, ctext}).
385 |
386 | xpath_attr_test() ->
387 | {Xml, _} = xmerl_scan:string("hi", [{namespace_conformant, true}]),
388 | Ns = [],
389 | Fun = ?xpath_attr("/a/b[@name='foo']/c/@name", b, name),
390 | Rec = Fun(#b{}),
391 | ?assertMatch(#b{name = "bar"}, Rec),
392 | Fun2 = ?xpath_attr("/a/b[@name='foobar']/c/@name", b, name),
393 | Rec2 = Fun2(Rec),
394 | ?assertMatch(#b{name = "foofoo"}, Rec2),
395 | Fun3 = ?xpath_attr("/a/b[@name='bar']/c/@name", b, name),
396 | Rec3 = Fun3(Rec2),
397 | ?assertMatch(Rec2, Rec3).
398 |
399 | xpath_attr_trans_test() ->
400 | {Xml, _} = xmerl_scan:string("hi", [{namespace_conformant, true}]),
401 | Ns = [],
402 | Fun = ?xpath_attr("/a/b[@name='foobar']/c/@name", b, name, fun(X) -> list_to_atom(X) end),
403 | Rec = Fun(#b{}),
404 | ?assertMatch(#b{name = foofoo}, Rec).
405 |
406 | xpath_text_test() ->
407 | {Xml, _} = xmerl_scan:string("hi", [{namespace_conformant, true}]),
408 | Ns = [],
409 | Fun = ?xpath_text("/a/b[@name='foo']/c/text()", b, ctext),
410 | Rec = Fun(#b{}),
411 | ?assertMatch(#b{ctext = "hi"}, Rec),
412 | Fun2 = ?xpath_text("/a/b[@name='foobar']/c/text()", b, ctext),
413 | Rec2 = Fun2(Rec),
414 | ?assertMatch(Rec, Rec2),
415 | Fun3 = ?xpath_text("/a/b[@name='bar']/c/text()", b, name),
416 | Rec3 = Fun3(Rec2),
417 | ?assertMatch(Rec2, Rec3).
418 |
419 | -endif.
420 |
--------------------------------------------------------------------------------
/src/xmerl_c14n.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | %% @doc XML canonocialisation for xmerl
10 | %%
11 | %% Functions for performing XML canonicalisation (C14n), as specified
12 | %% at http://www.w3.org/TR/xml-c14n .
13 | %%
14 | %% These routines work on xmerl data structures (see the xmerl user guide
15 | %% for details).
16 | -module(xmerl_c14n).
17 |
18 | -export([c14n/3, c14n/2, c14n/1, xml_safe_string/2, xml_safe_string/1, canon_name/1]).
19 |
20 | -include_lib("xmerl/include/xmerl.hrl").
21 | -include_lib("public_key/include/public_key.hrl").
22 |
23 | %% @doc Returns the canonical namespace-URI-prefix-resolved version of an XML name.
24 | %% @private
25 | -spec canon_name(Prefix :: string(), Name :: string() | atom(), Nsp :: #xmlNamespace{}) -> string().
26 | canon_name(Ns, Name, Nsp) ->
27 | NsPartRaw = case Ns of
28 | empty -> Nsp#xmlNamespace.default;
29 | [] ->
30 | if Nsp == [] -> 'urn:oasis:names:tc:SAML:2.0:assertion';
31 | true -> Nsp#xmlNamespace.default
32 | end;
33 | _ ->
34 | case proplists:get_value(Ns, Nsp#xmlNamespace.nodes) of
35 | undefined ->
36 | error({ns_not_found, Ns, Nsp});
37 | Uri -> atom_to_list(Uri)
38 | end
39 | end,
40 | NsPart = if is_atom(NsPartRaw) -> atom_to_list(NsPartRaw); true -> NsPartRaw end,
41 | NamePart = if is_atom(Name) -> atom_to_list(Name); true -> Name end,
42 | lists:flatten([NsPart | NamePart]).
43 |
44 | %% @doc Returns the canonical URI name of an XML element or attribute.
45 | %% @private
46 | -spec canon_name(#xmlElement{} | #xmlAttribute{}) -> string().
47 | canon_name(#xmlAttribute{name = Name, nsinfo = Exp, namespace = Nsp}) ->
48 | case Exp of
49 | {Ns, Nme} -> canon_name(Ns, Nme, Nsp);
50 | _ -> canon_name([], Name, Nsp)
51 | end;
52 | canon_name(#xmlElement{name = Name, nsinfo = Exp, namespace = Nsp}) ->
53 | case Exp of
54 | {Ns, Nme} -> canon_name(Ns, Nme, Nsp);
55 | _ -> canon_name([], Name, Nsp)
56 | end.
57 |
58 | %% @doc Compares two XML attributes for c14n purposes
59 | -spec attr_lte(A :: #xmlAttribute{}, B :: #xmlAttribute{}) -> true | false.
60 | attr_lte(AttrA, AttrB) ->
61 | A = canon_name(AttrA), B = canon_name(AttrB),
62 | PrefixedA = case AttrA#xmlAttribute.nsinfo of {_, _} -> true; _ -> false end,
63 | PrefixedB = case AttrB#xmlAttribute.nsinfo of {_, _} -> true; _ -> false end,
64 | if (PrefixedA) andalso (not PrefixedB) ->
65 | false;
66 | (not PrefixedA) andalso (PrefixedB) ->
67 | true;
68 | true ->
69 | A =< B
70 | end.
71 |
72 | %% @doc Cleans out all namespace definitions from an attribute list and returns it sorted.
73 | %% @private
74 | -spec clean_sort_attrs(Attrs :: [#xmlAttribute{}]) -> [#xmlAttribute{}].
75 | clean_sort_attrs(Attrs) ->
76 | lists:sort(fun(A,B) ->
77 | attr_lte(A, B)
78 | end, lists:filter(fun(Attr) ->
79 | case Attr#xmlAttribute.nsinfo of
80 | {"xmlns", _} -> false;
81 | _ -> case Attr#xmlAttribute.name of
82 | 'xmlns' -> false;
83 | _ -> true
84 | end
85 | end
86 | end, Attrs)).
87 |
88 | %% @doc Returns the list of namespace prefixes "needed" by an element in canonical form
89 | %% @private
90 | -spec needed_ns(Elem :: #xmlElement{}, InclNs :: [string()]) -> [string()].
91 | needed_ns(#xmlElement{nsinfo = NsInfo, attributes = Attrs}, InclNs) ->
92 | NeededNs1 = case NsInfo of
93 | {Nas, _} -> [Nas];
94 | _ -> []
95 | end,
96 | % show through namespaces that apply at the bottom level? this part of the spec is retarded
97 | %KidElems = [K || K <- Kids, element(1, K) =:= xmlElement],
98 | NeededNs2 = NeededNs1, %case KidElems of
99 | %[] -> [K || {K,V} <- E#xmlElement.namespace#xmlNamespace.nodes];
100 | %_ -> NeededNs1
101 | %end,
102 | lists:foldl(fun(Attr, Needed) ->
103 | case Attr#xmlAttribute.nsinfo of
104 | {"xmlns", Prefix} ->
105 | case lists:member(Prefix, InclNs) and not lists:member(Prefix, Needed) of
106 | true -> [Prefix | Needed];
107 | _ -> Needed
108 | end;
109 | {Ns, _Name} ->
110 | case lists:member(Ns, Needed) of
111 | true -> Needed;
112 | _ -> [Ns | Needed]
113 | end;
114 | _ -> Needed
115 | end
116 | end, NeededNs2, Attrs).
117 |
118 | %% @doc Make xml ok to eat, in a non-quoted situation.
119 | %% @private
120 | -spec xml_safe_string(term()) -> string().
121 | xml_safe_string(Term) -> xml_safe_string(Term, false).
122 |
123 | %% @doc Make xml ok to eat
124 | %% @private
125 | -spec xml_safe_string(String :: term(), Quotes :: boolean()) -> string().
126 | xml_safe_string(Atom, Quotes) when is_atom(Atom) -> xml_safe_string(atom_to_list(Atom), Quotes);
127 | xml_safe_string(Bin, Quotes) when is_binary(Bin) -> xml_safe_string(binary_to_list(Bin), Quotes);
128 | xml_safe_string([], _) -> [];
129 | xml_safe_string(Str, Quotes) when is_list(Str) ->
130 | [Next | Rest] = Str,
131 | if
132 | (not Quotes andalso ([Next] =:= "\n")) -> [Next | xml_safe_string(Rest, Quotes)];
133 | (Next < 32) ->
134 | lists:flatten(["" ++ integer_to_list(Next, 16) ++ ";" | xml_safe_string(Rest, Quotes)]);
135 | (Quotes andalso ([Next] =:= "\"")) -> lists:flatten([""" | xml_safe_string(Rest, Quotes)]);
136 | ([Next] =:= "&") -> lists:flatten(["&" | xml_safe_string(Rest, Quotes)]);
137 | ([Next] =:= "<") -> lists:flatten(["<" | xml_safe_string(Rest, Quotes)]);
138 | (not Quotes andalso ([Next] =:= ">")) -> lists:flatten([">" | xml_safe_string(Rest, Quotes)]);
139 | true -> [Next | xml_safe_string(Rest, Quotes)]
140 | end;
141 | xml_safe_string(Term, Quotes) ->
142 | xml_safe_string(io_lib:format("~p", [Term]), Quotes).
143 |
144 | %% @doc Worker function for canonicalisation (c14n). It accumulates the canonical string data
145 | %% for a given XML "thing" (element/attribute/whatever)
146 | %% @private
147 | -type xml_thing() :: #xmlDocument{} | #xmlElement{} | #xmlAttribute{} | #xmlPI{} | #xmlText{} | #xmlComment{}.
148 | -spec c14n(XmlThing :: xml_thing(), KnownNs :: [{string(), string()}], ActiveNS :: [string()], Comments :: boolean(), InclNs :: [string()], Acc :: [string() | number()]) -> [string() | number()].
149 |
150 | c14n(#xmlText{value = Text}, _KnownNS, _ActiveNS, _Comments, _InclNs, Acc) ->
151 | [xml_safe_string(Text) | Acc];
152 |
153 | c14n(#xmlComment{value = Text}, _KnownNS, _ActiveNS, true, _InclNs, Acc) ->
154 | ["-->", xml_safe_string(Text), "\n\n\n\n\n\n", [{namespace_conformant, true}, {document, true}]),
331 | WithoutComments = "\nHello, world!\n",
332 | WithoutComments = c14n(Doc, false),
333 |
334 | WithComments = "\nHello, world!\n\n\n",
335 | WithComments = c14n(Doc, true).
336 |
337 | c14n_3_2_test() ->
338 | {Doc, _} = xmerl_scan:string("\n \n A B \n \n A\n \n B\n A B \n C\n \n", [{namespace_conformant, true}, {document, true}]),
339 |
340 | Target = "\n \n A B \n \n A\n \n B\n A B \n C\n \n",
341 | Target = c14n(Doc, true).
342 |
343 | c14n_3_3_test() ->
344 | {Doc, _} = xmerl_scan:string("]>\n\n \n \n \n \n \n \n \n \n \n \n \n \n", [{namespace_conformant, true}, {document, true}]),
345 |
346 | Target = "\n \n \n \n \n \n \n \n \n \n \n \n \n",
347 | Target = c14n(Doc, true).
348 |
349 | c14n_3_4_test() ->
350 | {Doc, _} = xmerl_scan:string("\n\n]>\n\n First line
Second line\n 2\n \"0\" && value<\"10\" ?\"valid\":\"error\"]]>\n valid\n \n \n \n", [{namespace_conformant, true}, {document, true}]),
351 |
352 | Target = "\n First line\n\nSecond line\n 2\n value>\"0\" && value<\"10\" ?\"valid\":\"error\"\n "0" && value<"10" ?"valid":"error"\">valid\n \n \n \n",
353 | Target = c14n(Doc, true).
354 |
355 | default_ns_test() ->
356 | {Doc, _} = xmerl_scan:string("blah", [{namespace_conformant, true}]),
357 |
358 | Target = "blah",
359 | Target = c14n(Doc, true),
360 |
361 | {Doc2, _} = xmerl_scan:string("foo", [{namespace_conformant, true}]),
362 |
363 | Target2 = "foo",
364 | Target2 = c14n(Doc2, true).
365 |
366 | omit_default_ns_test() ->
367 | {Doc, _} = xmerl_scan:string("", [{namespace_conformant, true}]),
368 |
369 | Target = "",
370 | Target = c14n(Doc, true).
371 |
372 | c14n_inclns_test() ->
373 | {Doc, []} = xmerl_scan:string("foo", [{namespace_conformant, true}]),
374 |
375 | Target1 = "foo",
376 | Target1 = c14n(Doc, false),
377 |
378 | Target2 = "foo",
379 | Target2 = c14n(Doc, false, ["bar"]).
380 |
381 | c14n_dont_dupe_ns_test() ->
382 | {Doc, []} = xmerl_scan:string("foo", [{namespace_conformant, true}]),
383 | Target1 = "foo",
384 | Target1 = c14n(Doc, false, ["foo", "bar"]).
385 |
386 | -endif.
387 |
--------------------------------------------------------------------------------
/src/xmerl_dsig.erl:
--------------------------------------------------------------------------------
1 | %% -*- coding: utf-8 -*-
2 | %%
3 | %% esaml - SAML for erlang
4 | %%
5 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
6 | %% All rights reserved.
7 | %%
8 | %% Distributed subject to the terms of the 2-clause BSD license, see
9 | %% the LICENSE file in the root of the distribution.
10 |
11 | %% @doc XML digital signatures for xmerl
12 | %%
13 | %% Functions for performing XML digital signature generation and
14 | %% verification, as specified at http://www.w3.org/TR/xmldsig-core/ .
15 | %%
16 | %% These routines work on xmerl data structures (see the xmerl user guide
17 | %% for details).
18 | %%
19 | %% Currently only RSA + SHA1|SHA256 signatures are supported, in the typical
20 | %% enveloped mode.
21 | -module(xmerl_dsig).
22 |
23 | -export([verify/1, verify/2, sign/3, sign/4, strip/1, digest/1]).
24 |
25 | -include_lib("xmerl/include/xmerl.hrl").
26 | -include_lib("public_key/include/public_key.hrl").
27 |
28 | -type xml_thing() :: #xmlDocument{} | #xmlElement{} | #xmlAttribute{} | #xmlPI{} | #xmlText{} | #xmlComment{}.
29 | -type sig_method() :: rsa_sha1 | rsa_sha256.
30 | -type sig_method_uri() :: string().
31 | -type fingerprint() :: binary() | {sha | sha256, binary()}.
32 |
33 | %% @doc Returns an xmlelement without any ds:Signature elements that are inside it.
34 | -spec strip(Element :: #xmlElement{} | #xmlDocument{}) -> #xmlElement{}.
35 | strip(#xmlDocument{content = Kids} = Doc) ->
36 | NewKids = [if (element(1,K) =:= xmlElement) -> strip(K); true -> K end || K <- Kids],
37 | Doc#xmlDocument{content = NewKids};
38 |
39 | strip(#xmlElement{content = Kids} = Elem) ->
40 | NewKids = [Kid || Kid <- Kids, is_valid_kid(Kid)],
41 | Elem#xmlElement{content = NewKids}.
42 |
43 | is_valid_kid(Kid) when is_record(Kid, xmlAttribute); is_record(Kid, xmlElement) ->
44 | Canon_Name = xmerl_c14n:canon_name(Kid),
45 | Canon_Name =/= "http://www.w3.org/2000/09/xmldsig#Signature";
46 | is_valid_kid(_Child) -> true.
47 |
48 | %% @doc Signs the given XML element by creating a ds:Signature element within it, returning
49 | %% the element with the signature added.
50 | %%
51 | %% Don't use "ds" as a namespace prefix in the envelope document, or things will go baaaad.
52 | -spec sign(Element :: #xmlElement{}, PrivateKey :: #'RSAPrivateKey'{}, CertBin :: binary()) -> #xmlElement{}.
53 | sign(ElementIn, PrivateKey = #'RSAPrivateKey'{}, CertBin) when is_binary(CertBin) ->
54 | sign(ElementIn, PrivateKey, CertBin, "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256").
55 |
56 | -spec sign(Element :: #xmlElement{}, PrivateKey :: #'RSAPrivateKey'{}, CertBin :: binary(), SignatureMethod :: sig_method() | sig_method_uri()) -> #xmlElement{}.
57 | sign(ElementIn, PrivateKey = #'RSAPrivateKey'{}, CertBin, SigMethod) when is_binary(CertBin) ->
58 | % get rid of any previous signature
59 | ElementStrip = strip(ElementIn),
60 |
61 | % make sure the root element has an ID... if it doesn't yet, add one
62 | {Element, Id} = case lists:keyfind('ID', 2, ElementStrip#xmlElement.attributes) of
63 | #xmlAttribute{value = CapId} -> {ElementStrip, CapId};
64 | _ ->
65 | case lists:keyfind('id', 2, ElementStrip#xmlElement.attributes) of
66 | #xmlAttribute{value = LowId} -> {ElementStrip, LowId};
67 | _ ->
68 | NewId = esaml_util:unique_id(),
69 | Attr = #xmlAttribute{name = 'ID', value = NewId, namespace = #xmlNamespace{}},
70 | NewAttrs = [Attr | ElementStrip#xmlElement.attributes],
71 | Elem = ElementStrip#xmlElement{attributes = NewAttrs},
72 | {Elem, NewId}
73 | end
74 | end,
75 |
76 | {HashFunction, DigestMethod, SignatureMethodAlgorithm} = signature_props(SigMethod),
77 |
78 | % first we need the digest, to generate our SignedInfo element
79 | CanonXml = xmerl_c14n:c14n(Element),
80 | DigestValue = base64:encode_to_string(
81 | crypto:hash(HashFunction, unicode:characters_to_binary(CanonXml, unicode, utf8))),
82 |
83 | Ns = #xmlNamespace{nodes = [{"ds", 'http://www.w3.org/2000/09/xmldsig#'}]},
84 | SigInfo = esaml_util:build_nsinfo(Ns, #xmlElement{
85 | name = 'ds:SignedInfo',
86 | content = [
87 | #xmlElement{name = 'ds:CanonicalizationMethod',
88 | attributes = [#xmlAttribute{name = 'Algorithm', value = "http://www.w3.org/2001/10/xml-exc-c14n#"}]},
89 | #xmlElement{name = 'ds:SignatureMethod',
90 | attributes = [#xmlAttribute{name = 'Algorithm', value = SignatureMethodAlgorithm}]},
91 | #xmlElement{name = 'ds:Reference',
92 | attributes = [#xmlAttribute{name = 'URI', value = lists:flatten(["#" | Id])}],
93 | content = [
94 | #xmlElement{name = 'ds:Transforms', content = [
95 | #xmlElement{name = 'ds:Transform',
96 | attributes = [#xmlAttribute{name = 'Algorithm', value = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"}]},
97 | #xmlElement{name = 'ds:Transform',
98 | attributes = [#xmlAttribute{name = 'Algorithm', value = "http://www.w3.org/2001/10/xml-exc-c14n#"}]}]},
99 | #xmlElement{name = 'ds:DigestMethod',
100 | attributes = [#xmlAttribute{name = 'Algorithm', value = DigestMethod}]},
101 | #xmlElement{name = 'ds:DigestValue',
102 | content = [#xmlText{value = DigestValue}]}
103 | ]}
104 | ]
105 | }),
106 |
107 | % now we sign the SignedInfo element...
108 | SigInfoCanon = xmerl_c14n:c14n(SigInfo),
109 | Data = unicode:characters_to_binary(SigInfoCanon, unicode, utf8),
110 |
111 | Signature = public_key:sign(Data, HashFunction, PrivateKey),
112 | Sig64 = base64:encode_to_string(Signature),
113 | Cert64 = base64:encode_to_string(CertBin),
114 |
115 | % and wrap it all up with the signature and certificate
116 | SigElem = esaml_util:build_nsinfo(Ns, #xmlElement{
117 | name = 'ds:Signature',
118 | attributes = [#xmlAttribute{name = 'xmlns:ds', value = "http://www.w3.org/2000/09/xmldsig#"}],
119 | content = [
120 | SigInfo,
121 | #xmlElement{name = 'ds:SignatureValue', content = [#xmlText{value = Sig64}]},
122 | #xmlElement{name = 'ds:KeyInfo', content = [
123 | #xmlElement{name = 'ds:X509Data', content = [
124 | #xmlElement{name = 'ds:X509Certificate', content = [#xmlText{value = Cert64} ]}]}]}
125 | ]
126 | }),
127 | Element#xmlElement{content = [SigElem | Element#xmlElement.content]}.
128 |
129 | %% @doc Returns the canonical digest of an (optionally signed) element
130 | %%
131 | %% Strips any XML digital signatures and applies any relevant InclusiveNamespaces
132 | %% before generating the digest.
133 | -spec digest(Element :: #xmlElement{}) -> binary().
134 | digest(Element) -> digest(Element, sha256).
135 |
136 | -spec digest(Element :: #xmlElement{}, HashFunction :: sha | sha256) -> binary().
137 | digest(Element, HashFunction) ->
138 | DsNs = [{"ds", 'http://www.w3.org/2000/09/xmldsig#'},
139 | {"ec", 'http://www.w3.org/2001/10/xml-exc-c14n#'}],
140 |
141 | Txs = xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:Reference/ds:Transforms/ds:Transform[@Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#']", Element, [{namespace, DsNs}]),
142 | InclNs = case Txs of
143 | [C14nTx = #xmlElement{}] ->
144 | case xmerl_xpath:string("ec:InclusiveNamespaces/@PrefixList", C14nTx, [{namespace, DsNs}]) of
145 | [] -> [];
146 | [#xmlAttribute{value = NsList}] -> string:tokens(NsList, " ,")
147 | end;
148 | _ -> []
149 | end,
150 |
151 | CanonXml = xmerl_c14n:c14n(strip(Element), false, InclNs),
152 | CanonXmlUtf8 = unicode:characters_to_binary(CanonXml, unicode, utf8),
153 | crypto:hash(HashFunction, CanonXmlUtf8).
154 |
155 | %% @doc Verifies an XML digital signature on the given element.
156 | %%
157 | %% Fingerprints is a list of valid cert fingerprints that can be
158 | %% accepted.
159 | %%
160 | %% Will throw badmatch errors if you give it XML that is not signed
161 | %% according to the xml-dsig spec. If you're using something other
162 | %% than rsa+sha1 or sha256 this will asplode. Don't say I didn't warn you.
163 | -spec verify(Element :: #xmlElement{}, Fingerprints :: [fingerprint()] | any) -> ok | {error, bad_digest | bad_signature | cert_not_accepted}.
164 | verify(Element, Fingerprints) ->
165 | DsNs = [{"ds", 'http://www.w3.org/2000/09/xmldsig#'},
166 | {"ec", 'http://www.w3.org/2001/10/xml-exc-c14n#'}],
167 |
168 | case xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:SignatureMethod/@Algorithm", Element, [{namespace, DsNs}]) of
169 | [] ->
170 | {error, no_signature};
171 | [#xmlAttribute{value = SignatureMethodAlgorithm}] ->
172 | {HashFunction, _, _} = signature_props(SignatureMethodAlgorithm),
173 |
174 | [#xmlAttribute{value = "http://www.w3.org/2001/10/xml-exc-c14n#"}] = xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:CanonicalizationMethod/@Algorithm", Element, [{namespace, DsNs}]),
175 | [#xmlAttribute{value = SignatureMethodAlgorithm}] = xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:SignatureMethod/@Algorithm", Element, [{namespace, DsNs}]),
176 | [C14nTx = #xmlElement{}] = xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:Reference/ds:Transforms/ds:Transform[@Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#']", Element, [{namespace, DsNs}]),
177 | InclNs = case xmerl_xpath:string("ec:InclusiveNamespaces/@PrefixList", C14nTx, [{namespace, DsNs}]) of
178 | [] -> [];
179 | [#xmlAttribute{value = NsList}] -> string:tokens(NsList, " ,")
180 | end,
181 |
182 | CanonXml = xmerl_c14n:c14n(strip(Element), false, InclNs),
183 | CanonXmlUtf8 = unicode:characters_to_binary(CanonXml, unicode, utf8),
184 | CanonSha = crypto:hash(HashFunction, CanonXmlUtf8),
185 |
186 | [#xmlText{value = Sha64}] = xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:Reference/ds:DigestValue/text()", Element, [{namespace, DsNs}]),
187 | CanonSha2 = base64:decode(Sha64),
188 |
189 | if not (CanonSha =:= CanonSha2) ->
190 | {error, bad_digest};
191 |
192 | true ->
193 | [SigInfo] = xmerl_xpath:string("ds:Signature/ds:SignedInfo", Element, [{namespace, DsNs}]),
194 | SigInfoCanon = xmerl_c14n:c14n(SigInfo),
195 | Data = list_to_binary(SigInfoCanon),
196 |
197 | [#xmlText{value = Sig64}] = xmerl_xpath:string("ds:Signature//ds:SignatureValue/text()", Element, [{namespace, DsNs}]),
198 | Sig = base64:decode(Sig64),
199 |
200 | [#xmlText{value = Cert64}] = xmerl_xpath:string("ds:Signature//ds:X509Certificate/text()", Element, [{namespace, DsNs}]),
201 | CertBin = base64:decode(Cert64),
202 | CertHash = crypto:hash(sha, CertBin),
203 | CertHash2 = crypto:hash(sha256, CertBin),
204 |
205 | Cert = public_key:pkix_decode_cert(CertBin, plain),
206 | KeyBin = case Cert#'Certificate'.tbsCertificate#'TBSCertificate'.subjectPublicKeyInfo#'SubjectPublicKeyInfo'.subjectPublicKey of
207 | {_, KeyBin2} -> KeyBin2;
208 | KeyBin3 -> KeyBin3
209 | end,
210 | Key = public_key:pem_entry_decode({'RSAPublicKey', KeyBin, not_encrypted}),
211 |
212 | case public_key:verify(Data, HashFunction, Sig, Key) of
213 | true ->
214 | case Fingerprints of
215 | any ->
216 | ok;
217 | _ ->
218 | case lists:any(fun(X) -> lists:member(X, Fingerprints) end, [CertHash, {sha,CertHash}, {sha256,CertHash2}]) of
219 | true ->
220 | ok;
221 | false ->
222 | {error, cert_not_accepted}
223 | end
224 | end;
225 | false ->
226 | {error, bad_signature}
227 | end
228 | end;
229 | _ ->
230 | {error, multiple_signatures}
231 | end.
232 |
233 | %% @doc Verifies an XML digital signature, trusting any valid certificate.
234 | %%
235 | %% This is really not recommended for production use, but it's handy in
236 | %% testing/development.
237 | -spec verify(Element :: xml_thing()) -> ok | {error, bad_digest | bad_signature | cert_not_accepted}.
238 | verify(Element) ->
239 | verify(Element, any).
240 |
241 | -spec signature_props(atom() | string()) -> {HashFunction :: atom(), DigestMethodUrl :: string(), SignatureMethodUrl :: string()}.
242 | signature_props("http://www.w3.org/2000/09/xmldsig#rsa-sha1") ->
243 | signature_props(rsa_sha1);
244 | signature_props(rsa_sha1) ->
245 | HashFunction = sha,
246 | DigestMethod = "http://www.w3.org/2000/09/xmldsig#sha1",
247 | Url = "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
248 | {HashFunction, DigestMethod, Url};
249 | signature_props("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") ->
250 | signature_props(rsa_sha256);
251 | signature_props(rsa_sha256) ->
252 | HashFunction = sha256,
253 | DigestMethod = "http://www.w3.org/2001/04/xmlenc#sha256",
254 | Url = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
255 | {HashFunction, DigestMethod, Url}.
256 |
257 | -ifdef(TEST).
258 | -include_lib("eunit/include/eunit.hrl").
259 |
260 | verify_valid_sha1_test() ->
261 | {Doc, _} = xmerl_scan:string("xPVYXCs5uMMmIbfTiTZ5R5DVhTU=rYk+WAghakHfR9VtpLz3AkMD1xLD1wISfNgch9+i+PC72RqhmfeMCZMkBaw0EO+CTKEoFBQIQaJYlEj8rIG+XN+8HyBV75BrMKZs1rdN+459Rpn2FOOJuHVb2jLDPecC9Ok/DGaNu6lol60hG9di66EZkL8ErQCuCeZqiw9tiXMUPQyVa2GxqT2UeXvJ5YtkNMDweUc3HhEnTG3ovYt1vOZt679w4N0HAwUa9rk40Z12fOTx77BbMICZ9Q4N2m3UbaFU24YHYpHR+WUTiwzXcmdkrHiE5IF37h7rTKAEixD2bTojaefmrobAz0+mBhCqBPcbfNLhLrpT43xhMenjpA==MIIDfTCCAmWgAwIBAgIJANCSQXrTqpDjMA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApRdWVlbnNsYW5kMREwDwYDVQQHDAhCcmlzYmFuZTEMMAoGA1UECgwDRm9vMRAwDgYDVQQDDAdzYW1saWRwMB4XDTEzMDQyOTA2MTAyOVoXDTIzMDQyOTA2MTAyOVowVTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClF1ZWVuc2xhbmQxETAPBgNVBAcMCEJyaXNiYW5lMQwwCgYDVQQKDANGb28xEDAOBgNVBAMMB3NhbWxpZHAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFhBuEO3fX+FlyT2YYzozxmXNXEmQjksigJSKD4hvsgsyGyl1iLkqNT6IbkuMXoyJG6vXufMNVLoktcLBd6eu6LQwwRjSU62AVCWZhIJP8U6lHqVsxiP90h7/b1zM7Hm9uM9RHtG+nKB7W0xNRihG8BUQOocSaLIMZZXqDPW1h/UvUqmpEzCtT0kJyXX0UAmDHzTYWHt8dqOYdcO2RAlJX0UKnwG1bHjTAfw01lJeOZiF66kH777nStYSElrHXr0NmCO/2gt6ouEnnUqJWDWRzaLbzhMLmGj83lmPgwZCBbIbnbQWLYPQ438EWfEYELq9nSQrgfUmmDPb4rtsQOXqZAgMBAAGjUDBOMB0GA1UdDgQWBBT64y2JSqY96YTYv1QbFyCPp3To/zAfBgNVHSMEGDAWgBT64y2JSqY96YTYv1QbFyCPp3To/zAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAecr+C4w3LYAU4pCbLAW2BbFWGZRqBAr6ZKZKQrrqSMUJUiRDoKc5FYJrkjl/sGHAe3b5vBrU3lb/hpSiKXVf4/zBP7uqAF75B6LwnMwYpPcXlnRyPngQcdTL5EyQT5vwqv+H3zB64TblMYbsvqm6+1ippRNq4IXQX+3NGTEkhh0xgH+e3wE8BjjiygDu0MqopaIVPemMVQIm3HI+4jmf60bz8GLD1J4dj5CvyW1jQCXu2K2fcS1xJS0FLrxh/QxR0+3prGkYiZeOWE/dHlTTvQLB+NftyamUthVxMFe8dvXMTix/egox+ps2NuO2XTkDaeeRFjUhPhS8SvZO9l0lZblah", [{namespace_conformant, true}]),
262 | ok = verify(Doc),
263 | ok = verify(Doc, [<<198,86,10,182,119,241,20,3,198,88,35,42,145,76,251,113,52,21,246,156>>]).
264 |
265 | verify_valid_sha256_test() ->
266 | {Doc, _} = xmerl_scan:string("http://www.okta.com/kzk0hhgeJEEBMWPZLFWIjE916v/l6/Hh1+orj1OuounIq73STjkXd8ZjJdnm0sk=KFK1J0eP8jcnM+YPyiONtgZEUhCoKSTs9Md2tKWr+rZLq+RLxfuEVOBgeQeoWLzMIkbhrsOuKdk/w/FfgYxhlyO7EA3IoE87oQi98B3IFYA17qgsosSOXeNra68WuCmmSxFncWMkw/VkQxcUXa8vqaRgVBXL7BgTVYi++NdYdTg=MIICmTCCAgKgAwIBAgIGAUjq/PsnMA0GCSqGSIb3DQEBBQUAMIGPMQswCQYDVQQGEwJVUzETMBEG
267 | A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
268 | MBIGA1UECwwLU1NPUHJvdmlkZXIxEDAOBgNVBAMMB2thdG9faW0xHDAaBgkqhkiG9w0BCQEWDWlu
269 | Zm9Ab2t0YS5jb20wHhcNMTQxMDA3MTQyMTAwWhcNNDQxMDA3MTQyMjAwWjCBjzELMAkGA1UEBhMC
270 | VVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNVBAoM
271 | BE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRAwDgYDVQQDDAdrYXRvX2ltMRwwGgYJKoZIhvcN
272 | AQkBFg1pbmZvQG9rdGEuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC2O65TnPiD1aJC
273 | EDnT4d7PvUhZtIyEygxs8OYmVB4sPR6tfwDtXaoQ6SxC9egXNvZb9tBYdgkJ5+/R5fxuu+Rw2dJv
274 | Fmt8+BffB6rS3fMDfyeUBpwDdOEHYV/8gwAkAOXCLwatNQW9awgfSjniHvMvWYclTfSwiOnnx422
275 | qte8uwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBADy3G1EbTA+Af27Ci8DwbYlBOVezqpH+fak8Y0EY
276 | 2pYIoWQgIj2/E6mTEQHThk25qgaXwiaBGF9096/GxipgZe75Us9mFz2CUCGAHx8nGGiNtUDCeQFE
277 | z+CClhkG4RiRcwuxMtkA9m0GmjEYh7TeDZJ3ntXaexH3s+IKFwEq2BsFhttp://www.okta.com/kzk0hhgeJEEBMWPZLFWITbfbYyN9Gw/0hNL+ylMeYR5zKaN8GvppmCJcwHhrqso=gJhmBIQ1Yk1TRHDQRjZM4bPpJHAEw7pmOrQ1k76y3l4rGnuXflRtHoJ7VrsytBI5eYFVSuPD8ojmkFeokdYQYcMpOdl6gDmWskdFenPGP/jPR27sapf8AWhAjMQgmaA8AOAPbcZmfXxSbVO+Ljpo6NhSK7qVhydnLNFitwKw69s=MIICmTCCAgKgAwIBAgIGAUjq/PsnMA0GCSqGSIb3DQEBBQUAMIGPMQswCQYDVQQGEwJVUzETMBEG
278 | A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
279 | MBIGA1UECwwLU1NPUHJvdmlkZXIxEDAOBgNVBAMMB2thdG9faW0xHDAaBgkqhkiG9w0BCQEWDWlu
280 | Zm9Ab2t0YS5jb20wHhcNMTQxMDA3MTQyMTAwWhcNNDQxMDA3MTQyMjAwWjCBjzELMAkGA1UEBhMC
281 | VVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNVBAoM
282 | BE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRAwDgYDVQQDDAdrYXRvX2ltMRwwGgYJKoZIhvcN
283 | AQkBFg1pbmZvQG9rdGEuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC2O65TnPiD1aJC
284 | EDnT4d7PvUhZtIyEygxs8OYmVB4sPR6tfwDtXaoQ6SxC9egXNvZb9tBYdgkJ5+/R5fxuu+Rw2dJv
285 | Fmt8+BffB6rS3fMDfyeUBpwDdOEHYV/8gwAkAOXCLwatNQW9awgfSjniHvMvWYclTfSwiOnnx422
286 | qte8uwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBADy3G1EbTA+Af27Ci8DwbYlBOVezqpH+fak8Y0EY
287 | 2pYIoWQgIj2/E6mTEQHThk25qgaXwiaBGF9096/GxipgZe75Us9mFz2CUCGAHx8nGGiNtUDCeQFE
288 | z+CClhkG4RiRcwuxMtkA9m0GmjEYh7TeDZJ3ntXaexH3s+IKFwEq2BsFyaroslav@kato.imhttps://api.kato.im/saml/v2/demo-okta/metadataurn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", [{namespace_conformant, true}]),
289 | ok = verify(Doc),
290 | ok = verify(Doc, [<<219,7,85,249,71,184,75,241,1,217,88,92,235,58,17,143,84,113,64,215>>]).
291 | % ok.
292 |
293 | verify_invalid_test() ->
294 | {Doc, _} = xmerl_scan:string("blah", [{namespace_conformant, true}]),
295 | {'EXIT', _} = (catch verify(Doc)).
296 |
297 | verify_unknown_cert_test() ->
298 | {Doc, _} = xmerl_scan:string("xPVYXCs5uMMmIbfTiTZ5R5DVhTU=rYk+WAghakHfR9VtpLz3AkMD1xLD1wISfNgch9+i+PC72RqhmfeMCZMkBaw0EO+CTKEoFBQIQaJYlEj8rIG+XN+8HyBV75BrMKZs1rdN+459Rpn2FOOJuHVb2jLDPecC9Ok/DGaNu6lol60hG9di66EZkL8ErQCuCeZqiw9tiXMUPQyVa2GxqT2UeXvJ5YtkNMDweUc3HhEnTG3ovYt1vOZt679w4N0HAwUa9rk40Z12fOTx77BbMICZ9Q4N2m3UbaFU24YHYpHR+WUTiwzXcmdkrHiE5IF37h7rTKAEixD2bTojaefmrobAz0+mBhCqBPcbfNLhLrpT43xhMenjpA==MIIDfTCCAmWgAwIBAgIJANCSQXrTqpDjMA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApRdWVlbnNsYW5kMREwDwYDVQQHDAhCcmlzYmFuZTEMMAoGA1UECgwDRm9vMRAwDgYDVQQDDAdzYW1saWRwMB4XDTEzMDQyOTA2MTAyOVoXDTIzMDQyOTA2MTAyOVowVTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClF1ZWVuc2xhbmQxETAPBgNVBAcMCEJyaXNiYW5lMQwwCgYDVQQKDANGb28xEDAOBgNVBAMMB3NhbWxpZHAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFhBuEO3fX+FlyT2YYzozxmXNXEmQjksigJSKD4hvsgsyGyl1iLkqNT6IbkuMXoyJG6vXufMNVLoktcLBd6eu6LQwwRjSU62AVCWZhIJP8U6lHqVsxiP90h7/b1zM7Hm9uM9RHtG+nKB7W0xNRihG8BUQOocSaLIMZZXqDPW1h/UvUqmpEzCtT0kJyXX0UAmDHzTYWHt8dqOYdcO2RAlJX0UKnwG1bHjTAfw01lJeOZiF66kH777nStYSElrHXr0NmCO/2gt6ouEnnUqJWDWRzaLbzhMLmGj83lmPgwZCBbIbnbQWLYPQ438EWfEYELq9nSQrgfUmmDPb4rtsQOXqZAgMBAAGjUDBOMB0GA1UdDgQWBBT64y2JSqY96YTYv1QbFyCPp3To/zAfBgNVHSMEGDAWgBT64y2JSqY96YTYv1QbFyCPp3To/zAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAecr+C4w3LYAU4pCbLAW2BbFWGZRqBAr6ZKZKQrrqSMUJUiRDoKc5FYJrkjl/sGHAe3b5vBrU3lb/hpSiKXVf4/zBP7uqAF75B6LwnMwYpPcXlnRyPngQcdTL5EyQT5vwqv+H3zB64TblMYbsvqm6+1ippRNq4IXQX+3NGTEkhh0xgH+e3wE8BjjiygDu0MqopaIVPemMVQIm3HI+4jmf60bz8GLD1J4dj5CvyW1jQCXu2K2fcS1xJS0FLrxh/QxR0+3prGkYiZeOWE/dHlTTvQLB+NftyamUthVxMFe8dvXMTix/egox+ps2NuO2XTkDaeeRFjUhPhS8SvZO9l0lZblah", [{namespace_conformant, true}]),
299 | {error, cert_not_accepted} = verify(Doc, [<<198>>]).
300 |
301 | verify_bad_digest_test() ->
302 | {Doc, _} = xmerl_scan:string("xPVYXCs5uMMmIbfTiTZ5R5DVhTU=b1ah", [{namespace_conformant, true}]),
303 | {error, bad_digest} = verify(Doc).
304 |
305 | verify_bad_signature_test() ->
306 | {Doc, _} = xmerl_scan:string("FzMI9JNIp2IYjB5pnReqi+khe1k=rYk+WAghakHfR9VtpLz3AkMD1xLD1wISfNgch9+i+PC72RqhmfeMCZMkBaw0EO+CTKEoFBQIQaJYlEj8rIG+XN+8HyBV75BrMKZs1rdN+459Rpn2FOOJuHVb2jLDPecC9Ok/DGaNu6lol60hG9di66EZkL8ErQCuCeZqiw9tiXMUPQyVa2GxqT2UeXvJ5YtkNMDweUc3HhEnTG3ovYt1vOZt679w4N0HAwUa9rk40Z12fOTx77BbMICZ9Q4N2m3UbaFU24YHYpHR+WUTiwzXcmdkrHiE5IF37h7rTKAEixD2bTojaefmrobAz0+mBhCqBPcbfNLhLrpT43xhMenjpA==MIIDfTCCAmWgAwIBAgIJANCSQXrTqpDjMA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApRdWVlbnNsYW5kMREwDwYDVQQHDAhCcmlzYmFuZTEMMAoGA1UECgwDRm9vMRAwDgYDVQQDDAdzYW1saWRwMB4XDTEzMDQyOTA2MTAyOVoXDTIzMDQyOTA2MTAyOVowVTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClF1ZWVuc2xhbmQxETAPBgNVBAcMCEJyaXNiYW5lMQwwCgYDVQQKDANGb28xEDAOBgNVBAMMB3NhbWxpZHAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFhBuEO3fX+FlyT2YYzozxmXNXEmQjksigJSKD4hvsgsyGyl1iLkqNT6IbkuMXoyJG6vXufMNVLoktcLBd6eu6LQwwRjSU62AVCWZhIJP8U6lHqVsxiP90h7/b1zM7Hm9uM9RHtG+nKB7W0xNRihG8BUQOocSaLIMZZXqDPW1h/UvUqmpEzCtT0kJyXX0UAmDHzTYWHt8dqOYdcO2RAlJX0UKnwG1bHjTAfw01lJeOZiF66kH777nStYSElrHXr0NmCO/2gt6ouEnnUqJWDWRzaLbzhMLmGj83lmPgwZCBbIbnbQWLYPQ438EWfEYELq9nSQrgfUmmDPb4rtsQOXqZAgMBAAGjUDBOMB0GA1UdDgQWBBT64y2JSqY96YTYv1QbFyCPp3To/zAfBgNVHSMEGDAWgBT64y2JSqY96YTYv1QbFyCPp3To/zAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAecr+C4w3LYAU4pCbLAW2BbFWGZRqBAr6ZKZKQrrqSMUJUiRDoKc5FYJrkjl/sGHAe3b5vBrU3lb/hpSiKXVf4/zBP7uqAF75B6LwnMwYpPcXlnRyPngQcdTL5EyQT5vwqv+H3zB64TblMYbsvqm6+1ippRNq4IXQX+3NGTEkhh0xgH+e3wE8BjjiygDu0MqopaIVPemMVQIm3HI+4jmf60bz8GLD1J4dj5CvyW1jQCXu2K2fcS1xJS0FLrxh/QxR0+3prGkYiZeOWE/dHlTTvQLB+NftyamUthVxMFe8dvXMTix/egox+ps2NuO2XTkDaeeRFjUhPhS8SvZO9l0lZb1ah", [{namespace_conformant, true}]),
307 | {error, bad_signature} = verify(Doc).
308 |
309 | test_sign_key() ->
310 | CertBin = <<48,130,1,173,48,130,1,103,160,3,2,1,2,2,9,0,155,15,116,226,54,
311 | 209,145,118,48,13,6,9,42,134,72,134,247,13,1,1,5,5,0,48,66,49,
312 | 11,48,9,6,3,85,4,6,19,2,88,88,49,21,48,19,6,3,85,4,7,12,12,68,
313 | 101,102,97,117,108,116,32,67,105,116,121,49,28,48,26,6,3,85,4,
314 | 10,12,19,68,101,102,97,117,108,116,32,67,111,109,112,97,110,
315 | 121,32,76,116,100,48,30,23,13,49,51,48,53,48,50,48,54,48,48,51,
316 | 52,90,23,13,50,51,48,53,48,50,48,54,48,48,51,52,90,48,66,49,11,
317 | 48,9,6,3,85,4,6,19,2,88,88,49,21,48,19,6,3,85,4,7,12,12,68,101,
318 | 102,97,117,108,116,32,67,105,116,121,49,28,48,26,6,3,85,4,10,
319 | 12,19,68,101,102,97,117,108,116,32,67,111,109,112,97,110,121,
320 | 32,76,116,100,48,76,48,13,6,9,42,134,72,134,247,13,1,1,1,5,0,3,
321 | 59,0,48,56,2,49,0,205,22,207,74,179,213,185,209,141,250,249,
322 | 250,90,172,216,115,36,248,202,38,35,250,140,203,148,166,140,
323 | 157,135,4,125,142,129,148,170,140,171,183,154,14,45,63,60,99,
324 | 68,109,247,155,2,3,1,0,1,163,80,48,78,48,29,6,3,85,29,14,4,22,
325 | 4,20,217,116,226,255,194,252,218,129,177,246,103,26,72,200,32,
326 | 122,187,222,157,58,48,31,6,3,85,29,35,4,24,48,22,128,20,217,
327 | 116,226,255,194,252,218,129,177,246,103,26,72,200,32,122,187,
328 | 222,157,58,48,12,6,3,85,29,19,4,5,48,3,1,1,255,48,13,6,9,42,
329 | 134,72,134,247,13,1,1,5,5,0,3,49,0,66,238,235,142,200,32,210,
330 | 110,101,63,239,197,154,4,128,26,192,193,3,10,250,95,242,106,
331 | 110,98,1,100,8,229,143,141,180,42,219,11,94,149,187,74,164,45,
332 | 37,79,228,71,103,175>>,
333 | Key = {'RSAPrivateKey','two-prime',
334 | 31566101599917470453416065772975030637050267921499643485243561060280673467204714198784209398028051515492879184033691,
335 | 65537,
336 | 18573989898799417322963879097353191425554564320258643998367520268996258880659389403428515182780052189009731243940089,
337 | 6176779427556368800436097873318862403597526763704995657789,
338 | 5110446628398630915379329225736384395133647699411033691319,
339 | 3629707330424811560529090457257061337677158715287651140161,
340 | 3337927863271614430989022488622788202360360154126504237157,
341 | 3289563093010152325531764796397097457944832648507910197015,
342 | asn1_NOVALUE},
343 | {Key, CertBin}.
344 |
345 | test_sign_256_key() ->
346 | CertBin = <<48,130,2,88,48,130,1,193,160,3,2,1,2,2,9,0,143,6,244,72,167,203,103,249,48,
347 | 13,6,9,42,134,72,134,247,13,1,1,11,5,0,48,69,49,11,48,9,6,3,85,4,6,19,2,65,
348 | 85,49,19,48,17,6,3,85,4,8,12,10,83,111,109,101,45,83,116,97,116,101,49,33,48,
349 | 31,6,3,85,4,10,12,24,73,110,116,101,114,110,101,116,32,87,105,100,103,105,
350 | 116,115,32,80,116,121,32,76,116,100,48,30,23,13,49,53,48,49,48,57,48,53,53,
351 | 56,50,56,90,23,13,49,56,48,49,48,56,48,53,53,56,50,56,90,48,69,49,11,48,9,6,
352 | 3,85,4,6,19,2,65,85,49,19,48,17,6,3,85,4,8,12,10,83,111,109,101,45,83,116,97,
353 | 116,101,49,33,48,31,6,3,85,4,10,12,24,73,110,116,101,114,110,101,116,32,87,
354 | 105,100,103,105,116,115,32,80,116,121,32,76,116,100,48,129,159,48,13,6,9,42,
355 | 134,72,134,247,13,1,1,1,5,0,3,129,141,0,48,129,137,2,129,129,0,226,96,97,235,
356 | 98,1,16,138,195,252,131,198,89,74,61,140,212,78,159,123,99,28,153,153,53,193,
357 | 67,109,72,5,148,219,215,43,114,158,115,146,245,138,110,187,86,167,232,15,75,
358 | 90,39,50,192,75,180,64,97,107,84,135,124,189,87,96,62,133,63,147,146,200,97,
359 | 209,193,17,186,23,41,243,247,94,51,116,64,104,108,253,157,152,31,189,28,67,
360 | 24,20,12,216,67,144,186,216,245,111,142,219,106,11,59,106,147,184,89,104,55,
361 | 80,79,112,40,181,99,211,254,130,151,2,109,137,153,40,216,255,2,3,1,0,1,163,
362 | 80,48,78,48,29,6,3,85,29,14,4,22,4,20,226,28,15,2,132,199,176,227,86,54,191,
363 | 35,102,122,246,50,138,160,135,239,48,31,6,3,85,29,35,4,24,48,22,128,20,226,
364 | 28,15,2,132,199,176,227,86,54,191,35,102,122,246,50,138,160,135,239,48,12,6,
365 | 3,85,29,19,4,5,48,3,1,1,255,48,13,6,9,42,134,72,134,247,13,1,1,11,5,0,3,129,
366 | 129,0,205,96,78,143,187,166,157,119,160,185,177,84,220,232,121,254,52,50,111,
367 | 54,114,42,132,147,98,202,12,7,194,120,234,67,26,218,126,193,245,72,75,95,224,
368 | 211,23,244,240,57,207,46,99,142,76,218,100,184,132,172,34,73,193,145,142,72,
369 | 53,165,23,144,255,102,86,99,42,254,82,107,53,119,240,62,200,212,83,220,57,80,
370 | 230,146,109,43,211,31,166,82,178,55,114,110,148,164,247,254,162,135,126,157,
371 | 123,185,30,146,185,60,125,234,98,188,205,109,134,74,58,230,84,245,87,233,232,
372 | 133,5,2>>,
373 | Key = {'RSAPrivateKey', 'two-prime',
374 | 158966980232852666772927195913239826068125056530979279609712979168793279569950881734703825673400914686519075266453462906345312980842795804140929898282998881309114359443174166979208804324900933216050217378336424610098894747923637370129796798783736195833452722831496313972485597624172644388752444143966442019071,
375 | 65537,
376 | 81585278241787073666896657377387148477980168094656271566789692148593343582026914676392925775132211811359523575799353416465883426318681613016771856031686932947271317419547861320644294073546214321361245588222429356422579589512434099189282561422126611592192445638395200306602306031474495398876927483244443369593,
377 | 12815152123986810526369994227491082588178787406540561310765978351462418958697931052574961306076834858513248417634296430722377133684866082077619514584491459,
378 | 12404611251965211323458298415076779598256259333742031592133644354834252221601927657224330177651511823990769238743820731690160529549534378492093966021787669,
379 | 12713470949925240093275522448216850277486308815036508762104942467263257296453352812079684136246663289377845680597663167924634849028624106358859697266275251,
380 | 6810924077860081545742457087875899675964008664805732102649450821129373208143854079642954317600927742717607462760847234526126256852014054284747688684682049,
381 | 4159324767638175662417764641421395971040638684938277905991804960733387537828956767796004537366153684030130407445292440219293856342103196426697248208199489,
382 | asn1_NOVALUE},
383 | {Key, CertBin}.
384 |
385 |
386 | sign_and_verify_test() ->
387 | {Doc, _} = xmerl_scan:string("blah", [{namespace_conformant, true}]),
388 | {Key, CertBin} = test_sign_key(),
389 | SignedXml = sign(Doc, Key, CertBin, "http://www.w3.org/2000/09/xmldsig#rsa-sha1"),
390 | Doc = strip(SignedXml),
391 | false = (Doc =:= SignedXml),
392 | ok = verify(SignedXml, [crypto:hash(sha, CertBin)]).
393 |
394 | sign_and_verify_sha256_test() ->
395 | {Doc, _} = xmerl_scan:string("blah", [{namespace_conformant, true}]),
396 | {Key, CertBin} = test_sign_256_key(),
397 | SignedXml = sign(Doc, Key, CertBin, rsa_sha256),
398 | Doc = strip(SignedXml),
399 | false = (Doc =:= SignedXml),
400 | ok = verify(SignedXml, [crypto:hash(sha, CertBin)]).
401 |
402 | sign_generate_id_test() ->
403 | {Doc, _} = xmerl_scan:string("blah", [{namespace_conformant, true}]),
404 | {Key, CertBin} = test_sign_key(),
405 | SignedXml = sign(Doc, Key, CertBin, "http://www.w3.org/2000/09/xmldsig#rsa-sha1"),
406 | Ns = [{"ds", 'http://www.w3.org/2000/09/xmldsig#'}],
407 | [#xmlAttribute{name = 'ID', value = RootId}] = xmerl_xpath:string("@ID", SignedXml, [{namespace, Ns}]),
408 | [#xmlAttribute{value = "#" ++ RootId}] = xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:Reference/@URI", SignedXml, [{namespace, Ns}]).
409 |
410 | utf8_test() ->
411 | Name = <<208,152,208,179,208,190,209,128,209,140,32,208,154,
412 | 208,176,209,128,209,139,208,188,208,190,208,178,32>>,
413 | ThisPerson = <<227,129,157,227,129,174,228,186,186,10>>,
414 | XmlData = <<"",ThisPerson/binary,"">>,
415 | {Doc, _} = xmerl_scan:string(binary_to_list(XmlData), [{namespace_conformant, true}]),
416 | {Key, CertBin} = test_sign_key(),
417 | SignedXml = sign(Doc, Key, CertBin, "http://www.w3.org/2000/09/xmldsig#rsa-sha1"),
418 | Ns = [{"ds", 'http://www.w3.org/2000/09/xmldsig#'}, {"x", 'urn:foo:x#'}],
419 | [#xmlAttribute{name = 'ID', value = RootId}] = xmerl_xpath:string("@ID", SignedXml, [{namespace, Ns}]),
420 | [#xmlAttribute{value = "#" ++ RootId}] = xmerl_xpath:string("ds:Signature/ds:SignedInfo/ds:Reference/@URI", SignedXml, [{namespace, Ns}]),
421 | AttrValue = unicode:characters_to_list(Name),
422 | [#xmlAttribute{name = 'attr', value = AttrValue}] = xmerl_xpath:string("x:name/@attr", SignedXml, [{namespace, Ns}]),
423 | TextValue = unicode:characters_to_list(ThisPerson),
424 | [#xmlText{value = TextValue}] = xmerl_xpath:string("x:name/text()", SignedXml, [{namespace, Ns}]),
425 | ok = verify(SignedXml, [crypto:hash(sha, CertBin)]).
426 |
427 | -endif.
428 |
--------------------------------------------------------------------------------
/src/esaml.erl:
--------------------------------------------------------------------------------
1 | %% esaml - SAML for erlang
2 | %%
3 | %% Copyright (c) 2013, Alex Wilson and the University of Queensland
4 | %% All rights reserved.
5 | %%
6 | %% Distributed subject to the terms of the 2-clause BSD license, see
7 | %% the LICENSE file in the root of the distribution.
8 |
9 | %% @doc SAML for Erlang
10 | -module(esaml).
11 | -behaviour(application).
12 | -behaviour(supervisor).
13 |
14 | -include_lib("xmerl/include/xmerl.hrl").
15 | -include_lib("public_key/include/public_key.hrl").
16 | -include("esaml.hrl").
17 |
18 | -export([start/2, stop/1, init/1]).
19 | -export([stale_time/1]).
20 | -export([config/2, config/1, to_xml/1, decode_response/1, decode_assertion/1, validate_assertion/3]).
21 | -export([decode_logout_request/1, decode_logout_response/1, decode_idp_metadata/1]).
22 |
23 | -type org() :: #esaml_org{}.
24 | -type contact() :: #esaml_contact{}.
25 | -type sp_metadata() :: #esaml_sp_metadata{}.
26 | -type idp_metadata() :: #esaml_idp_metadata{}.
27 | -type authnreq() :: #esaml_authnreq{}.
28 | -type subject() :: #esaml_subject{}.
29 | -type assertion() :: #esaml_assertion{}.
30 | -type logoutreq() :: #esaml_logoutreq{}.
31 | -type logoutresp() :: #esaml_logoutresp{}.
32 | -type response() :: #esaml_response{}.
33 | -type sp() :: #esaml_sp{}.
34 | -type saml_record() :: org() | contact() | sp_metadata() | idp_metadata() | authnreq() | subject() | assertion() | logoutreq() | logoutresp() | response().
35 |
36 | -export_type([org/0, contact/0, sp_metadata/0, idp_metadata/0,
37 | authnreq/0, subject/0, assertion/0, logoutreq/0,
38 | logoutresp/0, response/0, sp/0, saml_record/0]).
39 |
40 | -type localized_string() :: string() | [{Locale :: atom(), LocalizedString :: string()}].
41 | -type name_format() :: email | x509 | windows | krb | persistent | transient | unknown.
42 | -type logout_reason() :: user | admin.
43 | -type status_code() :: success | request_error | response_error | bad_version | authn_failed | bad_attr | denied | bad_binding | unknown.
44 | -type version() :: string().
45 | -type datetime() :: string() | binary().
46 | -type condition() :: {not_before, esaml:datetime()} | {not_on_or_after, esaml:datetime()} | {audience, string()}.
47 | -type conditions() :: [condition()].
48 | -export_type([localized_string/0, name_format/0, logout_reason/0, status_code/0, version/0, datetime/0, conditions/0]).
49 |
50 | %% @private
51 | start(_StartType, _StartArgs) ->
52 | supervisor:start_link({local, ?MODULE}, ?MODULE, []).
53 |
54 | %% @private
55 | stop(_State) ->
56 | ok.
57 |
58 | %% @private
59 | init([]) ->
60 | DupeEts = {esaml_ets_table_owner,
61 | {esaml_util, start_ets, []},
62 | permanent, 5000, worker, [esaml]},
63 | {ok,
64 | {{one_for_one, 60, 600},
65 | [DupeEts]}}.
66 |
67 | %% @doc Retrieve a config record
68 | -spec config(Name :: atom()) -> term() | undefined.
69 | config(N) -> config(N, undefined).
70 | %% @doc Retrieve a config record with default
71 | -spec config(Name :: atom(), Default :: term()) -> term().
72 | config(N, D) ->
73 | case application:get_env(esaml, N) of
74 | {ok, V} -> V;
75 | _ -> D
76 | end.
77 |
78 | -spec nameid_map(string()) -> name_format().
79 | nameid_map("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress") -> email;
80 | nameid_map("urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName") -> x509;
81 | nameid_map("urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName") -> windows;
82 | nameid_map("urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos") -> krb;
83 | nameid_map("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent") -> persistent;
84 | nameid_map("urn:oasis:names:tc:SAML:2.0:nameid-format:transient") -> transient;
85 | nameid_map(S) when is_list(S) -> unknown.
86 |
87 | -spec nameid_name_qualifier_map(string()) -> undefined | string().
88 | nameid_name_qualifier_map("") -> undefined;
89 | nameid_name_qualifier_map(S) when is_list(S) -> S.
90 |
91 | -spec nameid_sp_name_qualifier_map(string()) -> undefined | string().
92 | nameid_sp_name_qualifier_map("") -> undefined;
93 | nameid_sp_name_qualifier_map(S) when is_list(S) -> S.
94 |
95 | -spec nameid_format_map(string()) -> undefined | string().
96 | nameid_format_map("") -> undefined;
97 | nameid_format_map(S) when is_list(S) -> S.
98 |
99 | -spec subject_method_map(string()) -> bearer | unknown.
100 | subject_method_map("urn:oasis:names:tc:SAML:2.0:cm:bearer") -> bearer;
101 | subject_method_map(_) -> unknown.
102 |
103 | -spec status_code_map(string()) -> status_code() | atom().
104 | status_code_map("urn:oasis:names:tc:SAML:2.0:status:Success") -> success;
105 | status_code_map("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch") -> bad_version;
106 | status_code_map("urn:oasis:names:tc:SAML:2.0:status:AuthnFailed") -> authn_failed;
107 | status_code_map("urn:oasis:names:tc:SAML:2.0:status:InvalidAttrNameOrValue") -> bad_attr;
108 | status_code_map("urn:oasis:names:tc:SAML:2.0:status:RequestDenied") -> denied;
109 | status_code_map("urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding") -> bad_binding;
110 | status_code_map(Urn = "urn:" ++ _) -> list_to_atom(lists:last(string:tokens(Urn, ":")));
111 | status_code_map(S) when is_list(S) -> unknown.
112 |
113 | -spec rev_status_code_map(status_code()) -> string().
114 | rev_status_code_map(success) -> "urn:oasis:names:tc:SAML:2.0:status:Success";
115 | rev_status_code_map(bad_version) -> "urn:oasis:names:tc:SAML:2.0:status:VersionMismatch";
116 | rev_status_code_map(authn_failed) -> "urn:oasis:names:tc:SAML:2.0:status:AuthnFailed";
117 | rev_status_code_map(bad_attr) -> "urn:oasis:names:tc:SAML:2.0:status:InvalidAttrNameOrValue";
118 | rev_status_code_map(denied) -> "urn:oasis:names:tc:SAML:2.0:status:RequestDenied";
119 | rev_status_code_map(bad_binding) -> "urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding";
120 | rev_status_code_map(_) -> error(bad_status_code).
121 |
122 | -spec logout_reason_map(string()) -> logout_reason().
123 | logout_reason_map("urn:oasis:names:tc:SAML:2.0:logout:user") -> user;
124 | logout_reason_map("urn:oasis:names:tc:SAML:2.0:logout:admin") -> admin;
125 | logout_reason_map(S) when is_list(S) -> unknown.
126 |
127 | -spec rev_logout_reason_map(logout_reason()) -> string().
128 | rev_logout_reason_map(user) -> "urn:oasis:names:tc:SAML:2.0:logout:user";
129 | rev_logout_reason_map(admin) -> "urn:oasis:names:tc:SAML:2.0:logout:admin".
130 |
131 | -spec common_attrib_map(string()) -> atom().
132 | common_attrib_map("urn:oid:2.16.840.1.113730.3.1.3") -> employeeNumber;
133 | common_attrib_map("urn:oid:1.3.6.1.4.1.5923.1.1.1.6") -> eduPersonPrincipalName;
134 | common_attrib_map("urn:oid:0.9.2342.19200300.100.1.3") -> mail;
135 | common_attrib_map("urn:oid:2.5.4.42") -> givenName;
136 | common_attrib_map("urn:oid:2.16.840.1.113730.3.1.241") -> displayName;
137 | common_attrib_map("urn:oid:2.5.4.3") -> commonName;
138 | common_attrib_map("urn:oid:2.5.4.20") -> telephoneNumber;
139 | common_attrib_map("urn:oid:2.5.4.10") -> organizationName;
140 | common_attrib_map("urn:oid:2.5.4.11") -> organizationalUnitName;
141 | common_attrib_map("urn:oid:1.3.6.1.4.1.5923.1.1.1.9") -> eduPersonScopedAffiliation;
142 | common_attrib_map("urn:oid:2.16.840.1.113730.3.1.4") -> employeeType;
143 | common_attrib_map("urn:oid:0.9.2342.19200300.100.1.1") -> uid;
144 | common_attrib_map("urn:oid:2.5.4.4") -> surName;
145 | common_attrib_map(Uri = "http://" ++ _) -> list_to_atom(lists:last(string:tokens(Uri, "/")));
146 | common_attrib_map(Other) when is_list(Other) -> list_to_atom(Other).
147 |
148 | -include("xmerl_xpath_macros.hrl").
149 |
150 | %% @private
151 | -spec decode_idp_metadata(Xml :: #xmlElement{}) -> {ok, #esaml_idp_metadata{}} | {error, term()}.
152 | decode_idp_metadata(Xml) ->
153 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
154 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'},
155 | {"md", 'urn:oasis:names:tc:SAML:2.0:metadata'},
156 | {"ds", 'http://www.w3.org/2000/09/xmldsig#'}],
157 | esaml_util:threaduntil([
158 | ?xpath_attr_required("/md:EntityDescriptor/@entityID", esaml_idp_metadata, entity_id, bad_entity),
159 | ?xpath_attr_required("/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleSignOnService[@Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']/@Location",
160 | esaml_idp_metadata, login_location, missing_sso_location),
161 | ?xpath_attr("/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleLogoutService[@Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']/@Location",
162 | esaml_idp_metadata, logout_location),
163 | ?xpath_text("/md:EntityDescriptor/md:IDPSSODescriptor/md:NameIDFormat/text()",
164 | esaml_idp_metadata, name_format, fun nameid_map/1),
165 | ?xpath_text("/md:EntityDescriptor/md:IDPSSODescriptor/md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate/text()", esaml_idp_metadata, certificate, fun(X) -> base64:decode(list_to_binary(X)) end),
166 | ?xpath_recurse("/md:EntityDescriptor/md:ContactPerson[@contactType='technical']", esaml_idp_metadata, tech, decode_contact),
167 | ?xpath_recurse("/md:EntityDescriptor/md:Organization", esaml_idp_metadata, org, decode_org)
168 | ], #esaml_idp_metadata{}).
169 |
170 | %% @private
171 | -spec decode_org(Xml :: #xmlElement{}) -> {ok, #esaml_org{}} | {error, term()}.
172 | decode_org(Xml) ->
173 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
174 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'},
175 | {"md", 'urn:oasis:names:tc:SAML:2.0:metadata'}],
176 | esaml_util:threaduntil([
177 | ?xpath_text_required("/md:Organization/md:OrganizationName/text()", esaml_org, name, bad_org),
178 | ?xpath_text("/md:Organization/md:OrganizationDisplayName/text()", esaml_org, displayname),
179 | ?xpath_text("/md:Organization/md:OrganizationURL/text()", esaml_org, url)
180 | ], #esaml_org{}).
181 |
182 | %% @private
183 | -spec decode_contact(Xml :: #xmlElement{}) -> {ok, #esaml_contact{}} | {error, term()}.
184 | decode_contact(Xml) ->
185 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
186 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'},
187 | {"md", 'urn:oasis:names:tc:SAML:2.0:metadata'}],
188 | esaml_util:threaduntil([
189 | ?xpath_text_required("/md:ContactPerson/md:EmailAddress/text()", esaml_contact, email, bad_contact),
190 | ?xpath_text("/md:ContactPerson/md:GivenName/text()", esaml_contact, name),
191 | ?xpath_text_append("/md:ContactPerson/md:SurName/text()", esaml_contact, name, " ")
192 | ], #esaml_contact{}).
193 |
194 | %% @private
195 | -spec decode_logout_request(Xml :: #xmlElement{}) -> {ok, #esaml_logoutreq{}} | {error, term()}.
196 | decode_logout_request(Xml) ->
197 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
198 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
199 | esaml_util:threaduntil([
200 | ?xpath_attr_required("/samlp:LogoutRequest/@Version", esaml_logoutreq, version, bad_version),
201 | ?xpath_attr_required("/samlp:LogoutRequest/@IssueInstant", esaml_logoutreq, issue_instant, bad_response),
202 | ?xpath_text_required("/samlp:LogoutRequest/saml:NameID/text()", esaml_logoutreq, name, bad_name),
203 | ?xpath_attr("/samlp:LogoutRequest/saml:NameID/@SPNameQualifier", esaml_logoutreq, sp_name_qualifier, fun nameid_sp_name_qualifier_map/1),
204 | ?xpath_attr("/samlp:LogoutRequest/saml:NameID/@Format", esaml_logoutreq, name_format, fun nameid_format_map/1),
205 | ?xpath_attr("/samlp:LogoutRequest/@Destination", esaml_logoutreq, destination),
206 | ?xpath_attr("/samlp:LogoutRequest/@Reason", esaml_logoutreq, reason, fun logout_reason_map/1),
207 | ?xpath_text("/samlp:LogoutRequest/saml:Issuer/text()", esaml_logoutreq, issuer)
208 | ], #esaml_logoutreq{}).
209 |
210 | %% @private
211 | -spec decode_logout_response(Xml :: #xmlElement{}) -> {ok, #esaml_logoutresp{}} | {error, term()}.
212 | decode_logout_response(Xml) ->
213 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
214 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
215 | esaml_util:threaduntil([
216 | ?xpath_attr_required("/samlp:LogoutResponse/@Version", esaml_logoutresp, version, bad_version),
217 | ?xpath_attr_required("/samlp:LogoutResponse/@IssueInstant", esaml_logoutresp, issue_instant, bad_response),
218 | ?xpath_attr_required("/samlp:LogoutResponse/samlp:Status/samlp:StatusCode/@Value", esaml_logoutresp, status, fun status_code_map/1, bad_response),
219 | ?xpath_attr("/samlp:LogoutResponse/@Destination", esaml_logoutresp, destination),
220 | ?xpath_text("/samlp:LogoutResponse/saml:Issuer/text()", esaml_logoutresp, issuer)
221 | ], #esaml_logoutresp{}).
222 |
223 | %% @private
224 | -spec decode_response(Xml :: #xmlElement{}) -> {ok, #esaml_response{}} | {error, term()}.
225 | decode_response(Xml) ->
226 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
227 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
228 | esaml_util:threaduntil([
229 | ?xpath_attr_required("/samlp:Response/@Version", esaml_response, version, bad_version),
230 | ?xpath_attr_required("/samlp:Response/@IssueInstant", esaml_response, issue_instant, bad_response),
231 | ?xpath_attr("/samlp:Response/@Destination", esaml_response, destination),
232 | ?xpath_text("/samlp:Response/saml:Issuer/text()", esaml_response, issuer),
233 | ?xpath_attr("/samlp:Response/samlp:Status/samlp:StatusCode/@Value", esaml_response, status, fun status_code_map/1),
234 | ?xpath_recurse("/samlp:Response/saml:Assertion", esaml_response, assertion, decode_assertion)
235 | ], #esaml_response{}).
236 |
237 | %% @private
238 | -spec decode_assertion(Xml :: #xmlElement{}) -> {ok, #esaml_assertion{}} | {error, term()}.
239 | decode_assertion(Xml) ->
240 | Ns = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
241 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
242 | esaml_util:threaduntil([
243 | ?xpath_attr_required("/saml:Assertion/@Version", esaml_assertion, version, bad_version),
244 | ?xpath_attr_required("/saml:Assertion/@IssueInstant", esaml_assertion, issue_instant, bad_assertion),
245 | ?xpath_attr_required("/saml:Assertion/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData/@Recipient", esaml_assertion, recipient, bad_recipient),
246 | ?xpath_text("/saml:Assertion/saml:Issuer/text()", esaml_assertion, issuer),
247 | ?xpath_recurse("/saml:Assertion/saml:Subject", esaml_assertion, subject, decode_assertion_subject),
248 | ?xpath_recurse("/saml:Assertion/saml:Conditions", esaml_assertion, conditions, decode_assertion_conditions),
249 | ?xpath_recurse("/saml:Assertion/saml:AttributeStatement", esaml_assertion, attributes, decode_assertion_attributes),
250 | ?xpath_recurse("/saml:Assertion/saml:AuthnStatement", esaml_assertion, authn, decode_assertion_authn)
251 | ], #esaml_assertion{}).
252 |
253 | -spec decode_assertion_subject(#xmlElement{}) -> {ok, #esaml_subject{}} | {error, term()}.
254 | decode_assertion_subject(Xml) ->
255 | Ns = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
256 | esaml_util:threaduntil([
257 | ?xpath_text("/saml:Subject/saml:NameID/text()", esaml_subject, name),
258 | ?xpath_attr("/saml:Subject/saml:NameID/@NameQualifier", esaml_subject, name_qualifier, fun nameid_name_qualifier_map/1),
259 | ?xpath_attr("/saml:Subject/saml:NameID/@SPNameQualifier", esaml_subject, sp_name_qualifier, fun nameid_sp_name_qualifier_map/1),
260 | ?xpath_attr("/saml:Subject/saml:NameID/@Format", esaml_subject, name_format, fun nameid_format_map/1),
261 | ?xpath_attr("/saml:Subject/saml:SubjectConfirmation/@Method", esaml_subject, confirmation_method, fun subject_method_map/1),
262 | ?xpath_attr("/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData/@NotOnOrAfter", esaml_subject, notonorafter),
263 | ?xpath_attr("/saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData/@InResponseTo", esaml_subject, in_response_to)
264 | ], #esaml_subject{}).
265 |
266 | -spec decode_assertion_conditions(#xmlElement{}) -> {ok, conditions()} | {error, term()}.
267 | decode_assertion_conditions(Xml) ->
268 | Ns = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
269 | esaml_util:threaduntil([
270 | fun(C) ->
271 | case xmerl_xpath:string("/saml:Conditions/@NotBefore", Xml, [{namespace, Ns}]) of
272 | [#xmlAttribute{value = V}] -> [{not_before, V} | C]; _ -> C
273 | end
274 | end,
275 | fun(C) ->
276 | case xmerl_xpath:string("/saml:Conditions/@NotOnOrAfter", Xml, [{namespace, Ns}]) of
277 | [#xmlAttribute{value = V}] -> [{not_on_or_after, V} | C]; _ -> C
278 | end
279 | end,
280 | fun(C) ->
281 | case xmerl_xpath:string("/saml:Conditions/saml:AudienceRestriction/saml:Audience/text()", Xml, [{namespace, Ns}]) of
282 | [#xmlText{value = V}] -> [{audience, V} | C]; _ -> C
283 | end
284 | end
285 | ], []).
286 |
287 | -spec decode_assertion_attributes(#xmlElement{}) -> {ok, [{atom(), string()}]} | {error, term()}.
288 | decode_assertion_attributes(Xml) ->
289 | Ns = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
290 | Attrs = xmerl_xpath:string("/saml:AttributeStatement/saml:Attribute", Xml, [{namespace, Ns}]),
291 | {ok, lists:foldl(fun(AttrElem, In) ->
292 | case [X#xmlAttribute.value || X <- AttrElem#xmlElement.attributes, X#xmlAttribute.name =:= 'Name'] of
293 | [Name] ->
294 | case xmerl_xpath:string("saml:AttributeValue/text()", AttrElem, [{namespace, Ns}]) of
295 | [#xmlText{value = Value}] ->
296 | [{common_attrib_map(Name), Value} | In];
297 | List ->
298 | if (length(List) > 0) ->
299 | Value = [X#xmlText.value || X <- List, element(1, X) =:= xmlText],
300 | [{common_attrib_map(Name), Value} | In];
301 | true ->
302 | In
303 | end
304 | end;
305 | _ -> In
306 | end
307 | end, [], Attrs)}.
308 |
309 | -spec decode_assertion_authn(#xmlElement{}) -> {ok, conditions()} | {error, term()}.
310 | decode_assertion_authn(Xml) ->
311 | Ns = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}],
312 | esaml_util:threaduntil([
313 | fun(C) ->
314 | case xmerl_xpath:string("/saml:AuthnStatement/@AuthnInstant", Xml, [{namespace, Ns}]) of
315 | [#xmlAttribute{value = V}] -> [{authn_instant, V} | C]; _ -> C
316 | end
317 | end,
318 | fun(C) ->
319 | case xmerl_xpath:string("/saml:AuthnStatement/@SessionNotOnOrAfter", Xml, [{namespace, Ns}]) of
320 | [#xmlAttribute{value = V}] -> [{session_not_on_or_after, V} | C]; _ -> C
321 | end
322 | end,
323 | fun(C) ->
324 | case xmerl_xpath:string("/saml:AuthnStatement/@SessionIndex", Xml, [{namespace, Ns}]) of
325 | [#xmlAttribute{value = V}] -> [{session_index, V} | C]; _ -> C
326 | end
327 | end,
328 | fun(C) ->
329 | case xmerl_xpath:string("/saml:AuthnStatement/saml:AuthnContext/saml:AuthnContextClassRef/text()", Xml, [{namespace, Ns}]) of
330 | [#xmlText{value = V}] -> [{authn_context, V} | C]; _ -> C
331 | end
332 | end
333 | ], []).
334 |
335 | %% @doc Returns the time at which an assertion is considered stale.
336 | %% @private
337 | -spec stale_time(#esaml_assertion{}) -> integer().
338 | stale_time(A) ->
339 | esaml_util:thread([
340 | fun(T) ->
341 | case A#esaml_assertion.subject of
342 | #esaml_subject{notonorafter = ""} -> T;
343 | #esaml_subject{notonorafter = Restrict} ->
344 | Secs = calendar:datetime_to_gregorian_seconds(
345 | esaml_util:saml_to_datetime(Restrict)),
346 | if (Secs < T) -> Secs; true -> T end
347 | end
348 | end,
349 | fun(T) ->
350 | Conds = A#esaml_assertion.conditions,
351 | case proplists:get_value(not_on_or_after, Conds) of
352 | undefined -> T;
353 | Restrict ->
354 | Secs = calendar:datetime_to_gregorian_seconds(
355 | esaml_util:saml_to_datetime(Restrict)),
356 | if (Secs < T) -> Secs; true -> T end
357 | end
358 | end,
359 | fun(T) ->
360 | if (T =:= none) ->
361 | II = A#esaml_assertion.issue_instant,
362 | IISecs = calendar:datetime_to_gregorian_seconds(
363 | esaml_util:saml_to_datetime(II)),
364 | IISecs + 5*60;
365 | true ->
366 | T
367 | end
368 | end
369 | ], none).
370 |
371 | check_stale(A) ->
372 | Now = erlang:localtime_to_universaltime(erlang:localtime()),
373 | NowSecs = calendar:datetime_to_gregorian_seconds(Now),
374 | T = stale_time(A),
375 | if (NowSecs > T) ->
376 | {error, stale_assertion};
377 | true ->
378 | A
379 | end.
380 |
381 | %% @doc Parse and validate an assertion, returning it as a record
382 | %% @private
383 | -spec validate_assertion(AssertionXml :: #xmlElement{}, Recipient :: string(), Audience :: string()) ->
384 | {ok, #esaml_assertion{}} | {error, Reason :: term()}.
385 | validate_assertion(AssertionXml, Recipient, Audience) ->
386 | case decode_assertion(AssertionXml) of
387 | {error, Reason} ->
388 | {error, Reason};
389 | {ok, Assertion} ->
390 | esaml_util:threaduntil([
391 | fun(A) -> case A of
392 | #esaml_assertion{version = "2.0"} -> A;
393 | _ -> {error, bad_version}
394 | end end,
395 | fun(A) -> case A of
396 | #esaml_assertion{recipient = Recipient} -> A;
397 | _ -> {error, bad_recipient}
398 | end end,
399 | fun(A) -> case A of
400 | #esaml_assertion{conditions = Conds} ->
401 | case proplists:get_value(audience, Conds) of
402 | undefined -> A;
403 | Audience -> A;
404 | _ -> {error, bad_audience}
405 | end;
406 | _ -> A
407 | end end,
408 | fun check_stale/1
409 | ], Assertion)
410 | end.
411 |
412 | %% @doc Produce cloned elements with xml:lang set to represent
413 | %% multi-locale strings.
414 | %% @private
415 | -spec lang_elems(#xmlElement{}, localized_string()) -> [#xmlElement{}].
416 | lang_elems(BaseTag, Vals = [{Lang, _} | _]) when is_atom(Lang) ->
417 | [BaseTag#xmlElement{
418 | attributes = BaseTag#xmlElement.attributes ++
419 | [#xmlAttribute{name = 'xml:lang', value = atom_to_list(L)}],
420 | content = BaseTag#xmlElement.content ++
421 | [#xmlText{value = V}]} || {L,V} <- Vals];
422 | lang_elems(BaseTag, Val) ->
423 | [BaseTag#xmlElement{
424 | attributes = BaseTag#xmlElement.attributes ++
425 | [#xmlAttribute{name = 'xml:lang', value = "en"}],
426 | content = BaseTag#xmlElement.content ++
427 | [#xmlText{value = Val}]}].
428 |
429 | %% @doc Convert a SAML request/metadata record into XML
430 | %% @private
431 | -spec to_xml(saml_record()) -> #xmlElement{}.
432 | to_xml(#esaml_authnreq{version = V, issue_instant = Time, destination = Dest,
433 | issuer = Issuer, name_format = Format, consumer_location = Consumer}) ->
434 | Ns = #xmlNamespace{nodes = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
435 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}]},
436 |
437 | esaml_util:build_nsinfo(Ns, #xmlElement{name = 'samlp:AuthnRequest',
438 | attributes = [#xmlAttribute{name = 'xmlns:samlp', value = proplists:get_value("samlp", Ns#xmlNamespace.nodes)},
439 | #xmlAttribute{name = 'xmlns:saml', value = proplists:get_value("saml", Ns#xmlNamespace.nodes)},
440 | #xmlAttribute{name = 'IssueInstant', value = Time},
441 | #xmlAttribute{name = 'Version', value = V},
442 | #xmlAttribute{name = 'Destination', value = Dest},
443 | #xmlAttribute{name = 'AssertionConsumerServiceURL', value = Consumer},
444 | #xmlAttribute{name = 'ProtocolBinding', value = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"}],
445 | content = [#xmlElement{name = 'saml:Issuer', content = [#xmlText{value = Issuer}]}] ++
446 | case is_list(Format) of
447 | true ->
448 | [#xmlElement{name = 'samlp:NameIDPolicy',
449 | attributes = [#xmlAttribute{name = 'Format', value = Format}]}];
450 | false ->
451 | []
452 | end
453 | });
454 |
455 | to_xml(#esaml_logoutreq{version = V, issue_instant = Time, destination = Dest, issuer = Issuer,
456 | name = NameID, name_qualifier = NameQualifier,
457 | sp_name_qualifier = SPNameQualifier, name_format = NameFormat,
458 | session_index = SessionIndex, reason = Reason}) ->
459 | Ns = #xmlNamespace{nodes = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
460 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}]},
461 | NameIDAttrs =
462 | case is_list(NameQualifier) of
463 | true -> [#xmlAttribute{name = 'NameQualifier', value = NameQualifier}];
464 | false -> []
465 | end ++
466 | case is_list(SPNameQualifier) of
467 | true -> [#xmlAttribute{name = 'SPNameQualifier', value = SPNameQualifier}];
468 | false -> []
469 | end ++
470 | case is_list(NameFormat) of
471 | true -> [#xmlAttribute{name = 'Format', value = NameFormat}];
472 | false -> []
473 | end,
474 | esaml_util:build_nsinfo(Ns, #xmlElement{name = 'samlp:LogoutRequest',
475 | attributes = [#xmlAttribute{name = 'xmlns:samlp', value = proplists:get_value("samlp", Ns#xmlNamespace.nodes)},
476 | #xmlAttribute{name = 'xmlns:saml', value = proplists:get_value("saml", Ns#xmlNamespace.nodes)},
477 | #xmlAttribute{name = 'IssueInstant', value = Time},
478 | #xmlAttribute{name = 'Version', value = V},
479 | #xmlAttribute{name = 'Destination', value = Dest},
480 | #xmlAttribute{name = 'Reason', value = rev_logout_reason_map(Reason)}],
481 | content = [
482 | #xmlElement{name = 'saml:Issuer', content = [#xmlText{value = Issuer}]},
483 | #xmlElement{name = 'saml:NameID',
484 | attributes = NameIDAttrs,
485 | content = [#xmlText{value = NameID}]},
486 | #xmlElement{name = 'samlp:SessionIndex', content = [#xmlText{value = SessionIndex}]}
487 | ]
488 | });
489 |
490 | to_xml(#esaml_logoutresp{version = V, issue_instant = Time,
491 | destination = Dest, issuer = Issuer, status = Status}) ->
492 | Ns = #xmlNamespace{nodes = [{"samlp", 'urn:oasis:names:tc:SAML:2.0:protocol'},
493 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}]},
494 | esaml_util:build_nsinfo(Ns, #xmlElement{name = 'samlp:LogoutResponse',
495 | attributes = [#xmlAttribute{name = 'xmlns:samlp', value = proplists:get_value("samlp", Ns#xmlNamespace.nodes)},
496 | #xmlAttribute{name = 'xmlns:saml', value = proplists:get_value("saml", Ns#xmlNamespace.nodes)},
497 | #xmlAttribute{name = 'IssueInstant', value = Time},
498 | #xmlAttribute{name = 'Version', value = V},
499 | #xmlAttribute{name = 'Destination', value = Dest}],
500 | content = [
501 | #xmlElement{name = 'saml:Issuer', content = [#xmlText{value = Issuer}]},
502 | #xmlElement{name = 'samlp:Status', content = [
503 | #xmlElement{name = 'samlp:StatusCode', content = [
504 | #xmlText{value = rev_status_code_map(Status)}]}]}
505 | ]
506 | });
507 |
508 | to_xml(#esaml_sp_metadata{org = #esaml_org{name = OrgName, displayname = OrgDisplayName,
509 | url = OrgUrl },
510 | tech = #esaml_contact{name = TechName, email = TechEmail},
511 | signed_requests = SignReq, signed_assertions = SignAss,
512 | certificate = CertBin, cert_chain = CertChain, entity_id = EntityID,
513 | consumer_location = ConsumerLoc,
514 | logout_location = SLOLoc
515 | }) ->
516 |
517 | Ns = #xmlNamespace{nodes = [{"md", 'urn:oasis:names:tc:SAML:2.0:metadata'},
518 | {"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'},
519 | {"dsig", 'http://www.w3.org/2000/09/xmldsig#'}]},
520 |
521 | KeyDescriptorElems = case CertBin of
522 | undefined -> [];
523 | C when is_binary(C) -> [
524 | #xmlElement{name = 'md:KeyDescriptor',
525 | attributes = [#xmlAttribute{name = 'use', value = "signing"}],
526 | content = [#xmlElement{name = 'dsig:KeyInfo',
527 | content = [#xmlElement{name = 'dsig:X509Data',
528 | content =
529 | [#xmlElement{name = 'dsig:X509Certificate',
530 | content = [#xmlText{value = base64:encode_to_string(CertBin)}]} |
531 | [#xmlElement{name = 'dsig:X509Certificate',
532 | content = [#xmlText{value = base64:encode_to_string(CertChainBin)}]} || CertChainBin <- CertChain]]}]}]},
533 |
534 | #xmlElement{name = 'md:KeyDescriptor',
535 | attributes = [#xmlAttribute{name = 'use', value = "encryption"}],
536 | content = [#xmlElement{name = 'dsig:KeyInfo',
537 | content = [#xmlElement{name = 'dsig:X509Data',
538 | content =
539 | [#xmlElement{name = 'dsig:X509Certificate',
540 | content = [#xmlText{value = base64:encode_to_string(CertBin)}]} |
541 | [#xmlElement{name = 'dsig:X509Certificate',
542 | content = [#xmlText{value = base64:encode_to_string(CertChainBin)}]} || CertChainBin <- CertChain]]}]}]}
543 |
544 | ]
545 | end,
546 |
547 | SingleLogoutServiceElems = case SLOLoc of
548 | undefined -> [];
549 | _ -> [
550 | #xmlElement{name = 'md:SingleLogoutService',
551 | attributes = [#xmlAttribute{name = 'Binding', value = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"},
552 | #xmlAttribute{name = 'Location', value = SLOLoc}]},
553 | #xmlElement{name = 'md:SingleLogoutService',
554 | attributes = [#xmlAttribute{name = 'Binding', value = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"},
555 | #xmlAttribute{name = 'Location', value = SLOLoc}]}
556 | ]
557 | end,
558 |
559 | AssertionConsumerServiceElems = [
560 | #xmlElement{name = 'md:AssertionConsumerService',
561 | attributes = [#xmlAttribute{name = 'isDefault', value = "true"},
562 | #xmlAttribute{name = 'index', value = "0"},
563 | #xmlAttribute{name = 'Binding', value = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"},
564 | #xmlAttribute{name = 'Location', value = ConsumerLoc}]},
565 | #xmlElement{name = 'md:AssertionConsumerService',
566 | attributes = [#xmlAttribute{name = 'index', value = "1"},
567 | #xmlAttribute{name = 'Binding', value = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"},
568 | #xmlAttribute{name = 'Location', value = ConsumerLoc}]}
569 | ],
570 |
571 | OrganizationElem = #xmlElement{name = 'md:Organization',
572 | content =
573 | lang_elems(#xmlElement{name = 'md:OrganizationName'}, OrgName) ++
574 | lang_elems(#xmlElement{name = 'md:OrganizationDisplayName'}, OrgDisplayName) ++
575 | lang_elems(#xmlElement{name = 'md:OrganizationURL'}, OrgUrl)
576 | },
577 |
578 | ContactElem = #xmlElement{name = 'md:ContactPerson',
579 | attributes = [#xmlAttribute{name = 'contactType', value = "technical"}],
580 | content = [
581 | #xmlElement{name = 'md:SurName', content = [#xmlText{value = TechName}]},
582 | #xmlElement{name = 'md:EmailAddress', content = [#xmlText{value = TechEmail}]}
583 | ]
584 | },
585 |
586 | SPSSODescriptorElem = #xmlElement{name = 'md:SPSSODescriptor',
587 | attributes = [#xmlAttribute{name = 'protocolSupportEnumeration', value = "urn:oasis:names:tc:SAML:2.0:protocol"},
588 | #xmlAttribute{name = 'AuthnRequestsSigned', value = atom_to_list(SignReq)},
589 | #xmlAttribute{name = 'WantAssertionsSigned', value = atom_to_list(SignAss)}],
590 | content = KeyDescriptorElems ++ SingleLogoutServiceElems ++ AssertionConsumerServiceElems
591 | },
592 |
593 | esaml_util:build_nsinfo(Ns, #xmlElement{
594 | name = 'md:EntityDescriptor',
595 | attributes = [
596 | #xmlAttribute{name = 'xmlns:md', value = atom_to_list(proplists:get_value("md", Ns#xmlNamespace.nodes))},
597 | #xmlAttribute{name = 'xmlns:saml', value = atom_to_list(proplists:get_value("saml", Ns#xmlNamespace.nodes))},
598 | #xmlAttribute{name = 'xmlns:dsig', value = atom_to_list(proplists:get_value("dsig", Ns#xmlNamespace.nodes))},
599 | #xmlAttribute{name = 'entityID', value = EntityID}
600 | ], content = [
601 | SPSSODescriptorElem,
602 | OrganizationElem,
603 | ContactElem
604 | ]
605 | });
606 |
607 | to_xml(_) -> error("unknown record").
608 |
609 |
610 | -ifdef(TEST).
611 | -include_lib("eunit/include/eunit.hrl").
612 |
613 | decode_response_test() ->
614 | {Doc, _} = xmerl_scan:string("", [{namespace_conformant, true}]),
615 | Resp = decode_response(Doc),
616 | ?assertMatch({ok, #esaml_response{issue_instant = "2013-01-01T01:01:01Z", destination = "foo", status = unknown}}, Resp).
617 |
618 | decode_response_no_version_test() ->
619 | {Doc, _} = xmerl_scan:string("", [{namespace_conformant, true}]),
620 | Resp = decode_response(Doc),
621 | {error, bad_version} = Resp.
622 |
623 | decode_response_no_issue_instant_test() ->
624 | {Doc, _} = xmerl_scan:string("", [{namespace_conformant, true}]),
625 | Resp = decode_response(Doc),
626 | {error, bad_response} = Resp.
627 |
628 | decode_response_destination_optional_test() ->
629 | {Doc, _} = xmerl_scan:string("", [{namespace_conformant, true}]),
630 | Resp = decode_response(Doc),
631 | {ok, #esaml_response{issue_instant = "2013-01-01T01:01:01Z", status = unknown}} = Resp.
632 |
633 | decode_response_status_test() ->
634 | {Doc, _} = xmerl_scan:string("foo", [{namespace_conformant, true}]),
635 | Resp = decode_response(Doc),
636 | {ok, #esaml_response{issue_instant = "2013-01-01T01:01:01Z", status = success, issuer = "foo"}} = Resp.
637 |
638 | decode_response_bad_assertion_test() ->
639 | {Doc, _} = xmerl_scan:string("foo", [{namespace_conformant, true}]),
640 | Resp = decode_response(Doc),
641 | {error, bad_version} = Resp.
642 |
643 | decode_assertion_no_recipient_test() ->
644 | {Doc, _} = xmerl_scan:string("foofoofoobar", [{namespace_conformant, true}]),
645 | Resp = decode_response(Doc),
646 | {error, bad_recipient} = Resp.
647 |
648 | decode_assertion_test() ->
649 | {Doc, _} = xmerl_scan:string("foofoofoobar", [{namespace_conformant, true}]),
650 | Resp = decode_response(Doc),
651 | {ok, #esaml_response{issue_instant = "2013-01-01T01:01:01Z", issuer = "foo", status = success, assertion = #esaml_assertion{issue_instant = "test", issuer = "foo", recipient = "foobar123", subject = #esaml_subject{name = "foobar", confirmation_method = bearer}}}} = Resp.
652 |
653 | decode_conditions_test() ->
654 | {Doc, _} = xmerl_scan:string("foofoofoobarfoobaraudience", [{namespace_conformant, true}]),
655 | Resp = decode_response(Doc),
656 | {ok, #esaml_response{assertion = #esaml_assertion{conditions = Conds}}} = Resp,
657 | [{audience, "foobaraudience"}, {not_before, "before"}, {not_on_or_after, "notafter"}] = lists:sort(Conds).
658 |
659 | decode_attributes_test() ->
660 | {Doc, _} = xmerl_scan:string("foobartest@test.comgeorgebartest@test.com", [{namespace_conformant, true}]),
661 | Assertion = decode_assertion(Doc),
662 | {ok, #esaml_assertion{attributes = Attrs}} = Assertion,
663 | [{emailaddress, "test@test.com"}, {foo, ["george", "bar"]}, {mail, "test@test.com"}] = lists:sort(Attrs).
664 |
665 | decode_solicited_in_response_to_test() ->
666 | {Doc, _} = xmerl_scan:string("foofoofoobarfoobaraudience", [{namespace_conformant, true}]),
667 | Resp = decode_response(Doc),
668 | {ok, #esaml_response{assertion = #esaml_assertion{subject = #esaml_subject{in_response_to = ReqId}}}} = Resp,
669 | "_1234567890" = ReqId.
670 |
671 | decode_unsolicited_in_response_to_test() ->
672 | {Doc, _} = xmerl_scan:string("foofoofoobarfoobaraudience", [{namespace_conformant, true}]),
673 | Resp = decode_response(Doc),
674 | {ok, #esaml_response{assertion = #esaml_assertion{subject = #esaml_subject{in_response_to = ReqId}}}} = Resp,
675 | "" = ReqId.
676 |
677 | validate_assertion_test() ->
678 | Now = erlang:localtime_to_universaltime(erlang:localtime()),
679 | DeathSecs = calendar:datetime_to_gregorian_seconds(Now) + 1,
680 | Death = esaml_util:datetime_to_saml(calendar:gregorian_seconds_to_datetime(DeathSecs)),
681 |
682 | Ns = #xmlNamespace{nodes = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}]},
683 |
684 | E1 = esaml_util:build_nsinfo(Ns, #xmlElement{name = 'saml:Assertion',
685 | attributes = [#xmlAttribute{name = 'xmlns:saml', value = "urn:oasis:names:tc:SAML:2.0:assertion"}, #xmlAttribute{name = 'Version', value = "2.0"}, #xmlAttribute{name = 'IssueInstant', value = "now"}],
686 | content = [
687 | #xmlElement{name = 'saml:Subject', content = [
688 | #xmlElement{name = 'saml:SubjectConfirmation', content = [
689 | #xmlElement{name = 'saml:SubjectConfirmationData',
690 | attributes = [#xmlAttribute{name = 'Recipient', value = "foobar"},
691 | #xmlAttribute{name = 'NotOnOrAfter', value = Death}]
692 | } ]} ]},
693 | #xmlElement{name = 'saml:Conditions', content = [
694 | #xmlElement{name = 'saml:AudienceRestriction', content = [
695 | #xmlElement{name = 'saml:Audience', content = [#xmlText{value = "foo"}]}] }] } ]
696 | }),
697 | {ok, Assertion} = validate_assertion(E1, "foobar", "foo"),
698 | #esaml_assertion{issue_instant = "now", recipient = "foobar", subject = #esaml_subject{notonorafter = Death}, conditions = [{audience, "foo"}]} = Assertion,
699 | {error, bad_recipient} = validate_assertion(E1, "foo", "something"),
700 | {error, bad_audience} = validate_assertion(E1, "foobar", "something"),
701 |
702 | E2 = esaml_util:build_nsinfo(Ns, #xmlElement{name = 'saml:Assertion',
703 | attributes = [#xmlAttribute{name = 'xmlns:saml', value = "urn:oasis:names:tc:SAML:2.0:assertion"}, #xmlAttribute{name = 'Version', value = "2.0"}, #xmlAttribute{name = 'IssueInstant', value = "now"}],
704 | content = [
705 | #xmlElement{name = 'saml:Subject', content = [
706 | #xmlElement{name = 'saml:SubjectConfirmation', content = [ ]} ]},
707 | #xmlElement{name = 'saml:Conditions', content = [
708 | #xmlElement{name = 'saml:AudienceRestriction', content = [
709 | #xmlElement{name = 'saml:Audience', content = [#xmlText{value = "foo"}]}] }] } ]
710 | }),
711 | {error, bad_recipient} = validate_assertion(E2, "", "").
712 |
713 | validate_stale_assertion_test() ->
714 | Ns = #xmlNamespace{nodes = [{"saml", 'urn:oasis:names:tc:SAML:2.0:assertion'}]},
715 | OldStamp = esaml_util:datetime_to_saml({{1990,1,1}, {1,1,1}}),
716 | E1 = esaml_util:build_nsinfo(Ns, #xmlElement{name = 'saml:Assertion',
717 | attributes = [#xmlAttribute{name = 'xmlns:saml', value = "urn:oasis:names:tc:SAML:2.0:assertion"}, #xmlAttribute{name = 'Version', value = "2.0"}, #xmlAttribute{name = 'IssueInstant', value = "now"}],
718 | content = [
719 | #xmlElement{name = 'saml:Subject', content = [
720 | #xmlElement{name = 'saml:SubjectConfirmation', content = [
721 | #xmlElement{name = 'saml:SubjectConfirmationData',
722 | attributes = [#xmlAttribute{name = 'Recipient', value = "foobar"},
723 | #xmlAttribute{name = 'NotOnOrAfter', value = OldStamp}]
724 | } ]} ]} ]
725 | }),
726 | {error, stale_assertion} = validate_assertion(E1, "foobar", "foo").
727 |
728 | -endif.
729 |
--------------------------------------------------------------------------------