├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── samly.ex └── samly │ ├── assertion.ex │ ├── auth_handler.ex │ ├── auth_router.ex │ ├── config_error.ex │ ├── cspr_router.ex │ ├── esaml.ex │ ├── helper.ex │ ├── idp_data.ex │ ├── provider.ex │ ├── router.ex │ ├── router_util.ex │ ├── sp_data.ex │ ├── sp_handler.ex │ ├── sp_router.ex │ ├── state.ex │ ├── state │ ├── ets.ex │ ├── session.ex │ └── store.ex │ └── subject.ex ├── mix.exs ├── mix.lock └── test ├── data ├── azure_fed_metadata.xml ├── idp_metadata.xml ├── onelogin_idp_metadata.xml ├── shibboleth_idp_metadata.xml ├── simplesaml_idp_metadata.xml ├── test.crt ├── test.pem └── testshib_metadata.xml ├── samly_idp_data_test.exs ├── samly_sp_data_test.exs ├── samly_state_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "mix.exs", 4 | "{config,lib,test}/**/*.{ex,exs}" 5 | ], 6 | 7 | locals_without_parens: [ 8 | plug: 1, 9 | plug: 2 10 | ] 11 | ] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | .sobelow 22 | /docs/ 23 | /.elixir_ls/ 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ### v1.0.0 4 | 5 | + `target_url` query parameter for the sign-in/sign-out requests must be 6 | `x-www-form-urlencoded`. 7 | 8 | + Redirect URLs are properly encoded. 9 | 10 | + Switched to `report-to` in content security policy. 11 | 12 | + `cache-control` header value updated. 13 | 14 | + Issue: #33 - Content Security Policy 15 | Enabled `Content-Security-Policy` in the HTTP response. 16 | 17 | + PR: #41 - Config support for nameid format 18 | `Samly` uses the nameid format from the IdP metadata XML file. 19 | It is possible now to override this using `nameid_fomat` config setting. 20 | If this format information is not present in the IdP metadata XML and not 21 | specified in the config setting, it defaults to `:transient`. 22 | Thanks to [calvinb](https://github.com/calvinb) for the PR. 23 | 24 | + Uptake `esaml 4.2` bringing in support for encrypted assertions. 25 | Check [Assertion Encryption](https://github.com/handnot2/esaml#assertion-encryption) 26 | for supported encryption algorithms. Use this information to enable assertion 27 | encryption on IdP. Thanks to [tcrossland](https://github.com/tcrossland) 28 | for the `esaml` PR. 29 | 30 | ### v0.10.1 31 | 32 | + Issues: #39, #40 - Downcase response header names 33 | (PR from [calvinb](https://github.com/calvinb)) 34 | 35 | ### v0.10.0 36 | 37 | + Issue: #31 - Support for Cowboy 2.x 38 | Uptake `esaml` v4.0.0 which includes support for Cowboy 2.x. 39 | If support for Cowboy 1.x is needed, you need an override with 40 | `esaml` v3.6.x in your application `mix.exs` file. 41 | 42 | + Issue: #32 - Support for custom State Storage 43 | Includes support for ETS and Plug Sessions based authenticated SAML 44 | assertion storage. It is possible to create custom stores by 45 | implementing `Samly.State.Store`. 46 | 47 | + Issue: #34 - Included filename in error messages 48 | Include metadata/cert/key filenames when there is an error relevant to 49 | those files. 50 | 51 | ### v0.9.3 52 | 53 | + Uptake `esaml` v3.6.0 that includes fixes for schema validation errors. 54 | 55 | ### v0.9.2 56 | 57 | + PR merged fixing reopened Issue #16 (from @peterox) 58 | 59 | ### v0.9.1 60 | 61 | + Remove the need for supplying certicate and key files if the requests are 62 | not signed (Issue #16). Useful during development when the corresponding 63 | Identity Provider is setup for unsigned requests/responses. Use signing 64 | for production deployments. The defaults expect signed requests/responses. 65 | 66 | ### v0.9.0 67 | 68 | + Issue: #12. Support for IDP initiated SSO flow. 69 | 70 | + Original auth request ID when returned in auth response is made available 71 | in the assertion subject (SP initiated SSO flows). For IDP initiated 72 | SSO flows, this will be an empty string. 73 | 74 | + Issue: #14. Remove built-in referer check. 75 | Not specific to `Samly`. It is better handled by the consuming application. 76 | 77 | ### v0.8.4 78 | 79 | + Shibboleth Single Logout session match related fix. Uptake `esaml v3.3.0`. 80 | 81 | ### v0.8.3 82 | 83 | + Generates SP metadata XML that passes XSD validation 84 | 85 | ### v0.8.2 86 | 87 | + Handle namespaces in Identity Provider Metadata XML file 88 | 89 | ### v0.8.0 90 | 91 | + Added support for multiple Identity Providers. Check issue: #4. 92 | Instructions for migrating from v0.7.x available in github project wiki. 93 | 94 | ### v0.7.2 95 | 96 | + Added `use_redirect_for_idp_req` config parameter. By default `Samly` uses HTTP POST when sending requests to IdP. Set this config parameter to `true` if HTTP redirection should be used instead. 97 | 98 | ### v0.7.1 99 | 100 | + Added config option (`entity_id`). OOTB uses metadata URI as entity ID. Can be specified (`urn` entity ID for example) to override the default. 101 | 102 | ### v0.7.0 103 | 104 | + Added config options to control if requests and/or responses are signed or not 105 | 106 | ### v0.6.3 107 | 108 | + Added Inch CI 109 | + Corresponding doc updates 110 | 111 | ### v0.6.2 112 | 113 | + Doc updates 114 | + Config handling changes and corresponding tests 115 | 116 | ### v0.6.1 117 | 118 | + `target_url` query parameter form url encoded 119 | 120 | ### v0.6.0 121 | 122 | + Plug Pipeline config `:pre_session_create_pipeline` 123 | + Computed attributes available in `Samly.Assertion` 124 | + Updates to `Samly.Provider` `base_url` config handling 125 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 handnot2 (handnot2@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Samly 2 | 3 | A SAML 2.0 Service Provider Single-Sign-On Authentication library. This Plug library can be used to SAML enable a Plug/Phoenix application. 4 | 5 | This has been used in the wild with the following Identity Providers: 6 | 7 | + Okta 8 | + Ping Identity 9 | + OneLogin 10 | + ADFS 11 | + Nexus GO 12 | + Shibboleth 13 | + SimpleSAMLphp 14 | 15 | Please send a note by DM if you have successfully used `Samly` with other Identity Providers. 16 | 17 | [![Inline docs](http://inch-ci.org/github/handnot2/samly.svg)](http://inch-ci.org/github/handnot2/samly) 18 | 19 | This library uses Erlang [`esaml`](https://github.com/handnot2/esaml) to provide 20 | plug enabled routes. 21 | 22 | ## Setup 23 | 24 | ```elixir 25 | # mix.exs 26 | 27 | # v1.0.0 uses esaml v4.2 which in turn relies on cowboy 2.x 28 | # If you need to work with cowboy 1.x, you need the following override: 29 | # {:esaml, "~> 3.7", override: true} 30 | 31 | defp deps() do 32 | [ 33 | # ... 34 | {:samly, "~> 1.0.0"}, 35 | ] 36 | end 37 | ``` 38 | 39 | ## Supervision Tree 40 | 41 | Add `Samly.Provider` to your application supervision tree. 42 | 43 | ```elixir 44 | # application.ex 45 | 46 | children = [ 47 | # ... 48 | {Samly.Provider, []}, 49 | ] 50 | ``` 51 | 52 | ## Router Change 53 | 54 | Make the following change in your application router. 55 | 56 | ```elixir 57 | # router.ex 58 | 59 | # Add the following scope ahead of other routes 60 | # Keep this as a top-level scope and **do not** add 61 | # any plugs or pipelines explicitly to this scope. 62 | scope "/sso" do 63 | forward "/", Samly.Router 64 | end 65 | ``` 66 | 67 | ## Certificate and Key for Samly 68 | 69 | `Samly` needs a private key and a corresponding certificate. These are used to 70 | sign the SAML requests when communicating with the Identity Provider. This certificate 71 | should be made available to `Samly` via config settings. It should also be made 72 | available to the Identity Provider so it can verify the SAML signed requests. 73 | 74 | You can create a self-signed certificate for this purpose. You can use `phx.gen.cert` 75 | mix task that is available as part of Phoenix 1.4 or use `openssl` directly to generate 76 | the key and corresponding certificate. 77 | (Check out [`samly_howto`](https://github.com/handnot2/samly_howto) `README.md` for this.) 78 | 79 | ## Identity Provider Metadata 80 | 81 | `Samly` expects information about the Identity Provider including information about 82 | its SAML endpoints in an XML file. Most Identity Providers have some way of 83 | exporting the IdP metadata in XML form. Some may provide a web UI to export/save 84 | the XML locally. Others may provide a URL that can be used to fetch the metadata. 85 | 86 | For example, `SimpleSAMLPhp` IdP provides a URL for the metadata. You can fetch 87 | it using `wget`. 88 | 89 | ``` 90 | wget --no-check-certificate -O idp1_metadata.xml https://idp1.samly:9091/simplesaml/saml2/idp/metadata.php 91 | ``` 92 | 93 | If you are using the `SimpleSAMLPhp` administrative Web UI, login with you 94 | admin credentials (`https://idp1.samly:9091/simplesaml`). Go to the `Federation` 95 | tab. At the top there will be a section titled "SAML 2.0 IdP Metadata". Click 96 | on the `Show metadata` link. Copy the metadata XML from this page and save it 97 | in a local file (`idp1_metadata.xml` for example). 98 | 99 | Make sure to save this XML file and provide the path to the saved file in 100 | `Samly` configuration. 101 | 102 | ## Identity Provider ID in Samly 103 | 104 | `Samly` has the ability to support multiple Identity Providers. All IdPs that 105 | `Samly` needs to talk to must have an identifier (idp_id). This IdP id will be 106 | used in the service provider URLs. This is how `Samly` figures out which SAML 107 | request corresponds to what IdP so that it can perform relevant validation checks 108 | and process the requests/responses. 109 | 110 | There are two options when it comes to how the idp_id is represented in the 111 | Service Provider SAML URLs. 112 | 113 | #### URL Path Segment 114 | 115 | In this model, the idp_id is present as a URL path segment. Here is an 116 | example URL: `https://do-good.org/sso/auth/signin/affiliates`. The idp_id 117 | in this URL is "affiliates". If you have more than one IdP, only this last 118 | part changes. The URLs for this model are: 119 | 120 | | Description | URL | 121 | |:----|:----| 122 | | Sign-in button/link in Web UI | `/sso/auth/signin/affiliates` | 123 | | Sign-out button/link in Web UI | `/sso/auth/signout/affiliates` | 124 | | SP Metadata URL | `https://do-good.org/sso/sp/metadata/affiliates` | 125 | | SAML Assertion Consumer Service | `https://do-good.org/sso/sp/consume/affiliates` | 126 | | SAML SingleLogout Service | `https://do-good.org/sso/sp/logout/affiliates` | 127 | 128 | The path segment model is the default one in `Samly`. If there is only one Identity Provider, use this mode. 129 | 130 | > These URL routes are automatically created based on the configuration information and 131 | > the above mentioned router scope definition. 132 | > 133 | > Use the Sign-in and Sign-out URLs shown above in your application's Web UI buttons/links. 134 | > When the end-user clicks on these buttons/links, the HTTP `GET` request is handled by `Samly` 135 | > which internally does a `POST` that in turn sends the appropriate SAML request to the IdP. 136 | 137 | #### Subdomain in Host Name 138 | 139 | In this model, the subdomain name is used as the idp_id. Here is an example URL: `https://ngo.do-good.org/sso/auth/signin`. Here `ngo` is the idp_id. The URLs supported by `Samly` 140 | in this model look different. 141 | 142 | | Description | URL | 143 | |:----|:----| 144 | | Sign-in button/link in Web UI | `/sso/auth/signin` | 145 | | Sign-out button/link in Web UI | `/sso/auth/signout` | 146 | | SP Metadata URL | `https://ngo.do-good.org/sso/sp/metadata` | 147 | | SAML Assertion Consumer Service | `https://ngo.do-good.org/sso/sp/consume` | 148 | | SAML SingleLogout Service | `https://ngo.do-good.org/sso/sp/logout` | 149 | 150 | > Take a look at [`samly_howto`](https://github.com/handnot2/samly_howto) - a reference/demo 151 | > application on how to use this library. 152 | > 153 | > Make sure to use HTTPS URLs in production deployments. 154 | 155 | #### Target URL for Sign-In and Sign-Out Actions 156 | 157 | The sign-in and sign-out URLs (HTTP GET) mentioned above optionally take a `target_url` 158 | query parameter. `Samly` will redirect the browser to these URLs upon successfuly 159 | completing the sign-in/sign-out operations initiated from your application. 160 | 161 | > This `target_url` query parameter value must be `x-www-form-urlencoded`. 162 | 163 | ## Samly Configuration 164 | 165 | ```elixir 166 | # config/dev.exs 167 | 168 | config :samly, Samly.Provider, 169 | idp_id_from: :path_segment, 170 | service_providers: [ 171 | %{ 172 | id: "do-good-affiliates-sp", 173 | entity_id: "urn:do-good.org:affiliates-app", 174 | certfile: "path/to/samly/certfile.pem", 175 | keyfile: "path/to/samly/keyfile.pem", 176 | #contact_name: "Affiliates Admin", 177 | #contact_email: "affiliates-admin@do-good.org", 178 | #org_name: "Do Good", 179 | #org_displayname: "Goodly, No evil!", 180 | #org_url: "https://do-good.org" 181 | } 182 | ], 183 | identity_providers: [ 184 | %{ 185 | id: "affiliates", 186 | sp_id: "do-good-affiliates-sp", 187 | base_url: "https://do-good.org/sso", 188 | metadata_file: "idp1_metadata.xml", 189 | #pre_session_create_pipeline: MySamlyPipeline, 190 | #use_redirect_for_req: false, 191 | #sign_requests: true, 192 | #sign_metadata: true, 193 | #signed_assertion_in_resp: true, 194 | #signed_envelopes_in_resp: true, 195 | #allow_idp_initiated_flow: false, 196 | #allowed_target_urls: ["https://do-good.org"], 197 | #nameid_format: :transient 198 | } 199 | ] 200 | ``` 201 | 202 | | Parameters | Description | 203 | |:------------|:-----------| 204 | | `idp_id_from` | _(optional)_`:path_segment` or `:subdomain`. Default is `:path_segment`. | 205 | | **Service Provider Parameters** | | 206 | | `id` | _(mandatory)_ | 207 | | `identity_id` | _(optional)_ If omitted, the metadata URL will be used | 208 | | `certfile` | _(optional)_ This is needed when SAML requests/responses from `Samly` need to be signed. Make sure to **set this in a production deployment**. Could be omitted during development if your IDP is setup to not require signing. If that is the case, the following **Identity Provider Parameters** must be explicitly set to false: `sign_requests`, `sign_metadata`| 209 | | `keyfile` | _(optional)_ Similar to `certfile` | 210 | | `contact_name` | _(optional)_ Technical contact name for the Service Provider | 211 | | `contact_email` | _(optional)_ Technical contact email address | 212 | | `org_name` | _(optional)_ SAML Service Provider (your app) Organization name | 213 | | `org_displayname` | _(optional)_ SAML SP Organization displayname | 214 | | `org_url` | _(optional)_ Service Provider Organization web site URL | 215 | | **Identity Provider Parameters** | | 216 | | `id` | _(mandatory)_ This will be the idp_id in the URLs | 217 | | `sp_id` | _(mandatory)_ The service provider definition to be used with this Identity Provider definition | 218 | | `base_url` | _(optional)_ If missing `Samly` will use the current URL to derive this. It is better to define this in production deployment. | 219 | | `metadata_file` | _(mandatory)_ Path to the IdP metadata XML file obtained from the Identity Provider. | 220 | | `pre_session_create_pipeline` | _(optional)_ Check the customization section. | 221 | | `use_redirect_for_req` | _(optional)_ Default is `false`. When this is `false`, `Samly` will POST to the IdP SAML endpoints. | 222 | | `sign_requests`, `sign_metadata` | _(optional)_ Default is `true`. | 223 | | `signed_assertion_in_resp`, `signed_envelopes_in_resp` | _(optional)_ Default is `true`. When `true`, `Samly` expects the requests and responses from IdP to be signed. | 224 | | `allow_idp_initiated_flow` | _(optional)_ Default is `false`. IDP initiated SSO is allowed only when this is set to `true`. | 225 | | `allowed_target_urls` | _(optional)_ Default is `[]`. `Samly` uses this **only** when `allow_idp_initiated_flow` parameter is set to `true`. Make sure to set this to one or more exact URLs you want to allow (whitelist). The URL to redirect the user after completing the SSO flow is sent from IDP in auth response as `relay_state`. This `relay_state` target URL is matched against this URL list. Set the value to `nil` if you do not want this whitelist capability. | 226 | | `nameid_format` | _(optional)_ When specified, `Samly` includes the value as the `NameIDPolicy` element's `Format` attribute in the login request. Value must either be a string or one of the following atoms: `:email`, `:x509`, `:windows`, `:krb`, `:persistent`, `:transient`. Use the string value when you need to specify a non-standard/custom nameid format supported by your IdP. | 227 | 228 | #### Authenticated SAML Assertion State Store 229 | 230 | `Samly` internally maintains the authenticated SAML assertions (from `LoginResponse` SAML requests). 231 | There are two built-in state store options available - one based on ETS and the other on Plug Sessions. 232 | The ETS store can be setup using the following configuration: 233 | 234 | ```elixir 235 | config :samly, Samly.State, 236 | store: Samly.State.ETS, 237 | opts: [table: :my_ets_table] 238 | ``` 239 | 240 | This state configuration is optional. If omitted, `Samly` uses `Samly.State.ETS` provider by default. 241 | 242 | | Options | Description | 243 | |:------------|:-----------| 244 | | `opts` | _(optional)_ The `:table` option is the ETS table name for storing the assertions. This ETS table is created during the store provider initialization if it is not already present. Default is `samly_assertions_table`. | 245 | 246 | > Use `Samly.State.Session` provider in a clustered deployment. This provider uses 247 | > the Plug Sessions to keep the authenticated SAML assertions. 248 | 249 | This session based provider can be enabled using the following: 250 | 251 | ```elixir 252 | config :samly, Samly.State, 253 | store: Samly.State.Session, 254 | opts: [key: :my_assertion_key] 255 | ``` 256 | 257 | | Options | Description | 258 | |:------------|:-----------| 259 | | `opts` | _(optional)_ The `:key` is the name of the session key where assertion is stored. Default is `:samly_assertion`. | 260 | 261 | ## SAML Assertion 262 | 263 | Once authentication is completed successfully, IdP sends a "consume" SAML 264 | request to `Samly`. `Samly` in-turn performs its own checks (including checking 265 | the integrity of the "consume" request). At this point, the SAML assertion 266 | with the authenticated user subject and attributes is available. 267 | 268 | The subject in the SAML assertion is tracked by `Samly` so that subsequent 269 | logout/signout request, either service provider initiated or IdP initiated 270 | would result in proper removal of the corresponding SAML assertion. 271 | 272 | Use the `Samly.get_active_assertion` function to get the SAML assertion 273 | for the currently authenticated user. This function will return `nil` if 274 | the user is not authenticated. 275 | 276 | > Avoid using the subject in the SAML assertion in UI. Depending on how the 277 | > IdP is setup, this might be a randomly generated id. 278 | > 279 | > You should only rely on the user attributes in the assertion. 280 | > As an application working with an IdP, you should know which attributes 281 | > will be made available to your application and out of 282 | > those attributes which one should be treated as the logged in userid/name. 283 | > For example it could be "uid" or "email" depending on how the authentication 284 | > source is setup in the IdP. 285 | 286 | ## Customization 287 | 288 | #### Pipeline 289 | 290 | `Samly` allows you to specify a Plug Pipeline if you need more control over 291 | the authenticated user's attributes and/or do a Just-in-time user creation. 292 | The Plug Pipeline is invoked after the user has successfully authenticated 293 | with the IdP but before a session is created. 294 | 295 | This is just a vanilla Plug Pipeline. The SAML assertion from 296 | the IdP is made available in the Plug connection as a "private". 297 | (The pipeline plugs have access to the `idp_id` in this assertion.) 298 | If you want to derive new attributes, create an Elixir map data (`%{}`) 299 | and update the `computed` field of the SAML assertion and put it back 300 | in the Plug connection private with `Conn.put_private` call. 301 | 302 | Here is a sample pipeline that shows this: 303 | 304 | ```elixir 305 | defmodule MySamlyPipeline do 306 | use Plug.Builder 307 | alias Samly.{Assertion} 308 | 309 | plug :compute_attributes 310 | plug :jit_provision_user 311 | 312 | def compute_attributes(conn, _opts) do 313 | assertion = conn.private[:samly_assertion] 314 | 315 | # This assertion has the idp_id 316 | # %Assertion{idp_id: idp_id} = assertion 317 | 318 | first_name = Map.get(assertion.attributes, "first_name") 319 | last_name = Map.get(assertion.attributes, "last_name") 320 | 321 | computed = %{"full_name" => "#{first_name} #{last_name}"} 322 | 323 | assertion = %Assertion{assertion | computed: computed} 324 | 325 | conn 326 | |> put_private(:samly_assertion, assertion) 327 | 328 | # If you have an error condition: 329 | # conn 330 | # |> send_resp(404, "attribute mapping failed") 331 | # |> halt() 332 | end 333 | 334 | def jit_provision_user(conn, _opts) do 335 | # your user creation here ... 336 | conn 337 | end 338 | end 339 | ``` 340 | 341 | Make this pipeline available in your config: 342 | 343 | ```elixir 344 | config :samly, Samly.Provider, 345 | identity_providers: [ 346 | %{ 347 | # ... 348 | pre_session_create_pipeline: MySamlyPipeline, 349 | # ... 350 | } 351 | ] 352 | ``` 353 | 354 | #### State Store 355 | 356 | Take a look at the implementation of `Samly.State.ETS` or `Samly.State.Session` and use those as examples showing how to create your own state store (based on redis, memcached, database etc.). 357 | 358 | ## Security Related 359 | 360 | + `Samly` initiated sign-in/sign-out requests send `RelayState` to IdP and expect to get that back. Mismatched or missing `RelayState` in IdP responses to SP initiated requests will fail (with HTTP `403 access_denied`). 361 | + Besides the `RelayState`, the request and response `idp_id`s must match. Reponse is rejected if they don't. 362 | + `Samly` makes the original request ID that an auth response corresponds to 363 | in `Samly.Subject.in_response_to` field. It is the responsibility of the consuming application to use this information along with the validity period in the assertion to check for **replay attacks**. The consuming application should use the `pre_session_create_pipeline` to perform this check. You may need a database or a distributed cache such as memcache in a clustered setup to keep track of these request IDs for their validity period to perform this check. Be aware that `in_response_to` field is **not** set when IDP initialized authorization flow is used. 364 | + OOTB SAML requests and responses are signed. 365 | + Signature digest method supported: `SHA256`. 366 | > Some Identity Providers may be using `SHA1` by default. 367 | > Make sure to configure the IdP to use `SHA256`. `Samly` 368 | > will reject (`access_denied`) IdP responses using `SHA1`. 369 | + `esaml` provides additional checks such as trusted certificate verification, recipient verification among others. 370 | + By default, `Samly` signs the SAML requests it sends to the Identity Provider. It also 371 | expects the SAML reqsponses to be signed (both assertion and envelopes). If your IdP is 372 | not configured to sign, you will have to explicitly turn them off in the configuration. 373 | It is highly recommended to turn signing on in production deployments. 374 | + Encypted Assertions are supported in `Samly`. There are no explicit config settings for this. Decryption happens automatically when encrypted assertions are detected in the SAML response. 375 | > [Supported Encryption algorithms](https://github.com/handnot2/esaml#assertion-encryption) 376 | + Make sure to use HTTPS URLs in production deployments. 377 | 378 | ## FAQ 379 | 380 | #### How to setup a SAML 2.0 IdP for development purposes? 381 | 382 | Docker based setup of [`SimpleSAMLPhp`](https://simplesamlphp.org) is made available 383 | at [`samly_simplesaml`](https://github.com/handnot2/samly_simplesaml) Git Repo. 384 | Check out the `README.md` file of this repo. 385 | 386 | There is also a Docker based setup of [`Shibboleth`](https://www.shibboleth.net/). 387 | Checkout the corresponding `README.md` file in [`samly_shibboleth`](https://github.com/handnot2/samly_shibboleth) Git Repo. 388 | 389 | #### Any sample Phoenix application that shows how to use Samly? 390 | 391 | Clone the [`samly_howto`](https://github.com/handnot2/samly_howto) Git Repo. 392 | Detailed instructions on how to setup and run this application are available 393 | in the `README.md` file in this repo. 394 | 395 | > It is recommended that you use the `SamlyHowto` application to 396 | > sort out any configuration issues by making that demo application work 397 | > successfully with your Identity Provider (IdP) before attempting your 398 | > application. 399 | > 400 | > This demo application supports experimentation with multiple IdPs. 401 | 402 | #### How to register the service provider with IdP 403 | 404 | If you are using `samly_simplesaml` or `samly_shibboleth`, the instructions 405 | you followed there would take care of registering your Phoenix SAML Service provider 406 | appliccation. For any other IdP, follow the instructions from the respective 407 | IdP vendor. 408 | 409 | #### Common Errors 410 | 411 | `access_denied {:error, :bad_recipient}` - Check the `base_url` in your `Samly` 412 | config setting under `indentity_providers`. 413 | 414 | `access_denied {:error, :bad_audience}` - Make sure that the `entity_id` in 415 | the `Samly` config setting is correct. 416 | 417 | `access_denied {:envelope, {:error, :cert_no_accepted}}` - Make sure the 418 | Identity Provider metadata XML file you are using in the `Samly` config setting 419 | is correct and corresponds to the IdP you are attempting to talk to. You get 420 | this error if the certificate used by the IdP to sign the SAML responses 421 | has changed and you don't have the updated IdP metadata XML file on the `Samly` end. 422 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :samly, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:samly, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/samly.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly do 2 | @moduledoc """ 3 | Elixir library used to enable SAML SP SSO to a Phoenix/Plug based application. 4 | """ 5 | 6 | alias Plug.Conn 7 | alias Samly.{Assertion, State} 8 | 9 | @doc """ 10 | Returns authenticated user SAML Assertion. 11 | 12 | The struct includes the attributes sent from IdP as well as any corresponding locally 13 | computed/derived attributes. Returns `nil` if the current Plug session 14 | is not authenticated. 15 | 16 | ## Parameters 17 | 18 | + `conn` - Plug connection 19 | 20 | ## Examples 21 | 22 | # When there is an authenticated SAML assertion 23 | %Assertion{} = Samly.get_active_assertion() 24 | """ 25 | @spec get_active_assertion(Conn.t()) :: nil | Assertion.t() 26 | def get_active_assertion(conn) do 27 | case Conn.get_session(conn, "samly_assertion_key") do 28 | {_idp_id, _nameid} = assertion_key -> 29 | State.get_assertion(conn, assertion_key) 30 | 31 | _ -> 32 | nil 33 | end 34 | end 35 | 36 | @doc """ 37 | Returns value of the specified attribute name in the given SAML Assertion. 38 | 39 | Checks for the attribute in `computed` map first and `attributes` map next. 40 | Returns a UTF-8 binary or a list of UTF-8 binaries (in case of multi-valued) 41 | if the given attribute is present. Returns `nil` if attribute is not present. 42 | 43 | ## Parameters 44 | 45 | + `assertion` - SAML assertion obtained by calling `get_active_assertion/1` 46 | + `name`: Attribute name 47 | 48 | ## Examples 49 | 50 | assertion = Samly.get_active_assertion() 51 | # returns a list if the attribute is multi-valued 52 | roles = Samly.get_attribute(assertion, "roles") 53 | computed_fullname = Samly.get_attribute(assertion, "fullname") 54 | """ 55 | @spec get_attribute(nil | Assertion.t(), Assertion.attr_name_t()) :: 56 | nil | Assertion.attr_value_t() 57 | def get_attribute(nil, _name), do: nil 58 | 59 | def get_attribute(%Assertion{} = assertion, name) do 60 | Map.get(assertion.computed, name) || Map.get(assertion.attributes, name) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/samly/assertion.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.Assertion do 2 | @moduledoc """ 3 | SAML assertion returned from IDP upon successful user authentication. 4 | 5 | The assertion attributes returned by the IdP are available in `attributes` field 6 | as a map. Any computed attributes (using a Plug Pipeline by way of configuration) 7 | are available in `computed` field as map. 8 | 9 | The attributes can be accessed directly from `attributes` or `computed` maps. 10 | The `Samly.get_attribute/2` function can be used as well. This function will 11 | first look at the `computed` attributes. If the request attribute is not present there, 12 | it will check in `attributes` next. 13 | """ 14 | 15 | require Samly.Esaml 16 | alias Samly.{Esaml, Subject} 17 | 18 | @type attr_name_t :: String.t() 19 | @type attr_value_t :: String.t() | [String.t()] 20 | 21 | defstruct version: "2.0", 22 | issue_instant: "", 23 | recipient: "", 24 | issuer: "", 25 | subject: %Subject{}, 26 | conditions: %{}, 27 | attributes: %{}, 28 | authn: %{}, 29 | computed: %{}, 30 | idp_id: "" 31 | 32 | @type t :: %__MODULE__{ 33 | version: String.t(), 34 | issue_instant: String.t(), 35 | recipient: String.t(), 36 | issuer: String.t(), 37 | subject: Subject.t(), 38 | conditions: map, 39 | attributes: %{required(attr_name_t()) => attr_value_t()}, 40 | authn: map, 41 | computed: %{required(attr_name_t()) => attr_value_t()}, 42 | idp_id: String.t() 43 | } 44 | 45 | @doc false 46 | def from_rec(assertion_rec) do 47 | Esaml.esaml_assertion( 48 | version: version, 49 | issue_instant: issue_instant, 50 | recipient: recipient, 51 | issuer: issuer, 52 | subject: subject_rec, 53 | conditions: conditions, 54 | attributes: attributes, 55 | authn: authn 56 | ) = assertion_rec 57 | 58 | %__MODULE__{ 59 | version: List.to_string(version), 60 | issue_instant: List.to_string(issue_instant), 61 | recipient: List.to_string(recipient), 62 | issuer: List.to_string(issuer), 63 | subject: Subject.from_rec(subject_rec), 64 | conditions: conditions |> stringize(), 65 | attributes: attributes |> stringize(), 66 | authn: authn |> stringize() 67 | } 68 | end 69 | 70 | defp stringize(proplist) do 71 | proplist 72 | |> Enum.map(fn 73 | {k, []} -> 74 | {to_string(k), ""} 75 | 76 | {k, values} when is_list(values) and is_list(hd(values)) -> 77 | {to_string(k), Enum.map(values, fn v -> List.to_string(v) end)} 78 | 79 | {k, v} when is_list(v) -> 80 | {to_string(k), List.to_string(v)} 81 | end) 82 | |> Enum.into(%{}) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/samly/auth_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.AuthHandler do 2 | @moduledoc false 3 | 4 | require Logger 5 | import Plug.Conn 6 | alias Samly.{Assertion, IdpData, Helper, State, Subject} 7 | 8 | import Samly.RouterUtil, only: [ensure_sp_uris_set: 2, send_saml_request: 5, redirect: 3] 9 | 10 | @sso_init_resp_template """ 11 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 29 |
\"> 30 | <%= if target_url do %> 31 | \" /> 32 | <% end %> 33 | \" /> 34 | 35 |
36 | 37 | 38 | """ 39 | 40 | def initiate_sso_req(conn) do 41 | import Plug.CSRFProtection, only: [get_csrf_token: 0] 42 | 43 | target_url = conn.private[:samly_target_url] || "/" 44 | 45 | opts = [ 46 | nonce: conn.private[:samly_nonce], 47 | action: URI.encode(conn.request_path), 48 | target_url: URI.encode_www_form(target_url), 49 | csrf_token: get_csrf_token() 50 | ] 51 | 52 | conn 53 | |> put_resp_header("content-type", "text/html") 54 | |> send_resp(200, EEx.eval_string(@sso_init_resp_template, opts)) 55 | end 56 | 57 | def send_signin_req(conn) do 58 | %IdpData{id: idp_id} = idp = conn.private[:samly_idp] 59 | %IdpData{esaml_idp_rec: idp_rec, esaml_sp_rec: sp_rec} = idp 60 | sp = ensure_sp_uris_set(sp_rec, conn) 61 | 62 | target_url = conn.private[:samly_target_url] || "/" 63 | assertion_key = get_session(conn, "samly_assertion_key") 64 | 65 | case State.get_assertion(conn, assertion_key) do 66 | %Assertion{idp_id: ^idp_id} -> 67 | conn |> redirect(302, target_url) 68 | 69 | _ -> 70 | relay_state = State.gen_id() 71 | 72 | {idp_signin_url, req_xml_frag} = 73 | Helper.gen_idp_signin_req(sp, idp_rec, Map.get(idp, :nameid_format)) 74 | 75 | conn 76 | |> configure_session(renew: true) 77 | |> put_session("relay_state", relay_state) 78 | |> put_session("idp_id", idp_id) 79 | |> put_session("target_url", target_url) 80 | |> send_saml_request( 81 | idp_signin_url, 82 | idp.use_redirect_for_req, 83 | req_xml_frag, 84 | relay_state 85 | ) 86 | end 87 | 88 | # rescue 89 | # error -> 90 | # Logger.error("#{inspect error}") 91 | # conn |> send_resp(500, "request_failed") 92 | end 93 | 94 | def send_signout_req(conn) do 95 | %IdpData{id: idp_id} = idp = conn.private[:samly_idp] 96 | %IdpData{esaml_idp_rec: idp_rec, esaml_sp_rec: sp_rec} = idp 97 | sp = ensure_sp_uris_set(sp_rec, conn) 98 | 99 | target_url = conn.private[:samly_target_url] || "/" 100 | assertion_key = get_session(conn, "samly_assertion_key") 101 | 102 | case State.get_assertion(conn, assertion_key) do 103 | %Assertion{idp_id: ^idp_id, authn: authn, subject: subject} -> 104 | session_index = Map.get(authn, "session_index", "") 105 | subject_rec = Subject.to_rec(subject) 106 | 107 | {idp_signout_url, req_xml_frag} = 108 | Helper.gen_idp_signout_req(sp, idp_rec, subject_rec, session_index) 109 | 110 | conn = State.delete_assertion(conn, assertion_key) 111 | relay_state = State.gen_id() 112 | 113 | conn 114 | |> put_session("target_url", target_url) 115 | |> put_session("relay_state", relay_state) 116 | |> put_session("idp_id", idp_id) 117 | |> delete_session("samly_assertion_key") 118 | |> send_saml_request( 119 | idp_signout_url, 120 | idp.use_redirect_for_req, 121 | req_xml_frag, 122 | relay_state 123 | ) 124 | 125 | _ -> 126 | conn |> send_resp(403, "access_denied") 127 | end 128 | 129 | # rescue 130 | # error -> 131 | # Logger.error("#{inspect error}") 132 | # conn |> send_resp(500, "request_failed") 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/samly/auth_router.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.AuthRouter do 2 | @moduledoc false 3 | 4 | use Plug.Router 5 | import Plug.Conn 6 | import Samly.RouterUtil, only: [check_idp_id: 2, check_target_url: 2] 7 | 8 | plug :fetch_session 9 | plug Plug.CSRFProtection 10 | plug :match 11 | plug :check_idp_id 12 | plug :check_target_url 13 | plug :dispatch 14 | 15 | get "/signin/*idp_id_seg" do 16 | conn |> Samly.AuthHandler.initiate_sso_req() 17 | end 18 | 19 | post "/signin/*idp_id_seg" do 20 | conn |> Samly.AuthHandler.send_signin_req() 21 | end 22 | 23 | get "/signout/*idp_id_seg" do 24 | conn |> Samly.AuthHandler.initiate_sso_req() 25 | end 26 | 27 | post "/signout/*idp_id_seg" do 28 | conn |> Samly.AuthHandler.send_signout_req() 29 | end 30 | 31 | match _ do 32 | conn |> send_resp(404, "not_found") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/samly/config_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.ConfigError do 2 | @moduledoc false 3 | 4 | defexception [:message] 5 | 6 | @spec exception(map) :: Exception.t() 7 | def exception(data) when is_map(data) do 8 | %__MODULE__{message: "invalid_config: #{inspect(data)}"} 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/samly/cspr_router.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.CsprRouter do 2 | @moduledoc false 3 | 4 | use Plug.Router 5 | import Plug.Conn 6 | 7 | require Logger 8 | 9 | plug :match 10 | plug :dispatch 11 | 12 | match _ do 13 | {:ok, body, conn} = Plug.Conn.read_body(conn) 14 | Logger.error(body) 15 | conn |> send_resp(200, "OK") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/samly/esaml.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.Esaml do 2 | @moduledoc false 3 | 4 | require Record 5 | import Record, only: [defrecord: 2, extract: 2] 6 | 7 | @esaml_hrl "esaml/include/esaml.hrl" 8 | @public_key_hrl "public_key/include/OTP-PUB-KEY.hrl" 9 | 10 | defrecord :esaml_org, extract(:esaml_org, from_lib: @esaml_hrl) 11 | defrecord :esaml_contact, extract(:esaml_contact, from_lib: @esaml_hrl) 12 | defrecord :esaml_sp_metadata, extract(:esaml_sp_metadata, from_lib: @esaml_hrl) 13 | defrecord :esaml_idp_metadata, extract(:esaml_idp_metadata, from_lib: @esaml_hrl) 14 | defrecord :esaml_authnreq, extract(:esaml_authnreq, from_lib: @esaml_hrl) 15 | defrecord :esaml_subject, extract(:esaml_subject, from_lib: @esaml_hrl) 16 | defrecord :esaml_assertion, extract(:esaml_assertion, from_lib: @esaml_hrl) 17 | defrecord :esaml_logoutreq, extract(:esaml_logoutreq, from_lib: @esaml_hrl) 18 | defrecord :esaml_logoutresp, extract(:esaml_logoutresp, from_lib: @esaml_hrl) 19 | defrecord :esaml_response, extract(:esaml_response, from_lib: @esaml_hrl) 20 | defrecord :esaml_sp, extract(:esaml_sp, from_lib: @esaml_hrl) 21 | defrecord :RSAPrivateKey, extract(:RSAPrivateKey, from_lib: @public_key_hrl) 22 | end 23 | -------------------------------------------------------------------------------- /lib/samly/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.Helper do 2 | @moduledoc false 3 | 4 | require Samly.Esaml 5 | alias Samly.{Assertion, Esaml, IdpData} 6 | 7 | @spec get_idp(binary) :: nil | IdpData.t() 8 | def get_idp(idp_id) do 9 | idps = Application.get_env(:samly, :identity_providers, %{}) 10 | Map.get(idps, idp_id) 11 | end 12 | 13 | @spec get_metadata_uri(nil | binary, binary) :: nil | charlist 14 | def get_metadata_uri(nil, _idp_id), do: nil 15 | 16 | def get_metadata_uri(sp_base_url, nil) when is_binary(sp_base_url) do 17 | "#{sp_base_url}/sp/metadata" |> String.to_charlist() 18 | end 19 | 20 | def get_metadata_uri(sp_base_url, idp_id) when is_binary(sp_base_url) do 21 | "#{sp_base_url}/sp/metadata/#{idp_id}" |> String.to_charlist() 22 | end 23 | 24 | @spec get_consume_uri(nil | binary, binary) :: nil | charlist 25 | def get_consume_uri(nil, _idp_id), do: nil 26 | 27 | def get_consume_uri(sp_base_url, nil) when is_binary(sp_base_url) do 28 | "#{sp_base_url}/sp/consume" |> String.to_charlist() 29 | end 30 | 31 | def get_consume_uri(sp_base_url, idp_id) when is_binary(sp_base_url) do 32 | "#{sp_base_url}/sp/consume/#{idp_id}" |> String.to_charlist() 33 | end 34 | 35 | @spec get_logout_uri(nil | binary, binary) :: nil | charlist 36 | def get_logout_uri(nil, _idp_id), do: nil 37 | 38 | def get_logout_uri(sp_base_url, nil) when is_binary(sp_base_url) do 39 | "#{sp_base_url}/sp/logout" |> String.to_charlist() 40 | end 41 | 42 | def get_logout_uri(sp_base_url, idp_id) when is_binary(sp_base_url) do 43 | "#{sp_base_url}/sp/logout/#{idp_id}" |> String.to_charlist() 44 | end 45 | 46 | def sp_metadata(sp) do 47 | :xmerl.export([:esaml_sp.generate_metadata(sp)], :xmerl_xml) 48 | end 49 | 50 | def gen_idp_signin_req(sp, idp_metadata, nameid_format) do 51 | idp_signin_url = Esaml.esaml_idp_metadata(idp_metadata, :login_location) 52 | 53 | xml_frag = :esaml_sp.generate_authn_request(idp_signin_url, sp, nameid_format) 54 | 55 | {idp_signin_url, xml_frag} 56 | end 57 | 58 | def gen_idp_signout_req(sp, idp_metadata, subject_rec, session_index) do 59 | idp_signout_url = Esaml.esaml_idp_metadata(idp_metadata, :logout_location) 60 | xml_frag = :esaml_sp.generate_logout_request(idp_signout_url, session_index, subject_rec, sp) 61 | {idp_signout_url, xml_frag} 62 | end 63 | 64 | def gen_idp_signout_resp(sp, idp_metadata, signout_status) do 65 | idp_signout_url = Esaml.esaml_idp_metadata(idp_metadata, :logout_location) 66 | xml_frag = :esaml_sp.generate_logout_response(idp_signout_url, signout_status, sp) 67 | {idp_signout_url, xml_frag} 68 | end 69 | 70 | def decode_idp_auth_resp(sp, saml_encoding, saml_response) do 71 | with {:ok, xml_frag} <- decode_saml_payload(saml_encoding, saml_response), 72 | {:ok, assertion_rec} <- :esaml_sp.validate_assertion(xml_frag, sp) do 73 | {:ok, Assertion.from_rec(assertion_rec)} 74 | else 75 | {:error, reason} -> {:error, reason} 76 | error -> {:error, {:invalid_request, "#{inspect(error)}"}} 77 | end 78 | end 79 | 80 | def decode_idp_signout_resp(sp, saml_encoding, saml_response) do 81 | resp_ns = [ 82 | {'samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'}, 83 | {'saml', 'urn:oasis:names:tc:SAML:2.0:assertion'}, 84 | {'ds', 'http://www.w3.org/2000/09/xmldsig#'} 85 | ] 86 | 87 | with {:ok, xml_frag} <- decode_saml_payload(saml_encoding, saml_response), 88 | nodes when is_list(nodes) and length(nodes) == 1 <- 89 | :xmerl_xpath.string('/samlp:LogoutResponse', xml_frag, [{:namespace, resp_ns}]) do 90 | :esaml_sp.validate_logout_response(xml_frag, sp) 91 | else 92 | _ -> {:error, :invalid_request} 93 | end 94 | end 95 | 96 | def decode_idp_signout_req(sp, saml_encoding, saml_request) do 97 | req_ns = [ 98 | {'samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'}, 99 | {'saml', 'urn:oasis:names:tc:SAML:2.0:assertion'} 100 | ] 101 | 102 | with {:ok, xml_frag} <- decode_saml_payload(saml_encoding, saml_request), 103 | nodes when is_list(nodes) and length(nodes) == 1 <- 104 | :xmerl_xpath.string('/samlp:LogoutRequest', xml_frag, [{:namespace, req_ns}]) do 105 | :esaml_sp.validate_logout_request(xml_frag, sp) 106 | else 107 | _ -> {:error, :invalid_request} 108 | end 109 | end 110 | 111 | defp decode_saml_payload(saml_encoding, saml_payload) do 112 | try do 113 | xml = :esaml_binding.decode_response(saml_encoding, saml_payload) 114 | {:ok, xml} 115 | rescue 116 | error -> {:error, {:invalid_response, "#{inspect(error)}"}} 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/samly/idp_data.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.IdpData do 2 | @moduledoc false 3 | 4 | import SweetXml 5 | require Logger 6 | require Samly.Esaml 7 | alias Samly.{Esaml, Helper, IdpData, SpData} 8 | 9 | @type nameid_format :: :unknown | charlist() 10 | @type certs :: [binary()] 11 | @type url :: nil | binary() 12 | 13 | defstruct id: "", 14 | sp_id: "", 15 | base_url: nil, 16 | metadata_file: nil, 17 | pre_session_create_pipeline: nil, 18 | use_redirect_for_req: false, 19 | sign_requests: true, 20 | sign_metadata: true, 21 | signed_assertion_in_resp: true, 22 | signed_envelopes_in_resp: true, 23 | allow_idp_initiated_flow: false, 24 | allowed_target_urls: [], 25 | entity_id: "", 26 | signed_requests: "", 27 | certs: [], 28 | sso_redirect_url: nil, 29 | sso_post_url: nil, 30 | slo_redirect_url: nil, 31 | slo_post_url: nil, 32 | nameid_format: :unknown, 33 | fingerprints: [], 34 | esaml_idp_rec: Esaml.esaml_idp_metadata(), 35 | esaml_sp_rec: Esaml.esaml_sp(), 36 | valid?: false 37 | 38 | @type t :: %__MODULE__{ 39 | id: binary(), 40 | sp_id: binary(), 41 | base_url: nil | binary(), 42 | metadata_file: nil | binary(), 43 | pre_session_create_pipeline: nil | module(), 44 | use_redirect_for_req: boolean(), 45 | sign_requests: boolean(), 46 | sign_metadata: boolean(), 47 | signed_assertion_in_resp: boolean(), 48 | signed_envelopes_in_resp: boolean(), 49 | allow_idp_initiated_flow: boolean(), 50 | allowed_target_urls: nil | [binary()], 51 | entity_id: binary(), 52 | signed_requests: binary(), 53 | certs: certs(), 54 | sso_redirect_url: url(), 55 | sso_post_url: url(), 56 | slo_redirect_url: url(), 57 | slo_post_url: url(), 58 | nameid_format: nameid_format(), 59 | fingerprints: [binary()], 60 | esaml_idp_rec: :esaml_idp_metadata, 61 | esaml_sp_rec: :esaml_sp, 62 | valid?: boolean() 63 | } 64 | 65 | @entdesc "md:EntityDescriptor" 66 | @idpdesc "md:IDPSSODescriptor" 67 | @signedreq "WantAuthnRequestsSigned" 68 | @nameid "md:NameIDFormat" 69 | @keydesc "md:KeyDescriptor" 70 | @ssos "md:SingleSignOnService" 71 | @slos "md:SingleLogoutService" 72 | @redirect "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 73 | @post "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" 74 | 75 | @entity_id_selector ~x"//#{@entdesc}/@entityID"sl 76 | @nameid_format_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@nameid}/text()"s 77 | @req_signed_selector ~x"//#{@entdesc}/#{@idpdesc}/@#{@signedreq}"s 78 | @sso_redirect_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@ssos}[@Binding = '#{@redirect}']/@Location"s 79 | @sso_post_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@ssos}[@Binding = '#{@post}']/@Location"s 80 | @slo_redirect_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@slos}[@Binding = '#{@redirect}']/@Location"s 81 | @slo_post_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@slos}[@Binding = '#{@post}']/@Location"s 82 | @signing_keys_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@keydesc}[@use != 'encryption']"l 83 | @enc_keys_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@keydesc}[@use = 'encryption']"l 84 | @cert_selector ~x"./ds:KeyInfo/ds:X509Data/ds:X509Certificate/text()"s 85 | 86 | @type id :: binary() 87 | 88 | @spec load_providers([map], %{required(id()) => %SpData{}}) :: 89 | %{required(id()) => %IdpData{}} | no_return() 90 | def load_providers(prov_config, service_providers) do 91 | prov_config 92 | |> Enum.map(fn idp_config -> load_provider(idp_config, service_providers) end) 93 | |> Enum.filter(fn idp_data -> idp_data.valid? end) 94 | |> Enum.map(fn idp_data -> {idp_data.id, idp_data} end) 95 | |> Enum.into(%{}) 96 | end 97 | 98 | @spec load_provider(map(), %{required(id()) => %SpData{}}) :: %IdpData{} | no_return 99 | def load_provider(idp_config, service_providers) do 100 | %IdpData{} 101 | |> save_idp_config(idp_config) 102 | |> load_metadata(idp_config) 103 | |> override_nameid_format(idp_config) 104 | |> update_esaml_recs(service_providers, idp_config) 105 | |> verify_slo_url() 106 | end 107 | 108 | @spec save_idp_config(%IdpData{}, map()) :: %IdpData{} 109 | defp save_idp_config(idp_data, %{id: id, sp_id: sp_id} = opts_map) 110 | when is_binary(id) and is_binary(sp_id) do 111 | %IdpData{idp_data | id: id, sp_id: sp_id, base_url: Map.get(opts_map, :base_url)} 112 | |> set_metadata_file(opts_map) 113 | |> set_pipeline(opts_map) 114 | |> set_allowed_target_urls(opts_map) 115 | |> set_boolean_attr(opts_map, :use_redirect_for_req) 116 | |> set_boolean_attr(opts_map, :sign_requests) 117 | |> set_boolean_attr(opts_map, :sign_metadata) 118 | |> set_boolean_attr(opts_map, :signed_assertion_in_resp) 119 | |> set_boolean_attr(opts_map, :signed_envelopes_in_resp) 120 | |> set_boolean_attr(opts_map, :allow_idp_initiated_flow) 121 | end 122 | 123 | @spec load_metadata(%IdpData{}, map()) :: %IdpData{} 124 | defp load_metadata(idp_data, _opts_map) do 125 | with {:reading, {:ok, raw_xml}} <- {:reading, File.read(idp_data.metadata_file)}, 126 | {:parsing, {:ok, idp_data}} <- {:parsing, from_xml(raw_xml, idp_data)} do 127 | idp_data 128 | else 129 | {:reading, {:error, reason}} -> 130 | Logger.error( 131 | "[Samly] Failed to read metadata_file [#{inspect(idp_data.metadata_file)}]: #{ 132 | inspect(reason) 133 | }" 134 | ) 135 | 136 | idp_data 137 | 138 | {:parsing, {:error, reason}} -> 139 | Logger.error( 140 | "[Samly] Invalid metadata_file content [#{inspect(idp_data.metadata_file)}]: #{ 141 | inspect(reason) 142 | }" 143 | ) 144 | 145 | idp_data 146 | end 147 | end 148 | 149 | @spec update_esaml_recs(%IdpData{}, %{required(id()) => %SpData{}}, map()) :: %IdpData{} 150 | defp update_esaml_recs(idp_data, service_providers, opts_map) do 151 | case Map.get(service_providers, idp_data.sp_id) do 152 | %SpData{} = sp -> 153 | idp_data = %IdpData{idp_data | esaml_idp_rec: to_esaml_idp_metadata(idp_data, opts_map)} 154 | idp_data = %IdpData{idp_data | esaml_sp_rec: get_esaml_sp(sp, idp_data)} 155 | %IdpData{idp_data | valid?: cert_config_ok?(idp_data, sp)} 156 | 157 | _ -> 158 | Logger.error("[Samly] Unknown/invalid sp_id: #{idp_data.sp_id}") 159 | idp_data 160 | end 161 | end 162 | 163 | @spec cert_config_ok?(%IdpData{}, %SpData{}) :: boolean 164 | defp cert_config_ok?(%IdpData{} = idp_data, %SpData{} = sp_data) do 165 | if (idp_data.sign_metadata || idp_data.sign_requests) && 166 | (sp_data.cert == :undefined || sp_data.key == :undefined) do 167 | Logger.error("[Samly] SP cert or key missing - Skipping identity provider: #{idp_data.id}") 168 | false 169 | else 170 | true 171 | end 172 | end 173 | 174 | @spec verify_slo_url(%IdpData{}) :: %IdpData{} 175 | defp verify_slo_url(%IdpData{} = idp_data) do 176 | if idp_data.valid? && idp_data.slo_redirect_url == nil && idp_data.slo_post_url == nil do 177 | Logger.warn("[Samly] SLO Endpoint missing in [#{inspect(idp_data.metadata_file)}]") 178 | end 179 | 180 | idp_data 181 | end 182 | 183 | @default_metadata_file "idp_metadata.xml" 184 | 185 | @spec set_metadata_file(%IdpData{}, map()) :: %IdpData{} 186 | defp set_metadata_file(%IdpData{} = idp_data, %{} = opts_map) do 187 | %IdpData{idp_data | metadata_file: Map.get(opts_map, :metadata_file, @default_metadata_file)} 188 | end 189 | 190 | @spec set_pipeline(%IdpData{}, map()) :: %IdpData{} 191 | defp set_pipeline(%IdpData{} = idp_data, %{} = opts_map) do 192 | pipeline = Map.get(opts_map, :pre_session_create_pipeline) 193 | %IdpData{idp_data | pre_session_create_pipeline: pipeline} 194 | end 195 | 196 | defp set_allowed_target_urls(%IdpData{} = idp_data, %{} = opts_map) do 197 | target_urls = 198 | case Map.get(opts_map, :allowed_target_urls, nil) do 199 | nil -> nil 200 | urls when is_list(urls) -> Enum.filter(urls, &is_binary/1) 201 | end 202 | 203 | %IdpData{idp_data | allowed_target_urls: target_urls} 204 | end 205 | 206 | @spec override_nameid_format(%IdpData{}, map()) :: %IdpData{} 207 | defp override_nameid_format(%IdpData{} = idp_data, idp_config) do 208 | nameid_format = 209 | case Map.get(idp_config, :nameid_format, "") do 210 | "" -> 211 | idp_data.nameid_format 212 | 213 | format when is_binary(format) -> 214 | to_charlist(format) 215 | 216 | :email -> 217 | 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' 218 | 219 | :x509 -> 220 | 'urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName' 221 | 222 | :windows -> 223 | 'urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName' 224 | 225 | :krb -> 226 | 'urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos' 227 | 228 | :persistent -> 229 | 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' 230 | 231 | :transient -> 232 | 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' 233 | 234 | invalid_nameid_format -> 235 | Logger.error( 236 | "[Samly] invalid nameid_format [#{inspect(idp_data.metadata_file)}]: #{ 237 | inspect(invalid_nameid_format) 238 | }" 239 | ) 240 | 241 | idp_data.nameid_format 242 | end 243 | 244 | %IdpData{idp_data | nameid_format: nameid_format} 245 | end 246 | 247 | @spec set_boolean_attr(%IdpData{}, map(), atom()) :: %IdpData{} 248 | defp set_boolean_attr(%IdpData{} = idp_data, %{} = opts_map, attr_name) 249 | when is_atom(attr_name) do 250 | v = Map.get(opts_map, attr_name) 251 | if is_boolean(v), do: Map.put(idp_data, attr_name, v), else: idp_data 252 | end 253 | 254 | @spec from_xml(binary, %IdpData{}) :: {:ok, %IdpData{}} 255 | def from_xml(metadata_xml, idp_data) when is_binary(metadata_xml) do 256 | xml_opts = [ 257 | space: :normalize, 258 | namespace_conformant: true, 259 | comments: false, 260 | default_attrs: true 261 | ] 262 | 263 | md_xml = SweetXml.parse(metadata_xml, xml_opts) 264 | signing_certs = get_signing_certs(md_xml) 265 | 266 | {:ok, 267 | %IdpData{ 268 | idp_data 269 | | entity_id: get_entity_id(md_xml), 270 | signed_requests: get_req_signed(md_xml), 271 | certs: signing_certs, 272 | fingerprints: idp_cert_fingerprints(signing_certs), 273 | sso_redirect_url: get_sso_redirect_url(md_xml), 274 | sso_post_url: get_sso_post_url(md_xml), 275 | slo_redirect_url: get_slo_redirect_url(md_xml), 276 | slo_post_url: get_slo_post_url(md_xml), 277 | nameid_format: get_nameid_format(md_xml) 278 | }} 279 | end 280 | 281 | # @spec to_esaml_idp_metadata(IdpData.t(), map()) :: :esaml_idp_metadata 282 | defp to_esaml_idp_metadata(%IdpData{} = idp_data, %{} = idp_config) do 283 | {sso_url, slo_url} = get_sso_slo_urls(idp_data, idp_config) 284 | sso_url = if sso_url, do: String.to_charlist(sso_url), else: [] 285 | slo_url = if slo_url, do: String.to_charlist(slo_url), else: :undefined 286 | 287 | Esaml.esaml_idp_metadata( 288 | entity_id: String.to_charlist(idp_data.entity_id), 289 | login_location: sso_url, 290 | logout_location: slo_url, 291 | name_format: idp_data.nameid_format 292 | ) 293 | end 294 | 295 | defp get_sso_slo_urls(%IdpData{} = idp_data, %{use_redirect_for_req: true}) do 296 | {idp_data.sso_redirect_url, idp_data.slo_redirect_url} 297 | end 298 | 299 | defp get_sso_slo_urls(%IdpData{} = idp_data, %{use_redirect_for_req: false}) do 300 | {idp_data.sso_post_url, idp_data.slo_post_url} 301 | end 302 | 303 | defp get_sso_slo_urls(%IdpData{} = idp_data, _opts_map) do 304 | { 305 | idp_data.sso_post_url || idp_data.sso_redirect_url, 306 | idp_data.slo_post_url || idp_data.slo_redirect_url 307 | } 308 | end 309 | 310 | @spec idp_cert_fingerprints(certs()) :: [binary()] 311 | defp idp_cert_fingerprints(certs) when is_list(certs) do 312 | certs 313 | |> Enum.map(&Base.decode64!/1) 314 | |> Enum.map(&cert_fingerprint/1) 315 | |> Enum.map(&String.to_charlist/1) 316 | |> :esaml_util.convert_fingerprints() 317 | end 318 | 319 | defp cert_fingerprint(dercert) do 320 | "sha256:" <> (:sha256 |> :crypto.hash(dercert) |> Base.encode64()) 321 | end 322 | 323 | # @spec get_esaml_sp(%SpData{}, %IdpData{}) :: :esaml_sp 324 | defp get_esaml_sp(%SpData{} = sp_data, %IdpData{} = idp_data) do 325 | idp_id_from = Application.get_env(:samly, :idp_id_from) 326 | path_segment_idp_id = if idp_id_from == :subdomain, do: nil, else: idp_data.id 327 | 328 | sp_entity_id = 329 | case sp_data.entity_id do 330 | "" -> :undefined 331 | id -> String.to_charlist(id) 332 | end 333 | 334 | Esaml.esaml_sp( 335 | org: 336 | Esaml.esaml_org( 337 | name: String.to_charlist(sp_data.org_name), 338 | displayname: String.to_charlist(sp_data.org_displayname), 339 | url: String.to_charlist(sp_data.org_url) 340 | ), 341 | tech: 342 | Esaml.esaml_contact( 343 | name: String.to_charlist(sp_data.contact_name), 344 | email: String.to_charlist(sp_data.contact_email) 345 | ), 346 | key: sp_data.key, 347 | certificate: sp_data.cert, 348 | sp_sign_requests: idp_data.sign_requests, 349 | sp_sign_metadata: idp_data.sign_metadata, 350 | idp_signs_envelopes: idp_data.signed_envelopes_in_resp, 351 | idp_signs_assertions: idp_data.signed_assertion_in_resp, 352 | trusted_fingerprints: idp_data.fingerprints, 353 | metadata_uri: Helper.get_metadata_uri(idp_data.base_url, path_segment_idp_id), 354 | consume_uri: Helper.get_consume_uri(idp_data.base_url, path_segment_idp_id), 355 | logout_uri: Helper.get_logout_uri(idp_data.base_url, path_segment_idp_id), 356 | entity_id: sp_entity_id 357 | ) 358 | end 359 | 360 | @spec get_entity_id(:xmlElement) :: binary() 361 | def get_entity_id(md_elem) do 362 | md_elem |> xpath(@entity_id_selector |> add_ns()) |> hd() |> String.trim() 363 | end 364 | 365 | @spec get_nameid_format(:xmlElement) :: nameid_format() 366 | def get_nameid_format(md_elem) do 367 | case get_data(md_elem, @nameid_format_selector) do 368 | "" -> :unknown 369 | nameid_format -> to_charlist(nameid_format) 370 | end 371 | end 372 | 373 | @spec get_req_signed(:xmlElement) :: binary() 374 | def get_req_signed(md_elem), do: get_data(md_elem, @req_signed_selector) 375 | 376 | @spec get_signing_certs(:xmlElement) :: certs() 377 | def get_signing_certs(md_elem), do: get_certs(md_elem, @signing_keys_selector) 378 | 379 | @spec get_enc_certs(:xmlElement) :: certs() 380 | def get_enc_certs(md_elem), do: get_certs(md_elem, @enc_keys_selector) 381 | 382 | @spec get_certs(:xmlElement, %SweetXpath{}) :: certs() 383 | defp get_certs(md_elem, key_selector) do 384 | md_elem 385 | |> xpath(key_selector |> add_ns()) 386 | |> Enum.map(fn e -> 387 | # Extract base64 encoded cert from XML (strip away any whitespace) 388 | cert = xpath(e, @cert_selector |> add_ns()) 389 | 390 | cert 391 | |> String.split() 392 | |> Enum.map(&String.trim/1) 393 | |> Enum.join() 394 | end) 395 | end 396 | 397 | @spec get_sso_redirect_url(:xmlElement) :: url() 398 | def get_sso_redirect_url(md_elem), do: get_url(md_elem, @sso_redirect_url_selector) 399 | 400 | @spec get_sso_post_url(:xmlElement) :: url() 401 | def get_sso_post_url(md_elem), do: get_url(md_elem, @sso_post_url_selector) 402 | 403 | @spec get_slo_redirect_url(:xmlElement) :: url() 404 | def get_slo_redirect_url(md_elem), do: get_url(md_elem, @slo_redirect_url_selector) 405 | 406 | @spec get_slo_post_url(:xmlElement) :: url() 407 | def get_slo_post_url(md_elem), do: get_url(md_elem, @slo_post_url_selector) 408 | 409 | @spec get_url(:xmlElement, %SweetXpath{}) :: url() 410 | defp get_url(md_elem, selector) do 411 | case get_data(md_elem, selector) do 412 | "" -> nil 413 | url -> url 414 | end 415 | end 416 | 417 | @spec get_data(:xmlElement, %SweetXpath{}) :: binary() 418 | def get_data(md_elem, selector) do 419 | md_elem |> xpath(selector |> add_ns()) |> String.trim() 420 | end 421 | 422 | @spec add_ns(%SweetXpath{}) :: %SweetXpath{} 423 | defp add_ns(xpath) do 424 | xpath 425 | |> SweetXml.add_namespace("md", "urn:oasis:names:tc:SAML:2.0:metadata") 426 | |> SweetXml.add_namespace("ds", "http://www.w3.org/2000/09/xmldsig#") 427 | end 428 | end 429 | -------------------------------------------------------------------------------- /lib/samly/provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.Provider do 2 | @moduledoc """ 3 | SAML 2.0 Service Provider 4 | 5 | This should be added to the hosting Phoenix/Plug application's supervision tree. 6 | This GenServer initializes the SP configuration and loads the IDP medata XML 7 | containing information on how to communicate with the IDP. 8 | 9 | ```elixir 10 | # application.ex 11 | 12 | children = [ 13 | # ... 14 | worker(Samly.Provider, []), 15 | ] 16 | ``` 17 | 18 | Check README.md `Configuration` section. 19 | """ 20 | 21 | use GenServer 22 | require Logger 23 | 24 | require Samly.Esaml 25 | alias Samly.{State} 26 | 27 | @doc false 28 | def start_link(gs_opts \\ []) do 29 | GenServer.start_link(__MODULE__, [], gs_opts) 30 | end 31 | 32 | @doc false 33 | def init([]) do 34 | store_env = Application.get_env(:samly, Samly.State, []) 35 | store_provider = store_env[:store] || Samly.State.ETS 36 | store_opts = store_env[:opts] || [] 37 | State.init(store_provider, store_opts) 38 | 39 | opts = Application.get_env(:samly, Samly.Provider, []) 40 | 41 | # must be done prior to loading the providers 42 | idp_id_from = 43 | case opts[:idp_id_from] do 44 | nil -> 45 | :path_segment 46 | 47 | value when value in [:subdomain, :path_segment] -> 48 | value 49 | 50 | unknown -> 51 | Logger.warn( 52 | "[Samly] invalid_data idp_id_from: #{inspect(unknown)}. Using :path_segment" 53 | ) 54 | 55 | :path_segment 56 | end 57 | 58 | Application.put_env(:samly, :idp_id_from, idp_id_from) 59 | 60 | service_providers = Samly.SpData.load_providers(opts[:service_providers] || []) 61 | 62 | identity_providers = 63 | Samly.IdpData.load_providers(opts[:identity_providers] || [], service_providers) 64 | 65 | Application.put_env(:samly, :service_providers, service_providers) 66 | Application.put_env(:samly, :identity_providers, identity_providers) 67 | 68 | {:ok, %{}} 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/samly/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.Router do 2 | @moduledoc false 3 | 4 | use Plug.Router 5 | 6 | plug :secure_samly 7 | plug :match 8 | plug :dispatch 9 | 10 | forward("/auth", to: Samly.AuthRouter) 11 | forward("/sp", to: Samly.SPRouter) 12 | forward("/csp-report", to: Samly.CsprRouter) 13 | 14 | match _ do 15 | conn |> send_resp(404, "not_found") 16 | end 17 | 18 | @csp """ 19 | default-src 'none'; 20 | script-src 'self' 'nonce-<%= nonce %>' 'report-sample'; 21 | img-src 'self' 'report-sample'; 22 | report-to /sso/csp-report; 23 | """ 24 | |> String.replace("\n", " ") 25 | 26 | defp secure_samly(conn, _opts) do 27 | conn 28 | |> put_private(:samly_nonce, :crypto.strong_rand_bytes(18) |> Base.encode64()) 29 | |> register_before_send(fn connection -> 30 | nonce = connection.private[:samly_nonce] 31 | 32 | connection 33 | |> put_resp_header("cache-control", "no-cache, no-store, must-revalidate") 34 | |> put_resp_header("pragma", "no-cache") 35 | |> put_resp_header("x-frame-options", "SAMEORIGIN") 36 | |> put_resp_header("content-security-policy", EEx.eval_string(@csp, nonce: nonce)) 37 | |> put_resp_header("x-xss-protection", "1; mode=block") 38 | |> put_resp_header("x-content-type-options", "nosniff") 39 | end) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/samly/router_util.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.RouterUtil do 2 | @moduledoc false 3 | 4 | alias Plug.Conn 5 | require Logger 6 | require Samly.Esaml 7 | alias Samly.{Esaml, IdpData, Helper} 8 | 9 | @subdomain_re ~r/^(?([^.]+))?\./ 10 | 11 | def check_idp_id(conn, _opts) do 12 | idp_id_from = Application.get_env(:samly, :idp_id_from) 13 | 14 | idp_id = 15 | if idp_id_from == :subdomain do 16 | case Regex.named_captures(@subdomain_re, conn.host) do 17 | %{"subdomain" => idp_id} -> idp_id 18 | _ -> nil 19 | end 20 | else 21 | case conn.params["idp_id_seg"] do 22 | [idp_id] -> idp_id 23 | _ -> nil 24 | end 25 | end 26 | 27 | idp = idp_id && Helper.get_idp(idp_id) 28 | 29 | if idp do 30 | conn |> Conn.put_private(:samly_idp, idp) 31 | else 32 | conn |> Conn.send_resp(403, "invalid_request unknown IdP") |> Conn.halt() 33 | end 34 | end 35 | 36 | def check_target_url(conn, _opts) do 37 | try do 38 | target_url = conn.params["target_url"] && URI.decode_www_form(conn.params["target_url"]) 39 | conn |> Conn.put_private(:samly_target_url, target_url) 40 | rescue 41 | ArgumentError -> 42 | Logger.error( 43 | "[Samly] target_url must be x-www-form-urlencoded: #{inspect(conn.params["target_url"])}" 44 | ) 45 | 46 | conn |> Conn.send_resp(400, "target_url must be x-www-form-urlencoded") |> Conn.halt() 47 | end 48 | end 49 | 50 | # generate URIs using the idp_id 51 | @spec ensure_sp_uris_set(tuple, Conn.t()) :: tuple 52 | def ensure_sp_uris_set(sp, conn) do 53 | case Esaml.esaml_sp(sp, :metadata_uri) do 54 | [?/ | _] -> 55 | uri = %URI{ 56 | scheme: Atom.to_string(conn.scheme), 57 | host: conn.host, 58 | port: conn.port, 59 | path: "/sso" 60 | } 61 | 62 | base_url = URI.to_string(uri) 63 | idp_id_from = Application.get_env(:samly, :idp_id_from) 64 | 65 | path_segment_idp_id = 66 | if idp_id_from == :subdomain do 67 | nil 68 | else 69 | %IdpData{id: idp_id} = conn.private[:samly_idp] 70 | idp_id 71 | end 72 | 73 | Esaml.esaml_sp( 74 | sp, 75 | metadata_uri: Helper.get_metadata_uri(base_url, path_segment_idp_id), 76 | consume_uri: Helper.get_consume_uri(base_url, path_segment_idp_id), 77 | logout_uri: Helper.get_logout_uri(base_url, path_segment_idp_id) 78 | ) 79 | 80 | _ -> 81 | sp 82 | end 83 | end 84 | 85 | def send_saml_request(conn, idp_url, use_redirect?, signed_xml_payload, relay_state) do 86 | if use_redirect? do 87 | url = 88 | :esaml_binding.encode_http_redirect(idp_url, signed_xml_payload, :undefined, relay_state) 89 | 90 | conn |> redirect(302, url) 91 | else 92 | nonce = conn.private[:samly_nonce] 93 | resp_body = :esaml_binding.encode_http_post(idp_url, signed_xml_payload, relay_state, nonce) 94 | 95 | conn 96 | |> Conn.put_resp_header("content-type", "text/html") 97 | |> Conn.send_resp(200, resp_body) 98 | end 99 | end 100 | 101 | def redirect(conn, status_code, dest) do 102 | conn 103 | |> Conn.put_resp_header("location", URI.encode(dest)) 104 | |> Conn.send_resp(status_code, "") 105 | |> Conn.halt() 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/samly/sp_data.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.SpData do 2 | @moduledoc false 3 | 4 | require Logger 5 | require Samly.Esaml 6 | alias Samly.SpData 7 | 8 | defstruct id: "", 9 | entity_id: "", 10 | certfile: "", 11 | keyfile: "", 12 | contact_name: "", 13 | contact_email: "", 14 | org_name: "", 15 | org_displayname: "", 16 | org_url: "", 17 | cert: :undefined, 18 | key: :undefined, 19 | valid?: true 20 | 21 | @type t :: %__MODULE__{ 22 | id: binary(), 23 | entity_id: binary(), 24 | certfile: binary(), 25 | keyfile: binary(), 26 | contact_name: binary(), 27 | contact_email: binary(), 28 | org_name: binary(), 29 | org_displayname: binary(), 30 | org_url: binary(), 31 | cert: :undefined | binary(), 32 | key: :undefined | :RSAPrivateKey, 33 | valid?: boolean() 34 | } 35 | 36 | @type id :: binary 37 | 38 | @default_contact_name "Samly SP Admin" 39 | @default_contact_email "admin@samly" 40 | @default_org_name "Samly SP" 41 | @default_org_displayname "SAML SP built with Samly" 42 | @default_org_url "https://github.com/handnot2/samly" 43 | 44 | @spec load_providers(list(map)) :: %{required(id) => t} 45 | def load_providers(prov_configs) do 46 | prov_configs 47 | |> Enum.map(&load_provider/1) 48 | |> Enum.filter(fn sp_data -> sp_data.valid? end) 49 | |> Enum.map(fn sp_data -> {sp_data.id, sp_data} end) 50 | |> Enum.into(%{}) 51 | end 52 | 53 | @spec load_provider(map) :: %SpData{} | no_return 54 | def load_provider(%{} = opts_map) do 55 | sp_data = %__MODULE__{ 56 | id: Map.get(opts_map, :id, ""), 57 | entity_id: Map.get(opts_map, :entity_id, ""), 58 | certfile: Map.get(opts_map, :certfile, ""), 59 | keyfile: Map.get(opts_map, :keyfile, ""), 60 | contact_name: Map.get(opts_map, :contact_name, @default_contact_name), 61 | contact_email: Map.get(opts_map, :contact_email, @default_contact_email), 62 | org_name: Map.get(opts_map, :org_name, @default_org_name), 63 | org_displayname: Map.get(opts_map, :org_displayname, @default_org_displayname), 64 | org_url: Map.get(opts_map, :org_url, @default_org_url) 65 | } 66 | 67 | sp_data |> set_id(opts_map) |> load_cert(opts_map) |> load_key(opts_map) 68 | end 69 | 70 | @spec set_id(%SpData{}, map()) :: %SpData{} 71 | defp set_id(%SpData{} = sp_data, %{} = opts_map) do 72 | case Map.get(opts_map, :id, "") do 73 | "" -> 74 | Logger.error("[Samly] Invalid SP Config: #{inspect(opts_map)}") 75 | %SpData{sp_data | valid?: false} 76 | 77 | id -> 78 | %SpData{sp_data | id: id} 79 | end 80 | end 81 | 82 | @spec load_cert(%SpData{}, map()) :: %SpData{} 83 | defp load_cert(%SpData{certfile: ""} = sp_data, _) do 84 | %SpData{sp_data | cert: :undefined} 85 | end 86 | 87 | defp load_cert(%SpData{certfile: certfile} = sp_data, %{} = opts_map) do 88 | try do 89 | cert = :esaml_util.load_certificate(certfile) 90 | %SpData{sp_data | cert: cert} 91 | rescue 92 | _error -> 93 | Logger.error( 94 | "[Samly] Failed load SP certfile [#{inspect(certfile)}]: #{inspect(opts_map)}" 95 | ) 96 | 97 | %SpData{sp_data | valid?: false} 98 | end 99 | end 100 | 101 | @spec load_key(%SpData{}, map()) :: %SpData{} 102 | defp load_key(%SpData{keyfile: ""} = sp_data, _) do 103 | %SpData{sp_data | key: :undefined} 104 | end 105 | 106 | defp load_key(%SpData{keyfile: keyfile} = sp_data, %{} = opts_map) do 107 | try do 108 | key = :esaml_util.load_private_key(keyfile) 109 | %SpData{sp_data | key: key} 110 | rescue 111 | _error -> 112 | Logger.error("[Samly] Failed load SP keyfile [#{inspect(keyfile)}]: #{inspect(opts_map)}") 113 | %SpData{sp_data | key: :undefined, valid?: false} 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/samly/sp_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.SPHandler do 2 | @moduledoc false 3 | 4 | require Logger 5 | import Plug.Conn 6 | alias Plug.Conn 7 | require Samly.Esaml 8 | alias Samly.{Assertion, Esaml, Helper, IdpData, State, Subject} 9 | 10 | import Samly.RouterUtil, only: [ensure_sp_uris_set: 2, send_saml_request: 5, redirect: 3] 11 | 12 | def send_metadata(conn) do 13 | %IdpData{} = idp = conn.private[:samly_idp] 14 | %IdpData{esaml_idp_rec: _idp_rec, esaml_sp_rec: sp_rec} = idp 15 | sp = ensure_sp_uris_set(sp_rec, conn) 16 | metadata = Helper.sp_metadata(sp) 17 | 18 | conn 19 | |> put_resp_header("content-type", "text/xml") 20 | |> send_resp(200, metadata) 21 | 22 | # rescue 23 | # error -> 24 | # Logger.error("#{inspect error}") 25 | # conn |> send_resp(500, "request_failed") 26 | end 27 | 28 | def consume_signin_response(conn) do 29 | %IdpData{id: idp_id} = idp = conn.private[:samly_idp] 30 | %IdpData{pre_session_create_pipeline: pipeline, esaml_sp_rec: sp_rec} = idp 31 | sp = ensure_sp_uris_set(sp_rec, conn) 32 | 33 | saml_encoding = conn.body_params["SAMLEncoding"] 34 | saml_response = conn.body_params["SAMLResponse"] 35 | relay_state = conn.body_params["RelayState"] |> safe_decode_www_form() 36 | 37 | with {:ok, assertion} <- Helper.decode_idp_auth_resp(sp, saml_encoding, saml_response), 38 | :ok <- validate_authresp(conn, assertion, relay_state), 39 | assertion = %Assertion{assertion | idp_id: idp_id}, 40 | conn = conn |> put_private(:samly_assertion, assertion), 41 | {:halted, %Conn{halted: false} = conn} <- {:halted, pipethrough(conn, pipeline)} do 42 | updated_assertion = conn.private[:samly_assertion] 43 | computed = updated_assertion.computed 44 | assertion = %Assertion{assertion | computed: computed, idp_id: idp_id} 45 | 46 | nameid = assertion.subject.name 47 | assertion_key = {idp_id, nameid} 48 | conn = State.put_assertion(conn, assertion_key, assertion) 49 | target_url = auth_target_url(conn, assertion, relay_state) 50 | 51 | conn 52 | |> configure_session(renew: true) 53 | |> put_session("samly_assertion_key", assertion_key) 54 | |> redirect(302, target_url) 55 | else 56 | {:halted, conn} -> conn 57 | {:error, reason} -> conn |> send_resp(403, "access_denied #{inspect(reason)}") 58 | _ -> conn |> send_resp(403, "access_denied") 59 | end 60 | 61 | # rescue 62 | # error -> 63 | # Logger.error("#{inspect error}") 64 | # conn |> send_resp(500, "request_failed") 65 | end 66 | 67 | # IDP-initiated flow auth response 68 | @spec validate_authresp(Conn.t(), Assertion.t(), binary) :: :ok | {:error, atom} 69 | defp validate_authresp(conn, %{subject: %{in_response_to: ""}}, relay_state) do 70 | idp_data = conn.private[:samly_idp] 71 | 72 | if idp_data.allow_idp_initiated_flow do 73 | if idp_data.allowed_target_urls do 74 | if relay_state in idp_data.allowed_target_urls do 75 | :ok 76 | else 77 | {:error, :invalid_target_url} 78 | end 79 | else 80 | :ok 81 | end 82 | else 83 | {:error, :idp_first_flow_not_allowed} 84 | end 85 | end 86 | 87 | # SP-initiated flow auth response 88 | defp validate_authresp(conn, _assertion, relay_state) do 89 | %IdpData{id: idp_id} = conn.private[:samly_idp] 90 | rs_in_session = get_session(conn, "relay_state") 91 | idp_id_in_session = get_session(conn, "idp_id") 92 | url_in_session = get_session(conn, "target_url") 93 | 94 | cond do 95 | rs_in_session == nil || rs_in_session != relay_state -> 96 | {:error, :invalid_relay_state} 97 | 98 | idp_id_in_session == nil || idp_id_in_session != idp_id -> 99 | {:error, :invalid_idp_id} 100 | 101 | url_in_session == nil -> 102 | {:error, :invalid_target_url} 103 | 104 | true -> 105 | :ok 106 | end 107 | end 108 | 109 | defp pipethrough(conn, nil), do: conn 110 | 111 | defp pipethrough(conn, pipeline) do 112 | pipeline.call(conn, []) 113 | end 114 | 115 | defp auth_target_url(_conn, %{subject: %{in_response_to: ""}}, ""), do: "/" 116 | defp auth_target_url(_conn, %{subject: %{in_response_to: ""}}, url), do: url 117 | 118 | defp auth_target_url(conn, _assertion, _relay_state) do 119 | get_session(conn, "target_url") || "/" 120 | end 121 | 122 | def handle_logout_response(conn) do 123 | %IdpData{id: idp_id} = idp = conn.private[:samly_idp] 124 | %IdpData{esaml_idp_rec: _idp_rec, esaml_sp_rec: sp_rec} = idp 125 | sp = ensure_sp_uris_set(sp_rec, conn) 126 | 127 | saml_encoding = conn.body_params["SAMLEncoding"] 128 | saml_response = conn.body_params["SAMLResponse"] 129 | relay_state = conn.body_params["RelayState"] |> safe_decode_www_form() 130 | 131 | with {:ok, _payload} <- Helper.decode_idp_signout_resp(sp, saml_encoding, saml_response), 132 | ^relay_state when relay_state != nil <- get_session(conn, "relay_state"), 133 | ^idp_id <- get_session(conn, "idp_id"), 134 | target_url when target_url != nil <- get_session(conn, "target_url") do 135 | conn 136 | |> configure_session(drop: true) 137 | |> redirect(302, target_url) 138 | else 139 | error -> conn |> send_resp(403, "invalid_request #{inspect(error)}") 140 | end 141 | 142 | # rescue 143 | # error -> 144 | # Logger.error("#{inspect error}") 145 | # conn |> send_resp(500, "request_failed") 146 | end 147 | 148 | # non-ui logout request from IDP 149 | def handle_logout_request(conn) do 150 | %IdpData{id: idp_id} = idp = conn.private[:samly_idp] 151 | %IdpData{esaml_idp_rec: idp_rec, esaml_sp_rec: sp_rec} = idp 152 | sp = ensure_sp_uris_set(sp_rec, conn) 153 | 154 | saml_encoding = conn.body_params["SAMLEncoding"] 155 | saml_request = conn.body_params["SAMLRequest"] 156 | relay_state = conn.body_params["RelayState"] |> safe_decode_www_form() 157 | 158 | with {:ok, payload} <- Helper.decode_idp_signout_req(sp, saml_encoding, saml_request) do 159 | Esaml.esaml_logoutreq(name: nameid, issuer: _issuer) = payload 160 | assertion_key = {idp_id, nameid} 161 | 162 | {conn, return_status} = 163 | case State.get_assertion(conn, assertion_key) do 164 | %Assertion{idp_id: ^idp_id, subject: %Subject{name: ^nameid}} -> 165 | conn = State.delete_assertion(conn, assertion_key) 166 | {conn, :success} 167 | 168 | _ -> 169 | {conn, :denied} 170 | end 171 | 172 | {idp_signout_url, resp_xml_frag} = Helper.gen_idp_signout_resp(sp, idp_rec, return_status) 173 | 174 | conn 175 | |> configure_session(drop: true) 176 | |> send_saml_request(idp_signout_url, idp.use_redirect_for_req, resp_xml_frag, relay_state) 177 | else 178 | error -> 179 | Logger.error("#{inspect(error)}") 180 | {idp_signout_url, resp_xml_frag} = Helper.gen_idp_signout_resp(sp, idp_rec, :denied) 181 | 182 | conn 183 | |> send_saml_request( 184 | idp_signout_url, 185 | idp.use_redirect_for_req, 186 | resp_xml_frag, 187 | relay_state 188 | ) 189 | end 190 | 191 | # rescue 192 | # error -> 193 | # Logger.error("#{inspect error}") 194 | # conn |> send_resp(500, "request_failed") 195 | end 196 | 197 | defp safe_decode_www_form(nil), do: "" 198 | defp safe_decode_www_form(data), do: URI.decode_www_form(data) 199 | end 200 | -------------------------------------------------------------------------------- /lib/samly/sp_router.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.SPRouter do 2 | @moduledoc false 3 | 4 | use Plug.Router 5 | import Plug.Conn 6 | import Samly.RouterUtil, only: [check_idp_id: 2] 7 | 8 | plug :fetch_session 9 | plug :match 10 | plug :check_idp_id 11 | plug :dispatch 12 | 13 | get "/metadata/*idp_id_seg" do 14 | # TODO: Make a release task to generate SP metadata 15 | conn |> Samly.SPHandler.send_metadata() 16 | end 17 | 18 | post "/consume/*idp_id_seg" do 19 | conn |> Samly.SPHandler.consume_signin_response() 20 | end 21 | 22 | post "/logout/*idp_id_seg" do 23 | cond do 24 | conn.params["SAMLResponse"] != nil -> Samly.SPHandler.handle_logout_response(conn) 25 | conn.params["SAMLRequest"] != nil -> Samly.SPHandler.handle_logout_request(conn) 26 | true -> conn |> send_resp(403, "invalid_request") 27 | end 28 | end 29 | 30 | match _ do 31 | conn |> send_resp(404, "not_found") 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/samly/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.State do 2 | @moduledoc false 3 | 4 | @state_store :state_store 5 | 6 | def init(store_provider), do: init(store_provider, []) 7 | 8 | def init(store_provider, opts) do 9 | opts = store_provider.init(opts) 10 | Application.put_env(:samly, @state_store, %{provider: store_provider, opts: opts}) 11 | end 12 | 13 | def get_assertion(conn, assertion_key) do 14 | %{provider: store_provider, opts: opts} = Application.get_env(:samly, @state_store) 15 | store_provider.get_assertion(conn, assertion_key, opts) 16 | end 17 | 18 | def put_assertion(conn, assertion_key, assertion) do 19 | %{provider: store_provider, opts: opts} = Application.get_env(:samly, @state_store) 20 | store_provider.put_assertion(conn, assertion_key, assertion, opts) 21 | end 22 | 23 | def delete_assertion(conn, assertion_key) do 24 | %{provider: store_provider, opts: opts} = Application.get_env(:samly, @state_store) 25 | store_provider.delete_assertion(conn, assertion_key, opts) 26 | end 27 | 28 | def gen_id() do 29 | 24 |> :crypto.strong_rand_bytes() |> Base.url_encode64() 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/samly/state/ets.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.State.ETS do 2 | @moduledoc """ 3 | Stores SAML assertion in ETS. 4 | 5 | This provider creates an ETS table (during initialization) to keep the 6 | authenticated SAML assertions from IdP. The ETS table name in the 7 | configuration is optional. 8 | 9 | ## Options 10 | 11 | + `:table` - ETS table name (optional) 12 | Value must be an atom 13 | 14 | Do not rely on how the state is stored in the ETS table. 15 | 16 | ## Configuration Example 17 | 18 | config :samly, Samly.State, 19 | opts: [table: :my_ets_table] 20 | 21 | This can be used as an example when creating custom stores based on 22 | redis, memcached, database etc. 23 | """ 24 | 25 | alias Samly.Assertion 26 | 27 | @behaviour Samly.State.Store 28 | 29 | @assertions_table :samly_assertions_table 30 | 31 | @impl Samly.State.Store 32 | def init(opts) do 33 | assertions_table = Keyword.get(opts, :table, @assertions_table) 34 | 35 | if is_atom(assertions_table) == false do 36 | raise "Samly.State.ETS table name must be an atom: #{inspect(assertions_table)}" 37 | end 38 | 39 | if :ets.info(assertions_table) == :undefined do 40 | :ets.new(assertions_table, [:set, :public, :named_table]) 41 | end 42 | 43 | assertions_table 44 | end 45 | 46 | @impl Samly.State.Store 47 | def get_assertion(_conn, assertion_key, assertions_table) do 48 | case :ets.lookup(assertions_table, assertion_key) do 49 | [{^assertion_key, %Assertion{} = assertion}] -> assertion 50 | _ -> nil 51 | end 52 | end 53 | 54 | @impl Samly.State.Store 55 | def put_assertion(conn, assertion_key, assertion, assertions_table) do 56 | :ets.insert(assertions_table, {assertion_key, assertion}) 57 | conn 58 | end 59 | 60 | @impl Samly.State.Store 61 | def delete_assertion(conn, assertion_key, assertions_table) do 62 | :ets.delete(assertions_table, assertion_key) 63 | conn 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/samly/state/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.State.Session do 2 | @moduledoc """ 3 | Stores SAML assertion in Plug session. 4 | 5 | This provider uses Plug session to save the authenticated SAML 6 | assertions from IdP. The session key name in the configuration is optional. 7 | 8 | ## Options 9 | 10 | + `:key` - Session key name used when saving the assertion (optional) 11 | Value is either a binary or an atom 12 | 13 | ## Configuration Example 14 | 15 | config :samly, Samly.State, 16 | store: Samly.State.Session, 17 | opts: [key: :my_assertion] 18 | """ 19 | 20 | alias Plug.Conn 21 | alias Samly.Assertion 22 | 23 | @behaviour Samly.State.Store 24 | 25 | @session_key "samly_assertion" 26 | 27 | @impl Samly.State.Store 28 | def init(opts) do 29 | opts |> Map.new() |> Map.put_new(:key, @session_key) 30 | end 31 | 32 | @impl Samly.State.Store 33 | def get_assertion(conn, assertion_key, opts) do 34 | %{key: key} = opts 35 | 36 | case Conn.get_session(conn, key) do 37 | {^assertion_key, %Assertion{} = assertion} -> assertion 38 | _ -> nil 39 | end 40 | end 41 | 42 | @impl Samly.State.Store 43 | def put_assertion(conn, assertion_key, assertion, opts) do 44 | %{key: key} = opts 45 | Conn.put_session(conn, key, {assertion_key, assertion}) 46 | end 47 | 48 | @impl Samly.State.Store 49 | def delete_assertion(conn, _assertion_key, opts) do 50 | %{key: key} = opts 51 | Conn.delete_session(conn, key) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/samly/state/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.State.Store do 2 | @moduledoc """ 3 | Specification for Samly state stores. 4 | """ 5 | alias Plug.Conn 6 | alias Samly.Assertion 7 | 8 | @typedoc """ 9 | Options passed during the store initialization. 10 | """ 11 | @type opts :: Plug.opts() 12 | 13 | @typedoc """ 14 | IdP identifier associated with the assertion. 15 | """ 16 | @type idp_id :: binary 17 | 18 | @typedoc """ 19 | SAML `nameid` returned by IdP. 20 | """ 21 | @type name_id :: binary 22 | 23 | @typedoc """ 24 | The `name_id` should not be used independent of the `idp_id`. It is within the scope of `idp_id`. 25 | Together these form the assertion key. 26 | """ 27 | @type assertion_key :: {idp_id(), name_id()} 28 | 29 | @doc """ 30 | Initializes the store. 31 | 32 | The options returned from this function will be given 33 | to `get_assertion/3`, `put_assertion/4` and `delete_assertion/3`. 34 | """ 35 | @callback init(opts()) :: opts() | no_return() 36 | 37 | @doc """ 38 | Returns a Samly assertion if present in the store. 39 | 40 | Returns `nil` if the assertion for the given key is not present in the store. 41 | """ 42 | @callback get_assertion(Conn.t(), assertion_key(), opts()) :: Assertion.t() | nil 43 | 44 | @doc """ 45 | Saves the given SAML assertion in the store. 46 | 47 | May raise an error if there is a failure. An authenticated session should not be 48 | established in that case. 49 | """ 50 | @callback put_assertion(Conn.t(), assertion_key(), Assertion.t(), opts()) :: 51 | Conn.t() | no_return() 52 | 53 | @doc """ 54 | Removes the given SAML assertion from the store. 55 | 56 | May raise an error if there is a failure. An authenticated session must be terminated 57 | after calling this. 58 | """ 59 | @callback delete_assertion(Conn.t(), assertion_key(), opts()) :: Conn.t() | no_return() 60 | end 61 | -------------------------------------------------------------------------------- /lib/samly/subject.ex: -------------------------------------------------------------------------------- 1 | defmodule Samly.Subject do 2 | @moduledoc """ 3 | The subject in a SAML 2.0 Assertion. 4 | 5 | This is part of the `Samly.Assertion` struct. The `name` field in this struct should not 6 | be used in any UI directly. It might be a temporary randomly generated 7 | ID from IdP. `Samly` internally uses this to deal with IdP initiated logout requests. 8 | 9 | If an authentication request was sent from `Samly` (SP initiated), the SAML response 10 | is expected to include the original request ID. This ID is made available in 11 | `Samly.Subject.in_response_to`. 12 | 13 | If the authentication request originated from the IDP (IDP initiated), there won't 14 | be a `Samly` request ID associated with it. The `Samly.Subject.in_response_to` 15 | will be an empty string in that case. 16 | """ 17 | 18 | require Samly.Esaml 19 | alias Samly.Esaml 20 | 21 | defstruct name: "", 22 | name_qualifier: :undefined, 23 | sp_name_qualifier: :undefined, 24 | name_format: :undefined, 25 | confirmation_method: :bearer, 26 | notonorafter: "", 27 | in_response_to: "" 28 | 29 | @type t :: %__MODULE__{ 30 | name: String.t(), 31 | name_qualifier: :undefined | String.t(), 32 | sp_name_qualifier: :undefined | String.t(), 33 | name_format: :undefined | String.t(), 34 | confirmation_method: atom, 35 | notonorafter: String.t(), 36 | in_response_to: String.t() 37 | } 38 | 39 | @doc false 40 | def from_rec(subject_rec) do 41 | Esaml.esaml_subject( 42 | name: name, 43 | name_qualifier: name_qualifier, 44 | sp_name_qualifier: sp_name_qualifier, 45 | name_format: name_format, 46 | confirmation_method: confirmation_method, 47 | notonorafter: notonorafter, 48 | in_response_to: in_response_to 49 | ) = subject_rec 50 | 51 | %__MODULE__{ 52 | name: name |> List.to_string(), 53 | name_qualifier: to_string_or_undefined(name_qualifier), 54 | sp_name_qualifier: to_string_or_undefined(sp_name_qualifier), 55 | name_format: to_string_or_undefined(name_format), 56 | confirmation_method: confirmation_method, 57 | notonorafter: notonorafter |> List.to_string(), 58 | in_response_to: in_response_to |> List.to_string() 59 | } 60 | end 61 | 62 | @doc false 63 | def to_rec(subject) do 64 | Esaml.esaml_subject( 65 | name: String.to_charlist(subject.name), 66 | name_qualifier: from_string_or_undefined(subject.name_qualifier), 67 | sp_name_qualifier: from_string_or_undefined(subject.sp_name_qualifier), 68 | name_format: from_string_or_undefined(subject.name_format), 69 | confirmation_method: subject.confirmation_method, 70 | notonorafter: String.to_charlist(subject.notonorafter), 71 | in_response_to: String.to_charlist(subject.in_response_to) 72 | ) 73 | end 74 | 75 | defp to_string_or_undefined(:undefined), do: :undefined 76 | defp to_string_or_undefined(s) when is_list(s), do: List.to_string(s) 77 | 78 | defp from_string_or_undefined(:undefined), do: :undefined 79 | defp from_string_or_undefined(s) when is_binary(s), do: String.to_charlist(s) 80 | end 81 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Samly.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.0.0" 5 | @description "SAML Single-Sign-On Authentication for Plug/Phoenix Applications" 6 | @source_url "https://github.com/handnot2/samly" 7 | @blog_url "https://handnot2.github.io/blog/auth/saml-auth-for-phoenix" 8 | 9 | def project() do 10 | [ 11 | app: :samly, 12 | version: @version, 13 | description: @description, 14 | docs: docs(), 15 | package: package(), 16 | elixir: "~> 1.6", 17 | start_permanent: Mix.env() == :prod, 18 | deps: deps() 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application() do 24 | [ 25 | extra_applications: [:logger, :eex] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps() do 31 | [ 32 | {:plug, "~> 1.6"}, 33 | {:esaml, "~> 4.2"}, 34 | {:sweet_xml, "~> 0.6.6"}, 35 | {:ex_doc, "~> 0.19.0", only: :dev, runtime: false}, 36 | {:inch_ex, "~> 1.0", only: [:dev, :test]} 37 | ] 38 | end 39 | 40 | defp docs() do 41 | [ 42 | extras: ["README.md"], 43 | main: "readme", 44 | source_ref: "v#{@version}", 45 | source_url: @source_url 46 | ] 47 | end 48 | 49 | defp package() do 50 | [ 51 | maintainers: ["handnot2"], 52 | files: ["config", "lib", "LICENSE", "mix.exs", "README.md"], 53 | licenses: ["MIT"], 54 | links: %{ 55 | "GitHub" => @source_url, 56 | "Blog" => @blog_url 57 | } 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cowboy": {:hex, :cowboy, "2.6.0", "dc1ff5354c89e36a3e3ef8d10433396dcff0dcbb1d4223b58c64c2d51a6d88d9", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"}, 4 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, 5 | "esaml": {:hex, :esaml, "4.2.0", "e3236ec9c1974c50f50766b9923f7f127a95a17b75ea229ef5dad36a7e6dd5fc", [:rebar3], [{:cowboy, "2.6.0", [hex: :cowboy, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "inch_ex": {:hex, :inch_ex, "1.0.1", "1f0af1a83cec8e56f6fc91738a09c838e858db3d78ef5f2ec040fe4d5a62dabf", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 11 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 12 | "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 14 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 15 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, 16 | "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm"}, 17 | } 18 | -------------------------------------------------------------------------------- /test/data/azure_fed_metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PxBXxVXHYmtelxnhr2ewHTGKEkuviPF429grQa1WNIw= 17 | 18 | 19 | o/8AXiWeX8TpD76ix+hlpX//CnBjL4i5xxniJLDTqcKsVBCa544vBOJsCFKS4Syd7AoXD5Yhj6PPzpfUq6kR+vQRZS1w5/XQ7juSv/GCSLSJaL3Zhhm2Uej/jtJf+Wxq8yrsTXyECPfqx8XTD6RIbEOGcn9Ug3wkuUpnzf3Kgl8PNUNhYa+bKyehsUNmPr5geFqxk/o1Pq/prVFy7gBGXEGPMHigkczYya2vxhZCpLkKUcqm3JlQvPdCFuH060F5/MSKxEJttt/KQIer7fvLD832Yw1LeI+aarJxOXEAgJpbohlE5Wzxs2GvrSvzCqiECTCXZOVAdQ+LGIHJMvehcA== 20 | 21 | 22 | MIIDBTCCAe2gAwIBAgIQQm0sN9lDrblM/7U/vYMVmTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTE3MDkwNjAwMDAwMFoXDTE5MDkwNzAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONDCuIodN3I6J7+Wc9j5sY5MgLkGZZuUxLApHEmIhgWIVSSyMgM+eu0Nrr5FuIylJrKg/SHOJeLwFuYZOEMfRUVcKRewUTIBKmILqlg5+d56ztYZATuhm60B5bp1RUlUQMZ0GTepz9xpS9hO4Mv5GRBxR5xoBKCOF74xfVuHNkk/wnbV/Yggwt82FrGm8CpiXjbmFzy61Vmp6KbFDadJDij2mmICSwzAJEoVvklLzcL/4Vf03v3l8aBkI/SPfDjm6k55dqeDm+nEIN/baWbs7M2WtNkJXNePy8dR0GkdlhbgESEIJdSVLWeBFt8eV0JQqUXcPCjhwpJE89jrHhwyCECAwEAAaMhMB8wHQYDVR0OBBYEFNISA3dtAzEd0muqNDbWm3kvNlJDMA0GCSqGSIb3DQEBCwUAA4IBAQClLLoAvg3dYqWO63Z6O5L7yataGcilmL3YUqCFoRKsuwej2T833qyc1iLG0iWCGeWAUonKXuGwfCSSSj2E3ksLtgV6xmuMl+NuVPpRpQo+38n+OxUoWKu963dMxnORFENEqKW0pMioipMk/HBaW3aJWyH1oT2rZ3KhFm67SFjKscF8ShAE82tQQIFwEFAXjMItW2oZVGDz3vDOaJN5xC8rfA6xkXTdcCuzy74SalKkLhpBO8S3XIOBVRZw+l0Koog8YNqhsvGsGS+hGXXNlCZTg0I1tR3g2DcSuHRcuTZKh7Z7XPPsDgleNirtvYFEvdvD4K2I7gb2H1xQn87oYAIX 23 | 24 | 25 | 26 | 27 | 32 | 33 | 34 | 35 | MIIDBTCCAe2gAwIBAgIQQm0sN9lDrblM/7U/vYMVmTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTE3MDkwNjAwMDAwMFoXDTE5MDkwNzAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONDCuIodN3I6J7+Wc9j5sY5MgLkGZZuUxLApHEmIhgWIVSSyMgM+eu0Nrr5FuIylJrKg/SHOJeLwFuYZOEMfRUVcKRewUTIBKmILqlg5+d56ztYZATuhm60B5bp1RUlUQMZ0GTepz9xpS9hO4Mv5GRBxR5xoBKCOF74xfVuHNkk/wnbV/Yggwt82FrGm8CpiXjbmFzy61Vmp6KbFDadJDij2mmICSwzAJEoVvklLzcL/4Vf03v3l8aBkI/SPfDjm6k55dqeDm+nEIN/baWbs7M2WtNkJXNePy8dR0GkdlhbgESEIJdSVLWeBFt8eV0JQqUXcPCjhwpJE89jrHhwyCECAwEAAaMhMB8wHQYDVR0OBBYEFNISA3dtAzEd0muqNDbWm3kvNlJDMA0GCSqGSIb3DQEBCwUAA4IBAQClLLoAvg3dYqWO63Z6O5L7yataGcilmL3YUqCFoRKsuwej2T833qyc1iLG0iWCGeWAUonKXuGwfCSSSj2E3ksLtgV6xmuMl+NuVPpRpQo+38n+OxUoWKu963dMxnORFENEqKW0pMioipMk/HBaW3aJWyH1oT2rZ3KhFm67SFjKscF8ShAE82tQQIFwEFAXjMItW2oZVGDz3vDOaJN5xC8rfA6xkXTdcCuzy74SalKkLhpBO8S3XIOBVRZw+l0Koog8YNqhsvGsGS+hGXXNlCZTg0I1tR3g2DcSuHRcuTZKh7Z7XPPsDgleNirtvYFEvdvD4K2I7gb2H1xQn87oYAIX 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | MIIDBTCCAe2gAwIBAgIQG3bMDDyO6q1GrI5sdZXCrTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTE3MTAxODAwMDAwMFoXDTE5MTAxOTAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMPkg1ILQtpwFPPqiBBV3wXd89KoDW5NA/DiBQPZYINe//g6gdrOcnT6JjS5mnntXURsvlC8a8QPNCeX9zuxi0MvEoXjuyROJwnAhZyHmkWQh850pUiFTICCuDJXBrMiMdLYfeaJ9C0SApw2OhDiCED77Hh3oxuP49ooS5My96/b3PM76KC/GTv4VM5ydPOXpvUpLzMoavQdOGUCjUYU2z9twCYXmCLtwOiNWFW9yqof6tSAxEZRezq0Hi9qU6DF0MIp0TT6FKvUC8ui4pcYX7hZ6U9yOXJVB0+6HXdpGQ42sypvoK1h8mcRRLA5Ad/ox6YIJ39j0tJm5y+4H37NvHUCAwEAAaMhMB8wHQYDVR0OBBYEFCO/QGygHvo1YiKeQVulJFVxO9dnMA0GCSqGSIb3DQEBCwUAA4IBAQBZTJK52b+QnBbLicaT5uxC3JnRwps6RovQzPZRBLpxATq4kj5jNMhegb5fx4Rc1dpepXWJHAGzD0Nwsab/vYSx7iqyU02IAUkwt3k7XyYK17R6gTgUAxEFBfRKM3PSFiH0b3tGA+baLT3BdY5U6ZqjxhFA0Rh7tzPZM1TO2WtENk3hKmG5r5GKECnwa5NiE5jxN+d6i8dqM+vMqDvIrfqTA3ooQWXpvs0I9YUWl/LjBNFqyY3rMzxLX3STobLFf8ayHIvVmtiFSM3glCO+8UtGKLwNnPFIfYx3VstJjOO8rjP0Z/oaZwhD0A7MrNp4ztwmXAIzYkGTVyDsNuQJgi1e 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | MIIDKDCCAhCgAwIBAgIQBHJvVNxP1oZO4HYKh+rypDANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwHhcNMTYxMTE2MDgwMDAwWhcNMTgxMTE2MDgwMDAwWjAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQChn5BCs24Hh6L0BNitPrV5s+2/DBhaeytOmnghJKnqeJlhv3ZczShRM2Cp38LW8Y3wn7L3AJtolaSkF/joKN1l6GupzM+HOEdq7xZxFehxIHW7+25mG/WigBnhsBzLv1SR4uIbrQeS5M0kkLwJ9pOnVH3uzMGG6TRXPnK3ivlKl97AiUEKdlRjCQNLXvYf1ZqlC77c/ZCOHSX4kvIKR2uG+LNlSTRq2rn8AgMpFT4DSlEZz4RmFQvQupQzPpzozaz/gadBpJy/jgDmJlQMPXkHp7wClvbIBGiGRaY6eZFxNV96zwSR/GPNkTObdw2S8/SiAgvIhIcqWTPLY6aVTqJfAgMBAAGjWDBWMFQGA1UdAQRNMEuAEDUj0BrjP0RTbmoRPTRMY3WhJTAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXOCEARyb1TcT9aGTuB2Cofq8qQwDQYJKoZIhvcNAQELBQADggEBAGnLhDHVz2gLDiu9L34V3ro/6xZDiSWhGyHcGqky7UlzQH3pT5so8iF5P0WzYqVtogPsyC2LPJYSTt2vmQugD4xlu/wbvMFLcV0hmNoTKCF1QTVtEQiAiy0Aq+eoF7Al5fV1S3Sune0uQHimuUFHCmUuF190MLcHcdWnPAmzIc8fv7quRUUsExXmxSX2ktUYQXzqFyIOSnDCuWFm6tpfK5JXS8fW5bpqTlrysXXz/OW/8NFGq/alfjrya4ojrOYLpunGriEtNPwK7hxj1AlCYEWaRHRXaUIW1ByoSff/6Y6+ZhXPUe0cDlNRt/qIz5aflwO7+W8baTS4O8m/icu7ItE= 52 | 53 | 54 | 55 | 56 | 57 | 60 | Name 61 | The mutable display name of the user. 62 | 63 | 66 | Subject 67 | An immutable, globally unique, non-reusable identifier of the user that is unique to the application for which a token is issued.Given Name 68 | First name of the user. 69 | 70 | 73 | Surname 74 | Last name of the user. 75 | 76 | 79 | Display Name 80 | Display name of the user. 81 | 82 | 85 | Nick Name 86 | Nick name of the user. 87 | 88 | 91 | Authentication Instant 92 | The time (UTC) when the user is authenticated to Windows Azure Active Directory. 93 | 94 | 97 | Authentication Method 98 | The method that Windows Azure Active Directory uses to authenticate users. 99 | 100 | 103 | ObjectIdentifier 104 | Primary identifier for the user in the directory. Immutable, globally unique, non-reusable. 105 | 106 | 109 | TenantId 110 | Identifier for the user's tenant. 111 | 112 | 115 | IdentityProvider 116 | Identity provider for the user. 117 | 118 | 121 | Email 122 | Email address of the user. 123 | 124 | 127 | Groups 128 | Groups of the user. 129 | 130 | 133 | External Access Token 134 | Access token issued by external identity provider. 135 | 136 | 139 | External Access Token Expiration 140 | UTC expiration time of access token issued by external identity provider. 141 | 142 | 145 | External OpenID 2.0 Identifier 146 | OpenID 2.0 identifier issued by external identity provider. 147 | 148 | 151 | GroupsOverageClaim 152 | Issued when number of user's group claims exceeds return limit. 153 | 154 | 157 | Role Claim 158 | Roles that the user or Service Principal is attached to 159 | 160 | 163 | RoleTemplate Id Claim 164 | Role template id of the Built-in Directory Roles that the user is a member of 165 | 166 | 167 | 168 | 169 | https://login.microsoftonline.com/common/wsfed 170 | 171 | 172 | 173 | 174 | https://login.microsoftonline.com/common/wsfed 175 | 176 | 177 | 178 | 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https://sts.windows.net/%7Btenantid%7D/https://login.microsoftonline.com/common/wsfedhttps://login.microsoftonline.com/common/wsfed 179 | 180 | 181 | 182 | 183 | 184 | MIIDBTCCAe2gAwIBAgIQQm0sN9lDrblM/7U/vYMVmTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTE3MDkwNjAwMDAwMFoXDTE5MDkwNzAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONDCuIodN3I6J7+Wc9j5sY5MgLkGZZuUxLApHEmIhgWIVSSyMgM+eu0Nrr5FuIylJrKg/SHOJeLwFuYZOEMfRUVcKRewUTIBKmILqlg5+d56ztYZATuhm60B5bp1RUlUQMZ0GTepz9xpS9hO4Mv5GRBxR5xoBKCOF74xfVuHNkk/wnbV/Yggwt82FrGm8CpiXjbmFzy61Vmp6KbFDadJDij2mmICSwzAJEoVvklLzcL/4Vf03v3l8aBkI/SPfDjm6k55dqeDm+nEIN/baWbs7M2WtNkJXNePy8dR0GkdlhbgESEIJdSVLWeBFt8eV0JQqUXcPCjhwpJE89jrHhwyCECAwEAAaMhMB8wHQYDVR0OBBYEFNISA3dtAzEd0muqNDbWm3kvNlJDMA0GCSqGSIb3DQEBCwUAA4IBAQClLLoAvg3dYqWO63Z6O5L7yataGcilmL3YUqCFoRKsuwej2T833qyc1iLG0iWCGeWAUonKXuGwfCSSSj2E3ksLtgV6xmuMl+NuVPpRpQo+38n+OxUoWKu963dMxnORFENEqKW0pMioipMk/HBaW3aJWyH1oT2rZ3KhFm67SFjKscF8ShAE82tQQIFwEFAXjMItW2oZVGDz3vDOaJN5xC8rfA6xkXTdcCuzy74SalKkLhpBO8S3XIOBVRZw+l0Koog8YNqhsvGsGS+hGXXNlCZTg0I1tR3g2DcSuHRcuTZKh7Z7XPPsDgleNirtvYFEvdvD4K2I7gb2H1xQn87oYAIX 185 | 186 | 187 | 188 | 189 | 190 | 191 | MIIDBTCCAe2gAwIBAgIQG3bMDDyO6q1GrI5sdZXCrTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTE3MTAxODAwMDAwMFoXDTE5MTAxOTAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMPkg1ILQtpwFPPqiBBV3wXd89KoDW5NA/DiBQPZYINe//g6gdrOcnT6JjS5mnntXURsvlC8a8QPNCeX9zuxi0MvEoXjuyROJwnAhZyHmkWQh850pUiFTICCuDJXBrMiMdLYfeaJ9C0SApw2OhDiCED77Hh3oxuP49ooS5My96/b3PM76KC/GTv4VM5ydPOXpvUpLzMoavQdOGUCjUYU2z9twCYXmCLtwOiNWFW9yqof6tSAxEZRezq0Hi9qU6DF0MIp0TT6FKvUC8ui4pcYX7hZ6U9yOXJVB0+6HXdpGQ42sypvoK1h8mcRRLA5Ad/ox6YIJ39j0tJm5y+4H37NvHUCAwEAAaMhMB8wHQYDVR0OBBYEFCO/QGygHvo1YiKeQVulJFVxO9dnMA0GCSqGSIb3DQEBCwUAA4IBAQBZTJK52b+QnBbLicaT5uxC3JnRwps6RovQzPZRBLpxATq4kj5jNMhegb5fx4Rc1dpepXWJHAGzD0Nwsab/vYSx7iqyU02IAUkwt3k7XyYK17R6gTgUAxEFBfRKM3PSFiH0b3tGA+baLT3BdY5U6ZqjxhFA0Rh7tzPZM1TO2WtENk3hKmG5r5GKECnwa5NiE5jxN+d6i8dqM+vMqDvIrfqTA3ooQWXpvs0I9YUWl/LjBNFqyY3rMzxLX3STobLFf8ayHIvVmtiFSM3glCO+8UtGKLwNnPFIfYx3VstJjOO8rjP0Z/oaZwhD0A7MrNp4ztwmXAIzYkGTVyDsNuQJgi1e 192 | 193 | 194 | 195 | 196 | 197 | 198 | MIIDKDCCAhCgAwIBAgIQBHJvVNxP1oZO4HYKh+rypDANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwHhcNMTYxMTE2MDgwMDAwWhcNMTgxMTE2MDgwMDAwWjAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQChn5BCs24Hh6L0BNitPrV5s+2/DBhaeytOmnghJKnqeJlhv3ZczShRM2Cp38LW8Y3wn7L3AJtolaSkF/joKN1l6GupzM+HOEdq7xZxFehxIHW7+25mG/WigBnhsBzLv1SR4uIbrQeS5M0kkLwJ9pOnVH3uzMGG6TRXPnK3ivlKl97AiUEKdlRjCQNLXvYf1ZqlC77c/ZCOHSX4kvIKR2uG+LNlSTRq2rn8AgMpFT4DSlEZz4RmFQvQupQzPpzozaz/gadBpJy/jgDmJlQMPXkHp7wClvbIBGiGRaY6eZFxNV96zwSR/GPNkTObdw2S8/SiAgvIhIcqWTPLY6aVTqJfAgMBAAGjWDBWMFQGA1UdAQRNMEuAEDUj0BrjP0RTbmoRPTRMY3WhJTAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXOCEARyb1TcT9aGTuB2Cofq8qQwDQYJKoZIhvcNAQELBQADggEBAGnLhDHVz2gLDiu9L34V3ro/6xZDiSWhGyHcGqky7UlzQH3pT5so8iF5P0WzYqVtogPsyC2LPJYSTt2vmQugD4xlu/wbvMFLcV0hmNoTKCF1QTVtEQiAiy0Aq+eoF7Al5fV1S3Sune0uQHimuUFHCmUuF190MLcHcdWnPAmzIc8fv7quRUUsExXmxSX2ktUYQXzqFyIOSnDCuWFm6tpfK5JXS8fW5bpqTlrysXXz/OW/8NFGq/alfjrya4ojrOYLpunGriEtNPwK7hxj1AlCYEWaRHRXaUIW1ByoSff/6Y6+ZhXPUe0cDlNRt/qIz5aflwO7+W8baTS4O8m/icu7ItE= 199 | 200 | 201 | 202 | 205 | 208 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /test/data/idp_metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MIIF1TCCA72gAwIBAgIJAKdEDzxP3jJLMA0GCSqGSIb3DQEBCwUAMIGAMQswCQYDVQQGEwJVUzERMA8GA1UECAwITWlkbGFuZHMxEjAQBgNVBAcMCVNhZmV2aWxsZTEWMBQGA1UECgwNSUQgRmVkZXJhdGlvbjEhMB8GA1UECwwYRGVwYXJ0bWVudCBvZiBJZGVudGl0aWVzMQ8wDQYDVQQDDAZteS5pZHAwHhcNMTcwOTA5MDExNzMwWhcNMTgwOTA5MDExNzMwWjCBgDELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pZGxhbmRzMRIwEAYDVQQHDAlTYWZldmlsbGUxFjAUBgNVBAoMDUlEIEZlZGVyYXRpb24xITAfBgNVBAsMGERlcGFydG1lbnQgb2YgSWRlbnRpdGllczEPMA0GA1UEAwwGbXkuaWRwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA5MXY8olt62YGPQQo4UIZ8Q48lMGGSA4DhEAooSA4yjXeKcmG/aDeINkcTzA/khv1iCy3gFDnJ6T6tGvNghWFDVLWrKRzg3PO0y3+6MaJAJ5CXtCaf+EV9KNFcrt8+DFwdudkWrwEak1gM8OHUfXvOqyD1a5g+vPrSm+QiI9+W4csTDB6zQKfMjnQJnUh+W8H4ZLPX2nJAeJLFhDfE1l66pjKHxVRl8QTiflpunMRNgTBknCUxs+3ilG+4QxXEgQdjzLJCc9SjgNEdqnJHd7BfUOsbIi5qjTAM3UkoumEdrLWNRCutDQoc5m79U9m+xcZso/z1qnrgPD6WkS6FiI4QFT8y/hjBEVbrXC5DZHu7ujeE8h2P2qOLjJszdW7SnvYFOshhMWD5JHkAn+Dzcch75pzvlsxxtmCw44IpCkeqjKeO2ajrQcx/fywSaCyme6gwmiF8hdXLrezj1sO0aFJhkEPM0Yb9JRkG2G3a8ITp4+3J8FCAXeLc7MJ/kxOJYVlNESSCzFhWYeSAR7doN9+eXCZ8dQhkOTN4FRT5u9ylq7tYHfAH1YNr2MKsEGrQtn/pTyb1Q4Oqb1IVpNnDRL2C9RPUt9Lfb8V3OHd1Mx81yoylXsvttX1DiPNY/KR/QCWQkvvfNTfUHTOGj8ic6G/t1/IenZ9RzNRPFxj4GNwHNcCAwEAAaNQME4wHQYDVR0OBBYEFJQbKzk3Twp4XgV0T6W0T5lZVFDZMB8GA1UdIwQYMBaAFJQbKzk3Twp4XgV0T6W0T5lZVFDZMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBALFPci1AfEp5bXuPWHbGSvAi+nVBzwHRE9VDIvsq1gVibmunwqFW7DlXi9xvPwrcQHVZtj2qeSWEc6Grw6iROgunclQIwaf6//uqb7Jkj9RhuhCs58CGcSBQ5umfffC/P2JbOte2hJjTTDNVvawAhPPBQ1bn/t8Y3eTHN/srQUtVBPX2szUqy5uRlpFYlZ2Layfcg3oLHg6Ube/HhEA2mjgCjod7zGtwuwsUOpjktQCp3aPLWXKN+ImcGAChpqAm9frgBWgZv4mxKynjOw15ur3wgSEJO7DNmEdjLqz2OiLHeX0MrDyGpcid5hPj4CgCN6vngeNXBEaoK2lJHp0I5HbdIoRBcghIVm9RzpfrDdK3/Ayy+wEAqjQRCuwBUEvrqsMnmnIjYw8HE+BE3pIS8hh9SyGCOgPJmHbYkwztkrNi6RJyeHROwAoER6J/D1saCKMK63bqLW5WLXDfftg6BNXXfvPjuQyaqlsP+HOjX8wTlKwemvUS93aDxwP8YdcFLt/uw/GXpmNKY+/wsymMYgXkTcS0FWgNUtpH/gZUIh3AMpCi1fBi71nc0uuLUBilRMrCHI5y1Rzi0qBfAkAKyUYbUO87KRvXQ3pjmpCC/IMPFqY4hkudY9juzIcOU2auml+wBSu1c45142wdz4x//oh0N5NrVuLBe74Auy4nVOfH 8 | 9 | 10 | 11 | 12 | 13 | 14 | MIIF1TCCA72gAwIBAgIJAKdEDzxP3jJLMA0GCSqGSIb3DQEBCwUAMIGAMQswCQYDVQQGEwJVUzERMA8GA1UECAwITWlkbGFuZHMxEjAQBgNVBAcMCVNhZmV2aWxsZTEWMBQGA1UECgwNSUQgRmVkZXJhdGlvbjEhMB8GA1UECwwYRGVwYXJ0bWVudCBvZiBJZGVudGl0aWVzMQ8wDQYDVQQDDAZteS5pZHAwHhcNMTcwOTA5MDExNzMwWhcNMTgwOTA5MDExNzMwWjCBgDELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pZGxhbmRzMRIwEAYDVQQHDAlTYWZldmlsbGUxFjAUBgNVBAoMDUlEIEZlZGVyYXRpb24xITAfBgNVBAsMGERlcGFydG1lbnQgb2YgSWRlbnRpdGllczEPMA0GA1UEAwwGbXkuaWRwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA5MXY8olt62YGPQQo4UIZ8Q48lMGGSA4DhEAooSA4yjXeKcmG/aDeINkcTzA/khv1iCy3gFDnJ6T6tGvNghWFDVLWrKRzg3PO0y3+6MaJAJ5CXtCaf+EV9KNFcrt8+DFwdudkWrwEak1gM8OHUfXvOqyD1a5g+vPrSm+QiI9+W4csTDB6zQKfMjnQJnUh+W8H4ZLPX2nJAeJLFhDfE1l66pjKHxVRl8QTiflpunMRNgTBknCUxs+3ilG+4QxXEgQdjzLJCc9SjgNEdqnJHd7BfUOsbIi5qjTAM3UkoumEdrLWNRCutDQoc5m79U9m+xcZso/z1qnrgPD6WkS6FiI4QFT8y/hjBEVbrXC5DZHu7ujeE8h2P2qOLjJszdW7SnvYFOshhMWD5JHkAn+Dzcch75pzvlsxxtmCw44IpCkeqjKeO2ajrQcx/fywSaCyme6gwmiF8hdXLrezj1sO0aFJhkEPM0Yb9JRkG2G3a8ITp4+3J8FCAXeLc7MJ/kxOJYVlNESSCzFhWYeSAR7doN9+eXCZ8dQhkOTN4FRT5u9ylq7tYHfAH1YNr2MKsEGrQtn/pTyb1Q4Oqb1IVpNnDRL2C9RPUt9Lfb8V3OHd1Mx81yoylXsvttX1DiPNY/KR/QCWQkvvfNTfUHTOGj8ic6G/t1/IenZ9RzNRPFxj4GNwHNcCAwEAAaNQME4wHQYDVR0OBBYEFJQbKzk3Twp4XgV0T6W0T5lZVFDZMB8GA1UdIwQYMBaAFJQbKzk3Twp4XgV0T6W0T5lZVFDZMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBALFPci1AfEp5bXuPWHbGSvAi+nVBzwHRE9VDIvsq1gVibmunwqFW7DlXi9xvPwrcQHVZtj2qeSWEc6Grw6iROgunclQIwaf6//uqb7Jkj9RhuhCs58CGcSBQ5umfffC/P2JbOte2hJjTTDNVvawAhPPBQ1bn/t8Y3eTHN/srQUtVBPX2szUqy5uRlpFYlZ2Layfcg3oLHg6Ube/HhEA2mjgCjod7zGtwuwsUOpjktQCp3aPLWXKN+ImcGAChpqAm9frgBWgZv4mxKynjOw15ur3wgSEJO7DNmEdjLqz2OiLHeX0MrDyGpcid5hPj4CgCN6vngeNXBEaoK2lJHp0I5HbdIoRBcghIVm9RzpfrDdK3/Ayy+wEAqjQRCuwBUEvrqsMnmnIjYw8HE+BE3pIS8hh9SyGCOgPJmHbYkwztkrNi6RJyeHROwAoER6J/D1saCKMK63bqLW5WLXDfftg6BNXXfvPjuQyaqlsP+HOjX8wTlKwemvUS93aDxwP8YdcFLt/uw/GXpmNKY+/wsymMYgXkTcS0FWgNUtpH/gZUIh3AMpCi1fBi71nc0uuLUBilRMrCHI5y1Rzi0qBfAkAKyUYbUO87KRvXQ3pjmpCC/IMPFqY4hkudY9juzIcOU2auml+wBSu1c45142wdz4x//oh0N5NrVuLBe74Auy4nVOfH 15 | 16 | 17 | 18 | 19 | 20 | urn:oasis:names:tc:SAML:2.0:nameid-format:transient 21 | 22 | 23 | 24 | 25 | Jane 26 | Doe 27 | jane.doe@company.com 28 | 29 | 30 | -------------------------------------------------------------------------------- /test/data/onelogin_idp_metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 10 | 11 | 12 | MIIDEDCCAfigAwIBAgIVALIKvj2cp9VIRuWNKjHwGiV1ITxfMA0GCSqGSIb3DQEB 13 | CwUAMBQxEjAQBgNVBAMMCXNhbWx5LmlkcDAeFw0xNzExMDcxNTE3NDNaFw0zNzEx 14 | MDcxNTE3NDNaMBQxEjAQBgNVBAMMCXNhbWx5LmlkcDCCASIwDQYJKoZIhvcNAQEB 15 | BQADggEPADCCAQoCggEBALmLey0ZWrMYz2O+CTTjr97UcDkaaUzbIfTjw2/0HofU 16 | czVl5b3ElzOjnB0pJ6xl8s27Qyctdq0EZrlmR9hHKUnF2U9v95rG005Cx+QQVdsg 17 | OaZjDZsmC7eLABQcLdfLP3f42dOozxCH9bBQcs+f/ndrumxdL2sIXflmer4mXfEg 18 | 57+HCRL3+s9y07fxdi2LB2ac5gVI8HJbIo8bPOeCyWLYc3UpSZUsxTZouK/wjft0 19 | TMNJ0gu5TCptiyxxZRhJcg6gm2L77d6rjbnax8fWqj/YNMlXkT7BagUxbPbEklAY 20 | YzIKnt6egw8SpOURgAJynZDl4cYxM1QynfIuWaYi5gECAwEAAaNZMFcwHQYDVR0O 21 | BBYEFJhsexKytkNruELg386zOyW1icH1MDYGA1UdEQQvMC2CCXNhbWx5LmlkcIYg 22 | aHR0cHM6Ly9zYW1seS5pZHAvaWRwL3NoaWJib2xldGgwDQYJKoZIhvcNAQELBQAD 23 | ggEBAAhCAuNPhWsrd/b3MSRK+I5GGe0eDSkpQiCT0ULbqucW+BHj0by0DOy6yP98 24 | 0mfATI6eDJ/LUpT+Wenxljujy5nh0EPu6t6RG/MvWTplnr0//m+41L8tQXEZtZkM 25 | NKkrFieiUBe+rcDx7xywzGUvM0qWRdTyD7Yb0JUU8bZKnIFAEZ+mm8Fu1KfXI6kK 26 | sdeh/6gdpah9v2mermegdNfGpktWtXOukvmR4M8ZYLEyAwGQQAuqJcUnOUwuVMFU 27 | chLUXbAfJUduUkGQ3WKw/SNKyv/7Z2ayr7wlkEA7fxIIrLaJzSm928y9wB9s7Irr 28 | 78rpJG67hSRlA+CGTGZyksrk2fE= 29 | 30 | 31 | 32 | 33 | 36 | 37 | urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress 38 | 39 | 42 | 45 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /test/data/shibboleth_idp_metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | example.org 14 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | MIIDEDCCAfigAwIBAgIVALIKvj2cp9VIRuWNKjHwGiV1ITxfMA0GCSqGSIb3DQEB 30 | CwUAMBQxEjAQBgNVBAMMCXNhbWx5LmlkcDAeFw0xNzExMDcxNTE3NDNaFw0zNzEx 31 | MDcxNTE3NDNaMBQxEjAQBgNVBAMMCXNhbWx5LmlkcDCCASIwDQYJKoZIhvcNAQEB 32 | BQADggEPADCCAQoCggEBALmLey0ZWrMYz2O+CTTjr97UcDkaaUzbIfTjw2/0HofU 33 | czVl5b3ElzOjnB0pJ6xl8s27Qyctdq0EZrlmR9hHKUnF2U9v95rG005Cx+QQVdsg 34 | OaZjDZsmC7eLABQcLdfLP3f42dOozxCH9bBQcs+f/ndrumxdL2sIXflmer4mXfEg 35 | 57+HCRL3+s9y07fxdi2LB2ac5gVI8HJbIo8bPOeCyWLYc3UpSZUsxTZouK/wjft0 36 | TMNJ0gu5TCptiyxxZRhJcg6gm2L77d6rjbnax8fWqj/YNMlXkT7BagUxbPbEklAY 37 | YzIKnt6egw8SpOURgAJynZDl4cYxM1QynfIuWaYi5gECAwEAAaNZMFcwHQYDVR0O 38 | BBYEFJhsexKytkNruELg386zOyW1icH1MDYGA1UdEQQvMC2CCXNhbWx5LmlkcIYg 39 | aHR0cHM6Ly9zYW1seS5pZHAvaWRwL3NoaWJib2xldGgwDQYJKoZIhvcNAQELBQAD 40 | ggEBAAhCAuNPhWsrd/b3MSRK+I5GGe0eDSkpQiCT0ULbqucW+BHj0by0DOy6yP98 41 | 0mfATI6eDJ/LUpT+Wenxljujy5nh0EPu6t6RG/MvWTplnr0//m+41L8tQXEZtZkM 42 | NKkrFieiUBe+rcDx7xywzGUvM0qWRdTyD7Yb0JUU8bZKnIFAEZ+mm8Fu1KfXI6kK 43 | sdeh/6gdpah9v2mermegdNfGpktWtXOukvmR4M8ZYLEyAwGQQAuqJcUnOUwuVMFU 44 | chLUXbAfJUduUkGQ3WKw/SNKyv/7Z2ayr7wlkEA7fxIIrLaJzSm928y9wB9s7Irr 45 | 78rpJG67hSRlA+CGTGZyksrk2fE= 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | MIIDDzCCAfegAwIBAgIUawrhfDAK6t2xrB0CCKKiLILXdUEwDQYJKoZIhvcNAQEL 56 | BQAwFDESMBAGA1UEAwwJc2FtbHkuaWRwMB4XDTE3MTEwNzE1MTczMVoXDTM3MTEw 57 | NzE1MTczMVowFDESMBAGA1UEAwwJc2FtbHkuaWRwMIIBIjANBgkqhkiG9w0BAQEF 58 | AAOCAQ8AMIIBCgKCAQEAgx/bOpdbzlR33T9ZgVkLWAYVTJvPnS6EbOouV9iSsrul 59 | 9Sg6kxrb3NK9HumFqalDxmwZH81snt+isgIUyIX0uZDEu0eBt++hGrLH4/gvZjQW 60 | Ow5ju1+dVOIt28Qy9+ExzWS4XEblId4m8xxNew2FlKKQwThYojuGH95FkMDo736A 61 | wLJNol7FY3BgZwcGajIDFQoAyBhUrfrScBvE/eEGmPyJPzIO7NmtrlPq5NmATi4W 62 | fG5U7I+dT6t3rasPbhbKf1xsN5dNOgHEYAZmp+wqMJ9t4GNDJgqt5Sftryd/zskk 63 | 9fPjk8MFll4XVJ9NGjg1AjUwS3swQBIK2xejK1zl2QIDAQABo1kwVzAdBgNVHQ4E 64 | FgQUs0m5/0iOU8Z9Rf7JrbfYd2EUrBowNgYDVR0RBC8wLYIJc2FtbHkuaWRwhiBo 65 | dHRwczovL3NhbWx5LmlkcC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0BAQsFAAOC 66 | AQEAYMBZvg8V9468Jn1mbMJ7YuOb1A8XFB5nuewpBzjnFoDZKRsUim6DUOAt/NYZ 67 | xWxaC7l8t70LdGskxaFgdE2+L7z7TibZRj2Ibc+CRg20O615rCR3C5fUdRv6Z4C/ 68 | pSu5yNPQz5NPWOI5ryFmbp9TCf+Yh8hwa49BY/pOIPSjGk5usJk9OVBSqwdJrBpp 69 | iO9wLLCB2z6ZFpK3LpF2DpGAewuJOzHaD8VfPoqqAcXnWR+Q263QOrfv+9GeFv8G 70 | odjFk9wMTYRX5iitBAank4vrE0USbovz30F+wK4VLxm/806Evh4wOPwkroxBomnc 71 | a/dmaqTz0EZ80cr5Le+54VhF/w== 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | MIIDDzCCAfegAwIBAgIUFjdO5mF6khL3fHOV3CcPpMD1juMwDQYJKoZIhvcNAQEL 82 | BQAwFDESMBAGA1UEAwwJc2FtbHkuaWRwMB4XDTE3MTEwNzE1MTczMloXDTM3MTEw 83 | NzE1MTczMlowFDESMBAGA1UEAwwJc2FtbHkuaWRwMIIBIjANBgkqhkiG9w0BAQEF 84 | AAOCAQ8AMIIBCgKCAQEA8X53oxFAwsIdsCltzcko0NgUg63NQtOxXVEoqmPpSKxX 85 | 8wvsXWa0/KLa5XOWuoxshc45yI0Tn9bxGxrzP90StABRIKSnbjs2k1sebXPbOHDM 86 | LPMyMcKq34qiIffg+KfPHiOajksB8gD1EPdu8hSTPuLsiJnCUrWtPB8s/IydEdTa 87 | o79Hk0cd8BQskB/l7PwuakPdsF6QRJj+kcFrBAkECzrET0LZahpIPKmSQpE3LQ08 88 | XxMLEoFJrhvt1Dwj4LTEbmjrJY+l+2HpJxXH9ibwVlJpTzqNAz81vC7Xad/c4oMg 89 | thnk1uhk73IqmeAgQA8FKFKCLwK7iFNJ8X/B99EfYwIDAQABo1kwVzAdBgNVHQ4E 90 | FgQU3jbqNvR4yPzb9R173XFB+E+VGCIwNgYDVR0RBC8wLYIJc2FtbHkuaWRwhiBo 91 | dHRwczovL3NhbWx5LmlkcC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0BAQsFAAOC 92 | AQEAHlcb/jlVDu8Lj4X6u/IAEiBzwtVjVLpH6X0GHWq1WU8IN4zAtPhsc4ELmtGj 93 | ZJNCZwD7eBi4Ibby6I3NCwvhafpRVB8+PCD/jGB53t/vV8VZII/Zpk/wgYTh11T+ 94 | TyOuW/JCckLeHLSwYPiDHmk3z9e8K9CVI/lhHvNXoEEfGtrIiGE8T00RhBAWvflv 95 | 2YATb9dsUGKXojAKi1xZpBToxQPllryjGqN+Big6IcR0Pv/IVOxVvcgU5ZOtYF0g 96 | HuK+YEeTSVMkW24+aC6185LocIKrUPg9d/kyUTi8WK047fctGv+7IdP5mbeGMRiU 97 | pgvjU5nUCkPOUOyZZiVO0VMEIA== 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | example.org 124 | 125 | 126 | 127 | 128 | 129 | 130 | MIIDEDCCAfigAwIBAgIVALIKvj2cp9VIRuWNKjHwGiV1ITxfMA0GCSqGSIb3DQEB 131 | CwUAMBQxEjAQBgNVBAMMCXNhbWx5LmlkcDAeFw0xNzExMDcxNTE3NDNaFw0zNzEx 132 | MDcxNTE3NDNaMBQxEjAQBgNVBAMMCXNhbWx5LmlkcDCCASIwDQYJKoZIhvcNAQEB 133 | BQADggEPADCCAQoCggEBALmLey0ZWrMYz2O+CTTjr97UcDkaaUzbIfTjw2/0HofU 134 | czVl5b3ElzOjnB0pJ6xl8s27Qyctdq0EZrlmR9hHKUnF2U9v95rG005Cx+QQVdsg 135 | OaZjDZsmC7eLABQcLdfLP3f42dOozxCH9bBQcs+f/ndrumxdL2sIXflmer4mXfEg 136 | 57+HCRL3+s9y07fxdi2LB2ac5gVI8HJbIo8bPOeCyWLYc3UpSZUsxTZouK/wjft0 137 | TMNJ0gu5TCptiyxxZRhJcg6gm2L77d6rjbnax8fWqj/YNMlXkT7BagUxbPbEklAY 138 | YzIKnt6egw8SpOURgAJynZDl4cYxM1QynfIuWaYi5gECAwEAAaNZMFcwHQYDVR0O 139 | BBYEFJhsexKytkNruELg386zOyW1icH1MDYGA1UdEQQvMC2CCXNhbWx5LmlkcIYg 140 | aHR0cHM6Ly9zYW1seS5pZHAvaWRwL3NoaWJib2xldGgwDQYJKoZIhvcNAQELBQAD 141 | ggEBAAhCAuNPhWsrd/b3MSRK+I5GGe0eDSkpQiCT0ULbqucW+BHj0by0DOy6yP98 142 | 0mfATI6eDJ/LUpT+Wenxljujy5nh0EPu6t6RG/MvWTplnr0//m+41L8tQXEZtZkM 143 | NKkrFieiUBe+rcDx7xywzGUvM0qWRdTyD7Yb0JUU8bZKnIFAEZ+mm8Fu1KfXI6kK 144 | sdeh/6gdpah9v2mermegdNfGpktWtXOukvmR4M8ZYLEyAwGQQAuqJcUnOUwuVMFU 145 | chLUXbAfJUduUkGQ3WKw/SNKyv/7Z2ayr7wlkEA7fxIIrLaJzSm928y9wB9s7Irr 146 | 78rpJG67hSRlA+CGTGZyksrk2fE= 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | MIIDDzCCAfegAwIBAgIUawrhfDAK6t2xrB0CCKKiLILXdUEwDQYJKoZIhvcNAQEL 157 | BQAwFDESMBAGA1UEAwwJc2FtbHkuaWRwMB4XDTE3MTEwNzE1MTczMVoXDTM3MTEw 158 | NzE1MTczMVowFDESMBAGA1UEAwwJc2FtbHkuaWRwMIIBIjANBgkqhkiG9w0BAQEF 159 | AAOCAQ8AMIIBCgKCAQEAgx/bOpdbzlR33T9ZgVkLWAYVTJvPnS6EbOouV9iSsrul 160 | 9Sg6kxrb3NK9HumFqalDxmwZH81snt+isgIUyIX0uZDEu0eBt++hGrLH4/gvZjQW 161 | Ow5ju1+dVOIt28Qy9+ExzWS4XEblId4m8xxNew2FlKKQwThYojuGH95FkMDo736A 162 | wLJNol7FY3BgZwcGajIDFQoAyBhUrfrScBvE/eEGmPyJPzIO7NmtrlPq5NmATi4W 163 | fG5U7I+dT6t3rasPbhbKf1xsN5dNOgHEYAZmp+wqMJ9t4GNDJgqt5Sftryd/zskk 164 | 9fPjk8MFll4XVJ9NGjg1AjUwS3swQBIK2xejK1zl2QIDAQABo1kwVzAdBgNVHQ4E 165 | FgQUs0m5/0iOU8Z9Rf7JrbfYd2EUrBowNgYDVR0RBC8wLYIJc2FtbHkuaWRwhiBo 166 | dHRwczovL3NhbWx5LmlkcC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0BAQsFAAOC 167 | AQEAYMBZvg8V9468Jn1mbMJ7YuOb1A8XFB5nuewpBzjnFoDZKRsUim6DUOAt/NYZ 168 | xWxaC7l8t70LdGskxaFgdE2+L7z7TibZRj2Ibc+CRg20O615rCR3C5fUdRv6Z4C/ 169 | pSu5yNPQz5NPWOI5ryFmbp9TCf+Yh8hwa49BY/pOIPSjGk5usJk9OVBSqwdJrBpp 170 | iO9wLLCB2z6ZFpK3LpF2DpGAewuJOzHaD8VfPoqqAcXnWR+Q263QOrfv+9GeFv8G 171 | odjFk9wMTYRX5iitBAank4vrE0USbovz30F+wK4VLxm/806Evh4wOPwkroxBomnc 172 | a/dmaqTz0EZ80cr5Le+54VhF/w== 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | MIIDDzCCAfegAwIBAgIUFjdO5mF6khL3fHOV3CcPpMD1juMwDQYJKoZIhvcNAQEL 183 | BQAwFDESMBAGA1UEAwwJc2FtbHkuaWRwMB4XDTE3MTEwNzE1MTczMloXDTM3MTEw 184 | NzE1MTczMlowFDESMBAGA1UEAwwJc2FtbHkuaWRwMIIBIjANBgkqhkiG9w0BAQEF 185 | AAOCAQ8AMIIBCgKCAQEA8X53oxFAwsIdsCltzcko0NgUg63NQtOxXVEoqmPpSKxX 186 | 8wvsXWa0/KLa5XOWuoxshc45yI0Tn9bxGxrzP90StABRIKSnbjs2k1sebXPbOHDM 187 | LPMyMcKq34qiIffg+KfPHiOajksB8gD1EPdu8hSTPuLsiJnCUrWtPB8s/IydEdTa 188 | o79Hk0cd8BQskB/l7PwuakPdsF6QRJj+kcFrBAkECzrET0LZahpIPKmSQpE3LQ08 189 | XxMLEoFJrhvt1Dwj4LTEbmjrJY+l+2HpJxXH9ibwVlJpTzqNAz81vC7Xad/c4oMg 190 | thnk1uhk73IqmeAgQA8FKFKCLwK7iFNJ8X/B99EfYwIDAQABo1kwVzAdBgNVHQ4E 191 | FgQU3jbqNvR4yPzb9R173XFB+E+VGCIwNgYDVR0RBC8wLYIJc2FtbHkuaWRwhiBo 192 | dHRwczovL3NhbWx5LmlkcC9pZHAvc2hpYmJvbGV0aDANBgkqhkiG9w0BAQsFAAOC 193 | AQEAHlcb/jlVDu8Lj4X6u/IAEiBzwtVjVLpH6X0GHWq1WU8IN4zAtPhsc4ELmtGj 194 | ZJNCZwD7eBi4Ibby6I3NCwvhafpRVB8+PCD/jGB53t/vV8VZII/Zpk/wgYTh11T+ 195 | TyOuW/JCckLeHLSwYPiDHmk3z9e8K9CVI/lhHvNXoEEfGtrIiGE8T00RhBAWvflv 196 | 2YATb9dsUGKXojAKi1xZpBToxQPllryjGqN+Big6IcR0Pv/IVOxVvcgU5ZOtYF0g 197 | HuK+YEeTSVMkW24+aC6185LocIKrUPg9d/kyUTi8WK047fctGv+7IdP5mbeGMRiU 198 | pgvjU5nUCkPOUOyZZiVO0VMEIA== 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /test/data/simplesaml_idp_metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 11 | 12 | 13 | MIIF1TCCA72gAwIBAgIJAKib45YfneRFMA0GCSqGSIb3DQEBCwUAMIGAMQswCQYDVQQGEwJVUzERMA8GA1UECAwITWlkbGFuZHMxEjAQBgNVBAcMCVNhZmV2aWxsZTEWMBQGA1UECgwNSUQgRmVkZXJhdGlvbjEhMB8GA1UECwwYRGVwYXJ0bWVudCBvZiBJZGVudGl0aWVzMQ8wDQYDVQQDDAZteS5pZHAwHhcNMTcwOTI5MjAyNDAxWhcNMTgwOTI5MjAyNDAxWjCBgDELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pZGxhbmRzMRIwEAYDVQQHDAlTYWZldmlsbGUxFjAUBgNVBAoMDUlEIEZlZGVyYXRpb24xITAfBgNVBAsMGERlcGFydG1lbnQgb2YgSWRlbnRpdGllczEPMA0GA1UEAwwGbXkuaWRwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtZ6sClsZ55ulmtcZYIf18FPd5Hwc/TF4J32fu6WY0J0R9zHbFo+2HiSHSwM8jFHVCbKbvOS0bspHSh40RI/hfdWCnGujbZTZ0z9NPCluziruerKr3lb7BVTw3YGAA1h7TsdrsFlosTYFcn4dJcXwrLXkCQyi5wXC4IcPzNrL1Wuved97oxAsLdKopyBjvYRTaxFgNh5dfxyxX8RU+LIpcNnE8ZvKUy3QeN1idN1mywYCIoNnXXVu+hDpden4WIHcDOj/upYE2RqdywE1yV4KlxUx3Wgpc3382vEFtRXDHABi/qwnxiN3H7GCB0LK3eduasaBLJek0D10ONO4kWxQDSqVjzISWXVylSHijFX1IuIJoEHr3CRiY/e5xRIgVRnMfnUQu+yv6Fjp/aN5gMJypi2QHAXNWAS7bUjAvnHTfCMz2oPZokCtmQSqfNzq3RFHQye/dcLI8f4IwZ5Te96NV7egle3owXYdxrdCgkGglwdLOkf469Z6MjniH0doGtIF/mDlvr1Fww2Gp9E9gblaaLj5pVxpTKfVnw1OPcpDwfXiK39+Pz2m0mMKhhYBg8WZN8dddigfGmlZhBgbMPrqr7tBHm+c99MktskZq23fvVtsif7ARsG26Sm3lkCb03T4zOvPmENRcM2VAD7C2I+2IseVElCyEOadku3WZULYIP8CAwEAAaNQME4wHQYDVR0OBBYEFNHR4yCVyK5oB1LGcD+qXxeYEgOmMB8GA1UdIwQYMBaAFNHR4yCVyK5oB1LGcD+qXxeYEgOmMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAFrx1ggzUb+BKpvxWU9Nypb+jJyuRPYpBbvCnNohUcm8hdEE2wwybkesX01CKIH8KMjNBRytq/1aoR2nwog4aPXHciZBM5/zX2tIzDdfk4x3rRR6IdEfsXkC919TuVM+Uj/A+VgGHbqtGxEt1+nqTy9jRCOuALuYbFt3cycgqH2WjJazUPbVlkx2KdjkOmSYi1IrxrW3HXjuDPgpQKMFfaXiS7DwKzN4U5DIfTnX476u3N5oTNHfWK/JsqtrfQ061uJGEMt4P8BaliAqHoHt289XHD03wAm/b2ajgJQoW/Hhkpz8gN785doKHjEeoEkZzFllUj7e69SmD7HWDWAFeO1OOpikgwuX93f+YYccTnUAznqN8r05t5WjqPypizhr193Qc5TlEm+FkzlfjzivltTLCv0aJDlRlrQVOrZL4Buh0E4weZFk97jKeghLO6qnwxID/jSptEeN5L69Q8QzOFWia7SFGszlvrl8Y/3CQxYCzRmtSZmqdY9a6taZZ/w433+Ktc6vOstjqRE67yq1pp+0Ee4haMB/E87hDuh3Drcq2tT+b9wLNNfhSTYIw4AGpW27xhPh65RouOi9ufGjyFs1ZqqEEmBMIJi95KhUCFokMRf8L29ijqQqpDve8EvEYkBa7KgzTq3R925D5G55orLXoG4Bfdq9FY35hmR8TbLC 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | MIIF1TCCA72gAwIBAgIJAKib45YfneRFMA0GCSqGSIb3DQEBCwUAMIGAMQswCQYDVQQGEwJVUzERMA8GA1UECAwITWlkbGFuZHMxEjAQBgNVBAcMCVNhZmV2aWxsZTEWMBQGA1UECgwNSUQgRmVkZXJhdGlvbjEhMB8GA1UECwwYRGVwYXJ0bWVudCBvZiBJZGVudGl0aWVzMQ8wDQYDVQQDDAZteS5pZHAwHhcNMTcwOTI5MjAyNDAxWhcNMTgwOTI5MjAyNDAxWjCBgDELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pZGxhbmRzMRIwEAYDVQQHDAlTYWZldmlsbGUxFjAUBgNVBAoMDUlEIEZlZGVyYXRpb24xITAfBgNVBAsMGERlcGFydG1lbnQgb2YgSWRlbnRpdGllczEPMA0GA1UEAwwGbXkuaWRwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtZ6sClsZ55ulmtcZYIf18FPd5Hwc/TF4J32fu6WY0J0R9zHbFo+2HiSHSwM8jFHVCbKbvOS0bspHSh40RI/hfdWCnGujbZTZ0z9NPCluziruerKr3lb7BVTw3YGAA1h7TsdrsFlosTYFcn4dJcXwrLXkCQyi5wXC4IcPzNrL1Wuved97oxAsLdKopyBjvYRTaxFgNh5dfxyxX8RU+LIpcNnE8ZvKUy3QeN1idN1mywYCIoNnXXVu+hDpden4WIHcDOj/upYE2RqdywE1yV4KlxUx3Wgpc3382vEFtRXDHABi/qwnxiN3H7GCB0LK3eduasaBLJek0D10ONO4kWxQDSqVjzISWXVylSHijFX1IuIJoEHr3CRiY/e5xRIgVRnMfnUQu+yv6Fjp/aN5gMJypi2QHAXNWAS7bUjAvnHTfCMz2oPZokCtmQSqfNzq3RFHQye/dcLI8f4IwZ5Te96NV7egle3owXYdxrdCgkGglwdLOkf469Z6MjniH0doGtIF/mDlvr1Fww2Gp9E9gblaaLj5pVxpTKfVnw1OPcpDwfXiK39+Pz2m0mMKhhYBg8WZN8dddigfGmlZhBgbMPrqr7tBHm+c99MktskZq23fvVtsif7ARsG26Sm3lkCb03T4zOvPmENRcM2VAD7C2I+2IseVElCyEOadku3WZULYIP8CAwEAAaNQME4wHQYDVR0OBBYEFNHR4yCVyK5oB1LGcD+qXxeYEgOmMB8GA1UdIwQYMBaAFNHR4yCVyK5oB1LGcD+qXxeYEgOmMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAFrx1ggzUb+BKpvxWU9Nypb+jJyuRPYpBbvCnNohUcm8hdEE2wwybkesX01CKIH8KMjNBRytq/1aoR2nwog4aPXHciZBM5/zX2tIzDdfk4x3rRR6IdEfsXkC919TuVM+Uj/A+VgGHbqtGxEt1+nqTy9jRCOuALuYbFt3cycgqH2WjJazUPbVlkx2KdjkOmSYi1IrxrW3HXjuDPgpQKMFfaXiS7DwKzN4U5DIfTnX476u3N5oTNHfWK/JsqtrfQ061uJGEMt4P8BaliAqHoHt289XHD03wAm/b2ajgJQoW/Hhkpz8gN785doKHjEeoEkZzFllUj7e69SmD7HWDWAFeO1OOpikgwuX93f+YYccTnUAznqN8r05t5WjqPypizhr193Qc5TlEm+FkzlfjzivltTLCv0aJDlRlrQVOrZL4Buh0E4weZFk97jKeghLO6qnwxID/jSptEeN5L69Q8QzOFWia7SFGszlvrl8Y/3CQxYCzRmtSZmqdY9a6taZZ/w433+Ktc6vOstjqRE67yq1pp+0Ee4haMB/E87hDuh3Drcq2tT+b9wLNNfhSTYIw4AGpW27xhPh65RouOi9ufGjyFs1ZqqEEmBMIJi95KhUCFokMRf8L29ijqQqpDve8EvEYkBa7KgzTq3R925D5G55orLXoG4Bfdq9FY35hmR8TbLC 22 | 23 | 24 | 25 | 26 | 27 | urn:oasis:names:tc:SAML:2.0:nameid-format:transient 28 | 29 | 30 | 31 | 32 | Jane 33 | Doe 34 | jane.doe@company.com 35 | 36 | 37 | -------------------------------------------------------------------------------- /test/data/test.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | 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 32 | 4WBwLGcoQsXB27G5lTXNqELSN/iVF/A= 33 | -----END CERTIFICATE----- 34 | -------------------------------------------------------------------------------- /test/data/test.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | qpuhVP1fFzr1d26vUtG9IBvI2PKrFB7YMg== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /test/data/testshib_metadata.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | testshib.org 27 | 28 | TestShib Test IdP 29 | TestShib IdP. Use this as a source of attributes 30 | for your test SP. 31 | https://www.testshib.org/testshibtwo.jpg 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | MIIDAzCCAeugAwIBAgIVAPX0G6LuoXnKS0Muei006mVSBXbvMA0GCSqGSIb3DQEB 43 | CwUAMBsxGTAXBgNVBAMMEGlkcC50ZXN0c2hpYi5vcmcwHhcNMTYwODIzMjEyMDU0 44 | WhcNMzYwODIzMjEyMDU0WjAbMRkwFwYDVQQDDBBpZHAudGVzdHNoaWIub3JnMIIB 45 | IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAg9C4J2DiRTEhJAWzPt1S3ryh 46 | m3M2P3hPpwJwvt2q948vdTUxhhvNMuc3M3S4WNh6JYBs53R+YmjqJAII4ShMGNEm 47 | lGnSVfHorex7IxikpuDPKV3SNf28mCAZbQrX+hWA+ann/uifVzqXktOjs6DdzdBn 48 | xoVhniXgC8WCJwKcx6JO/hHsH1rG/0DSDeZFpTTcZHj4S9MlLNUtt5JxRzV/MmmB 49 | 3ObaX0CMqsSWUOQeE4nylSlp5RWHCnx70cs9kwz5WrflnbnzCeHU2sdbNotBEeTH 50 | ot6a2cj/pXlRJIgPsrL/4VSicPZcGYMJMPoLTJ8mdy6mpR6nbCmP7dVbCIm/DQID 51 | AQABoz4wPDAdBgNVHQ4EFgQUUfaDa2mPi24x09yWp1OFXmZ2GPswGwYDVR0RBBQw 52 | EoIQaWRwLnRlc3RzaGliLm9yZzANBgkqhkiG9w0BAQsFAAOCAQEASKKgqTxhqBzR 53 | OZ1eVy++si+eTTUQZU4+8UywSKLia2RattaAPMAcXUjO+3cYOQXLVASdlJtt+8QP 54 | dRkfp8SiJemHPXC8BES83pogJPYEGJsKo19l4XFJHPnPy+Dsn3mlJyOfAa8RyWBS 55 | 80u5lrvAcr2TJXt9fXgkYs7BOCigxtZoR8flceGRlAZ4p5FPPxQR6NDYb645jtOT 56 | MVr3zgfjP6Wh2dt+2p04LG7ENJn8/gEwtXVuXCsPoSCDx9Y0QmyXTJNdV1aB0AhO 57 | RkWPlFYwp+zOyOIR+3m1+pqWFpn0eT/HrxpdKa74FA3R2kq4R7dXe4G0kUgXTdqX 58 | MLRKhDgdmA== 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 73 | 76 | 77 | urn:mace:shibboleth:1.0:nameIdentifier 78 | urn:oasis:names:tc:SAML:2.0:nameid-format:transient 79 | 80 | 82 | 84 | 86 | 88 | 89 | 90 | 91 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | MIIDAzCCAeugAwIBAgIVAPX0G6LuoXnKS0Muei006mVSBXbvMA0GCSqGSIb3DQEB 100 | CwUAMBsxGTAXBgNVBAMMEGlkcC50ZXN0c2hpYi5vcmcwHhcNMTYwODIzMjEyMDU0 101 | WhcNMzYwODIzMjEyMDU0WjAbMRkwFwYDVQQDDBBpZHAudGVzdHNoaWIub3JnMIIB 102 | IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAg9C4J2DiRTEhJAWzPt1S3ryh 103 | m3M2P3hPpwJwvt2q948vdTUxhhvNMuc3M3S4WNh6JYBs53R+YmjqJAII4ShMGNEm 104 | lGnSVfHorex7IxikpuDPKV3SNf28mCAZbQrX+hWA+ann/uifVzqXktOjs6DdzdBn 105 | xoVhniXgC8WCJwKcx6JO/hHsH1rG/0DSDeZFpTTcZHj4S9MlLNUtt5JxRzV/MmmB 106 | 3ObaX0CMqsSWUOQeE4nylSlp5RWHCnx70cs9kwz5WrflnbnzCeHU2sdbNotBEeTH 107 | ot6a2cj/pXlRJIgPsrL/4VSicPZcGYMJMPoLTJ8mdy6mpR6nbCmP7dVbCIm/DQID 108 | AQABoz4wPDAdBgNVHQ4EFgQUUfaDa2mPi24x09yWp1OFXmZ2GPswGwYDVR0RBBQw 109 | EoIQaWRwLnRlc3RzaGliLm9yZzANBgkqhkiG9w0BAQsFAAOCAQEASKKgqTxhqBzR 110 | OZ1eVy++si+eTTUQZU4+8UywSKLia2RattaAPMAcXUjO+3cYOQXLVASdlJtt+8QP 111 | dRkfp8SiJemHPXC8BES83pogJPYEGJsKo19l4XFJHPnPy+Dsn3mlJyOfAa8RyWBS 112 | 80u5lrvAcr2TJXt9fXgkYs7BOCigxtZoR8flceGRlAZ4p5FPPxQR6NDYb645jtOT 113 | MVr3zgfjP6Wh2dt+2p04LG7ENJn8/gEwtXVuXCsPoSCDx9Y0QmyXTJNdV1aB0AhO 114 | RkWPlFYwp+zOyOIR+3m1+pqWFpn0eT/HrxpdKa74FA3R2kq4R7dXe4G0kUgXTdqX 115 | MLRKhDgdmA== 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 129 | 131 | 132 | urn:mace:shibboleth:1.0:nameIdentifier 133 | urn:oasis:names:tc:SAML:2.0:nameid-format:transient 134 | 135 | 136 | 137 | 138 | TestShib Two Identity Provider 139 | TestShib Two 140 | http://www.testshib.org/testshib-two/ 141 | 142 | 143 | Nate 144 | Klingenstein 145 | ndk@internet2.edu 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | TestShib Test SP 185 | TestShib SP. Log into this to test your machine. 186 | Once logged in check that all attributes that you expected have been 187 | released. 188 | https://www.testshib.org/testshibtwo.jpg 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | MIIEPjCCAyagAwIBAgIBADANBgkqhkiG9w0BAQUFADB3MQswCQYDVQQGEwJVUzEV 197 | MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMSIwIAYD 198 | VQQKExlUZXN0U2hpYiBTZXJ2aWNlIFByb3ZpZGVyMRgwFgYDVQQDEw9zcC50ZXN0 199 | c2hpYi5vcmcwHhcNMDYwODMwMjEyNDM5WhcNMTYwODI3MjEyNDM5WjB3MQswCQYD 200 | VQQGEwJVUzEVMBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1 201 | cmdoMSIwIAYDVQQKExlUZXN0U2hpYiBTZXJ2aWNlIFByb3ZpZGVyMRgwFgYDVQQD 202 | Ew9zcC50ZXN0c2hpYi5vcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB 203 | AQDJyR6ZP6MXkQ9z6RRziT0AuCabDd3x1m7nLO9ZRPbr0v1LsU+nnC363jO8nGEq 204 | sqkgiZ/bSsO5lvjEt4ehff57ERio2Qk9cYw8XCgmYccVXKH9M+QVO1MQwErNobWb 205 | AjiVkuhWcwLWQwTDBowfKXI87SA7KR7sFUymNx5z1aoRvk3GM++tiPY6u4shy8c7 206 | vpWbVfisfTfvef/y+galxjPUQYHmegu7vCbjYP3On0V7/Ivzr+r2aPhp8egxt00Q 207 | XpilNai12LBYV3Nv/lMsUzBeB7+CdXRVjZOHGuQ8mGqEbsj8MBXvcxIKbcpeK5Zi 208 | JCVXPfarzuriM1G5y5QkKW+LAgMBAAGjgdQwgdEwHQYDVR0OBBYEFKB6wPDxwYrY 209 | StNjU5P4b4AjBVQVMIGhBgNVHSMEgZkwgZaAFKB6wPDxwYrYStNjU5P4b4AjBVQV 210 | oXukeTB3MQswCQYDVQQGEwJVUzEVMBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYD 211 | VQQHEwpQaXR0c2J1cmdoMSIwIAYDVQQKExlUZXN0U2hpYiBTZXJ2aWNlIFByb3Zp 212 | ZGVyMRgwFgYDVQQDEw9zcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN 213 | BgkqhkiG9w0BAQUFAAOCAQEAc06Kgt7ZP6g2TIZgMbFxg6vKwvDL0+2dzF11Onpl 214 | 5sbtkPaNIcj24lQ4vajCrrGKdzHXo9m54BzrdRJ7xDYtw0dbu37l1IZVmiZr12eE 215 | Iay/5YMU+aWP1z70h867ZQ7/7Y4HW345rdiS6EW663oH732wSYNt9kr7/0Uer3KD 216 | 9CuPuOidBacospDaFyfsaJruE99Kd6Eu/w5KLAGG+m0iqENCziDGzVA47TngKz2v 217 | PVA+aokoOyoz3b53qeti77ijatSEoKjxheBWpO+eoJeGq/e49Um3M2ogIX/JAlMa 218 | Inh+vYSYngQB2sx9LGkR9KHaMKNIGCDehk93Xla4pWJx1w== 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 237 | 239 | 241 | 243 | 244 | 245 | 246 | urn:oasis:names:tc:SAML:2.0:nameid-format:transient 247 | urn:mace:shibboleth:1.0:nameIdentifier 248 | 249 | 255 | 256 | 259 | 262 | 265 | 268 | 271 | 274 | 275 | 276 | 277 | 280 | 283 | 284 | 285 | 286 | 287 | 288 | TestShib Two Service Provider 289 | TestShib Two 290 | http://www.testshib.org/testshib-two/ 291 | 292 | 293 | Nate 294 | Klingenstein 295 | ndk@internet2.edu 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | -------------------------------------------------------------------------------- /test/samly_idp_data_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SamlyIdpDataTest do 2 | use ExUnit.Case 3 | require Samly.Esaml 4 | alias Samly.{Esaml, IdpData, SpData} 5 | 6 | @sp_config1 %{ 7 | id: "sp1", 8 | entity_id: "urn:test:sp1", 9 | certfile: "test/data/test.crt", 10 | keyfile: "test/data/test.pem" 11 | } 12 | 13 | @sp_config2 %{ 14 | id: "sp2", 15 | certfile: "test/data/test.crt", 16 | keyfile: "test/data/test.pem" 17 | } 18 | 19 | @sp_config3 %{ 20 | id: "sp3", 21 | keyfile: "test/data/test.pem" 22 | } 23 | 24 | @sp_config4 %{ 25 | id: "sp4", 26 | certfile: "test/data/test.crt" 27 | } 28 | 29 | @sp_config5 %{ 30 | id: "sp5" 31 | } 32 | 33 | @idp_config1 %{ 34 | id: "idp1", 35 | sp_id: "sp1", 36 | base_url: "http://samly.howto:4003/sso", 37 | metadata_file: "test/data/idp_metadata.xml" 38 | } 39 | 40 | @idp_config2 %{ 41 | id: "idp2", 42 | sp_id: "sp2", 43 | base_url: "http://samly.howto:4003/sso", 44 | metadata_file: "test/data/idp_metadata.xml" 45 | } 46 | 47 | setup context do 48 | sp_data1 = SpData.load_provider(@sp_config1) 49 | sp_data2 = SpData.load_provider(@sp_config2) 50 | sp_data3 = SpData.load_provider(@sp_config3) 51 | sp_data4 = SpData.load_provider(@sp_config4) 52 | sp_data5 = SpData.load_provider(@sp_config5) 53 | 54 | [ 55 | sps: %{ 56 | sp_data1.id => sp_data1, 57 | sp_data2.id => sp_data2, 58 | sp_data3.id => sp_data3, 59 | sp_data4.id => sp_data4, 60 | sp_data5.id => sp_data5 61 | } 62 | ] 63 | |> Enum.into(context) 64 | end 65 | 66 | test "valid-idp-config-1", %{sps: sps} do 67 | %IdpData{} = idp_data = IdpData.load_provider(@idp_config1, sps) 68 | assert idp_data.valid? 69 | end 70 | 71 | # verify defaults 72 | test "valid-idp-config-2", %{sps: sps} do 73 | %IdpData{} = idp_data = IdpData.load_provider(@idp_config1, sps) 74 | refute idp_data.use_redirect_for_req 75 | assert idp_data.sign_requests 76 | assert idp_data.sign_metadata 77 | assert idp_data.signed_assertion_in_resp 78 | assert idp_data.signed_envelopes_in_resp 79 | end 80 | 81 | test "valid-idp-config-3", %{sps: sps} do 82 | idp_config = 83 | Map.merge(@idp_config1, %{ 84 | use_redirect_for_req: false, 85 | sign_requests: true, 86 | sign_metadata: true, 87 | signed_assertion_in_resp: true, 88 | signed_envelopes_in_resp: true 89 | }) 90 | 91 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 92 | refute idp_data.use_redirect_for_req 93 | assert idp_data.sign_requests 94 | assert idp_data.sign_metadata 95 | assert idp_data.signed_assertion_in_resp 96 | assert idp_data.signed_envelopes_in_resp 97 | end 98 | 99 | test "valid-idp-config-4", %{sps: sps} do 100 | idp_config = 101 | Map.merge(@idp_config1, %{ 102 | use_redirect_for_req: true, 103 | sign_requests: false, 104 | sign_metadata: false, 105 | signed_assertion_in_resp: false, 106 | signed_envelopes_in_resp: false 107 | }) 108 | 109 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 110 | assert idp_data.use_redirect_for_req 111 | refute idp_data.sign_requests 112 | refute idp_data.sign_metadata 113 | refute idp_data.signed_assertion_in_resp 114 | refute idp_data.signed_envelopes_in_resp 115 | end 116 | 117 | test "valid-idp-config-5", %{sps: sps} do 118 | idp_config = %{@idp_config1 | base_url: nil} 119 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 120 | assert idp_data.valid? 121 | assert idp_data.base_url == nil 122 | end 123 | 124 | test "valid-idp-config-6", %{sps: sps} do 125 | idp_config = Map.put(@idp_config1, :pre_session_create_pipeline, MyPipeline) 126 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 127 | assert idp_data.valid? 128 | assert idp_data.pre_session_create_pipeline == MyPipeline 129 | end 130 | 131 | test "valid-idp-config-7", %{sps: sps} do 132 | idp_config = %{@idp_config1 | metadata_file: "test/data/azure_fed_metadata.xml"} 133 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 134 | assert idp_data.valid? 135 | end 136 | 137 | test "valid-idp-config-8", %{sps: sps} do 138 | idp_config = %{@idp_config1 | metadata_file: "test/data/onelogin_idp_metadata.xml"} 139 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 140 | assert idp_data.valid? 141 | end 142 | 143 | test "valid-idp-config-9", %{sps: sps} do 144 | idp_config = %{@idp_config1 | metadata_file: "test/data/shibboleth_idp_metadata.xml"} 145 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 146 | assert idp_data.valid? 147 | end 148 | 149 | test "valid-idp-config-10", %{sps: sps} do 150 | idp_config = %{@idp_config1 | metadata_file: "test/data/simplesaml_idp_metadata.xml"} 151 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 152 | assert idp_data.valid? 153 | end 154 | 155 | test "valid-idp-config-11", %{sps: sps} do 156 | idp_config = %{@idp_config1 | metadata_file: "test/data/testshib_metadata.xml"} 157 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 158 | assert idp_data.valid? 159 | end 160 | 161 | test "url-test-1", %{sps: sps} do 162 | idp_config = %{@idp_config1 | metadata_file: "test/data/shibboleth_idp_metadata.xml"} 163 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 164 | assert idp_data.valid? 165 | 166 | Esaml.esaml_idp_metadata( 167 | login_location: sso_url, 168 | logout_location: slo_url 169 | ) = idp_data.esaml_idp_rec 170 | 171 | assert sso_url |> List.to_string() |> String.ends_with?("/SAML2/POST/SSO") 172 | assert slo_url |> List.to_string() |> String.ends_with?("/SAML2/POST/SLO") 173 | end 174 | 175 | test "url-test-2", %{sps: sps} do 176 | idp_config = %{@idp_config1 | metadata_file: "test/data/shibboleth_idp_metadata.xml"} 177 | idp_config = Map.put(idp_config, :use_redirect_for_req, true) 178 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 179 | assert idp_data.valid? 180 | 181 | Esaml.esaml_idp_metadata( 182 | login_location: sso_url, 183 | logout_location: slo_url 184 | ) = idp_data.esaml_idp_rec 185 | 186 | assert sso_url |> List.to_string() |> String.ends_with?("/SAML2/Redirect/SSO") 187 | assert slo_url |> List.to_string() |> String.ends_with?("/SAML2/Redirect/SLO") 188 | end 189 | 190 | test "sp entity_id test-1", %{sps: sps} do 191 | %IdpData{} = idp_data = IdpData.load_provider(@idp_config2, sps) 192 | assert idp_data.valid? 193 | Esaml.esaml_sp(entity_id: entity_id) = idp_data.esaml_sp_rec 194 | assert entity_id == :undefined 195 | end 196 | 197 | @tag :skip 198 | test "invalid-idp-config-1", %{sps: sps} do 199 | idp_config = %{@idp_config1 | id: ""} 200 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 201 | refute idp_data.valid? 202 | end 203 | 204 | test "invalid-idp-config-2", %{sps: sps} do 205 | idp_config = %{@idp_config1 | sp_id: "unknown-sp"} 206 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 207 | refute idp_data.valid? 208 | end 209 | 210 | test "valid-idp-config-signing-turned-off", %{sps: sps} do 211 | idp_config = 212 | Map.merge(@idp_config1, %{ 213 | sp_id: "sp5", 214 | use_redirect_for_req: true, 215 | sign_requests: false, 216 | sign_metadata: false 217 | }) 218 | 219 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 220 | assert idp_data.valid? 221 | end 222 | 223 | test "invalid-idp-config-signing-on-cert-missing", %{sps: sps} do 224 | idp_config = 225 | Map.merge(@idp_config1, %{ 226 | sp_id: "sp3", 227 | use_redirect_for_req: true, 228 | sign_requests: true, 229 | sign_metadata: false 230 | }) 231 | 232 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 233 | refute idp_data.valid? 234 | end 235 | 236 | test "invalid-idp-config-signing-on-key-missing", %{sps: sps} do 237 | idp_config = 238 | Map.merge(@idp_config1, %{ 239 | sp_id: "sp4", 240 | use_redirect_for_req: true, 241 | sign_requests: true, 242 | sign_metadata: false 243 | }) 244 | 245 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 246 | refute idp_data.valid? 247 | end 248 | 249 | test "nameid-format-in-metadata-but-not-config-should-use-metadata", %{sps: sps} do 250 | %IdpData{} = idp_data = IdpData.load_provider(@idp_config1, sps) 251 | assert idp_data.nameid_format == 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' 252 | end 253 | 254 | test "nameid-format-in-config-but-not-metadata-should-use-config", %{sps: sps} do 255 | idp_config = 256 | Map.merge(@idp_config1, %{ 257 | metadata_file: "test/data/shibboleth_idp_metadata.xml", 258 | nameid_format: :email 259 | }) 260 | 261 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 262 | assert idp_data.nameid_format == 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' 263 | end 264 | 265 | test "nameid-format-in-metadata-and-config-should-use-config", %{sps: sps} do 266 | idp_config = 267 | Map.merge(@idp_config1, %{ 268 | nameid_format: :persistent 269 | }) 270 | 271 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 272 | assert idp_data.nameid_format == 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' 273 | end 274 | 275 | test "nameid-format-in-neither-metadata-nor-config-should-be-unknown", %{sps: sps} do 276 | idp_config = 277 | Map.merge(@idp_config1, %{ 278 | metadata_file: "test/data/shibboleth_idp_metadata.xml" 279 | }) 280 | 281 | %IdpData{} = idp_data = IdpData.load_provider(idp_config, sps) 282 | assert idp_data.nameid_format == :unknown 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /test/samly_sp_data_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SamlySpDataTest do 2 | use ExUnit.Case 3 | alias Samly.SpData 4 | 5 | @sp_config1 %{ 6 | id: "sp1", 7 | entity_id: "urn:test:sp1", 8 | certfile: "test/data/test.crt", 9 | keyfile: "test/data/test.pem" 10 | } 11 | 12 | test "valid-sp-config-1" do 13 | %SpData{} = sp_data = SpData.load_provider(@sp_config1) 14 | assert sp_data.valid? 15 | end 16 | 17 | test "cert-unspecified-sp-config" do 18 | sp_config = %{@sp_config1 | certfile: ""} 19 | %SpData{cert: :undefined} = sp_data = SpData.load_provider(sp_config) 20 | assert sp_data.valid? 21 | end 22 | 23 | test "key-unspecified-sp-config" do 24 | sp_config = %{@sp_config1 | keyfile: ""} 25 | %SpData{key: :undefined} = sp_data = SpData.load_provider(sp_config) 26 | assert sp_data.valid? 27 | end 28 | 29 | test "invalid-sp-config-1" do 30 | sp_config = %{@sp_config1 | id: ""} 31 | %SpData{} = sp_data = SpData.load_provider(sp_config) 32 | refute sp_data.valid? 33 | end 34 | 35 | test "invalid-sp-config-2" do 36 | sp_config = %{@sp_config1 | certfile: "non-existent.crt"} 37 | %SpData{} = sp_data = SpData.load_provider(sp_config) 38 | refute sp_data.valid? 39 | end 40 | 41 | test "invalid-sp-config-3" do 42 | sp_config = %{@sp_config1 | keyfile: "non-existent.pem"} 43 | %SpData{} = sp_data = SpData.load_provider(sp_config) 44 | refute sp_data.valid? 45 | end 46 | 47 | test "invalid-sp-config-4" do 48 | sp_config = %{@sp_config1 | certfile: "test/data/test.pem"} 49 | %SpData{} = sp_data = SpData.load_provider(sp_config) 50 | refute sp_data.valid? 51 | end 52 | 53 | @tag :skip 54 | test "invalid-sp-config-5" do 55 | sp_config = %{@sp_config1 | keyfile: "test/data/test.crt"} 56 | %SpData{} = sp_data = SpData.load_provider(sp_config) 57 | refute sp_data.valid? 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/samly_state_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Samly.StateTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | setup do 6 | opts = 7 | Plug.Session.init( 8 | store: :cookie, 9 | key: "_samly_state_test_session", 10 | encryption_salt: "salty enc", 11 | signing_salt: "salty signing", 12 | key_length: 64 13 | ) 14 | 15 | Samly.State.init(Samly.State.Session) 16 | 17 | conn = 18 | conn(:get, "/") 19 | |> Plug.Session.call(opts) 20 | |> fetch_session() 21 | 22 | [conn: conn] 23 | end 24 | 25 | test "put/get assertion", %{conn: conn} do 26 | assertion = %Samly.Assertion{} 27 | assertion_key = {"idp1", "name1"} 28 | conn = Samly.State.put_assertion(conn, assertion_key, assertion) 29 | assert assertion == Samly.State.get_assertion(conn, assertion_key) 30 | end 31 | 32 | test "get failure for unknown assertion key", %{conn: conn} do 33 | assertion = %Samly.Assertion{} 34 | assertion_key = {"idp1", "name1"} 35 | conn = Samly.State.put_assertion(conn, assertion_key, assertion) 36 | assert nil == Samly.State.get_assertion(conn, {"idp1", "name2"}) 37 | end 38 | 39 | test "delete assertion", %{conn: conn} do 40 | assertion = %Samly.Assertion{} 41 | assertion_key = {"idp1", "name1"} 42 | conn = Samly.State.put_assertion(conn, assertion_key, assertion) 43 | assert assertion == Samly.State.get_assertion(conn, assertion_key) 44 | conn = Samly.State.delete_assertion(conn, assertion_key) 45 | assert nil == Samly.State.get_assertion(conn, assertion_key) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------