├── 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 demo

Hi 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 |
>,Dest,<<"\"> 111 | >,Type,<<"\" value=\"">>,Req,<<"\" /> 112 | >,RelayState,<<"\" /> 113 | 114 |
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(["&#x" ++ 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 | --------------------------------------------------------------------------------