├── .github └── workflows │ └── ci-functional-perl.yml ├── LICENSE ├── NOTICE ├── README.md ├── frontend.conf ├── saml_sp.js ├── saml_sp.server_conf ├── saml_sp_configuration.conf └── t └── js_saml.t /.github/workflows/ci-functional-perl.yml: -------------------------------------------------------------------------------- 1 | name: CI for NJS-based SAML Implementation 2 | run-name: ${{ github.actor }} is triggering pipeline 3 | 4 | on: 5 | push: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test-njs-saml: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Install prerequisites 16 | run: | 17 | sudo apt-get update 18 | sudo apt-get install -y apt-transport-https lsb-release apt-utils ubuntu-keyring gnupg2 \ 19 | ca-certificates wget mercurial 20 | 21 | - name: Prepare keys and certificates 22 | run: | 23 | sudo mkdir /etc/ssl/nginx 24 | echo '${{ secrets.NGINX_REPO_CRT }}' | sudo tee /etc/ssl/nginx/nginx-repo.crt > /dev/null 25 | echo '${{ secrets.NGINX_REPO_KEY }}' | sudo tee /etc/ssl/nginx/nginx-repo.key > /dev/null 26 | 27 | - name: Prepare NGINX Plus license token 28 | run: | 29 | echo '${{ secrets.NGINX_LIC }}' | tee $RUNNER_TEMP/lic > /dev/null 30 | 31 | - name: Configure NGINX Plus repository 32 | run: | 33 | wget --certificate=/etc/ssl/nginx/nginx-repo.crt --private-key=/etc/ssl/nginx/nginx-repo.key \ 34 | https://pkgs-test.nginx.com/keys/nginx_test_signing.key 35 | sudo gpg --no-default-keyring --keyring /usr/share/keyrings/nginx_test_signing.gpg \ 36 | --import nginx_test_signing.key 37 | echo "Acquire::https::pkgs-test.nginx.com::Verify-Peer \"true\";" | sudo tee -a /etc/apt/apt.conf.d/90nginx 38 | echo "Acquire::https::pkgs-test.nginx.com::Verify-Host \"true\";" | sudo tee -a /etc/apt/apt.conf.d/90nginx 39 | echo "Acquire::https::pkgs-test.nginx.com::SslCert \"/etc/ssl/nginx/nginx-repo.crt\";" \ 40 | | sudo tee -a /etc/apt/apt.conf.d/90nginx 41 | echo "Acquire::https::pkgs-test.nginx.com::SslKey \"/etc/ssl/nginx/nginx-repo.key\";" \ 42 | | sudo tee -a /etc/apt/apt.conf.d/90nginx 43 | printf "deb [signed-by=/usr/share/keyrings/nginx_test_signing.gpg] \ 44 | https://pkgs-test.nginx.com/nightly/ubuntu $(lsb_release -cs) nginx-plus\n" \ 45 | | sudo tee /etc/apt/sources.list.d/nginx-plus.list 46 | 47 | - name: Install NGINX Plus 48 | run: | 49 | sudo apt-get update 50 | sudo apt-get install -y nginx-plus nginx-plus-module-njs 51 | 52 | - name: Install required Perl modules 53 | run: | 54 | sudo apt-get install -y perl libxml-libxml-perl libdatetime-perl libcrypt-openssl-x509-perl \ 55 | libcrypt-openssl-rsa-perl 56 | 57 | - name: Checkout nginx-test 58 | run: | 59 | git clone https://github.com/nginx/nginx-tests.git 60 | 61 | - name: Run tests 62 | working-directory: t 63 | run: | 64 | PERL5LIB=../nginx-tests/lib TEST_NGINX_BINARY=/usr/sbin/nginx TEST_NGINX_VERBOSE=1 \ 65 | TEST_NGINX_GLOBALS="load_module /etc/nginx/modules/ngx_http_js_module-debug.so; mgmt {license_token $RUNNER_TEMP/lic;}" \ 66 | prove -v . 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | 2 | NGINX SAML SSO. 3 | 4 | Copyright 2017-2023 NGINX, Inc. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SAML SSO support for NGINX Plus 2 | 3 | Reference implementation of NGINX Plus as service provider for SAML authentication 4 | 5 | # Table of contents 6 | - [SAML SSO support for NGINX Plus](#saml-sso-support-for-nginx-plus) 7 | - [Table of contents](#table-of-contents) 8 | - [Description](#description) 9 | - [SAML Authentication Request](#saml-authentication-request) 10 | - [SAML Authentication Response](#saml-authentication-response) 11 | - [Response](#response) 12 | - [Issuer](#issuer) 13 | - [Status](#status) 14 | - [Assertion](#assertion) 15 | - [Subject](#subject) 16 | - [Conditions](#conditions) 17 | - [Audience](#audience) 18 | - [AuthnStatement](#authnstatement) 19 | - [AttributeStatement](#attributestatement) 20 | - [Response or Assertion Signature](#response-or-assertion-signature) 21 | - [Encrypted Assertion or NameID elements](#encrypted-assertion-or-nameid-elements) 22 | - [Redirect user after successful login](#redirect-user-after-successful-login) 23 | - [SAML Single Logout](#saml-single-logout) 24 | - [SP-Initiated Logout](#sp-initiated-logout) 25 | - [Sending LogoutRequest](#sending-logoutrequest) 26 | - [Receiving LogoutResponse](#receiving-logoutresponse) 27 | - [IdP-Initiated Logout](#idp-initiated-logout) 28 | - [Receiving LogoutRequest](#receiving-logoutrequest) 29 | - [Sending LogoutResponse](#sending-logoutresponse) 30 | - [Disabling Single Logout (SLO)](#disabling-single-logout-slo) 31 | - [Installation](#installation) 32 | - [Non-standard directories](#non-standard-directories) 33 | - [Configuring NGINX Plus](#configuring-nginx-plus) 34 | - [Configuring the Key-Value Store](#configuring-the-key-value-store) 35 | 36 | # Description 37 | 38 | This project provides an implementation of SAML Single Sign-On (SSO) for NGINX Plus. It enables NGINX Plus to act as a SAML Service Provider (SP), allowing it to participate in SSO with a SAML Identity Provider (IdP). 39 | This repository describes how to enable SAML Single Sign-On (SSO) integration for [NGINX Plus](https://www.nginx.com/products/nginx/). The solution depends on NGINX Plus component ([key-value store](http://nginx.org/en/docs/http/ngx_http_keyval_module.html)) and as such is not suitable for [open source NGINX](http://www.nginx.org/en) without additional modifications. 40 | 41 | This implementation assumes the following environment: 42 | 43 | - The identity provider (IdP) supports Security Assertion Markup Language (SAML) [2.0](https://www.oasis-open.org/committees/download.php/27819/sstc-saml-tech-overview-2.0-cd-02.pdf) 44 | - HTTP POST Binding for the IdP-to-SP (Response) message 45 | - NGINX Plus is configured as a SP for a specific IdP 46 | - The IdP knows NGINX Plus as a SP 47 | 48 | The communication between the IdP and NGINX Plus is indirect, always taking place through the [User Agent](https://en.wikipedia.org/wiki/User_agent). The flow of the SP-initiated SSO with POST bindings for request and response is shown in the following diagram: 49 | 50 | ```mermaid 51 | sequenceDiagram 52 | autonumber 53 | actor User Agent 54 | participant SP (NGINX) 55 | participant IdP 56 | User Agent->>SP (NGINX): Access resource 57 | SP (NGINX)->>User Agent: HTML Form (auto-submit) 58 | User Agent->>IdP: HTTP POST with SAML AuthnRequest 59 | IdP->>User Agent: Challenge for credentials 60 | User Agent->>IdP: User login 61 | IdP->>User Agent: SAML Response in HTML Form (auto-submit) 62 | User Agent->>SP (NGINX): HTTP POST with SAML Response to /saml/acs 63 | SP (NGINX)->>SP (NGINX): Validate Assertion and extract attributes 64 | SP (NGINX)->>User Agent: Grant/Deny Access 65 | ``` 66 | `Figure 1. SAML SP-Initiated SSO with POST Bindings for AuthnRequest and Response` 67 | 68 | NGINX Plus is configured to perform SAML authentication. Upon a first visit to a protected resource, NGINX Plus initiates the SP-initiated flow and redirects the client to the IdP using HTTP-POST binding. When the client returns to NGINX Plus with a SAML Response message, NGINX Plus validates the response, verifies the Response and Assertion signature using the imported public key, extracts preconfigured saml attributes and makes the decision to grant access. NGINX Plus then stores the access token variable and extracted attributes in the key-value store, issues a session cookie to the client using a random string with 160 bits of entropy, (which becomes the key to obtain the access token variable and attributes from the key-value store) and redirects the client to the original URI requested prior to authentication. 69 | 70 | Subsequent requests to protected resources are authenticated by exchanging the session cookie for access token variable in the key-value store. It is worth noting that user validation is performed solely on the basis of session cookies and session validity period is enforced by `saml_session_access` keyval zone timeout (default is 1 hour). After the session timeout expires, the user will be redirected to IdP for re-authentication. 71 | 72 | # SAML Authentication Request 73 | 74 | The SAML authentication request, also known as the SAML AuthnRequest, is a message sent by the SP to the IdP to initiate the SSO process. AuthnRequest could look like the following example: 75 | ```xml 76 | 84 | https://sp.example.com 85 | 88 | 89 | ``` 90 | The NGINX Plus implementation can be configured to use either `HTTP-POST` or `HTTP-Redirect` bindings for the AuthnRequest, depending on the requirements and capabilities of the IdP. The choice of the message delivery method is made through the `$saml_sp_request_binding` variable. 91 | The authentication request also includes the `issuer` element, which specifies the entity ID of the SP. This allows the IdP to identify the SP that is initiating the authentication request and to provide the appropriate assertion to the SP. This parameter is controlled by the variable `$saml_sp_entity_id`. 92 | The AuthnRequest may also include other optional parameters such as the `destination`, `nameid policy`, `force_authn`, and others. These parameters can be used to provide additional context for the SSO process and to specify the desired behavior of the IdP during the SSO process. These parameters are controlled through variables `$saml_idp_sso_url`, `$saml_sp_nameid_format` and `$saml_sp_force_authn` respectively. 93 | 94 | The AuthnRequest must be signed by the SP to ensure the authenticity and integrity of the request. The signature is created using the private key of the SP, and the public key of the SP is shared with the IdP to verify the signature. The decision to sign the AuthnRequest is made based on the `$saml_sp_sign_authn` variable and can be flexibly configured on per IdP basis. The SP private key are configured via `$saml_sp_signing_key` variable. 95 | 96 | > **Note:** NGINX currently does not support Detached Signature for the HTTP-Redirect binding. Additionally, the signature algorithm cannot be customized and is always set to rsa-sha256. 97 | 98 | # SAML Authentication Response 99 | 100 | The SAML authentication response is a message sent by the IdP to the SP in response to the SAML AuthnRequest. The SAML Response contains the user’s authentication status and any requested attributes. In the NGINX Plus SAML implementation, the SAML Response is sent to the SP via an HTTP POST request to the SP's Assertion Consumer Service (ACS) endpoint (`/saml/acs` by default). A response to a successful sign-on attempt looks like the following sample: 101 | 102 | ```xml 103 | 111 | https://idp.example.com/saml2/idp 112 | 113 | ... 114 | 115 | 116 | 117 | 118 | 124 | https://idp.example.com/saml2/idp 125 | 126 | user1@example.com 129 | 130 | 134 | 135 | 136 | 139 | 140 | https://sp.example.com 141 | 142 | 143 | 147 | 148 | urn:oasis:names:tc:SAML:2.0:ac:classes:Password 149 | 150 | 151 | 152 | 155 | 1 156 | 157 | 160 | group1, admins, students 161 | 162 | 165 | user1@example.com 166 | 167 | 168 | 169 | 170 | ``` 171 | 172 | Upon receiving the SAML response, NGINX Plus performs a series of validations and checks to ensure a secure and compliant SSO implementation. 173 | 174 | ## Response 175 | The Response element includes the result of the authorization request. NGINX Plus checks the "ID" to ensure it has not been reused, providing protection against replay attacks, "Version" and "IssueInstant" values in the Response element. It also verifies the following attributes (only if they are present): 176 | 177 | - `Destination`: must match the Assertion Consumer Service (ACS) URL of the Service Provider, which is determined by the `$saml_sp_acs_url` variable. 178 | - `InResponseTo`: must match the `ID` attribute of the AuthnRequest element that initiated the response. 179 | 180 | ## Issuer 181 | NGINX Plus verifies the `Issuer` element, which must match the IdP EntityID defined by the `$saml_idp_entity_id` variable. 182 | 183 | ## Status 184 | The `Status` element conveys the success or failure of the SSO. It can include the `StatusCode` element, which contains a code or a set of nested codes that represents the status of the request and the `StatusMessage` element, which contains custom error messages that are generated during the sign-on process by IdP. If the status does not match `urn:oasis:names:tc:SAML:2.0:status:Success`, access to the protected resource is denied. 185 | 186 | ## Assertion 187 | The Assertion is validated using the same approach as the Response, with the exception that we do not check the `ID` for replay attacks. Therefore, we recommend always signing the entire Response to ensure security. 188 | 189 | ### Subject 190 | The `Subject` element specifies the principle that is the subject of the statements in the assertion. It must contain a `NameID` element, which represents the authenticated user. The `NameID` is a unique identifier for the user within the context of the Identity Provider, while the `NameID Format` describes the format or namespace of the `NameID`. When processing the Subject, NGINX Plus parses both the NameID and the NameID Format, which are then stored in the `$saml_name_id` and `$saml_name_id_format` variables, respectively. 191 | 192 | ### Conditions 193 | The `Conditions` element defines the conditions under which the SAML Assertion is considered valid. It is a mandatory element, and an assertion without it will be deemed invalid. NGINX Plus checks the values of the `NotBefore` and `NotOnOrAfter` attributes to ensure the assertion is being used within the specified time window. 194 | 195 | NGINX Plus accommodates potential clock discrepancies between the Service Provider and the Identity Provider with the `$saml_allowed_clock_skew` variable. This variable defines an acceptable range of time skew in seconds, allowing NGINX Plus to adjust the validation window for slight time differences between systems. 196 | If `$saml_allowed_clock_skew` is not defined, NGINX Plus applies a default value of `120` seconds. 197 | 198 | ### Audience 199 | If the `AudienceRestriction` element is present, it restricts the assertion's applicability to specific intended audiences, or Service Providers, to which it may be sent. NGINX Plus verifies that the Service Provider's Entity ID, specified by the `$saml_sp_entity_id` variable, is listed as an acceptable audience for the assertion. This step ensures that the assertion is intended for the correct Service Provider and prevents unauthorized access to resources. 200 | 201 | ### AuthnStatement 202 | The `AuthnStatement` element asserts that the subject of the assertion has been authenticated using specific means at a particular time. If it contains a `SessionIndex` attribute, the value will be stored in the `$saml_session_index` variable. 203 | 204 | The `AuthnInstant` attribute indicates the time at which the user was authenticated by the Identity Provider and must be present. 205 | 206 | The `AuthnContext` element specifies the authentication context used for authenticating the user. The value of the `authnContextClassRef` element is stored in the `$saml_authn_context_class_ref` variable. This information can be useful for understanding the level of assurance provided by the authentication method and for making access control decisions based on that level of assurance. 207 | 208 | ### AttributeStatement 209 | The `AttributeStatement` element contains assertions about the subject or user. During the processing, we currently store only the `AttributeValue` in key-value variables. You must manually pre-create the key-value zone and variable for each attribute name (see examples in the `saml_sp_configuration.conf` file). This allows you to store and access user attributes provided by the Identity Provider for use in access control decisions, personalization, or other custom functionality within your application. 210 | 211 | ## Response or Assertion Signature 212 | 213 | The Identity Provider can choose to sign either the entire SAML Response or just the Assertion within the response upon successful authentication. The Signature element contains a digital signature that NGINX Plus can use to authenticate the source and verify the integrity of the assertion. The decision to validate the signature is based on the variables `$saml_sp_want_signed_response` and `$saml_sp_want_signed_assertion`. 214 | 215 | The selection of the IdP's public key is determined by the variable `$saml_idp_verification_certificate`. This variable represents the relative or absolute path to the certificate file in SPKI format. This file may contain one or more public keys, separated by "-----BEGIN PUBLIC KEY----- / -----END PUBLIC KEY-----" markers, which will be used sequentially for signature verification. This approach is necessary for handling the rotation of public keys. 216 | 217 | If you have a publc key in PEM format, you can use the following command to convert certificate to DER format and extract public key from DER certificate: 218 | 219 | ```shell 220 | $ openssl x509 -in saml_idp_verify.pem -text -noout # view/check PEM (Privacy-Enhanced Mail) encoded certificate 221 | $ openssl x509 -in saml_idp_verify.pem -outform DER -out saml_idp_verify.der # convert PEM to DER format 222 | $ openssl x509 -inform DER -in saml_idp_verify.der -pubkey -noout > saml_idp_verify.spki # extract public key from DER certificate 223 | $ openssl rsa -pubin -in saml_idp_verify.spki -text # view/check a public key in PKCS#1 format 224 | ``` 225 | 226 | The following signature algorithms are supported: 227 | - http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 228 | - http://www.w3.org/2000/09/xmldsig#rsa-sha1 229 | 230 | The following digest algorithms are supported: 231 | - http://www.w3.org/2000/09/xmldsig#sha1 232 | - http://www.w3.org/2001/04/xmlenc#sha256 233 | 234 | ## Encrypted Assertion or NameID elements 235 | 236 | A SAML Response may contain `EncryptedAssertion` and `EncryptedID` elements, which represent encrypted `Assertion` and `NameID` elements, respectively. NGINX Plus can decrypt these elements if they are present in the response. To specify the private key in PEM format to be used for decryption, use the variable `$saml_sp_decryption_key`. This variable represents the relative or absolute path to the key file. 237 | 238 | The following key encryption algorithms are supported: 239 | - http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p 240 | - http://www.w3.org/2009/xmlenc11#rsa-oaep 241 | - http://www.w3.org/2001/04/xmlenc#rsa-1_5 242 | 243 | The following data encryption algorithms are supported: 244 | - http://www.w3.org/2001/04/xmlenc#aes128-cbc 245 | - http://www.w3.org/2001/04/xmlenc#aes192-cbc 246 | - http://www.w3.org/2001/04/xmlenc#aes256-cbc 247 | - http://www.w3.org/2009/xmlenc11#aes128-gcm 248 | - http://www.w3.org/2009/xmlenc11#aes192-gcm 249 | - http://www.w3.org/2009/xmlenc11#aes256-gcm 250 | 251 | ## Redirect user after successful login 252 | 253 | After receiving a SAML Response with a successful status, the user is redirected by default to the address preceding the authentication request. If you want to change this behavior and redirect the user to a different address, you can use the configuration variable `$saml_sp_relay_state`. This can be either an absolute or relative URL. 254 | 255 | It's important to note that this will only work for SP-initiated Single Sign-On (SSO). For IdP-initiated SSO, the `RelayState` is provided by the IdP, and the user will be redirected to the address specified in the RelayState if it is present. If the RelayState is not provided, the user will be redirected to the application's root. 256 | 257 | # SAML Single Logout 258 | SAML Single Logout (SLO) is a feature that allows users to log out from all service providers (SPs) and identity providers (IdPs) involved in an SSO session with a single action. NGINX Plus supports both sending and processing LogoutRequest and LogoutResponse messages for both SP-initiated and IdP-initiated logout scenarios. 259 | 260 | ```mermaid 261 | sequenceDiagram 262 | autonumber 263 | actor User Agent 264 | participant SP (NGINX) 265 | participant IdP 266 | User Agent->>SP (NGINX): Access /logout location 267 | SP (NGINX)->>User Agent: HTML Form (auto-submit) 268 | User Agent->>IdP: HTTP POST/Redirect with SAML LogoutRequest 269 | IdP->>IdP: Terminate IdP session 270 | IdP->>User Agent: SAML LogoutResponse in HTML Form (auto-submit) 271 | User Agent->>SP (NGINX): HTTP POST/Redirect with SAML LogoutResponse to /saml/sls 272 | SP (NGINX)->>SP (NGINX): Validate LogoutResponse 273 | SP (NGINX)->>User Agent: Redirect to logout landing page 274 | ``` 275 | `Figure 2. SAML SP-Initiated SLO with POST/Redirect Bindings for LogoutRequest and LogoutResponse` 276 | 277 | We support both Redirect (HTTP GET) and HTTP POST bindings for sending and receiving SLO messages. Redirect binding uses HTTP GET requests to transmit SAML messages via URL query parameters, while HTTP POST binding utilizes HTTP POST requests to send SAML messages within the body of an HTML form. The choice of binding method for SLO messages can be configured using the `$saml_sp_slo_binding` configuration variable, which allows you to select either 'HTTP-POST' or 'HTTP-Redirect' methods as required for your IdP. 278 | 279 | By default, as the endpoint where NGINX Plus processes all SLO-related messages, we use the `/saml/sls` location, which can be modified in the `saml_sp.server_conf` file. At the same time, do not forget to update the `$saml_sp_slo_url` variable, which reflects the full URL, including the scheme (http or https) and domain name, corresponding to your service provider. 280 | 281 | ## SP-Initiated Logout 282 | In the SP-initiated logout process, NGINX Plus initiates the logout by sending a LogoutRequest message to the identity provider (IdP). Upon receiving the LogoutRequest, the IdP is responsible for terminating the user's session and then sending a LogoutResponse message back to NGINX Plus, confirming the successful completion of the logout process. 283 | 284 | ### Sending LogoutRequest 285 | When NGINX Plus creates and sends the LogoutRequest message, the destination for the request is determined by the `$saml_idp_slo_url` variable. This variable specifies the endpoint at the IdP to which the LogoutRequest should be sent. The following snippet shows an example of a LogoutRequest element: 286 | 287 | ```xml 288 | 294 | https://sp.example.com 295 | 296 | ... 297 | 298 | user1@example.com 299 | 300 | ``` 301 | 302 | The decision whether to sign the LogoutRequest message is made based on the value of the `$saml_sp_sign_slo` variable. If the variable is set to "true", NGINX Plus will sign the LogoutRequest message to ensure its authenticity and integrity. The signature is created using the private key of the SP. 303 | 304 | It is important to note that NGINX Plus does not use the `sessionindex` attribute when sending LogoutRequest messages. Instead, we rely on the `NameID` attribute to associate user sessions with the corresponding subject. This means that when NGINX Plus sends a LogoutRequest, only the `NameID` parameter is included in the message, allowing the IdP to identify the user session to be terminated. 305 | 306 | ### Receiving LogoutResponse 307 | After sending a LogoutRequest message to the IdP, NGINX Plus waits for the IdP to send a LogoutResponse message back. This message indicates the status of the logout process initiated by the service provider (SP). The following snippet shows an example of a LogoutResponse element: 308 | 309 | ```xml 310 | 318 | https://idp.example.com/saml2/idp 319 | 320 | 321 | 322 | 323 | ``` 324 | 325 | The user session will be terminated only if the IdP sends a successful LogoutResponse. 326 | 327 | The decision whether to require a signature for the LogoutResponse message is determined by the `$saml_sp_want_signed_slo` variable. If the variable is set to "true," NGINX Plus expects the LogoutResponse from the IdP to be digitally signed. This helps ensure the authenticity and integrity of the message. 328 | 329 | Upon successful logout, the user is redirected to the URL specified by the `$saml_logout_landing_page` variable. This is typically a non-authenticated page that says goodbye to the user and does not require any further authentication. 330 | 331 | ## IdP-Initiated Logout 332 | In the IdP-initiated logout process, the IdP initiates the logout by sending a LogoutRequest message to NGINX Plus. Upon receiving the LogoutRequest, NGINX Plus is responsible for terminating the user's session and then sending a LogoutResponse message back to the IdP, confirming the successful completion of the logout process. 333 | 334 | ### Receiving LogoutRequest 335 | In the IdP-initiated logout process, NGINX Plus receives a LogoutRequest message from the IdP without prior SP-initiated communication. The LogoutRequest message serves to initiate the logout process for the user session. 336 | 337 | The decision whether to require a signed LogoutRequest is determined by the `$saml_sp_want_signed_slo` variable. If set to "true," NGINX Plus expects the LogoutRequest from the IdP to be digitally signed, ensuring the authenticity and integrity of the message. 338 | 339 | NGINX Plus does not use `sessionindex` when receiving LogoutRequest messages. Instead, it relies on the `NameID` to link user sessions to the corresponding identity. When a LogoutRequest message is received, NGINX Plus checks whether the `NameID` received in the message matches the one stored in the session variable `$saml_name_id`. If the received `NameID` does not match, the LogoutResponse Status will be `urn:oasis:names:tc:SAML:2.0:status:Requester`. 340 | 341 | However, it is important to note that if NGINX Plus receives a LogoutRequest message for a non-existent session, it will still return a success status, as this complies with the SAML standard. 342 | 343 | ### Sending LogoutResponse 344 | In the IdP-initiated logout process, after receiving and processing the LogoutRequest message from the identity provider (IdP), NGINX Plus sends a LogoutResponse message back to the IdP. This message serves to confirm the successful logout of the user session and inform the IdP about the outcome of the logout process. 345 | 346 | The decision whether to sign the LogoutResponse message is determined by the `$saml_sp_sign_slo` variable. If set to "true," NGINX Plus will digitally sign the LogoutResponse message before sending it to the IdP, ensuring the authenticity and integrity of the message. 347 | 348 | ## Disabling Single Logout (SLO) 349 | There might be cases where you need to disable SLO, for example, if your IdP doesn't support it, or if you don't want SLO to initiate the logout process for all SPs that currently have active sessions with the IdP. 350 | 351 | To disable SLO, set the configuration variable `$saml_idp_slo_url` to an empty value. By doing so, you can still initiate the session termination process by sending a request to the `/logout` location. The user session will be cleared, and the user will be redirected to the `$saml_logout_landing_page` URL. 352 | 353 | It's important to note that disabling SLO does not terminate the IdP session. If the user tries to access the application after logging out, they will be redirected to the SP with a valid SAML Response without re-authentication. If you want to enforce re-authentication, you can change this behavior by setting the "force_authn = true" parameter in the AuthnRequest. For more information, refer to the description of the `$saml_sp_force_authn` variable in the `saml_sp_configuration.conf` file. 354 | 355 | # Installation 356 | 357 | Start by [installing NGINX Plus](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-plus/). In addition, the [NGINX JavaScript module](https://www.nginx.com/blog/introduction-nginscript/) (njs) is required for handling the interaction between NGINX Plus and the SAML Identity provider (IdP). Install the njs module after installing NGINX Plus by running one of the following: 358 | 359 | `$ sudo apt install nginx-plus-module-njs` for Debian/Ubuntu 360 | 361 | `$ sudo yum install nginx-plus-module-njs` for CentOS/RHEL 362 | 363 | The njs module needs to be loaded by adding the following configuration directive near the top of **nginx.conf**. 364 | 365 | ```nginx 366 | load_module modules/ngx_http_js_module.so; 367 | ``` 368 | 369 | Finally, create a clone of the GitHub repository. 370 | 371 | `$ git clone https://github.com/nginxinc/nginx-saml` 372 | 373 | All files can be copied to **/etc/nginx/conf.d** 374 | 375 | ## Non-standard directories 376 | 377 | The GitHub repository contains [`include`](http://nginx.org/en/docs/ngx_core_module.html#include) files for NGINX configuration, and JavaScript code for SAML request generation and processing response. These files are referenced with a relative path (relative to /etc/nginx). If NGINX Plus is running from a non-standard location then copy the files from the GitHub repository to `/path/to/conf/conf.d` and use the `-p` flag to start NGINX with a prefix path that specifies the location where the configuration files are located. 378 | 379 | ```shell 380 | $ nginx -p /path/to/conf -c /path/to/conf/nginx.conf 381 | ``` 382 | 383 | # Configuring NGINX Plus 384 | 385 | Configuration can typically be completed automatically by using SAML Metadata. SAML Metadata is a standard way of exchanging metadata information between SAML entities. It is used by the Service Provider (SP) and Identity Provider (IdP) to communicate configuration information, such as endpoints, signing keys, etc. 386 | > **Note:** SAML Metadata is not currently supported by NGINX Plus. 387 | 388 | Manual configuration involves reviewing the following files so that they match your IdP(s) configuration. 389 | 390 | - **saml_sp_configuration.conf** - this contains the primary configuration for one or more SPs and IdPs in `map{}` blocks 391 | - Modify all of the `map…$saml_sp_` blocks to match your SP configuration 392 | - Modify all of the `map…$saml_idp_` blocks to match your IdP configuration 393 | - Modify the URI defined in `map…$saml_logout_redirect` to specify an unprotected resource to be displayed after requesting the `/logout` location 394 | - If NGINX Plus is deployed behind another proxy or load balancer, modify the `map…$redirect_base` and `map…$proto` blocks to define how to obtain the original protocol and port number. 395 | - If you need to adjust the default allowable clock skew from the standard 120 seconds to accommodate time differences between the SP and IdP, add the `map…$saml_sp_clock_skew` block and specify the desired value in seconds. 396 | 397 | - **frontend.conf** - this is the reverse proxy configuration 398 | - Modify the upstream group to match your backend site or app 399 | - Configure the preferred listen port and [enable SSL/TLS configuration](https://docs.nginx.com/nginx/admin-guide/security-controls/terminating-ssl-http/) 400 | - Modify the severity level of the `error_log` directive to suit the deployment environment 401 | 402 | - **saml_sp.server_conf** - this is the NGINX configuration for handling IdP Responses 403 | - No changes are usually required here 404 | - Modify the `client_body_buffer_size` directive to match the maximum size of IdP response (post body) 405 | 406 | - **saml_sp.js** - this is the JavaScript code for performing the SAML Authentication 407 | - No changes are required 408 | 409 | ## Configuring the Key-Value Store 410 | 411 | This is part of the advanced configuration in **saml_sp_configuration.conf**. 412 | 413 | The [key-value store](http://nginx.org/en/docs/http/ngx_http_keyval_module.html) is used to maintain persistent storage for SAML sessons and extracted SAML attributes. The default configuration should be reviewed so that it suits the environment. If you need access to any extracted SAML attribute as a NGINX variable, you need to create a separate `keyval_zone`, as well as a `keyval` record for each such attribute, for example, if the SAML attribute name is `email` you need to add the following 2 entries: 414 | 415 | ```nginx 416 | keyval_zone zone=saml_attrib_email:1M state=/var/lib/nginx/state/saml_attrib_email.json timeout=1h; 417 | keyval $cookie_auth_token $saml_attrib_email zone=saml_attrib_email; 418 | ``` 419 | 420 | > **Note**: 421 | > - The NGINX variable name includes the prefix `$saml_attrib_`. In the example above, the full variable name would be `$saml_attrib_email`. 422 | > - If a SAML attribute name is a namespace-qualified (like "http://schemas.example.com/identity/claims/displayname"), the system will use the last segment after the final slash ("/") as the attribute name. So, in this case, `displayname` will be correctly saved. Review your configuration if you use URI-style SAML attributes. 423 | 424 | The following keyval zones are added by default: 425 | 426 | ```nginx 427 | # Zone for storing AuthnRequest and LogoutRequest message identifiers (ID) 428 | # to prevent replay attacks. (REQUIRED) 429 | # Timeout determines how long the SP waits for a response from the IDP, 430 | # i.e. how long the user authentication process can take. 431 | keyval_zone zone=saml_request_id:1M state=/var/lib/nginx/state/saml_request_id.json timeout=5m; 432 | 433 | # Zone for storing SAML Response message identifiers (ID) to prevent replay attacks. (REQUIRED) 434 | # Timeout determines how long the SP keeps IDs to prevent reuse. 435 | keyval_zone zone=saml_response_id:1M state=/var/lib/nginx/state/saml_response_id.json timeout=1h; 436 | 437 | # Zone for storing SAML session access information. (REQUIRED) 438 | # Timeout determines how long the SP keeps session access decision (the session lifetime). 439 | keyval_zone zone=saml_session_access:1M state=/var/lib/nginx/state/saml_session_access.json timeout=1h; 440 | 441 | # Zone for storing SAML NameID values. (REQUIRED) 442 | # Timeout determines how long the SP keeps NameID values. Must be equal to session lifetime. 443 | keyval_zone zone=saml_name_id:1M state=/var/lib/nginx/state/saml_name_id.json timeout=1h; 444 | 445 | # Zone for storing SAML NameID format values. (REQUIRED) 446 | # Timeout determines how long the SP keeps NameID format values. Must be equal to session lifetime. 447 | keyval_zone zone=saml_name_id_format:1M state=/var/lib/nginx/state/saml_name_id_format.json timeout=1h; 448 | 449 | # Zone for storing SAML SessionIndex values. (REQUIRED) 450 | # Timeout determines how long the SP keeps SessionIndex values. Must be equal to session lifetime. 451 | keyval_zone zone=saml_session_index:1M state=/var/lib/nginx/state/saml_session_index.json timeout=1h; 452 | 453 | # Zone for storing SAML AuthnContextClassRef values. (REQUIRED) 454 | # Timeout determines how long the SP keeps AuthnContextClassRef values. Must be equal to session lifetime. 455 | keyval_zone zone=saml_authn_context_class_ref:1M state=/var/lib/nginx/state/saml_authn_context_class_ref.json timeout=1h; 456 | ``` 457 | 458 | Each of the `keyval_zone` parameters are described below. 459 | 460 | - **zone** - Defines the name of the key-value store and the amount of memory allocated for it. Each session typically occupies less than 1KB, depending on the attribute size. To accommodate unique users who may authenticate, scale this value accordingly. 461 | 462 | - **state** (optional) - Specifies the location where all of the SAML-related attributes in the key-value store are saved, ensuring that sessions persist across restarts or reboots of the NGINX host. The NGINX Plus user account, typically **nginx**, must have write permission to the directory where the state file is stored. It is recommended to create a dedicated directory for this purpose. 463 | 464 | - **timeout** - Expired attributes are removed from the key-value store after the specified `timeout` value. Set `timeout` to the desired session duration to control attribute persistence. 465 | 466 | - **sync** (optional) - When deployed in a cluster, the key-value store can be synchronized across all instances in the cluster, enabling all instances to create and validate authenticated sessions. To configure each instance for state sharing, use the [zone_sync module](http://nginx.org/en/docs/stream/ngx_stream_zone_sync_module.html) and add the `sync` parameter to the `keyval_zone` directives mentioned earlier. 467 | -------------------------------------------------------------------------------- /frontend.conf: -------------------------------------------------------------------------------- 1 | # This is the backend application we are protecting with SAML SSO 2 | upstream my_backend { 3 | zone my_backend 64k; 4 | server localhost:8088; 5 | } 6 | 7 | # Custom log format to include the 'NameID' subject in the REMOTE_USER field 8 | log_format saml_sso '$remote_addr - $saml_name_id [$time_local] "$request" ' 9 | '$status $body_bytes_sent "$http_referer" ' 10 | '"$http_user_agent" "$http_x_forwarded_for"'; 11 | 12 | # The frontend server - reverse proxy with SAML SSO authentication 13 | # 14 | server { 15 | # Functional locations implementing SAML SSO support 16 | include conf.d/saml_sp.server_conf; 17 | 18 | # Reduce severity level as required 19 | error_log /var/log/nginx/error.log debug; 20 | 21 | listen 8010; # Use SSL/TLS in production 22 | 23 | location / { 24 | # When a user is not authenticated (i.e., the "saml_access_granted" 25 | # variable is not set to "1"), an HTTP 401 Unauthorized error is 26 | # returned, which is handled by the @do_samlsp_flow named location. 27 | error_page 401 = @do_samlsp_flow; 28 | 29 | if ($saml_access_granted != "1") { 30 | return 401; 31 | } 32 | 33 | # Successfully authenticated users are proxied to the backend, 34 | # with the NameID attribute passed as an HTTP header 35 | proxy_set_header username $saml_name_id; 36 | 37 | proxy_pass http://my_backend; # The backend site/app 38 | 39 | access_log /var/log/nginx/access.log saml_sso; 40 | } 41 | } 42 | 43 | # vim: syntax=nginx 44 | -------------------------------------------------------------------------------- /saml_sp.js: -------------------------------------------------------------------------------- 1 | /* 2 | * JavaScript functions for providing SAML SP with NGINX Plus 3 | * 4 | * Copyright (C) 2023 Nginx, Inc. 5 | */ 6 | 7 | export default { 8 | handleSingleSignOn, // Process SAML Response form IdP 9 | handleSingleLogout, // Process SAML LogoutRequest and LogoutResponse from IdP 10 | handleAllMessages, // Process all SAML messages from IdP 11 | initiateSingleSignOn, // Initiate SAML SSO by redirecting to IdP 12 | initiateSingleLogout // Initiate SAML SLO by redirecting to IdP 13 | }; 14 | 15 | const xml = require("xml"); 16 | const zlib = require("zlib"); 17 | const querystring = require("querystring"); 18 | const fs = require("fs"); 19 | 20 | const initiateSingleSignOn = produceSAMLMessage.bind(null, "AuthnRequest"); 21 | const initiateSingleLogout = produceSAMLMessage.bind(null, "LogoutRequest"); 22 | const handleSingleSignOn = handleSAMLMessage.bind(null, ["Response"]); 23 | const handleSingleLogout = handleSAMLMessage.bind(null, ["LogoutRequest", "LogoutResponse"]); 24 | const handleAllMessages = handleSAMLMessage.bind(null, ["Response", "LogoutRequest", "LogoutResponse"]); 25 | 26 | /** 27 | * Processing incoming SAML messages (Response, LogoutResponse, LogoutRequest). 28 | * @param {Array} messageType - Array of expected SAML message types. 29 | * @param {object} r - The request object. 30 | */ 31 | async function handleSAMLMessage(messageType, r) { 32 | let id; 33 | try { 34 | let nameID, node; 35 | 36 | /* Extract SAML parameters from the HTTP request */ 37 | const params = extractSAMLParameters(r); 38 | 39 | /* Parse the SAML message for an XML document */ 40 | let root = xml.parse(params.SAMLResponse).$root; 41 | 42 | /* Check the message type and validate the configuration */ 43 | messageType = checkSAMLMessageType(root, messageType); 44 | const opt = parseConfigurationOptions(r, messageType); 45 | 46 | /* Process the message header and verify the issuer */ 47 | id = processSAMLMessageHeader(r, opt, root); 48 | checkIssuer(root.Issuer, opt.idpEntityID); 49 | 50 | /* Verify the SAML signature if required */ 51 | opt.wantSignedResponse && await verifySAMLSignature(root, opt.verifyKeys); 52 | 53 | /* Check for SAML replay attacks */ 54 | checkReplayAttack(r, id, messageType); 55 | 56 | /* Handle different SAML message types */ 57 | switch (messageType) { 58 | case 'Response': 59 | /* Verify the SAML Response status */ 60 | verifyResponseStatus(root.Status); 61 | 62 | /* Decrypt the Encrypted Assertion if present */ 63 | if (root.EncryptedAssertion) { 64 | root = await decryptSAML(root.EncryptedAssertion, opt.decryptKey); 65 | } 66 | 67 | /* Process the Assertion header and verify the issuer */ 68 | opt.assertionId = processSAMLMessageHeader(r, opt, root.Assertion); 69 | checkIssuer(root.Assertion.Issuer, opt.idpEntityID); 70 | 71 | /* Verify the SAML Assertion signature if required */ 72 | opt.wantSignedAssertion && await verifySAMLSignature(root.Assertion, opt.verifyKeys); 73 | 74 | /* Exctract NameID, NameIDFormat and check the SubjectConfirmation if present */ 75 | node = root.Assertion.Subject.NameID ? root.Assertion.Subject.NameID 76 | : root.Assertion.Subject.EncryptedID || null; 77 | nameID = await extractNameID(node, opt.decryptKey); 78 | checkSubjectConfirmation(root.Assertion.Subject.SubjectConfirmation, 79 | opt.allowedClockSkew); 80 | 81 | /* Parse the Asserttion Conditions and Authentication Statement */ 82 | checkConditions(root.Assertion.Conditions, opt.spEntityID, opt.allowedClockSkew); 83 | const authnStatement = parseAuthnStatement(root.Assertion.AuthnStatement, null, 84 | opt.allowedClockSkew); 85 | 86 | /* Set session cookie and save SAML variables and attributes */ 87 | const sessionCookie = setSessionCookie(r); 88 | saveSAMLVariables(r, nameID, authnStatement); 89 | saveSAMLAttributes(r, root.Assertion.AttributeStatement); 90 | 91 | /* Redirect the user after successful login */ 92 | postLoginRedirect(r, params.RelayState || opt.relayState); 93 | r.variables.saml_access_granted = '1'; 94 | r.log("SAML SP success, creating session " + sessionCookie); 95 | return; 96 | case 'LogoutRequest': 97 | /* Exctract NameID and NameIDFormat */ 98 | node = root.NameID ? root.NameID : root.EncryptedID || null; 99 | nameID = await extractNameID(node, opt.decryptKey); 100 | 101 | /* Define necessary parameters needed to create a SAML LogoutResponse */ 102 | opt.nameID = nameID[0]; 103 | opt.inResponseTo = id; 104 | opt.relayState = params.RelayState; 105 | 106 | /* Rewrite the LogoutResponse URL if configured */ 107 | opt.idpServiceURL = opt.logoutResponseURL || opt.idpServiceURL; 108 | 109 | /* Issue a SAML LogoutResponse */ 110 | await produceSAMLMessage('LogoutResponse', r, opt); 111 | return; 112 | case 'LogoutResponse': 113 | /* Verify the SAML LogoutResponse status */ 114 | verifyResponseStatus(root.Status); 115 | 116 | /* Clear the session cookie and redirect the user */ 117 | clearSession(r); 118 | postLogoutRedirect(r, params.RelayState); 119 | return; 120 | } 121 | } catch (e) { 122 | samlError(r, 500, id, e); 123 | } 124 | } 125 | 126 | function samlError(r, http_code, id, e) { 127 | let msg = r.variables.saml_debug ? e.stack : "ReferenceError: " + e.message; 128 | r.error(`SAML SSO Error: ReferenceID: ${id} ${msg}`); 129 | 130 | r.variables.internal_error_message += `ReferenceID: ${id}`; 131 | r.variables.internal_error_details = msg; 132 | 133 | r.return(http_code); 134 | } 135 | 136 | /** 137 | * Processes the SAML message header, validating the required fields and checking optional 138 | * fields, such as Destination, according to the SAML 2.0 Core specification: 139 | * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 140 | * 141 | * - ID attribute (Required, see section 1.3.4): A unique identifier for the SAML message. 142 | * - InResponseTo attribute (Optional, see section 3.2.2 for SSO and 3.7.3.1 for SLO): 143 | * Indicates that the SAML message is a response to a previous request. 144 | * - IssueInstant attribute (Required, see section 1.3.4): The timestamp when the SAML message 145 | * was issued. 146 | * - Destination attribute (Optional, see section 3.2.2 for SSO and 3.7.3.1 for SLO): 147 | * The intended recipient of the SAML message. 148 | * 149 | * @param {Object} r - The incoming request object. 150 | * @param {Object} opt - An object containing the SP options, including the SP Service URL. 151 | * @param {Object} root - The SAML root element containing the message header. 152 | * @returns {string} - The SAML message ID attribute. 153 | * @throws {Error} - If the SAML message header contains invalid or unsupported values. 154 | */ 155 | function processSAMLMessageHeader(r, opt, root) { 156 | const type = root.$name; 157 | 158 | /* Check XML namespace for SAML message (Required) */ 159 | const expectedNs = type === 'Assertion' 160 | ? 'urn:oasis:names:tc:SAML:2.0:assertion' 161 | : 'urn:oasis:names:tc:SAML:2.0:protocol'; 162 | 163 | if (root.$ns !== expectedNs) { 164 | throw Error(`Unsupported XML namespace: "${root.$ns}" for ${type}`); 165 | } 166 | 167 | /* Check SAML message version (Required) */ 168 | if (root.$attr$Version !== "2.0") { 169 | throw Error (`Unsupported SAML Version: "${root.$attr$Version}"`); 170 | } 171 | 172 | /* Check the date and time when the SAML message was issued (Required) */ 173 | const issueInstant = root.$attr$IssueInstant; 174 | if (!issueInstant) { 175 | throw Error("IssueInstant attribute is missing in the SAML message"); 176 | } 177 | 178 | const issueInstantDate = new Date(issueInstant); 179 | checkTimeValidity(issueInstantDate, null, opt.allowedClockSkew); 180 | 181 | /* Check SAML message ID (Required) */ 182 | const id = root.$attr$ID; 183 | if (!id) { 184 | throw Error (`ID attribute is missing in the ${type} element`); 185 | } 186 | 187 | const inResponseTo = root.$attr$InResponseTo; 188 | if (inResponseTo) { 189 | /* SP-initiated SSO or SLO */ 190 | r.variables.saml_request_id = inResponseTo; 191 | if (r.variables.saml_request_redeemed != '1') { 192 | throw Error (`InResponseTo attribute value "${inResponseTo}" ` + 193 | `not found in key-value storage for ${type} message`); 194 | } 195 | } 196 | 197 | /* Check Destination if present (Optional) */ 198 | const destination = root.$attr$Destination; 199 | if (destination && destination !== opt.spServiceURL) { 200 | throw Error (`The SAML Destination "${destination}" does not match ` + 201 | `SP ACS URL "${opt.spServiceURL}"`); 202 | } 203 | 204 | return id; 205 | } 206 | 207 | /** 208 | * Checks the Issuer element in the SAML message according to the SAML 2.0 Core specification: 209 | * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 210 | * 211 | * The Issuer element (section 2.2.5) contains the SAML authority's unique identifier. 212 | * This function checks if the issuer in the SAML message matches the expected IdP EntityID. 213 | * 214 | * @param {Object} root - The SAML Issuer element. 215 | * @param {string} idpEntityId - The expected IdP EntityID. 216 | * @throws {Error} - If the Issuer in the SAML message does not match the expected IdP EntityID. 217 | */ 218 | function checkIssuer(root, idpEntityId) { 219 | const issuer = root.$text; 220 | if (issuer && issuer !== idpEntityId) { 221 | throw Error (`Issuer "${issuer}" does not match IdP EntityID "${idpEntityId}"`); 222 | } 223 | } 224 | 225 | /** 226 | * Verifies the SAML response status. 227 | * 228 | * According to SAML 2.0 Core specification (section 3.2.2.2): 229 | * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 230 | * The element contains the primary status code indicating the 231 | * success or failure of the corresponding request. The and 232 | * elements provide additional information. 233 | * 234 | * @param {Object} root - A SAML status XMLDoc object returned by xml.parse(). 235 | * @throws {Error} - If the SAML status is not "Success". 236 | */ 237 | function verifyResponseStatus (root) { 238 | if (!root) { 239 | throw Error("The Status element is missing in the SAML response"); 240 | } 241 | 242 | if (!root.StatusCode || !root.StatusCode.$attr$Value) { 243 | throw Error("The StatusCode element is missing in the Status"); 244 | } 245 | 246 | const statusCode = root.StatusCode.$attr$Value; 247 | 248 | const success = "urn:oasis:names:tc:SAML:2.0:status:Success"; 249 | if (statusCode !== success) { 250 | let message = "StatusCode: " + statusCode; 251 | if (root.statusMessage) { 252 | message += ", SatusMessage: " + root.statusMessage.$text; 253 | } 254 | 255 | if (root.statusDetail) { 256 | message += ", StatusDetail: " + JSON.stringify(root.statusDetail); 257 | } 258 | 259 | throw Error(message); 260 | } 261 | } 262 | 263 | /** 264 | * Extracts the NameID value and format from the given SAML root element, optionally 265 | * decrypting it if it's encrypted. 266 | * 267 | * @param {Object} root - The SAML root element containing the NameID or EncryptedID. 268 | * @param {string} keyData - The private key to decrypt the EncryptedID, if present. 269 | * @returns {Promise<[string, string]>} - A promise that resolves to a tuple containing the 270 | * NameID value and format. 271 | * @throws {Error} - If the NameID element is missing in the Subject. 272 | */ 273 | async function extractNameID(root, keyData) { 274 | if (!root) { 275 | throw Error("NameID element is missing in the Subject"); 276 | } 277 | 278 | const isEncrypted = root.$name === 'EncryptedID'; 279 | if (isEncrypted) { 280 | root = (await decryptSAML(root, keyData)).NameID; 281 | } 282 | 283 | return [root.$text, root.$attr$Format]; 284 | } 285 | 286 | /** 287 | * Checks the SubjectConfirmation element in the SAML response. 288 | * 289 | * According to SAML 2.0 Core specification (section 2.4.1.1 and 2.4.1.2): 290 | * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 291 | * The element is used to provide additional 292 | * information required to confirm the subject. The most common method is 293 | * "urn:oasis:names:tc:SAML:2.0:cm:bearer". 294 | * 295 | * @param {Object} root - A SAML SubjectConfirmation XMLDoc object returned by xml.parse(). 296 | * @param {number} [allowedClockSkew] - The allowed clock skew in seconds. 297 | * @throws {Error} - If the SubjectConfirmationData is missing or the subject has expired. 298 | */ 299 | function checkSubjectConfirmation(root, allowedClockSkew) { 300 | if (!root) { 301 | return; 302 | } 303 | 304 | if (root.$attr$Method === "urn:oasis:names:tc:SAML:2.0:cm:bearer") { 305 | root = root.SubjectConfirmationData; 306 | if (!root) { 307 | throw new Error('SubjectConfirmationData element is missing in the ' + 308 | 'SubjectConfirmation'); 309 | } 310 | 311 | const notOnOrAfter = root.$attr$NotOnOrAfter ? new Date(root.$attr$NotOnOrAfter) : null; 312 | checkTimeValidity(null, notOnOrAfter, allowedClockSkew); 313 | } 314 | } 315 | 316 | /** 317 | * Checks the Conditions element in the SAML Assertion. 318 | * 319 | * According to SAML 2.0 Core specification (section 2.5.1.1): 320 | * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 321 | * The element is used to specify conditions that must be evaluated 322 | * when assessing the validity of and/or evaluating an assertion. 323 | * 324 | * @param {Object} root - A SAML Conditions XMLDoc object returned by xml.parse(). 325 | * @param {string} spEntityId - The EntityID of the Service Provider (SP). 326 | * @param {number} [allowedClockSkew] - The allowed clock skew in seconds. 327 | * @throws {Error} - If Conditions element is missing or the assertion is not valid or expired. 328 | * Also throws an error if the audience restriction is not satisfied. 329 | */ 330 | function checkConditions(root, spEntityId, allowedClockSkew) { 331 | if (!root) { 332 | throw Error("Conditions element is missing in the Assertion"); 333 | } 334 | 335 | const notBefore = root.$attr$NotBefore ? new Date(root.$attr$NotBefore) : null; 336 | const notOnOrAfter = root.$attr$NotOnOrAfter ? new Date(root.$attr$NotOnOrAfter) : null; 337 | 338 | checkTimeValidity(notBefore, notOnOrAfter, allowedClockSkew); 339 | 340 | /* Check the audience restriction */ 341 | if (root.AudienceRestriction && root.AudienceRestriction.Audience) { 342 | let audience = root.AudienceRestriction.Audience.$text; 343 | if (!Array.isArray(audience)) { 344 | audience = [audience]; 345 | } 346 | 347 | const spFound = audience.indexOf(spEntityId) !== -1; 348 | if (!spFound) { 349 | throw Error("The Assertion is not intended for this Service Provider. " + 350 | `Expected audience: ${spEntityId}, received: ${audience}`); 351 | } 352 | } 353 | } 354 | 355 | /** 356 | * Parses the AuthnStatement element in the SAML Assertion. 357 | * 358 | * According to SAML 2.0 Core specification (section 2.7.2): 359 | * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 360 | * The element describes the act of authentication performed 361 | * on the principal by the identity provider (IdP). 362 | * 363 | * @param {Object} root - A SAML AuthnStatement XMLDoc object returned by xml.parse(). 364 | * @param {number} [maxAuthenticationAge] - The maximum age (in seconds) of the authentication 365 | * statement. If provided, the function will check 366 | * if the AuthnInstant is within the allowed age. 367 | * @param {number} [allowedClockSkew] - The allowed clock skew in seconds. 368 | * @throws {Error} - If AuthnInstant, SessionNotOnOrAfter, or AuthnContext elements are missing, 369 | * invalid, or expired. 370 | * @returns {Object} - An object with SessionIndex and AuthnContextClassRef properties. 371 | */ 372 | function parseAuthnStatement(root, maxAuthenticationAge, allowedClockSkew) { 373 | /* AuthnStatement element is optional */ 374 | if (!root) { 375 | return; 376 | } 377 | 378 | const authnInstant = root.$attr$AuthnInstant; 379 | if (!authnInstant) { 380 | throw Error("The AuthnInstant attribute is missing in the AuthnStatement"); 381 | } 382 | 383 | /* Placeholder for future maxAuthenticationAge conf option */ 384 | if (maxAuthenticationAge) { 385 | const authnInstantDate = new Date(authnInstant); 386 | const now = new Date(); 387 | if (now.getTime() - authnInstantDate.getTime() > maxAuthenticationAge*1000) { 388 | return false; 389 | } 390 | } 391 | 392 | const sessionIndex = root.$attr$SessionIndex || null; 393 | const sessionNotOnOrAfter = root.$attr$SessionNotOnOrAfter ? 394 | new Date(root.$attr$SessionNotOnOrAfter) : null; 395 | 396 | checkTimeValidity(null, sessionNotOnOrAfter, allowedClockSkew); 397 | 398 | root = root.AuthnContext; 399 | 400 | if (!root) { 401 | throw Error('The AuthnContext element is missing in the AuthnStatement'); 402 | } 403 | 404 | if (!root.AuthnContextClassRef) { 405 | throw Error('The AuthnContextClassRef element is missing in the AuthnContext'); 406 | } 407 | 408 | const authnContextClassRef = root.AuthnContextClassRef.$text; 409 | 410 | return [sessionIndex, authnContextClassRef]; 411 | } 412 | 413 | /** 414 | * Checks if the current time is within the allowed time range specified by 415 | * notBefore and notOnOrAfter. 416 | * 417 | * @param {Date} notBefore - The notBefore time. 418 | * @param {Date} notOnOrAfter - The notOnOrAfter time. 419 | * @param {number} [allowedClockSkew] - Allowed clock skew in seconds. 420 | * @throws {Error} - If the current time is outside the allowed time range. 421 | */ 422 | function checkTimeValidity(notBefore, notOnOrAfter, allowedClockSkew) { 423 | const now = new Date(); 424 | 425 | if (notBefore && notBefore > new Date(now.getTime() + allowedClockSkew * 1000)) { 426 | throw Error(`The Assertion is not yet valid. Current time is ${now} ` + 427 | `and NotBefore is ${notBefore}. ` + 428 | `Allowed clock skew is ${allowedClockSkew} seconds.`); 429 | } 430 | 431 | if (notOnOrAfter && notOnOrAfter < new Date(now.getTime() - allowedClockSkew * 1000)) { 432 | throw Error(`The Assertion has expired. Current time is ${now} ` + 433 | `and NotOnOrAfter is ${notOnOrAfter}. ` + 434 | `Allowed clock skew is ${allowedClockSkew} seconds.`); 435 | } 436 | } 437 | 438 | function saveSAMLVariables(r, nameID, authnStatement) { 439 | r.variables.saml_name_id = nameID[0]; 440 | r.variables.saml_name_id_format = nameID[1]; 441 | 442 | if (authnStatement) { 443 | if (authnStatement[0]) { 444 | try { 445 | r.variables.saml_session_index = authnStatement[0]; 446 | } catch(e) {} 447 | } 448 | 449 | try { 450 | r.variables.saml_authn_context_class_ref = authnStatement[1]; 451 | } catch(e) {} 452 | } 453 | } 454 | 455 | /** 456 | * Extracts attributes from a SAML attribute statement and returns them as an object. 457 | * 458 | * @param {Object} root - A SAML attribute statement XMLDoc object returned by xml.parse(). 459 | * @returns {Object} - An object containing the attributes, with the attribute names as keys and 460 | * attribute values as arrays of values. 461 | */ 462 | function getAttributes(root) { 463 | return root.reduce((a, v) => { 464 | a[v.$attr$Name] = v.$tags$AttributeValue.reduce((a, v) => { 465 | a.push(v.$text); 466 | return a; 467 | }, []); 468 | return a; 469 | }, {}); 470 | } 471 | 472 | function saveSAMLAttributes(r, root) { 473 | if (!root) { 474 | return; 475 | } 476 | 477 | let attrs = getAttributes(root.$tags$Attribute); 478 | 479 | for (var attributeName in attrs) { 480 | if (attrs.hasOwnProperty(attributeName)) { 481 | var attributeValue = attrs[attributeName]; 482 | 483 | /* If the attribute name is a URI, take only the last part after the last "/" */ 484 | if (attributeName.includes("http://") || attributeName.includes("https://")) { 485 | attributeName = attributeName.substring(attributeName.lastIndexOf('/')+1); 486 | } 487 | 488 | /* Save attributeName and value to the key-value store */ 489 | try { 490 | r.variables['saml_attrib_' + attributeName] = attributeValue; 491 | } catch(e) {} 492 | } 493 | } 494 | } 495 | 496 | function extractSAMLParameters(r) { 497 | try { 498 | const payload = getPayload(r); 499 | if (!payload) { 500 | throw Error("Unsupported HTTP method"); 501 | } 502 | return parsePayload(payload, r.method); 503 | } catch (e) { 504 | throw Error(`Failed to extract SAMLRequest or SAMLResponse parameter ` + 505 | `from the ${r.method} request: ${e.message}`); 506 | } 507 | } 508 | 509 | function getPayload(r) { 510 | switch (r.method) { 511 | case 'GET': 512 | return r.variables.arg_SAMLResponse || r.variables.arg_SAMLRequest ? r.variables.args 513 | : null; 514 | case 'POST': 515 | return r.headersIn['Content-Type'] === 'application/x-www-form-urlencoded' 516 | && r.requestText.length ? r.requestText : null; 517 | default: 518 | return null; 519 | } 520 | } 521 | 522 | function parsePayload(payload, method) { 523 | const params = querystring.parse(payload); 524 | let samlResponse = Buffer.from(decodeURIComponent(params.SAMLResponse || params.SAMLRequest), 525 | 'base64'); 526 | let relayState = decodeURIComponent(params.RelayState || "") 527 | 528 | if (method === "GET") { 529 | samlResponse = zlib.inflateRawSync(samlResponse); 530 | } 531 | 532 | return {SAMLResponse: samlResponse, RelayState: relayState}; 533 | } 534 | 535 | function checkSAMLMessageType(root, messageType) { 536 | const type = root.$name; 537 | if (!messageType.includes(type)) { 538 | throw Error(`Unsupported SAML message type: "${messageType}"`); 539 | } 540 | 541 | return type; 542 | } 543 | 544 | /** 545 | * Generates a random string ID with a specified length. 546 | * 547 | * @param {number} keyLength - Length of the generated ID. If it's less than 20, 548 | * the default value of 20 will be used. 549 | * @returns {string} - A randomly generated string ID in hexadecimal format. 550 | */ 551 | function generateID(keyLength) { 552 | keyLength = keyLength > 20 ? keyLength : 20; 553 | let buf = Buffer.alloc(keyLength); 554 | return (crypto.getRandomValues(buf)).toString('hex'); 555 | } 556 | 557 | function setSessionCookie(r) { 558 | /* Generate cookie_auth_token */ 559 | const authToken = "_" + generateID(); 560 | 561 | /* Save cookie_auth_token to keyval to store SAML session data */ 562 | r.variables.cookie_auth_token = authToken; 563 | 564 | /* Set cookie_auth_token in the cookie */ 565 | r.headersOut["Set-Cookie"] = `auth_token=${authToken}; ${r.variables.saml_cookie_flags}`; 566 | 567 | return authToken; 568 | } 569 | 570 | /** 571 | * Checks for potential replay attacks by verifying if a SAML message ID has already been used. 572 | * 573 | * According to SAML 2.0 Core specification (section 1.3.4): 574 | * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf 575 | * Replay attacks can be prevented by ensuring that each SAML message ID is unique and 576 | * is not reused within a reasonable time frame. 577 | * 578 | * @param {Object} r - The request object. 579 | * @param {string} id - The SAML message ID. 580 | * @param {string} type - The SAML message type (e.g., 'Response' or 'LogoutResponse'). 581 | * @throws {Error} - If a replay attack is detected (the SAML message ID has already been used). 582 | */ 583 | function checkReplayAttack(r, id, type) { 584 | r.variables.saml_response_id = id; 585 | if (r.variables.saml_response_redeemed === '1') { 586 | throw Error (`An attempt to reuse a ${type} ID was detected: ` + 587 | `ID "${id}" has already been redeemed`); 588 | } 589 | r.variables.saml_response_redeemed = '1'; 590 | } 591 | 592 | function postLoginRedirect(r, relayState) { 593 | /* If RelayState is not set in the case of IDP-initiated SSO, redirect to the root */ 594 | relayState = relayState || r.variables.cookie_auth_redir || '/'; 595 | 596 | const redirectUrl = (r.variables.redirect_base || '') + relayState; 597 | 598 | r.return(302, redirectUrl); 599 | } 600 | 601 | function postLogoutRedirect(r, relayState) { 602 | let redirectUrl = r.variables.redirect_base || ''; 603 | redirectUrl += relayState; 604 | r.return(302, redirectUrl); 605 | } 606 | 607 | function clearSession(r) { 608 | r.log("SAML logout for " + r.variables.saml_name_id); 609 | r.variables.saml_access_granted = "-"; 610 | r.variables.saml_name_id = "-"; 611 | 612 | const cookieFlags = r.variables.saml_cookie_flags; 613 | const expired = 'Expires=Thu, 01 Jan 1970 00:00:00 GMT; '; 614 | r.headersOut['Set-Cookie'] = [ 615 | "auth_token=; " + expired + cookieFlags, 616 | "auth_redir=; " + expired + cookieFlags 617 | ]; 618 | } 619 | 620 | /** 621 | * Generates an outgoing SAML message based on the messageType parameter. 622 | * @param {string} messageType - The type of the SAML message (AuthnRequest, LogoutRequest, or LogoutResponse). 623 | * @param {object} r - The NGINX request object. 624 | * @param {object} opt - Optional object containing configuration options. 625 | * @returns {Promise} 626 | * @throws {Error} - If there is an issue processing the SAML request. 627 | */ 628 | async function produceSAMLMessage(messageType, r, opt) { 629 | let id; 630 | try { 631 | /* Validate SAML message type */ 632 | validateMessageType(messageType); 633 | 634 | /** 635 | * Parse configuration options based on messageType. For the case of the LogoutResponse, 636 | * we reuse the 'opt' object, since it defines by the LogoutRequest. 637 | */ 638 | opt = opt || parseConfigurationOptions(r, messageType); 639 | 640 | /* Generate a unique ID for the SAML message */ 641 | id = "_" + generateID(20); 642 | 643 | /* Handle messageType actions */ 644 | switch (messageType) { 645 | case "AuthnRequest": 646 | /* Save the original request uri to the "auth_redir" cookie */ 647 | setAuthRedirCookie(r); 648 | break; 649 | case "LogoutRequest": 650 | /** 651 | * Perform simple session termination if SAML SLO is disabled or if the 652 | * session has already expired or not found. 653 | */ 654 | if (!opt.nameID || opt.isSLODisabled) { 655 | clearSession(r) 656 | postLogoutRedirect(r, opt.relayState); 657 | return; 658 | } 659 | break; 660 | case "LogoutResponse": 661 | /* Obtain the status code for the LogoutResponse message */ 662 | opt.statusCode = getLogoutStatusCode(r.variables.saml_name_id, opt.nameID) 663 | break; 664 | } 665 | 666 | /* Create the SAML message based on messageType */ 667 | const xmlDoc = await createSAMLMessage(opt, id, messageType); 668 | 669 | /* Clear session if LogoutResponse StatusCode is Success */ 670 | (opt.statusCode === 'urn:oasis:names:tc:SAML:2.0:status:Success') && clearSession(r); 671 | 672 | /* Determine whether the HTTP response should be sent via POST or GET and dispatch */ 673 | const isPost = opt.requestBinding === 'HTTP-POST'; 674 | const postParam = messageType === 'LogoutResponse' ? 'SAMLResponse' : 'SAMLRequest'; 675 | dispatchResponse(r, xmlDoc, opt.idpServiceURL, opt.relayState, postParam, isPost); 676 | 677 | /* Set SAML request ID and redeemed flag */ 678 | r.variables.saml_request_id = id; 679 | r.variables.saml_request_redeemed = "1"; 680 | } catch (e) { 681 | samlError(r, 500, id, e); 682 | } 683 | } 684 | 685 | /** 686 | * Validates the messageType, ensuring that it is one of the allowed values. 687 | * @param {string} messageType - The type of the SAML message. 688 | * @throws {Error} - If the messageType is not one of the allowed values. 689 | */ 690 | function validateMessageType(messageType) { 691 | const allowedMessageTypes = ['AuthnRequest', 'LogoutRequest', 'LogoutResponse']; 692 | if (!allowedMessageTypes.includes(messageType)) { 693 | throw new Error(`Invalid messageType: ${messageType}. ` + 694 | `Allowed values are: ${allowedMessageTypes.join(', ')}`); 695 | } 696 | } 697 | 698 | function setAuthRedirCookie(r) { 699 | r.headersOut['Set-Cookie'] = [ 700 | "auth_redir=" + r.variables.request_uri + "; " + r.variables.saml_cookie_flags 701 | ]; 702 | } 703 | 704 | function getLogoutStatusCode(sessionNameID, requestNameID) { 705 | /* If no session exists, return Logout Success */ 706 | if (!sessionNameID || sessionNameID === '-') { 707 | return 'urn:oasis:names:tc:SAML:2.0:status:Success'; 708 | } 709 | 710 | /* If session exists, return Logout Success if NameID matches */ 711 | return requestNameID === sessionNameID 712 | ? 'urn:oasis:names:tc:SAML:2.0:status:Success' 713 | : 'urn:oasis:names:tc:SAML:2.0:status:Requester'; 714 | } 715 | 716 | async function createSAMLMessage(opt, id, messageType) { 717 | const handlers = { 718 | AuthnRequest: () => ({ 719 | assertionConsumerServiceURL: ` AssertionConsumerServiceURL="${opt.spServiceURL}"`, 720 | protocolBinding: ' ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"', 721 | forceAuthn: opt.forceAuthn ? ' ForceAuthn="true"' : null, 722 | nameIDPolicy: ``, 723 | }), 724 | LogoutRequest: () => ({ 725 | nameID: `${opt.nameID}`, 726 | }), 727 | LogoutResponse: () => ({ 728 | inResponseTo: ` InResponseTo="${opt.inResponseTo}"`, 729 | status: ``, 730 | }), 731 | }; 732 | 733 | const handlerResult = handlers[messageType](); 734 | const assertionConsumerServiceURL = handlerResult.assertionConsumerServiceURL || ""; 735 | const protocolBinding = handlerResult.protocolBinding || ""; 736 | const forceAuthn = handlerResult.forceAuthn || ""; 737 | const nameIDPolicy = handlerResult.nameIDPolicy || ""; 738 | const nameID = handlerResult.nameID || ""; 739 | const inResponseTo = handlerResult.inResponseTo || ""; 740 | const status = handlerResult.status || ""; 741 | 742 | let message = 743 | `' + 755 | `${opt.spEntityID}` + 756 | `${opt.isSigned ? samlSignatureTemplate(id) : ''}` + 757 | nameID + 758 | nameIDPolicy + 759 | status + 760 | ``; 761 | 762 | let root; 763 | try { 764 | root = (xml.parse(message)).$root; 765 | } catch (e) { 766 | throw Error(`Failed to create ${messageType} from XML template: ${e.message}`); 767 | } 768 | 769 | if (opt.isSigned) { 770 | try { 771 | const rootSignature = root.Signature; 772 | await digestSAML(rootSignature, true); 773 | await signatureSAML(rootSignature, opt.signKey, true); 774 | } catch (e) { 775 | throw Error(`Failed to sign ${messageType}: ${e.message}`); 776 | } 777 | } 778 | 779 | return xml.serializeToString(root); 780 | } 781 | 782 | /** 783 | * Generates a SAML signature XML template with the provided ID. 784 | * 785 | * @param {string} id - The ID to use as a reference within the signature template. 786 | * @returns {string} - The SAML signature XML template with the specified ID. 787 | */ 788 | function samlSignatureTemplate(id) { 789 | const signTemplate = 790 | '' + 791 | '' + 792 | '' + 793 | '' + 794 | `` + 795 | '' + 796 | '' + 797 | '' + 798 | '' + 799 | '' + 800 | '' + 801 | '' + 802 | '' + 803 | '' + 804 | ''; 805 | 806 | return signTemplate; 807 | } 808 | 809 | function postFormTemplate(samlMessage, idpServiceUrl, relayState, messageType ) { 810 | relayState = relayState ? `` : ""; 811 | 812 | return ` 813 | 814 | 815 | 816 | 817 | NGINX SAML SSO 818 | 819 | 820 | 826 | 831 |
832 | 833 | 834 | ${relayState} 835 | 838 |
839 | 840 | `; 841 | } 842 | 843 | /** 844 | * Dispatches a SAML response to the IdP service URL using either HTTP-POST or HTTP-Redirect binding. 845 | * 846 | * @param {object} r - The NJS HTTP request object. 847 | * @param {string} root - The SAML response XML string. 848 | * @param {string} idpServiceUrl - The IdP service URL where the response should be sent. 849 | * @param {string} relayState - The RelayState parameter value to include with the response. 850 | * @param {string} postParam - The name of the POST parameter to use for sending the encoded XML. 851 | * @param {boolean} isPost - If true, use HTTP-POST binding; otherwise, use HTTP-Redirect binding. 852 | * @returns {object} - The NJS HTTP response object with appropriate headers and content. 853 | */ 854 | function dispatchResponse(r, root, idpServiceUrl, relayState, postParam, isPost) { 855 | let encodedXml; 856 | 857 | // Set outgoing headers for the response 858 | r.headersOut['Content-Type'] = "text/html"; 859 | 860 | if (isPost) { 861 | // Encode the XML string as base64 for the HTTP-POST binding 862 | encodedXml = Buffer.from(root).toString("base64"); 863 | 864 | // Return the response with the POST form template 865 | return r.return(200, postFormTemplate(encodedXml, idpServiceUrl, relayState, postParam)); 866 | } else { 867 | // Compress and encode the XML string as base64 for the HTTP-Redirect binding 868 | const compressedXml = zlib.deflateRawSync(root); 869 | encodedXml = Buffer.from(compressedXml).toString("base64"); 870 | 871 | // Construct the IdP service URL with the encoded XML and RelayState (if provided) 872 | const url = `${idpServiceUrl}?${postParam}=${encodeURIComponent(encodedXml)}` + 873 | `${relayState ? `&RelayState=${relayState}` : ""}`; 874 | 875 | // Return the response with a 302 redirect to the constructed URL 876 | return r.return(302, url); 877 | } 878 | } 879 | 880 | /* 881 | * verifySAMLSignature() implements a verify clause 882 | * from Profiles for the OASIS SAML V2.0 883 | * 4.1.4.3 Message Processing Rules 884 | * Verify any signatures present on the assertion(s) or the response 885 | * 886 | * verification is done in accordance with 887 | * Assertions and Protocols for the OASIS SAML V2.0 888 | * 5.4 XML Signature Profile 889 | * 890 | * The following signature algorithms are supported: 891 | * - http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 892 | * - http://www.w3.org/2000/09/xmldsig#rsa-sha1 893 | * 894 | * The following digest algorithms are supported: 895 | * - http://www.w3.org/2000/09/xmldsig#sha1 896 | * - http://www.w3.org/2001/04/xmlenc#sha256 897 | * 898 | * @param root an XMLDoc object returned by xml.parse(). 899 | * @param keyDataArray is array of SubjectPublicKeyInfo in PKCS#1 format. 900 | */ 901 | async function verifySAMLSignature(root, keyDataArray) { 902 | const type = root.$name; 903 | const rootSignature = root.Signature; 904 | 905 | if (!rootSignature) { 906 | throw Error(`Message is unsigned`); 907 | } 908 | 909 | const errors = []; 910 | for (let i = 0; i < keyDataArray.length; i++) { 911 | try { 912 | const digestResult = await digestSAML(rootSignature); 913 | const signatureResult = await signatureSAML(rootSignature, keyDataArray[i]); 914 | 915 | if (digestResult && signatureResult) { 916 | return; 917 | } else { 918 | errors.push(`Key index ${i}: signature verification failed`); 919 | } 920 | } catch (e) { 921 | errors.push(e.message); 922 | } 923 | } 924 | 925 | throw Error(`Error verifying ${type} message signature: ${errors.join(', ')}`); 926 | } 927 | 928 | async function digestSAML(signature, produce) { 929 | const parent = signature.$parent; 930 | const signedInfo = signature.SignedInfo; 931 | const reference = signedInfo.Reference; 932 | 933 | /* Sanity check. */ 934 | 935 | const URI = reference.$attr$URI; 936 | const ID = parent.$attr$ID; 937 | 938 | if (URI != `#${ID}`) { 939 | throw Error(`signed reference URI ${URI} does not point to the parent ${ID}`); 940 | } 941 | 942 | /* 943 | * Assertions and Protocols for the OASIS SAML V2.0 944 | * 5.4.4 Transforms 945 | * 946 | * Signatures in SAML messages SHOULD NOT contain transforms other than 947 | * the http://www.w3.org/2000/09/xmldsig#enveloped-signature and 948 | * canonicalization transforms http://www.w3.org/2001/10/xml-exc-c14n# or 949 | * http://www.w3.org/2001/10/xml-exc-c14n#WithComments. 950 | */ 951 | 952 | const transforms = reference.Transforms.$tags$Transform; 953 | const transformAlgs = transforms.map(t => t.$attr$Algorithm); 954 | 955 | if (transformAlgs[0] != 'http://www.w3.org/2000/09/xmldsig#enveloped-signature') { 956 | throw Error(`unexpected digest transform ${transforms[0]}`); 957 | } 958 | 959 | if (!transformAlgs[1].startsWith('http://www.w3.org/2001/10/xml-exc-c14n#')) { 960 | throw Error(`unexpected digest transform ${transforms[1]}`); 961 | } 962 | 963 | const namespaces = transforms[1].InclusiveNamespaces; 964 | const prefixList = namespaces ? namespaces.$attr$PrefixList: null; 965 | 966 | const withComments = transformAlgs[1].slice(39) == 'WithComments'; 967 | 968 | let hash; 969 | const alg = reference.DigestMethod.$attr$Algorithm; 970 | 971 | switch (alg) { 972 | case "http://www.w3.org/2000/09/xmldsig#sha1": 973 | hash = "SHA-1"; 974 | break; 975 | case "http://www.w3.org/2001/04/xmlenc#sha256": 976 | hash = "SHA-256"; 977 | break; 978 | default: 979 | throw Error(`unexpected digest Algorithm ${alg}`); 980 | } 981 | 982 | const c14n = xml.exclusiveC14n(parent, signature, withComments, prefixList); 983 | const dgst = await crypto.subtle.digest(hash, c14n); 984 | const b64dgst = Buffer.from(dgst).toString('base64'); 985 | 986 | if (produce) { 987 | signedInfo.Reference.DigestValue.$text = b64dgst; 988 | return b64dgst; 989 | } 990 | 991 | const expectedDigest = signedInfo.Reference.DigestValue.$text; 992 | 993 | return expectedDigest === b64dgst; 994 | } 995 | 996 | function keyPem2Der(pem, type) { 997 | const pemJoined = pem.toString().split('\n').join(''); 998 | const pemHeader = `-----BEGIN ${type} KEY-----`; 999 | const pemFooter = `-----END ${type} KEY-----`; 1000 | const pemContents = pemJoined.substring(pemHeader.length, pemJoined.length - pemFooter.length); 1001 | return Buffer.from(pemContents, 'base64'); 1002 | } 1003 | 1004 | function base64decode(b64) { 1005 | const joined = b64.toString().split('\n').join(''); 1006 | return Buffer.from(joined, 'base64'); 1007 | } 1008 | 1009 | /** 1010 | * Signs or verifies a SAML signature using the specified key data. 1011 | * 1012 | * @param {object} signature - The SAML signature XMLDoc object. 1013 | * @param {string} key_data - The key data, either a private key for signing or a public key for verification. 1014 | * @param {boolean} produce - If true, signs the SAML signature; if false, verifies the signature. 1015 | * @returns {Promise} - If produce is true, returns the updated signature object; 1016 | * if produce is false, returns a boolean indicating the verification result. 1017 | * @throws {Error} - If the signature algorithm is unexpected or unsupported. 1018 | */ 1019 | async function signatureSAML(signature, key_data, produce) { 1020 | let method, hash; 1021 | const signedInfo = signature.SignedInfo; 1022 | const alg = signedInfo.SignatureMethod.$attr$Algorithm; 1023 | 1024 | switch (alg) { 1025 | case "http://www.w3.org/2000/09/xmldsig#rsa-sha1": 1026 | method = "RSASSA-PKCS1-v1_5"; 1027 | hash = "SHA-1"; 1028 | break; 1029 | case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256": 1030 | method = "RSASSA-PKCS1-v1_5"; 1031 | hash = "SHA-256"; 1032 | break; 1033 | default: 1034 | throw Error(`unexpected signature Algorithm ${alg}`); 1035 | } 1036 | 1037 | const withComments = signedInfo.CanonicalizationMethod 1038 | .$attr$Algorithm.slice(39) == 'WithComments'; 1039 | 1040 | const signedInfoC14n = xml.exclusiveC14n(signedInfo, null, withComments); 1041 | 1042 | if (produce) { 1043 | const der = keyPem2Der(key_data, "PRIVATE"); 1044 | const key = await crypto.subtle.importKey("pkcs8", der, { name: method, hash }, 1045 | false, [ "sign" ]); 1046 | 1047 | let sig = await crypto.subtle.sign({ name: method }, key, signedInfoC14n); 1048 | 1049 | signature.SignatureValue.$text = Buffer.from(sig).toString('base64'); 1050 | return signature; 1051 | } 1052 | 1053 | const der = keyPem2Der(key_data, "PUBLIC"); 1054 | const key = await crypto.subtle.importKey("spki", der, { name: method, hash }, 1055 | false, [ "verify" ]); 1056 | 1057 | const expectedValue = base64decode(signature.SignatureValue.$text); 1058 | return await crypto.subtle.verify({ name: method }, key, expectedValue, 1059 | signedInfoC14n); 1060 | } 1061 | 1062 | /** 1063 | * decryptSAML() decrypts an EncryptedAssertion element of a SAML document. 1064 | * It supports various key and data encryption algorithms as defined in the 1065 | * XML Encryption Syntax and Processing Version 1.1 and 1.0 specifications. 1066 | * 1067 | * The following key encryption algorithms are supported: 1068 | * - http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p 1069 | * - http://www.w3.org/2009/xmlenc11#rsa-oaep 1070 | * - http://www.w3.org/2001/04/xmlenc#rsa-1_5 1071 | * 1072 | * The following data encryption algorithms are supported: 1073 | * - http://www.w3.org/2001/04/xmlenc#aes128-cbc 1074 | * - http://www.w3.org/2001/04/xmlenc#aes192-cbc 1075 | * - http://www.w3.org/2001/04/xmlenc#aes256-cbc 1076 | * - http://www.w3.org/2009/xmlenc11#aes128-gcm 1077 | * - http://www.w3.org/2009/xmlenc11#aes192-gcm 1078 | * - http://www.w3.org/2009/xmlenc11#aes256-gcm 1079 | * 1080 | * @async 1081 | * @function 1082 | * @param {Object} root - The root object containing EncryptedData and KeyInfo elements. 1083 | * @param {string} key_data - The private key in PEM format. 1084 | * @returns {Promise} - The decrypted XML document. 1085 | * @throws {Error} - If unsupported key or data encryption algorithm is encountered. 1086 | */ 1087 | async function decryptSAML(root, key_data) { 1088 | /* Extract key encryption algorithm and data encryption algorithm */ 1089 | const keyAlg = root.EncryptedData.KeyInfo.EncryptedKey.EncryptionMethod.$attr$Algorithm; 1090 | const dataAlg = root.EncryptedData.EncryptionMethod.$attr$Algorithm; 1091 | 1092 | /* Determine method and hash based on key encryption algorithm */ 1093 | let keyMethod, keyHash; 1094 | switch (keyAlg) { 1095 | case 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p': 1096 | keyMethod = 'RSA-OAEP'; 1097 | keyHash = 'SHA-1'; 1098 | break; 1099 | case 'http://www.w3.org/2009/xmlenc11#rsa-oaep': 1100 | keyMethod = 'RSA-OAEP'; 1101 | keyHash = 'SHA-256'; 1102 | break; 1103 | case 'http://www.w3.org/2001/04/xmlenc#rsa-1_5': 1104 | keyMethod = 'RSASSA-PKCS1-v1_5'; 1105 | keyHash = 'SHA-256'; 1106 | break; 1107 | default: 1108 | throw new Error(`Unsupported key encryption algorithm: "${keyAlg}"`); 1109 | } 1110 | 1111 | /* Determine cipher, mode, and IV length based on data encryption algorithm */ 1112 | let dataCipher, dataCipherMode, dataCipherIvLength; 1113 | switch (dataAlg) { 1114 | case 'http://www.w3.org/2001/04/xmlenc#aes128-cbc': 1115 | dataCipher = 'aes'; 1116 | dataCipherMode = 'cbc'; 1117 | dataCipherIvLength = 16; 1118 | break; 1119 | case 'http://www.w3.org/2001/04/xmlenc#aes192-cbc': 1120 | dataCipher = 'aes'; 1121 | dataCipherMode = 'cbc'; 1122 | dataCipherIvLength = 16; 1123 | break; 1124 | case 'http://www.w3.org/2001/04/xmlenc#aes256-cbc': 1125 | dataCipher = 'aes'; 1126 | dataCipherMode = 'cbc'; 1127 | dataCipherIvLength = 16; 1128 | break; 1129 | case 'http://www.w3.org/2009/xmlenc11#aes128-gcm': 1130 | dataCipher = 'aes'; 1131 | dataCipherMode = 'gcm'; 1132 | dataCipherIvLength = 12; 1133 | break; 1134 | case 'http://www.w3.org/2009/xmlenc11#aes192-gcm': 1135 | dataCipher = 'aes'; 1136 | dataCipherMode = 'gcm'; 1137 | dataCipherIvLength = 12; 1138 | break; 1139 | case 'http://www.w3.org/2009/xmlenc11#aes256-gcm': 1140 | dataCipher = 'aes'; 1141 | dataCipherMode = 'gcm'; 1142 | dataCipherIvLength = 12; 1143 | break; 1144 | default: 1145 | throw new Error(`Unsupported data encryption algorithm: "${dataAlg}"`); 1146 | } 1147 | 1148 | /* Load private key */ 1149 | const der = keyPem2Der(key_data, "PRIVATE"); 1150 | 1151 | /* Import the private key */ 1152 | const importedKey = await crypto.subtle.importKey( 1153 | 'pkcs8', 1154 | der, 1155 | { name: keyMethod, hash: keyHash }, 1156 | false, 1157 | ['decrypt'] 1158 | ); 1159 | 1160 | /* Decrypt EncryptedKey */ 1161 | const encryptedKeyNode = root.EncryptedData.KeyInfo.EncryptedKey.CipherData.CipherValue; 1162 | const encryptedKey = Buffer.from(encryptedKeyNode.$text, 'base64'); 1163 | const decryptedKey = await crypto.subtle.decrypt( 1164 | { name: keyMethod }, 1165 | importedKey, 1166 | encryptedKey 1167 | ); 1168 | 1169 | /* Import decrypted AES key */ 1170 | const aesKey = await crypto.subtle.importKey( 1171 | 'raw', 1172 | decryptedKey, 1173 | { name: `${dataCipher}-${dataCipherMode}` }, 1174 | false, 1175 | ['decrypt'] 1176 | ); 1177 | 1178 | /* Decrypt EncryptedData */ 1179 | const encryptedDataNode = root.EncryptedData.CipherData.CipherValue; 1180 | const encryptedData = Buffer.from(encryptedDataNode.$text, 'base64'); 1181 | const iv = encryptedData.slice(0, dataCipherIvLength); 1182 | const cipherText = encryptedData.slice(dataCipherIvLength); 1183 | 1184 | const decryptedData = await crypto.subtle.decrypt( 1185 | { 1186 | name: `${dataCipher}-${dataCipherMode}`, 1187 | iv: iv, 1188 | }, 1189 | aesKey, 1190 | cipherText 1191 | ); 1192 | 1193 | /* Parse decryptedData for an XML document */ 1194 | return xml.parse(decryptedData); 1195 | } 1196 | 1197 | function parseConfigurationOptions(r, messageType) { 1198 | const escapeXML = getEscapeXML(); 1199 | let opt = {}; 1200 | var prefix = `Failed to parse configuration options for ${messageType}:`; 1201 | 1202 | opt.spEntityID = validateUrlOrUrn('saml_sp_entity_id'); 1203 | opt.idpEntityID = validateUrlOrUrn('saml_idp_entity_id'); 1204 | 1205 | if (messageType === 'Response' || messageType === 'AuthnRequest') { 1206 | opt.spServiceURL = validateUrlOrUrn('saml_sp_acs_url'); 1207 | opt.idpServiceURL = validateUrlOrUrn('saml_idp_sso_url'); 1208 | opt.relayState = r.variables.saml_sp_relay_state; 1209 | } 1210 | 1211 | if (messageType === 'Response') { 1212 | opt.wantSignedResponse = validateTrueOrFalse('saml_sp_want_signed_response'); 1213 | opt.wantSignedAssertion = validateTrueOrFalse('saml_sp_want_signed_assertion'); 1214 | opt.wantEncryptedAssertion = validateTrueOrFalse('saml_sp_want_encrypted_assertion'); 1215 | opt.allowedClockSkew = validateClockSkew('saml_allowed_clock_skew', 120); 1216 | } 1217 | 1218 | if (messageType === 'AuthnRequest') { 1219 | opt.requestBinding = validateHttpPostOrRedirect('saml_sp_request_binding'); 1220 | opt.isSigned = validateTrueOrFalse('saml_sp_sign_authn'); 1221 | opt.forceAuthn = validateTrueOrFalse('saml_sp_force_authn'); 1222 | opt.nameIDFormat = validateNameIdFormat('saml_sp_nameid_format'); 1223 | } 1224 | 1225 | if (messageType === 'LogoutResponse' || messageType === 'LogoutRequest') { 1226 | opt.idpServiceURL = validateUrlOrUrn('saml_idp_slo_url', true); 1227 | opt.isSLODisabled = !opt.idpServiceURL ? true : false; 1228 | if (!opt.isSLODisabled) { 1229 | opt.spServiceURL = validateUrlOrUrn('saml_sp_slo_url'); 1230 | opt.logoutResponseURL = validateUrlOrUrn('saml_idp_slo_response_url', true); 1231 | opt.requestBinding = validateHttpPostOrRedirect('saml_sp_slo_binding'); 1232 | opt.isSigned = validateTrueOrFalse('saml_sp_sign_slo'); 1233 | opt.wantSignedResponse = validateTrueOrFalse('saml_sp_want_signed_slo'); 1234 | } 1235 | opt.relayState = r.variables.saml_logout_landing_page; 1236 | opt.nameID = r.variables.saml_name_id; 1237 | opt.allowedClockSkew = validateClockSkew('saml_allowed_clock_skew', 120); 1238 | } 1239 | 1240 | if (opt.wantSignedResponse || opt.wantSignedAssertion) { 1241 | opt.verifyKeys = readKeysFromFile(r.variables.saml_idp_verification_certificate); 1242 | } 1243 | 1244 | if (opt.isSigned) { 1245 | opt.signKey = readKeysFromFile(r.variables.saml_sp_signing_key)[0]; 1246 | } 1247 | 1248 | if (r.variables.saml_sp_decryption_key) { 1249 | opt.decryptKey = readKeysFromFile(r.variables.saml_sp_decryption_key)[0]; 1250 | } 1251 | 1252 | function validateUrlOrUrn(name, allowEmpty) { 1253 | let value = r.variables[name]; 1254 | 1255 | if (allowEmpty && (value === '' || value === undefined)) { 1256 | return value; 1257 | } 1258 | 1259 | if (!isUrlOrUrn(value)) { 1260 | throw Error(`${prefix} Invalid "${name}": "${value}", must be URI.`); 1261 | } 1262 | 1263 | return escapeXML(value); 1264 | } 1265 | 1266 | function validateTrueOrFalse(name) { 1267 | const value = (r.variables[name]).toLowerCase(); 1268 | if (value !== 'true' && value !== 'false') { 1269 | throw Error(`${prefix} Invalid "${name}": "${value}", must be "true" or "false".`); 1270 | } 1271 | 1272 | return value === 'true'; 1273 | } 1274 | 1275 | function validateHttpPostOrRedirect(name) { 1276 | const value = r.variables[name]; 1277 | if (value !== "HTTP-POST" && value !== "HTTP-Redirect") { 1278 | throw Error(`${prefix} Invalid "${name}": "${value}", ` + 1279 | `must be "HTTP-POST" or "HTTP-Redirect".`); 1280 | } 1281 | 1282 | return value; 1283 | } 1284 | 1285 | function validateNameIdFormat(name) { 1286 | const value = r.variables[name]; 1287 | const allowedFormats = [ 1288 | "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 1289 | "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", 1290 | "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", 1291 | "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName", 1292 | "urn:oasis:names:tc:SAML:1.1:nameid-format:kerberos", 1293 | "urn:oasis:names:tc:SAML:2.0:nameid-format:entity", 1294 | "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", 1295 | "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", 1296 | "urn:oasis:names:tc:SAML:2.0:nameid-format:encrypted", 1297 | ]; 1298 | 1299 | if (!allowedFormats.includes(value)) { 1300 | throw Error(`${prefix} Invalid "${name}": "${value}"`); 1301 | } 1302 | 1303 | return value; 1304 | } 1305 | 1306 | function validateClockSkew(name, defaultValue) { 1307 | const value = r.variables[name]; 1308 | if (value === undefined) { 1309 | return defaultValue; 1310 | } 1311 | 1312 | const parsedValue = parseInt(value, 10); 1313 | if (isNaN(parsedValue) || parsedValue.toString() !== value) { 1314 | throw Error(`${prefix} Invalid "${name}": "${value}", must be a valid integer.`); 1315 | } 1316 | 1317 | return parsedValue; 1318 | } 1319 | 1320 | return opt; 1321 | } 1322 | 1323 | function getEscapeXML() { 1324 | const fpc = Function.prototype.call; 1325 | const _replace = fpc.bind(fpc, String.prototype.replace); 1326 | 1327 | const tbl = { 1328 | '<': '<', 1329 | '>': '>', 1330 | "'": ''', 1331 | '"': '"', 1332 | '&': '&', 1333 | }; 1334 | tbl.__proto__ = null; 1335 | 1336 | return function (str) { 1337 | return _replace(str, /[<>'"&]/g, c => tbl[c]); 1338 | } 1339 | }; 1340 | 1341 | function isUrlOrUrn(str) { 1342 | const urlRegEx = /^((?:(?:https?):)\/\/)?((?:(?:[^:@]+(?::[^:@]+)?|[^:@]+@[^:@]+)(?::\d+)?)|(?:\[[a-fA-F0-9:]+]))(\/(?:[^?#]*))?(\\?(?:[^#]*))?(#(?:.*))?$/; 1343 | const urnRegEx = /^urn:[a-z0-9][a-z0-9-.]{1,31}:[a-z0-9()+,\-.:=@;$_!*'%/?#]+$/i; 1344 | 1345 | if (urlRegEx.test(str)) { 1346 | return "URL"; 1347 | } else if (urnRegEx.test(str)) { 1348 | return "URN"; 1349 | } else { 1350 | return false; 1351 | } 1352 | } 1353 | 1354 | /** 1355 | * Reads a file containing one or more keys (public or private) and returns an array of the keys. 1356 | * 1357 | * @param {string} keyFile - The path to the file containing the keys. 1358 | * @returns {Array} - An array of keys in PEM format. 1359 | */ 1360 | function readKeysFromFile(keyFile) { 1361 | try { 1362 | const pem = fs.readFileSync(keyFile, 'utf8'); 1363 | const regex = /-----BEGIN (PUBLIC|PRIVATE) KEY-----[\s\S]*?-----END (PUBLIC|PRIVATE) KEY-----/g; 1364 | const matches = pem.match(regex); 1365 | const pemList = []; 1366 | 1367 | for (var i = 0; i < matches.length; i++) { 1368 | pemList.push(matches[i]); 1369 | } 1370 | 1371 | return pemList; 1372 | } catch (e) { 1373 | throw Error(`Failed to read private or public key from file "${keyFile}": ${e.message}`); 1374 | } 1375 | } 1376 | -------------------------------------------------------------------------------- /saml_sp.server_conf: -------------------------------------------------------------------------------- 1 | # This file contains an extended NGINX SAML SSO configuration, 2 | # providing advanced options and settings. 3 | # In general, it is not intended for modifications, as the default values 4 | # are optimized for most use cases. 5 | 6 | set $saml_request_id ""; 7 | set $saml_response_id ""; 8 | 9 | set $internal_error_message "SAML Authentication failed. If problem persists, contact your system administrator. "; 10 | 11 | # This variable is set by the JavaScript code and contains the error details. 12 | js_var $internal_error_details; 13 | 14 | # Sets the maximum allowed size of the client request body. 15 | # Specifies the maximum size of an incoming SAML claim via the HTTP-POST. 16 | client_max_body_size 64k; 17 | 18 | # Sets buffer size for reading client request body. 19 | # To fit a SAML claim into one buffer. 20 | client_body_buffer_size 64k; 21 | 22 | # Decompress IdP responses if necessary. 23 | gunzip on; 24 | 25 | location = /saml/acs { 26 | # SAML Assertion Consumer Service (or ACS) location. 27 | # Receiving and processing SAML messages from IdP. 28 | js_content samlsp.handleSingleSignOn; 29 | status_zone "SAMLSSO ACS"; 30 | error_page 500 @saml_error; 31 | } 32 | 33 | location = /saml/sls { 34 | # SAML Single Logout Service (or SLS) location. 35 | # Receiving and processing SAML 36 | # or messages from IdP. 37 | js_content samlsp.handleSingleLogout; 38 | status_zone "SAMLSSO SLS"; 39 | error_page 500 @saml_error; 40 | } 41 | 42 | location @do_samlsp_flow { 43 | # Named location that initiates SAML Authentication by sending 44 | # SAML to the IdP if the user session is not found. 45 | js_content samlsp.initiateSingleSignOn; 46 | set $cookie_auth_token ""; 47 | } 48 | 49 | location = /logout { 50 | # Requests to this location initiate the logout process by sending 51 | # SAML to the IdP. 52 | js_content samlsp.initiateSingleLogout; 53 | status_zone "SAMLSSO logout"; 54 | error_page 500 @saml_error; 55 | } 56 | 57 | location = /_logout { 58 | # This location is the default value of $saml_logout_landing_page. 59 | default_type text/plain; 60 | return 200 "Logged out\n"; 61 | } 62 | 63 | location @saml_error { 64 | # This location is called when any SAML SSO error occurs 65 | status_zone "SAMLSP error"; 66 | default_type text/plain; 67 | return 500 "$internal_error_message $internal_error_details"; 68 | } 69 | 70 | location /api/ { 71 | api write=on; 72 | allow 127.0.0.1; # Only the NGINX host may call the NGINX Plus API 73 | deny all; 74 | access_log off; 75 | } 76 | -------------------------------------------------------------------------------- /saml_sp_configuration.conf: -------------------------------------------------------------------------------- 1 | # SAML SSO configuration 2 | # 3 | # Each map block allows multiple values so that multiple IdPs can be supported, 4 | # the $host variable is used as the default input parameter but can be changed. 5 | # 6 | map $host $saml_sp_entity_id { 7 | # Unique identifier that identifies the SP to the IdP. 8 | # Must be URL or URN. 9 | default "http://sp.example.com"; 10 | } 11 | 12 | map $host $saml_sp_acs_url { 13 | # The ACS URL, an endpoint on the SP where the IdP 14 | # will redirect to with its authentication response. 15 | # Must match the ACS location defined in the "saml_sp.serer_conf" file. 16 | default "http://sp.example.com:8010/saml/acs"; 17 | } 18 | 19 | map $host $saml_sp_request_binding { 20 | # Refers to the method by which an authentication request is sent from 21 | # the SP to an IdP during the Single Sign-On (SSO) process. 22 | # Only HTTP-POST or HTTP-Redirect methods are allowed. 23 | default 'HTTP-POST'; 24 | } 25 | 26 | map $host $saml_sp_sign_authn { 27 | # Whether the SP should sign the AuthnRequest sent to the IdP. 28 | default "true"; 29 | } 30 | 31 | map $host $saml_sp_signing_key { 32 | # Maps SP to the private key file that will be used to sign 33 | # the AuthnRequest or LogoutRequest sent to the IdP. 34 | default "conf.d/saml_sp_sign.key"; 35 | } 36 | 37 | map $host $saml_sp_decryption_key { 38 | # Specifies the private key that the SP uses to decrypt encrypted assertion 39 | # or NameID from the IdP. 40 | default ""; 41 | } 42 | 43 | map $host $saml_sp_force_authn { 44 | # Whether the SP should force re-authentication of the user by the IdP. 45 | default "false"; 46 | } 47 | 48 | map $host $saml_sp_nameid_format { 49 | # Indicates the desired format of the name identifier in the SAML assertion 50 | # generated by the IdP. Check section 8.3 of the SAML 2.0 Core specification 51 | # (http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf) 52 | # for the list of allowed NameID Formats. 53 | default "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"; 54 | } 55 | 56 | map $host $saml_sp_relay_state { 57 | # Relative or absolute URL the SP should redirect to 58 | # after successful sign on. 59 | default ""; 60 | } 61 | 62 | map $host $saml_sp_want_signed_response { 63 | # Whether the SP wants the SAML Response from the IdP 64 | # to be digitally signed. 65 | default "true"; 66 | } 67 | 68 | map $host $saml_sp_want_signed_assertion { 69 | # Whether the SP wants the SAML Assertion from the IdP 70 | # to be digitally signed. 71 | default "true"; 72 | } 73 | 74 | map $host $saml_sp_want_encrypted_assertion { 75 | # Whether the SP wants the SAML Assertion from the IdP 76 | # to be encrypted. 77 | default "false"; 78 | } 79 | 80 | map $host $saml_idp_entity_id { 81 | # Unique identifier that identifies the IdP to the SP. 82 | # Must be URL or URN. 83 | default "http://idp.example.com"; 84 | } 85 | 86 | map $host $saml_idp_sso_url { 87 | # IdP endpoint that the SP will send the SAML AuthnRequest to initiate 88 | # an authentication process. 89 | default "http://idp.example.com:8080/saml2/idp"; 90 | } 91 | 92 | map $host $saml_idp_verification_certificate { 93 | # Certificate file that will be used to verify the digital signature 94 | # on the SAML Response, LogoutRequest or LogoutResponse received from IdP. 95 | # Must be public key in PKCS#1 format. See documentation on how to convert 96 | # X.509 PEM to DER format. 97 | default "conf.d/saml_idp_verify.spki"; 98 | } 99 | 100 | ######### Single Logout (SLO) ######### 101 | 102 | map $host $saml_sp_slo_url { 103 | # SP endpoint that the IdP will send the SAML LogoutRequest to initiate 104 | # a logout process or LogoutResponse to confirm the logout. 105 | default "http://sp.example.com:8010/saml/sls"; 106 | } 107 | 108 | map $host $saml_sp_slo_binding { 109 | # Refers to the method by which a LogoutRequest or LogoutResponse 110 | # is sent from the SP to an IdP during the Single Logout (SLO) process. 111 | # Only HTTP-POST or HTTP-Redirect methods are allowed. 112 | default 'HTTP-POST'; 113 | } 114 | 115 | map $host $saml_sp_sign_slo { 116 | # Whether the SP must sign the LogoutRequest or LogoutResponse 117 | # sent to the IdP. 118 | default "true"; 119 | } 120 | 121 | map $host $saml_idp_slo_url { 122 | # IdP endpoint that the SP will send the LogoutRequest to initiate 123 | # a logout process or LogoutResponse to confirm the logout. 124 | # If not set, the SAML Single Logout (SLO) feature is DISABLED and 125 | # requests to the 'logout' location will result in the termination 126 | # of the user session and a redirect to the logout landing page. 127 | default "http://idp.example.com:8080/saml2/idp/sls"; 128 | } 129 | 130 | map $host $saml_sp_want_signed_slo { 131 | # Whether the SP wants the SAML LogoutRequest or LogoutResponse from the IdP 132 | # to be digitally signed. 133 | default "true"; 134 | } 135 | 136 | map $host $saml_logout_landing_page { 137 | # Where to redirect user after requesting /logout location. This can be 138 | # replaced with a custom logout page, or complete URL. 139 | default "/_logout"; # Built-in, simple logout page 140 | } 141 | 142 | map $proto $saml_cookie_flags { 143 | http "Path=/; SameSite=lax;"; # For HTTP/plaintext testing 144 | https "Path=/; SameSite=lax; HttpOnly; Secure;"; # Production recommendation 145 | } 146 | 147 | map $http_x_forwarded_port $redirect_base { 148 | "" $proto://$host:$server_port; 149 | default $proto://$host:$http_x_forwarded_port; 150 | } 151 | 152 | map $http_x_forwarded_proto $proto { 153 | "" $scheme; 154 | default $http_x_forwarded_proto; 155 | } 156 | 157 | # ADVANCED CONFIGURATION BELOW THIS LINE 158 | # Additional advanced configuration (server context) in saml_sp.server_conf 159 | 160 | ######### Shared memory zones that keep the SAML-related key-value databases 161 | 162 | # Zone for storing AuthnRequest and LogoutRequest message identifiers (ID) 163 | # to prevent replay attacks. (REQUIRED) 164 | # Timeout determines how long the SP waits for a response from the IDP, 165 | # i.e. how long the user authentication process can take. 166 | keyval_zone zone=saml_request_id:1M state=/var/lib/nginx/state/saml_request_id.json timeout=5m; 167 | 168 | # Zone for storing SAML Response message identifiers (ID) to prevent replay attacks. (REQUIRED) 169 | # Timeout determines how long the SP keeps IDs to prevent reuse. 170 | keyval_zone zone=saml_response_id:1M state=/var/lib/nginx/state/saml_response_id.json timeout=1h; 171 | 172 | # Zone for storing SAML session access information. (REQUIRED) 173 | # Timeout determines how long the SP keeps session access decision (the session lifetime). 174 | keyval_zone zone=saml_session_access:1M state=/var/lib/nginx/state/saml_session_access.json timeout=1h; 175 | 176 | # Zone for storing SAML NameID values. (REQUIRED) 177 | # Timeout determines how long the SP keeps NameID values. Must be equal to session lifetime. 178 | keyval_zone zone=saml_name_id:1M state=/var/lib/nginx/state/saml_name_id.json timeout=1h; 179 | 180 | # Zone for storing SAML NameID format values. (REQUIRED) 181 | # Timeout determines how long the SP keeps NameID format values. Must be equal to session lifetime. 182 | keyval_zone zone=saml_name_id_format:1M state=/var/lib/nginx/state/saml_name_id_format.json timeout=1h; 183 | 184 | # Zone for storing SAML SessionIndex values. (REQUIRED) 185 | # Timeout determines how long the SP keeps SessionIndex values. Must be equal to session lifetime. 186 | keyval_zone zone=saml_session_index:1M state=/var/lib/nginx/state/saml_session_index.json timeout=1h; 187 | 188 | # Zone for storing SAML AuthnContextClassRef values. (REQUIRED) 189 | # Timeout determines how long the SP keeps AuthnContextClassRef values. Must be equal to session lifetime. 190 | keyval_zone zone=saml_authn_context_class_ref:1M state=/var/lib/nginx/state/saml_authn_context_class_ref.json timeout=1h; 191 | 192 | # Zones for storing SAML attributes values. (OPTIONAL) 193 | # Timeout determines how long the SP keeps attributes values. Must be equal to session lifetime. 194 | keyval_zone zone=saml_attrib_uid:1M state=/var/lib/nginx/state/saml_attrib_uid.json timeout=1h; 195 | keyval_zone zone=saml_attrib_name:1M state=/var/lib/nginx/state/saml_attrib_name.json timeout=1h; 196 | keyval_zone zone=saml_attrib_memberOf:1M state=/var/lib/nginx/state/saml_attrib_memberOf.json timeout=1h; 197 | 198 | ######### SAML-related variables whose value is looked up by the key (session cookie) in the key-value database. 199 | 200 | # Required: 201 | keyval $saml_request_id $saml_request_redeemed zone=saml_request_id; # SAML Request ID 202 | keyval $saml_response_id $saml_response_redeemed zone=saml_response_id; # SAML Response ID 203 | keyval $cookie_auth_token $saml_access_granted zone=saml_session_access; # SAML Access decision 204 | keyval $cookie_auth_token $saml_name_id zone=saml_name_id; # SAML NameID 205 | keyval $cookie_auth_token $saml_name_id_format zone=saml_name_id_format; # SAML NameIDFormat 206 | keyval $cookie_auth_token $saml_session_index zone=saml_session_index; # SAML SessionIndex 207 | keyval $cookie_auth_token $saml_authn_context_class_ref zone=saml_authn_context_class_ref; # SAML AuthnContextClassRef 208 | 209 | # Optional: 210 | keyval $cookie_auth_token $saml_attrib_uid zone=saml_attrib_uid; 211 | keyval $cookie_auth_token $saml_attrib_name zone=saml_attrib_name; 212 | keyval $cookie_auth_token $saml_attrib_memberOf zone=saml_attrib_memberOf; 213 | 214 | ######### Imports a module that implements SAML SSO and SLO functionality 215 | js_import samlsp from conf.d/saml_sp.js; 216 | -------------------------------------------------------------------------------- /t/js_saml.t: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | # (C) Ivan Ovchinnikov 4 | # (C) Nginx, Inc. 5 | 6 | # Tests for njs-based SAML SSO solution. 7 | 8 | ############################################################################### 9 | 10 | use warnings; 11 | use strict; 12 | 13 | use Test::More; 14 | 15 | BEGIN { use FindBin; chdir($FindBin::Bin); } 16 | 17 | use lib 'lib'; 18 | use Test::Nginx; 19 | 20 | use MIME::Base64; 21 | use XML::LibXML; 22 | use JSON::PP; 23 | use URI::Escape; 24 | use DateTime; 25 | 26 | use IO::Uncompress::RawInflate qw(rawinflate $RawInflateError); 27 | use IO::Compress::RawDeflate qw(rawdeflate $RawDeflateError); 28 | 29 | use Crypt::OpenSSL::X509; 30 | use Crypt::OpenSSL::RSA; 31 | use Digest::SHA qw(sha1 sha256 sha384 sha512); 32 | 33 | use constant false => 0; 34 | use constant true => 1; 35 | 36 | ############################################################################### 37 | 38 | select STDERR; $| = 1; 39 | select STDOUT; $| = 1; 40 | 41 | eval { require JSON::PP; }; 42 | plan(skip_all => "JSON::PP not installed") if $@; 43 | 44 | my $t = Test::Nginx->new()->has(qw/http rewrite proxy gzip/) 45 | ->write_file_expand('nginx.conf', <<'EOF'); 46 | 47 | %%TEST_GLOBALS%% 48 | 49 | daemon off; 50 | 51 | events { 52 | } 53 | 54 | http { 55 | %%TEST_GLOBALS_HTTP%% 56 | 57 | variables_hash_max_size 2048; 58 | 59 | js_import samlsp from saml_sp.js; 60 | 61 | upstream my_backend { 62 | zone my_backend 64k; 63 | server localhost:8088; 64 | } 65 | 66 | map $host $saml_debug { 67 | default "1"; 68 | } 69 | 70 | keyval_zone zone=saml_sp_entity_id:1M state=%%TESTDIR%%/saml_sp_entity_id.json; 71 | keyval $host $saml_sp_entity_id zone=saml_sp_entity_id; 72 | 73 | keyval_zone zone=saml_sp_acs_url:1M state=%%TESTDIR%%/saml_sp_acs_url.json; 74 | keyval $host $saml_sp_acs_url zone=saml_sp_acs_url; 75 | 76 | keyval_zone zone=saml_sp_request_binding:1M state=%%TESTDIR%%/saml_sp_request_binding.json; 77 | keyval $host $saml_sp_request_binding zone=saml_sp_request_binding; 78 | 79 | keyval_zone zone=saml_sp_sign_authn:1M state=%%TESTDIR%%/saml_sp_sign_authn.json; 80 | keyval $host $saml_sp_sign_authn zone=saml_sp_sign_authn; 81 | 82 | keyval_zone zone=saml_sp_signing_key:1M state=%%TESTDIR%%/saml_sp_signing_key.json; 83 | keyval $host $saml_sp_signing_key zone=saml_sp_signing_key; 84 | 85 | keyval_zone zone=saml_sp_decryption_key:1M state=%%TESTDIR%%/saml_sp_decryption_key.json; 86 | keyval $host $saml_sp_decryption_key zone=saml_sp_decryption_key; 87 | 88 | keyval_zone zone=saml_sp_force_authn:1M state=%%TESTDIR%%/saml_sp_force_authn.json; 89 | keyval $host $saml_sp_force_authn zone=saml_sp_force_authn; 90 | 91 | keyval_zone zone=saml_sp_nameid_format:1M state=%%TESTDIR%%/saml_sp_nameid_format.json; 92 | keyval $host $saml_sp_nameid_format zone=saml_sp_nameid_format; 93 | 94 | keyval_zone zone=saml_sp_relay_state:1M state=%%TESTDIR%%/saml_sp_relay_state.json; 95 | keyval $host $saml_sp_relay_state zone=saml_sp_relay_state; 96 | 97 | keyval_zone zone=saml_sp_want_signed_response:1M state=%%TESTDIR%%/saml_sp_want_signed_response.json; 98 | keyval $host $saml_sp_want_signed_response zone=saml_sp_want_signed_response; 99 | 100 | keyval_zone zone=saml_sp_want_signed_assertion:1M state=%%TESTDIR%%/saml_sp_want_signed_assertion.json; 101 | keyval $host $saml_sp_want_signed_assertion zone=saml_sp_want_signed_assertion; 102 | 103 | keyval_zone zone=saml_sp_want_encrypted_assertion:1M state=%%TESTDIR%%/saml_sp_want_encrypted_assertion.json; 104 | keyval $host $saml_sp_want_encrypted_assertion zone=saml_sp_want_encrypted_assertion; 105 | 106 | keyval_zone zone=saml_idp_entity_id:1M state=%%TESTDIR%%/saml_idp_entity_id.json; 107 | keyval $host $saml_idp_entity_id zone=saml_idp_entity_id; 108 | 109 | keyval_zone zone=saml_idp_sso_url:1M state=%%TESTDIR%%/saml_idp_sso_url.json; 110 | keyval $host $saml_idp_sso_url zone=saml_idp_sso_url; 111 | 112 | keyval_zone zone=saml_idp_verification_certificate:1M state=%%TESTDIR%%/saml_idp_verification_certificate.json; 113 | keyval $host $saml_idp_verification_certificate zone=saml_idp_verification_certificate; 114 | 115 | keyval_zone zone=saml_sp_slo_url:1M state=%%TESTDIR%%/saml_sp_slo_url.json; 116 | keyval $host $saml_sp_slo_url zone=saml_sp_slo_url; 117 | 118 | keyval_zone zone=saml_sp_slo_binding:1M state=%%TESTDIR%%/saml_sp_slo_binding.json; 119 | keyval $host $saml_sp_slo_binding zone=saml_sp_slo_binding; 120 | 121 | keyval_zone zone=saml_sp_sign_slo:1M state=%%TESTDIR%%/saml_sp_sign_slo.json; 122 | keyval $host $saml_sp_sign_slo zone=saml_sp_sign_slo; 123 | 124 | keyval_zone zone=saml_idp_slo_url:1M state=%%TESTDIR%%/saml_idp_slo_url.json; 125 | keyval $host $saml_idp_slo_url zone=saml_idp_slo_url; 126 | 127 | keyval_zone zone=saml_sp_want_signed_slo:1M state=%%TESTDIR%%/saml_sp_want_signed_slo.json; 128 | keyval $host $saml_sp_want_signed_slo zone=saml_sp_want_signed_slo; 129 | 130 | keyval_zone zone=saml_logout_landing_page:1M state=%%TESTDIR%%/saml_logout_landing_page.json; 131 | keyval $host $saml_logout_landing_page zone=saml_logout_landing_page; 132 | 133 | keyval_zone zone=saml_cookie_flags:1M state=%%TESTDIR%%/saml_cookie_flags.json; 134 | keyval $host $saml_cookie_flags zone=saml_cookie_flags; 135 | 136 | keyval_zone zone=saml_allowed_clock_skew:1M state=%%TESTDIR%%/saml_allowed_clock_skew.json; 137 | keyval $host $saml_allowed_clock_skew zone=saml_allowed_clock_skew; 138 | 139 | keyval_zone zone=redirect_base:1M state=%%TESTDIR%%/redirect_base.json; 140 | keyval $host $redirect_base zone=redirect_base; 141 | 142 | keyval_zone zone=proto:1M state=%%TESTDIR%%/proto.json; 143 | keyval $host $proto zone=proto; 144 | 145 | keyval_zone zone=saml_request_id:1M state=saml_request_id.json timeout=5m; 146 | keyval_zone zone=saml_response_id:1M state=saml_response_id.json timeout=1h; 147 | keyval_zone zone=saml_session_access:1M state=saml_session_access.json timeout=1h; 148 | keyval_zone zone=saml_name_id:1M state=saml_name_id.json timeout=1h; 149 | keyval_zone zone=saml_name_id_format:1M state=saml_name_id_format.json timeout=1h; 150 | keyval_zone zone=saml_session_index:1M state=saml_session_index.json timeout=1h; 151 | keyval_zone zone=saml_authn_context_class_ref:1M state=saml_authn_context_class_ref.json timeout=1h; 152 | keyval_zone zone=saml_attrib_uid:1M state=saml_attrib_uid.json timeout=1h; 153 | keyval_zone zone=saml_attrib_name:1M state=saml_attrib_name.json timeout=1h; 154 | keyval_zone zone=saml_attrib_memberOf:1M state=saml_attrib_memberOf.json timeout=1h; 155 | keyval_zone zone=saml_attrib_foo:1M state=saml_attrib_foo.json timeout=1h; 156 | 157 | keyval $saml_request_id $saml_request_redeemed zone=saml_request_id; 158 | keyval $saml_response_id $saml_response_redeemed zone=saml_response_id; 159 | keyval $cookie_auth_token $saml_access_granted zone=saml_session_access; 160 | keyval $cookie_auth_token $saml_name_id zone=saml_name_id; 161 | keyval $cookie_auth_token $saml_name_id_format zone=saml_name_id_format; 162 | keyval $cookie_auth_token $saml_session_index zone=saml_session_index; 163 | keyval $cookie_auth_token $saml_authn_context_class_ref zone=saml_authn_context_class_ref; 164 | 165 | keyval $cookie_auth_token $saml_attrib_uid zone=saml_attrib_uid; 166 | keyval $cookie_auth_token $saml_attrib_name zone=saml_attrib_name; 167 | keyval $cookie_auth_token $saml_attrib_memberOf zone=saml_attrib_memberOf; 168 | keyval $cookie_auth_token $saml_attrib_foo zone=saml_attrib_foo; 169 | 170 | server { 171 | listen 127.0.0.1:8080; 172 | server_name sp.exmaple.com; 173 | 174 | set $saml_request_id ""; 175 | set $saml_response_id ""; 176 | set $internal_error_message "SAML Authentication failed. If problem persists, contact your system administrator. "; 177 | js_var $internal_error_details; 178 | client_max_body_size 64k; 179 | client_body_buffer_size 64k; 180 | gunzip on; 181 | 182 | location = /saml/acs { 183 | js_content samlsp.handleSingleSignOn; 184 | status_zone "SAMLSSO ACS"; 185 | error_page 500 @saml_error; 186 | } 187 | 188 | location = /saml/sls { 189 | js_content samlsp.handleSingleLogout; 190 | status_zone "SAMLSSO SLS"; 191 | error_page 500 @saml_error; 192 | } 193 | 194 | location @do_samlsp_flow { 195 | js_content samlsp.initiateSingleSignOn; 196 | set $cookie_auth_token ""; 197 | } 198 | 199 | location = /login { 200 | js_content samlsp.initiateSingleSignOn; 201 | status_zone "SAMLSSO login"; 202 | error_page 500 @saml_error; 203 | } 204 | 205 | location = /logout { 206 | js_content samlsp.initiateSingleLogout; 207 | status_zone "SAMLSSO logout"; 208 | error_page 500 @saml_error; 209 | } 210 | 211 | location = /_logout { 212 | default_type text/plain; 213 | return 200 "Logged out\n"; 214 | } 215 | 216 | location @saml_error { 217 | status_zone "SAMLSP error"; 218 | default_type text/plain; 219 | return 500 "$internal_error_message $internal_error_details"; 220 | } 221 | 222 | location /api { 223 | api write=on; 224 | allow all; 225 | } 226 | 227 | location / { 228 | error_page 401 = @do_samlsp_flow; 229 | if ($saml_access_granted != "1") { 230 | return 401; 231 | } 232 | proxy_set_header Cookie "user=$saml_name_id;"; 233 | proxy_pass http://my_backend; 234 | default_type text/html; 235 | } 236 | } 237 | 238 | server { 239 | listen 8088; 240 | server_name localhost; 241 | 242 | location / { 243 | return 200 "Welcome $cookie_user"; 244 | } 245 | } 246 | } 247 | 248 | EOF 249 | 250 | my $d = $t->testdir(); 251 | 252 | $t->write_file('openssl.conf', <>$d/openssl.out 2>&1") == 0 265 | or die "Can't create certificate for $name: $!\n"; 266 | 267 | system('openssl x509 ' 268 | . "-in $d/$name.crt -outform DER " 269 | . "-out $d/$name.der " 270 | . ">>$d/openssl.out 2>&1") == 0 271 | or die "Can't convert $name.pem to $name.der: $!\n"; 272 | 273 | system('openssl x509 -inform DER ' 274 | . "-in $d/$name.der -pubkey -noout " 275 | . "> $d/$name.spki 2>&1") == 0 276 | or die "Can't extract pub key from $name.der: $!\n"; 277 | } 278 | 279 | my @mspki = ("$d/sp.example.com.spki", "$d/idp.example.com.spki"); 280 | $t->write_file('multiple.spki', read_file(\@mspki)); 281 | 282 | my $idp_priv = $t->read_file('idp.example.com.key'); 283 | my $sp_pub = $t->read_file('sp.example.com.crt'); 284 | 285 | my $js_filename = 'saml_sp.js'; 286 | $t->write_file($js_filename, read_file("../$js_filename")); 287 | 288 | $t->try_run('no njs available')->plan(132); 289 | 290 | my $api_version = (sort { $a <=> $b } @{ api() })[-1]; 291 | my $kv = "/api/$api_version/http/keyvals"; 292 | 293 | my $acs = '/saml/acs'; 294 | my $sls = '/saml/sls'; 295 | 296 | ############################################################################### 297 | 298 | my $cfg = { 299 | saml_sp_entity_id => 'http://sp.example.com', 300 | saml_sp_acs_url => 'http://sp.example.com:8080/saml/acs', 301 | saml_sp_request_binding => 'HTTP-POST', 302 | saml_sp_sign_authn => 'true', 303 | saml_sp_signing_key => "$d/sp.example.com.key", 304 | saml_sp_decryption_key => "$d/sp.example.com.key", 305 | saml_sp_force_authn => 'true', 306 | saml_sp_nameid_format => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', 307 | saml_sp_relay_state => '/foo?a=b', 308 | saml_sp_want_signed_response => 'false', 309 | saml_sp_want_signed_assertion => 'false', 310 | saml_sp_want_encrypted_assertion => 'false', 311 | saml_idp_entity_id => 'http://idp.example.com', 312 | saml_idp_sso_url => 'http://idp.example.com:8090/sso', 313 | saml_idp_verification_certificate => "$d/idp.example.com.spki", 314 | saml_sp_slo_url => 'http://sp.example.com:8080/saml/sls', 315 | saml_sp_slo_binding => 'HTTP-POST', 316 | saml_sp_sign_slo => 'false', 317 | saml_idp_slo_url => 'http://idp.example.com:8090/slo', 318 | saml_sp_want_signed_slo => 'false', 319 | saml_logout_landing_page => '/_logout', 320 | saml_cookie_flags => 'Path=/; SameSite=lax;', 321 | }; 322 | 323 | cfg_post($cfg, 1); 324 | 325 | ## SAML Authentication Request 326 | 327 | my $r = parse_response(get('/')); 328 | 329 | is($r->{Action}, $cfg->{saml_idp_sso_url}, 'authn request post action'); 330 | is($r->{RelayState}, $cfg->{saml_sp_relay_state}, 331 | 'authn request post relaystate'); 332 | is($r->{Type}, 'AuthnRequest', 'authn request header type'); 333 | is($r->{Version}, '2.0', 'authn request version'); 334 | is($r->{ProtocolBinding}, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', 335 | 'authn request protocolbinding'); 336 | like($r->{ID}, qr/^_[a-f0-9]{40}$/, 'authn request id'); 337 | ok(is_issue_instant_valid($r->{IssueInstant}), 'authn request issueinstant'); 338 | is($r->{AssertionConsumerServiceURL}, $cfg->{saml_sp_acs_url}, 339 | 'authn request acs url'); 340 | is($r->{Destination}, $cfg->{saml_idp_sso_url}, 'authn request destination'); 341 | is($r->{ForceAuthn}, $cfg->{saml_sp_force_authn}, 342 | 'authn request forceauthn true'); 343 | is($r->{Issuer}, $cfg->{saml_sp_entity_id}, 'authn request issuer url'); 344 | is($r->{isValid}, 1, 'authn request sign valid'); 345 | is($r->{NameIDPolicyFormat}, $cfg->{saml_sp_nameid_format}, 346 | 'authn request nameidpolicy format'); 347 | like(get("$kv/saml_request_id"), qr/"$r->{ID}":"1"/, 348 | 'authn request id redeemed'); 349 | 350 | # Reconfiguration 351 | 352 | $cfg->{saml_sp_entity_id} = 'urn:example:a123,0%7C00~z456/789?+abc?=xyz#12/3'; 353 | $cfg->{saml_sp_request_binding} = 'HTTP-Redirect'; 354 | $cfg->{saml_sp_sign_authn} = 'false'; 355 | $cfg->{saml_sp_force_authn} = 'false'; 356 | $cfg->{saml_sp_relay_state} = '/foo?a=b'; 357 | cfg_post($cfg); 358 | 359 | $r = parse_response(get('/')); 360 | 361 | like($r->{Action}, qr/$cfg->{saml_idp_sso_url}\?SAMLRequest=/, 362 | 'authn request get location'); 363 | is($r->{RelayState}, $cfg->{saml_sp_relay_state}, 364 | 'authn request get relaystate'); 365 | ok(!defined($r->{ForceAuthn}), 'authn request forceauthn false'); 366 | is($r->{Issuer}, $cfg->{saml_sp_entity_id}, 'authn request issuer urn'); 367 | is($r->{isSigned}, 0, 'authn request not signed'); 368 | 369 | # AuthenRequest config validation 370 | 371 | cfg_verify('saml_sp_entity_id', 'saml_sp_entity_id validation'); 372 | cfg_verify('saml_sp_request_binding', 'saml_sp_request_binding validation'); 373 | cfg_verify('saml_sp_force_authn', 'saml_sp_force_authn validation'); 374 | cfg_verify('saml_sp_nameid_format', 'saml_sp_nameid_format validation'); 375 | cfg_verify('saml_sp_sign_authn', 'saml_sp_sign_authn validation'); 376 | 377 | ### SAML Authentication Response 378 | 379 | $r = init_sso($cfg, 1); 380 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 381 | 'sp-initiated sso'); 382 | like($r, qr{302.*http://sp.example.com:8080/foo\?a=b}s, 383 | 'sp sso redirect to relay state'); 384 | like($r, qr/lax/, 'sp sso cookie flags'); 385 | 386 | cfg_post({saml_sp_relay_state => ""}); 387 | $r = init_sso($cfg, 1, auth_redir => '/foo?a=b'); 388 | like($r, qr{302.*http://sp.example.com:8080/foo\?a=b}s, 389 | 'sp sso redirect to request uri'); 390 | 391 | # Keyval attributes validation 392 | 393 | like(get("$kv/saml_response_id"), qr/"_nginx_[^"]+":\s*"1"/, 394 | 'kv response id'); 395 | like(get("$kv/saml_name_id"), qr/user1/, 'kv response name id'); 396 | like(get("$kv/saml_name_id_format"), qr/unspecified/, 397 | 'kv response name id format'); 398 | like(get("$kv/saml_session_index"), qr/_nginx_sessionindex_/, 399 | 'kv response session index'); 400 | like(get("$kv/saml_authn_context_class_ref"), qr/Password/, 401 | 'kv authn context class ref'); 402 | like(get("$kv/saml_attrib_uid"), qr/"1"/, 'kv uid attr'); 403 | like(get("$kv/saml_attrib_name"), qr/"Alan Alda"/, 'kv name attr'); 404 | like(get("$kv/saml_attrib_memberOf"), qr/"group1, admins, students"/, 405 | 'kv memberof attr'); 406 | like(get("$kv/saml_attrib_foo"), qr/"bar"/, 'kv namespace-qualified attr'); 407 | 408 | ### Signature validation 409 | 410 | $cfg->{saml_sp_want_signed_response} = 'false'; 411 | $cfg->{saml_sp_want_signed_assertion} = 'false'; 412 | cfg_post({saml_sp_want_signed_response => 'false', 413 | saml_sp_want_signed_assertion => 'false'}); 414 | 415 | $r = init_sso($cfg); 416 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 417 | 'response and assertion unsigned'); 418 | 419 | cfg_post({saml_sp_want_signed_response => 'true'}); 420 | $r = init_sso($cfg); 421 | like($r, qr/500.*Message is unsigned/s, 422 | 'want signed response got unsigned'); 423 | 424 | $cfg->{saml_sp_want_signed_response} = 'true'; 425 | $r = init_sso($cfg); 426 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 427 | 'response signed'); 428 | 429 | cfg_post({saml_sp_want_signed_assertion => 'true'}); 430 | $r = init_sso($cfg); 431 | like($r, qr/500.*Message is unsigned/s, 432 | 'want signed assertion got unsigned'); 433 | 434 | $cfg->{saml_sp_want_signed_assertion} = 'true'; 435 | $r = init_sso($cfg); 436 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 437 | 'response and assertion signed'); 438 | 439 | cfg_post({saml_idp_verification_certificate => "$d/multiple.spki"}); 440 | $r = init_sso($cfg); 441 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 442 | 'multiple idp certs'); 443 | 444 | cfg_post({saml_idp_verification_certificate => "$d/sp.example.com.key"}); 445 | $r = init_sso($cfg); 446 | like($r, qr/500.*Error verifying Response message signature/s, 447 | 'wrong cert type'); 448 | 449 | cfg_post({saml_idp_verification_certificate => "not_found"}); 450 | $r = init_sso($cfg); 451 | like($r, qr/500.*Failed to read.*public key from file/s, 452 | 'idp cert file not found'); 453 | 454 | cfg_post({saml_idp_verification_certificate => "$d/sp.example.com.spki"}); 455 | $r = init_sso($cfg); 456 | like($r, qr/500.*Key index 0: signature verification failed/s, 457 | 'wrong idp cert'); 458 | cfg_post({saml_idp_verification_certificate => "$d/idp.example.com.spki"}); 459 | 460 | my $xml_obj = produce_saml('Response', $cfg); 461 | 462 | $r = modify_saml_obj($xml_obj, '//ds:Reference', 'URI', '#foo'); 463 | like($r, qr/500.*reference URI.*does not point to the parent/s, 464 | 'signature reference uri mismatch'); 465 | 466 | $r = modify_saml_obj($xml_obj, '//ds:Transform', 'Algorithm', 'foo'); 467 | like($r, qr/500.*unexpected digest transform/s, 468 | 'signature unexpected digest transform'); 469 | 470 | $r = modify_saml_obj($xml_obj, '//ds:DigestMethod', 'Algorithm', 'foo'); 471 | like($r, qr/500.*unexpected digest Algorithm/s, 472 | 'signature unexpected digest algorithm'); 473 | 474 | $r = modify_saml_obj($xml_obj, '//ds:DigestMethod', 'Algorithm', 475 | 'http://www.w3.org/2000/09/xmldsig#sha1'); 476 | like($r, qr/500.*signature verification failed/s, 477 | 'signature wrong digest algorithm'); 478 | 479 | $r = modify_saml_obj($xml_obj, '//ds:DigestValue', 'text', 'foo'); 480 | like($r, qr/500.*signature verification failed/s, 481 | 'signature digest value mismatch'); 482 | 483 | $r = modify_saml_obj($xml_obj, '//ds:SignatureMethod', 'Algorithm', 'foo'); 484 | like($r, qr/500.*unexpected signature Algorithm/s, 485 | 'signature unexpected algorithm'); 486 | 487 | $r = modify_saml_obj($xml_obj, '//ds:SignatureMethod', 'Algorithm', 488 | 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'); 489 | like($r, qr/500.*signature verification failed/s, 'signature wrong algorithm'); 490 | 491 | $r = modify_saml_obj($xml_obj, '//ds:SignatureValue', 'text', 'foo'); 492 | like($r, qr/500.*signature verification failed/s, 'signature value mismatch'); 493 | 494 | ### Response validation 495 | 496 | $cfg->{saml_sp_want_signed_response} = 'false'; 497 | $cfg->{saml_sp_want_signed_assertion} = 'false'; 498 | cfg_post({saml_sp_want_signed_response => 'false'}); 499 | cfg_post({saml_sp_want_signed_assertion => 'false'}); 500 | 501 | $xml_obj = produce_saml('Response', $cfg); 502 | 503 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'ID', 'foo'); 504 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'ID', 'foo'); 505 | like($r, qr/HTTP\/1\.1 500.*ID.*redeemed/s, 'response id redeemed'); 506 | 507 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'ID'); 508 | like($r, qr/HTTP\/1\.1 500.*ID.*is missing/s, 'response no id'); 509 | 510 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'InResponseTo', 'foo'); 511 | like($r, qr/HTTP\/1\.1 500.*"foo" not found/s, 'inresponseto not found'); 512 | 513 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'InResponseTo'); 514 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 515 | 'idp-initiated sso'); 516 | 517 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'Destination'); 518 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 519 | 'response no destination'); 520 | 521 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'Destination', 'foo'); 522 | like($r, qr/HTTP\/1\.1 500.*not match SP ACS URL/s, 523 | 'response wrong destination'); 524 | 525 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'Version', '1.0'); 526 | like($r, qr/HTTP\/1\.1 500.*Unsupported SAML Version/s, 527 | 'response unsupported version'); 528 | 529 | my ($ptime, $ftime) = get_time(); 530 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'IssueInstant', $ftime); 531 | like($r, qr/HTTP\/1\.1 500.*Assertion is not yet valid/s, 532 | 'response future issue instant'); 533 | 534 | $r = modify_saml_obj($xml_obj, '/samlp:Response', 'IssueInstant'); 535 | like($r, qr/HTTP\/1\.1 500.*IssueInstant.*is missing/s, 536 | 'response no issue instant'); 537 | 538 | $r = modify_saml_obj($xml_obj, '//saml:Issuer'); 539 | like($r, qr/HTTP\/1\.1 500/s, 'response no issuer'); 540 | 541 | $r = modify_saml_obj($xml_obj, '//saml:Issuer', 'text', 'foo'); 542 | like($r, qr/HTTP\/1\.1 500.*Issuer "foo" does not match IdP EntityID/s, 543 | 'response wrong issuer'); 544 | 545 | $r = modify_saml_obj($xml_obj, '//samlp:StatusCode', 'Value', 'foo'); 546 | like($r, qr/HTTP\/1\.1 500.*Error: StatusCode: foo/s, 547 | 'response status not success'); 548 | 549 | $r = modify_saml_obj($xml_obj, '//saml:Assertion', 'Version', '1.0'); 550 | like($r, qr/HTTP\/1\.1 500.*Unsupported SAML Version/s, 551 | 'assertion unsupported version'); 552 | 553 | $r = modify_saml_obj($xml_obj, '//saml:Assertion', 'ID'); 554 | like($r, qr/HTTP\/1\.1 500.*ID.*is missing/s, 'assertion no id'); 555 | 556 | $r = modify_saml_obj($xml_obj, '//saml:Assertion', 'IssueInstant', $ftime); 557 | like($r, qr/HTTP\/1\.1 500.*Assertion is not yet valid/s, 558 | 'assertion future issue instant'); 559 | 560 | $r = modify_saml_obj($xml_obj, '//saml:NameID'); 561 | like($r, qr/HTTP\/1\.1 500.*NameID element is missing/s, 562 | 'assertion no name id'); 563 | 564 | $r = modify_saml_obj($xml_obj, '//saml:SubjectConfirmation'); 565 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 566 | 'assertion no subject confirmation'); 567 | 568 | $r = modify_saml_obj($xml_obj, '//saml:SubjectConfirmationData'); 569 | like($r, qr/HTTP\/1\.1 500.*SubjectConfirmationData.*is missing/s, 570 | 'assertion no subject confirmation data'); 571 | 572 | $r = modify_saml_obj($xml_obj, '//saml:SubjectConfirmationData', 573 | 'NotOnOrAfter', $ptime); 574 | like($r, qr/HTTP\/1\.1 500.*Assertion has expired/s, 575 | 'assertion subject has expired'); 576 | 577 | $r = modify_saml_obj($xml_obj, '//saml:Conditions'); 578 | like($r, qr/HTTP\/1\.1 500.*Conditions.*is missing/s, 579 | 'assertion no conditions'); 580 | 581 | $r = modify_saml_obj($xml_obj, '//saml:Conditions', 'NotBefore', $ftime); 582 | like($r, qr/HTTP\/1\.1 500.*Assertion is not yet valid/s, 583 | 'assertion is not yet valid'); 584 | 585 | $r = modify_saml_obj($xml_obj, '//saml:Conditions', 'NotOnOrAfter', $ptime); 586 | like($r, qr/HTTP\/1\.1 500.*Assertion has expired/s, 'assertion has expired'); 587 | 588 | $r = modify_saml_obj($xml_obj, '//saml:AudienceRestriction'); 589 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 590 | 'assertion no audience restriction'); 591 | 592 | $r = modify_saml_obj($xml_obj, '//saml:Audience', 'text', 'foo'); 593 | like($r, qr/HTTP\/1\.1 500.*Assertion is not intended for this Service/s, 594 | 'assertion wrong audience'); 595 | 596 | $r = modify_saml_obj($xml_obj, '//saml:AuthnStatement'); 597 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 598 | 'assertion no authn statement'); 599 | 600 | $r = modify_saml_obj($xml_obj, '//saml:AuthnStatement', 'SessionNotOnOrAfter', 601 | $ptime); 602 | like($r, qr/HTTP\/1\.1 500.*Assertion has expired/s, 603 | 'assertion session has expired'); 604 | 605 | $r = modify_saml_obj($xml_obj, '//saml:AuthnStatement', 'SessionIndex'); 606 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 607 | 'assertion no sessionindex'); 608 | 609 | $r = modify_saml_obj($xml_obj, '//saml:AuthnContextClassRef'); 610 | like($r, qr/HTTP\/1\.1 500.*AuthnContextClassRef.*is missing/s, 611 | 'assertion no authncontextclassref'); 612 | 613 | $r = modify_saml_obj($xml_obj, '//saml:AttributeStatement'); 614 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 615 | 'assertion no attribute statement'); 616 | 617 | $r = modify_saml_obj($xml_obj, '//saml:Conditions', 'NotBefore', $ftime); 618 | like($r, qr/HTTP\/1\.1 500.*Allowed clock skew is 120 seconds/s, 619 | 'Allowed clock skew default 120'); 620 | 621 | cfg_post({saml_allowed_clock_skew => '60'}, 1); 622 | 623 | $r = modify_saml_obj($xml_obj, '//saml:Conditions', 'NotBefore', $ftime); 624 | like($r, qr/HTTP\/1\.1 500.*Allowed clock skew is 60 seconds/s, 625 | 'Allowed Clock scew 60'); 626 | 627 | cfg_post({saml_allowed_clock_skew => '360'}); 628 | 629 | $r = modify_saml_obj($xml_obj, '//saml:Conditions', 'NotBefore', $ftime); 630 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 631 | 'Allowed Clock scew NotBefore'); 632 | 633 | $r = modify_saml_obj($xml_obj, '//saml:Conditions', 'NotOnOrAfter', $ptime); 634 | like(get('/', auth_token => get_auth_token($r)), qr/Welcome user1/, 635 | 'Allowed Clock scew NotOnOrAfter'); 636 | 637 | ### SP-initiated logout 638 | 639 | # Logout Request 640 | 641 | $r = parse_response(get('/logout', 642 | auth_token => get_auth_token(init_sso($cfg)))); 643 | 644 | is($r->{Action}, $cfg->{saml_idp_slo_url}, 'sp logout request post action'); 645 | is($r->{RelayState}, $cfg->{saml_logout_landing_page}, 646 | 'sp logout request post relaystate'); 647 | is($r->{Type}, 'LogoutRequest', 'sp logout request msg type'); 648 | is($r->{Version}, '2.0', 'sp logout request version'); 649 | like($r->{ID}, qr/^_[a-f0-9]{40}$/, 'sp logout request id'); 650 | ok(is_issue_instant_valid($r->{IssueInstant}), 651 | 'sp logout request issueinstant'); 652 | is($r->{Destination}, $cfg->{saml_idp_slo_url}, 653 | 'sp logout request destination'); 654 | is($r->{Issuer}, $cfg->{saml_sp_entity_id}, 'sp logout request issuer'); 655 | is($r->{isSigned}, 0, 'sp logout request unsigned'); 656 | is($r->{NameID}, 'user1', 'sp logout request nameid'); 657 | like(get("$kv/saml_request_id"), qr/"$r->{ID}":"1"/, 658 | 'sp logout request id redeemed'); 659 | 660 | $r = parse_response(get('/logout')); 661 | like($r, qr{302.*Location:\shttp://sp.example.com:8080/_logout.* 662 | Set-Cookie:\sauth_token=;\sExpires.*1970.* 663 | Set-Cookie:\sauth_redir=;.*}msx, 'sp logout request with no session'); 664 | 665 | # Reconfiguration 666 | 667 | $cfg->{saml_sp_slo_binding} = 'HTTP-Redirect'; 668 | $cfg->{saml_sp_sign_slo} = 'true'; 669 | cfg_post({saml_sp_slo_binding => 'HTTP-Redirect', saml_sp_sign_slo => 'true'}); 670 | 671 | my $auth_token = get_auth_token(init_sso($cfg)); 672 | $r = parse_response(get('/logout', auth_token => $auth_token)); 673 | 674 | like($r->{Action}, qr/$cfg->{saml_idp_slo_url}/, 675 | 'sp logout request get location'); 676 | is($r->{RelayState}, $cfg->{saml_logout_landing_page}, 677 | 'sp logout request get relaystate'); 678 | is($r->{isValid}, 1, 'sp logout request signed'); 679 | 680 | # Logout Response 681 | 682 | ($r, $auth_token) = init_slo($cfg, sp_initiated => 1); 683 | like($r, qr{302.*Location:\shttp://sp.example.com:8080/_logout.* 684 | Set-Cookie:\sauth_token=;\sExpires.*1970.* 685 | Set-Cookie:\sauth_redir=;\sExpires.*1970.*}msx, 686 | 'idp logout response post method'); 687 | like(get("$kv/saml_name_id"), qr/"$auth_token":"-"/, 'slo nameid cleared'); 688 | like(get("$kv/saml_session_access"), qr/"$auth_token":"-"/, 689 | 'slo session access cleared'); 690 | 691 | ($r, undef) = init_slo($cfg, sp_initiated => 1, method => 'get'); 692 | like($r, qr{302.*Location:\shttp://sp.example.com:8080/_logout.* 693 | Set-Cookie:\sauth_token=;\sExpires.*1970.* 694 | Set-Cookie:\sauth_redir=;\sExpires.*1970.*}msx, 695 | 'idp logout response get method'); 696 | 697 | cfg_post({saml_sp_want_signed_slo => 'true'}); 698 | ($r, undef) = init_slo($cfg, sp_initiated => 1); 699 | like($r, qr/500.*Message is unsigned/s, 'idp logout response unsigned'); 700 | 701 | $cfg->{saml_sp_want_signed_slo} = 'true'; 702 | ($r, undef) = init_slo($cfg, sp_initiated => 1); 703 | like($r, qr{302.*Location:\shttp://sp.example.com:8080/_logout.* 704 | Set-Cookie:\sauth_token=;\sExpires.*1970.* 705 | Set-Cookie:\sauth_redir=;\sExpires.*1970.*}msx, 706 | 'idp logout response signed'); 707 | 708 | $cfg->{saml_sp_want_signed_slo} = 'false'; 709 | cfg_post({saml_sp_want_signed_slo => 'false'}); 710 | 711 | # Logout Response validation 712 | 713 | $auth_token = get_auth_token(init_sso($cfg)); 714 | $r = parse_response(get('/logout', auth_token => $auth_token)); 715 | $xml_obj = produce_saml('LogoutResponse', $cfg, $r->{ID}); 716 | 717 | $r = modify_saml_obj($xml_obj, '//samlp:Status', undef, undef, 718 | auth_token => $auth_token, relay_state => $cfg->{saml_logout_landing_page}); 719 | like($r, qr/500.*Status element is missing/s, 720 | 'idp logout response no status'); 721 | 722 | $r = modify_saml_obj($xml_obj, '//samlp:StatusCode', undef, undef, 723 | auth_token => $auth_token, relay_state => $cfg->{saml_logout_landing_page}); 724 | like($r, qr/500.*StatusCode element is missing/s, 725 | 'idp logout response no status code'); 726 | 727 | $r = modify_saml_obj($xml_obj, '//samlp:StatusCode', 'Value', undef, 728 | auth_token => $auth_token, relay_state => $cfg->{saml_logout_landing_page}); 729 | like($r, qr/500.*StatusCode element is missing/s, 730 | 'idp logout response status code no value'); 731 | 732 | $r = modify_saml_obj($xml_obj, '//samlp:StatusCode', 'Value', 'foo', 733 | auth_token => $auth_token, relay_state => $cfg->{saml_logout_landing_page}); 734 | like($r, qr/500.*StatusCode: foo/s, 'idp logout response status code not success'); 735 | 736 | ### IdP-initiated logout 737 | 738 | # Logout Request 739 | 740 | cfg_post({saml_sp_want_signed_slo => 'true'}); 741 | ($r, undef) = init_slo($cfg); 742 | like($r, qr/500.*Message is unsigned/s, 'idp logout request unsigned'); 743 | 744 | $cfg->{saml_sp_want_signed_slo} = 'true'; 745 | ($r, undef) = init_slo($cfg); 746 | is($r->{StatusCode}, 'urn:oasis:names:tc:SAML:2.0:status:Success', 747 | 'idp logout request signed'); 748 | 749 | # Logout Request validation 750 | 751 | $cfg->{saml_sp_want_signed_slo} = 'false'; 752 | cfg_post({saml_sp_want_signed_slo => 'false'}); 753 | 754 | $auth_token = get_auth_token(init_sso($cfg)); 755 | $xml_obj = produce_saml('LogoutRequest', $cfg); 756 | 757 | $r = modify_saml_obj($xml_obj, '//saml:NameID', undef, undef, 758 | auth_token => $auth_token); 759 | like($r, qr/500.*NameID element is missing in the Subject/s, 760 | 'idp logout request no nameid'); 761 | 762 | $r = parse_response(modify_saml_obj($xml_obj, '//saml:NameID', 'text', 'foo', 763 | auth_token => $auth_token)); 764 | is($r->{StatusCode}, 'urn:oasis:names:tc:SAML:2.0:status:Requester', 765 | 'idp logout request wrong nameid'); 766 | 767 | # Logout Response 768 | 769 | ($r, undef) = init_slo($cfg, relay_state => '/foo?a=b'); 770 | like($r->{Action}, qr/$cfg->{saml_idp_slo_url}\?SAMLResponse=/s, 771 | 'sp logout response get location'); 772 | like($r->{Action}, qr{&RelayState=/foo\?a=b}s, 773 | 'sp logout response get relaystate'); 774 | is($r->{Type}, 'LogoutResponse', 'sp logout response type'); 775 | is($r->{Version}, '2.0', 'sp logout response version'); 776 | like($r->{ID}, qr/^_[a-f0-9]{40}$/, 'sp logout response id'); 777 | ok(is_issue_instant_valid($r->{IssueInstant}), 778 | 'sp logout response issueinstant'); 779 | is($r->{Destination}, $cfg->{saml_idp_slo_url}, 780 | 'sp logout response destination'); 781 | is($r->{Issuer}, $cfg->{saml_sp_entity_id}, 'sp logout response issuer url'); 782 | is($r->{isValid}, 1, 'sp logout response sign valid'); 783 | is($r->{StatusCode}, 'urn:oasis:names:tc:SAML:2.0:status:Success', 784 | 'sp logout response status code'); 785 | is($r->{Cookie}, 'auth_token=; auth_redir=', 786 | 'sp logout response session cookie cleared'); 787 | like(get("$kv/saml_request_id"), qr/"$r->{ID}":"1"/, 788 | 'sp logout response request id redeemed'); 789 | 790 | # Reconfiguration 791 | $cfg->{saml_sp_slo_binding} = 'HTTP-POST'; 792 | $cfg->{saml_sp_sign_slo} = 'false'; 793 | cfg_post({saml_sp_slo_binding => 'HTTP-POST', saml_sp_sign_slo => 'false'}); 794 | 795 | ($r, undef) = init_slo($cfg, relay_state => '/foo?a=b'); 796 | is($r->{Action}, $cfg->{saml_idp_slo_url}, 'sp logout response post action'); 797 | is($r->{RelayState}, '/foo?a=b', 'sp logout response post relaystate'); 798 | is($r->{isSigned}, 0, 'sp logout response not signed'); 799 | 800 | ($r, undef) = init_slo($cfg); 801 | is($r->{RelayState}, undef, 'sp logout response no relaystate'); 802 | 803 | ############################################################################### 804 | 805 | sub get_auth_token { 806 | my ($r) = @_; 807 | 808 | return ($r =~ /Set-Cookie: auth_token=([^;]+);/)[0]; 809 | } 810 | 811 | sub init_sso { 812 | my ($config, $sp, %extra) = @_; 813 | 814 | my $id = $sp ? parse_response(get('/'))->{ID} : undef; 815 | my $xml = produce_saml('Response', $config, $id); 816 | return send_saml($xml, $acs, %extra); 817 | } 818 | 819 | sub init_slo { 820 | my ($cfg, %extra) = @_; 821 | 822 | my $auth_token = get_auth_token(init_sso($cfg)); 823 | 824 | if ($extra{sp_initiated}) { 825 | my $logout_response = parse_response(get('/logout', 826 | auth_token => $auth_token)); 827 | my $saml_response = produce_saml('LogoutResponse', $cfg, 828 | $logout_response->{ID}); 829 | return (send_saml($saml_response, $sls, auth_token => $auth_token, 830 | relay_state => $cfg->{saml_logout_landing_page}, %extra), $auth_token); 831 | } else { 832 | my $saml_request = produce_saml('LogoutRequest', $cfg); 833 | return (parse_response(send_saml($saml_request, $sls, 834 | auth_token => $auth_token, %extra)), $auth_token); 835 | } 836 | } 837 | 838 | sub modify_saml_obj { 839 | my ($xml_obj, $element, $attribute, $new_val, %extra) = @_; 840 | 841 | my $new_xml_obj = $xml_obj->cloneNode(1); 842 | my $xpc = initialize_saml_xpath_context($new_xml_obj); 843 | my $root = $new_xml_obj->documentElement(); 844 | 845 | if (!$xpc->findnodes('//ds:Signature')) { 846 | $root->setAttribute('ID', '_nginx_' . rand(1)); 847 | } 848 | 849 | my $url = $root->localname eq 'Response' ? $acs : $sls; 850 | return send_saml($new_xml_obj, $url, %extra) unless $element; 851 | 852 | my ($node) = $xpc->findnodes($element); 853 | return send_saml($new_xml_obj, $url, %extra) unless $node; 854 | 855 | if ($attribute) { 856 | if ($attribute eq 'text') { 857 | $node->removeChildNodes(); 858 | $node->appendText($new_val); 859 | } else { 860 | if (defined $new_val) { 861 | $node->setAttribute($attribute, $new_val); 862 | } else { 863 | $node->removeAttribute($attribute); 864 | } 865 | } 866 | } else { 867 | $node->unbindNode(); 868 | } 869 | 870 | return send_saml($new_xml_obj, $url, %extra); 871 | } 872 | 873 | sub parse_xml_string { 874 | my $parser = XML::LibXML->new(); 875 | $parser->keep_blanks(0); 876 | return $parser->parse_string(shift); 877 | } 878 | 879 | sub initialize_saml_xpath_context { 880 | my $xpc = XML::LibXML::XPathContext->new(shift); 881 | $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion'); 882 | $xpc->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'); 883 | $xpc->registerNs('ds', 'http://www.w3.org/2000/09/xmldsig#'); 884 | return $xpc; 885 | } 886 | 887 | sub parse_response { 888 | my ($r) = @_; 889 | my %result; 890 | my ($saml_base64, $relay_state, $dest, $xml_str); 891 | 892 | if ($r =~ /HTTP\/1\.. 302/) { 893 | ($dest, $saml_base64, $relay_state) = parse_http_302($r); 894 | return $r unless $saml_base64; 895 | $xml_str = inflate_base64($saml_base64); 896 | } elsif ($r =~ /HTTP\/1\.. 200/) { 897 | ($dest, $saml_base64, $relay_state) = parse_http_200($r); 898 | $xml_str = decode_base64(uri_unescape($saml_base64)); 899 | } else { 900 | return $r; 901 | } 902 | 903 | my @cookies = $r =~ /Set-Cookie: (.*?);/g; 904 | $result{Cookie} = join '; ', @cookies; 905 | 906 | my $xml_obj = parse_xml_string($xml_str); 907 | my $xpc = initialize_saml_xpath_context($xml_obj); 908 | 909 | extract_saml_attributes(\%result, $xml_obj, $xpc, $dest, $relay_state); 910 | 911 | return \%result; 912 | } 913 | 914 | sub parse_http_302 { 915 | my ($r) = @_; 916 | my ($dest) = $r =~ /Location: (.*?)\n/; 917 | my ($saml_base64) = $r =~ m{(?:SAMLResponse|SAMLRequest)=([^&]+)}; 918 | my ($relay_state) = $r =~ m{RelayState=([^&\r\n]+)}; 919 | return ($dest, $saml_base64, $relay_state); 920 | } 921 | 922 | sub parse_http_200 { 923 | my ($r) = @_; 924 | my ($dest) = $r =~ /
/; 925 | my ($saml_base64) = $r =~ 926 | /name="(?:SAMLResponse|SAMLRequest)" value="(.*?)"/; 927 | my ($relay_state) = $r =~ /name="RelayState" value="(.*?)"/; 928 | return ($dest, $saml_base64, $relay_state); 929 | } 930 | 931 | sub inflate_base64 { 932 | my ($saml_base64) = @_; 933 | my $deflated = decode_base64(uri_unescape($saml_base64)); 934 | my $xml_str; 935 | rawinflate(\$deflated => \$xml_str) 936 | or die "rawinflate failed: $RawInflateError\n"; 937 | return $xml_str; 938 | } 939 | 940 | sub extract_saml_attributes { 941 | my ($result, $xml_obj, $xpc, $dest, $relay_state) = @_; 942 | my $hdr = $xml_obj->documentElement(); 943 | 944 | foreach my $attr (qw(Version ID IssueInstant Destination ProtocolBinding 945 | AssertionConsumerServiceURL ForceAuthn)) { 946 | $result->{$attr} = $hdr->getAttribute($attr); 947 | } 948 | 949 | $result->{Type} = $hdr->localname; 950 | $result->{Action} = $dest; 951 | $result->{RelayState} = $relay_state; 952 | $result->{Issuer} = get_node_text($xpc, '//saml:Issuer'); 953 | $result->{NameID} = get_node_text($xpc, '//saml:NameID'); 954 | 955 | my ($signature_node) = $xpc->findnodes('//ds:Signature'); 956 | if ($signature_node) { 957 | $result->{isValid} = 958 | verify_saml_signature($signature_node,$sp_pub); 959 | } else { 960 | $result->{isSigned} = 0; 961 | } 962 | 963 | my ($name_id_policy_node) = $xpc->findnodes('//samlp:NameIDPolicy'); 964 | if ($name_id_policy_node) { 965 | $result->{NameIDPolicyFormat} = 966 | $name_id_policy_node->getAttribute('Format'); 967 | } 968 | 969 | my ($status_code) = $xpc->findnodes('//samlp:StatusCode'); 970 | if ($status_code) { 971 | $result->{StatusCode} = $status_code->getAttribute('Value'); 972 | } 973 | } 974 | 975 | sub get_node_text { 976 | my ($xpc, $xpath) = @_; 977 | my ($node) = $xpc->findnodes($xpath); 978 | return $node ? $node->textContent : undef; 979 | } 980 | 981 | sub produce_saml { 982 | my ($type, $cfg, $in_resp_to) = @_; 983 | 984 | my $xml_obj = parse_xml_string(gen_tmpl($type)); 985 | my $xpc = initialize_saml_xpath_context($xml_obj); 986 | my $msg = $xml_obj->documentElement(); 987 | 988 | my ($ptime, $ftime) = get_time(); 989 | 990 | # Header processing 991 | my $new_id = '_nginx_' . rand(1); 992 | $msg->setAttribute('ID', $new_id); 993 | $msg->setAttribute('IssueInstant', $ptime); 994 | 995 | 996 | if (defined $in_resp_to) { 997 | $msg->setAttribute('InResponseTo', $in_resp_to); 998 | } else { 999 | $msg->removeAttribute('InResponseTo'); 1000 | } 1001 | 1002 | # Issuer processing 1003 | my (@issuer_element) = $xpc->findnodes('//saml:Issuer'); 1004 | foreach my $issuer (@issuer_element) { 1005 | $issuer->removeChildNodes(); 1006 | $issuer->appendText($cfg->{saml_idp_entity_id}); 1007 | } 1008 | 1009 | my (@signature_element) = $xpc->findnodes('//ds:Signature'); 1010 | 1011 | if ($type eq 'Response') { 1012 | $msg->setAttribute('Destination', $cfg->{saml_sp_acs_url}); 1013 | 1014 | # Assertion processing 1015 | my ($assertion_element) = $xpc->findnodes('//saml:Assertion'); 1016 | $assertion_element->setAttribute('IssueInstant', $ptime); 1017 | 1018 | # Subject processing 1019 | my ($nameid_element) = $xpc->findnodes('//saml:NameID'); 1020 | $nameid_element->setAttribute('SPNameQualifier', 1021 | $cfg->{saml_sp_entity_id}); 1022 | 1023 | # Conditions processing 1024 | my ($conditions_element) = $xpc->findnodes('//saml:Conditions'); 1025 | $conditions_element->setAttribute('NotBefore', $ptime); 1026 | $conditions_element->setAttribute('NotOnOrAfter', $ftime); 1027 | my ($audience_element) = $xpc->findnodes('//saml:Audience'); 1028 | $audience_element->removeChildNodes(); 1029 | $audience_element->appendText($cfg->{saml_sp_entity_id}); 1030 | 1031 | # AuthnStatement processing 1032 | my ($authn_statement_element) = 1033 | $xpc->findnodes('//saml:AuthnStatement'); 1034 | $authn_statement_element->setAttribute('AuthnInstant', $ptime); 1035 | $authn_statement_element->setAttribute('SessionNotOnOrAfter', $ftime); 1036 | 1037 | # Signature processing 1038 | if ($cfg->{saml_sp_want_signed_assertion} eq 'true') { 1039 | digest_saml($signature_element[1], true); 1040 | signature_saml($signature_element[1], $idp_priv, true); 1041 | } else { 1042 | $signature_element[1]->parentNode-> 1043 | removeChild($signature_element[1]); 1044 | } 1045 | 1046 | if ($cfg->{saml_sp_want_signed_response} eq 'true') { 1047 | digest_saml($signature_element[0], true); 1048 | signature_saml($signature_element[0], $idp_priv, true); 1049 | } else { 1050 | $signature_element[0]->parentNode-> 1051 | removeChild($signature_element[0]); 1052 | } 1053 | } elsif ($type eq 'LogoutResponse') { 1054 | $msg->setAttribute('Destination', $cfg->{saml_sp_slo_url}); 1055 | 1056 | # Status processing 1057 | my ($status_code_element) = $xpc->findnodes('//samlp:StatusCode'); 1058 | $status_code_element->setAttribute('Value', 1059 | 'urn:oasis:names:tc:SAML:2.0:status:Success'); 1060 | 1061 | # Signature processing 1062 | if ($cfg->{saml_sp_want_signed_slo} eq 'true') { 1063 | digest_saml($signature_element[0], true); 1064 | signature_saml($signature_element[0], $idp_priv, true); 1065 | } else { 1066 | $signature_element[0]->parentNode-> 1067 | removeChild($signature_element[0]); 1068 | } 1069 | } elsif ($type eq 'LogoutRequest') { 1070 | $msg->setAttribute('Destination', $cfg->{saml_sp_slo_url}); 1071 | 1072 | # Subject processing 1073 | my ($nameid_element) = $xpc->findnodes('//saml:NameID'); 1074 | $nameid_element->setAttribute('SPNameQualifier', 1075 | $cfg->{saml_idp_entity_id}); 1076 | 1077 | # Signature processing 1078 | if ($cfg->{saml_sp_want_signed_slo} eq 'true') { 1079 | digest_saml($signature_element[0], true); 1080 | signature_saml($signature_element[0], $idp_priv, true); 1081 | } else { 1082 | $signature_element[0]->parentNode-> 1083 | removeChild($signature_element[0]); 1084 | } 1085 | } else { 1086 | die "Unknown SAML message type: $type"; 1087 | } 1088 | 1089 | return $xml_obj; 1090 | } 1091 | 1092 | sub send_saml { 1093 | my ($xml_obj, $dst, %extra) = @_; 1094 | my ($r, $b64); 1095 | 1096 | $dst //= $acs; 1097 | 1098 | $XML::LibXML::skipXMLDeclaration = 1; 1099 | my $xml_str = $xml_obj->toString(); 1100 | 1101 | if (exists($extra{method}) && $extra{method} eq 'get') { 1102 | my $compressed_xml; 1103 | rawdeflate(\$xml_str => \$compressed_xml) 1104 | or die "rawdeflate failed: $RawDeflateError\n"; 1105 | $b64 = encode_base64($compressed_xml, ''); 1106 | my $url = $dst . "?SAMLResponse=" . uri_escape($b64) . 1107 | ($extra{relay_state} ? "&RelayState=" . $extra{relay_state} : ""); 1108 | $r = get($url, %extra); 1109 | } else { 1110 | $b64 = encode_base64($xml_str, ''); 1111 | my $body = "SAMLResponse=" . uri_escape($b64) . 1112 | ($extra{relay_state} ? "&RelayState=" . $extra{relay_state} : ""); 1113 | $r = http_post($dst, $body, %extra); 1114 | } 1115 | 1116 | return $r; 1117 | } 1118 | 1119 | sub cfg_post { 1120 | my ($arg, $post, $host) = @_; 1121 | $host //= 'sp.example.com'; 1122 | my $json; 1123 | 1124 | if (ref $arg eq 'HASH') { 1125 | $json = $arg; 1126 | } elsif (!ref $arg) { 1127 | my ($key, $value) = each %{decode_json($arg)}; 1128 | $cfg->{$key} = $value; 1129 | $json = {$key => $value}; 1130 | } else { 1131 | die "Invalid arguments for cfg_post"; 1132 | } 1133 | 1134 | for my $key (keys %$json) { 1135 | my $value = $json->{$key}; 1136 | my $data = { $host => $value }; 1137 | my $data_string = encode_json($data); 1138 | 1139 | if ($post) { 1140 | http_post("$kv/$key", $data_string); 1141 | } else { 1142 | http_patch("$kv/$key", $data_string); 1143 | } 1144 | } 1145 | } 1146 | 1147 | sub cfg_verify { 1148 | my ($param, $test_name) = @_; 1149 | 1150 | my $url = "$kv/$param"; 1151 | my $original_value = getkv($url); 1152 | 1153 | http_patch($url, '{"sp.example.com": ""}'); 1154 | my $r = get('/login'); 1155 | http_patch($url, $original_value); 1156 | 1157 | like($r, qr/(?=.*Invalid)(?=.*$param)/s, $test_name); 1158 | 1159 | return $r; 1160 | } 1161 | 1162 | sub http_post { 1163 | my ($url, $body, %extra) = @_; 1164 | my $len = length($body); 1165 | 1166 | my $auth_token = $extra{auth_token} || ''; 1167 | my $auth_redir = $extra{auth_redir} || ''; 1168 | 1169 | http(<new()->canonical()->encode($json); 1211 | } 1212 | 1213 | sub getkv { 1214 | my ($uri) = @_; 1215 | 1216 | get($uri) =~ /\x0d\x0a?\x0d\x0a?(.*)/ms; 1217 | recode($1); 1218 | } 1219 | 1220 | sub api { 1221 | my ($uri, %extra) = @_; 1222 | 1223 | $uri = defined $uri ? "/api/$api_version$uri" : '/api'; 1224 | my ($body) = http_get($uri, %extra) =~ /.*?\x0d\x0a?\x0d\x0a?(.*)/ms; 1225 | 1226 | return decode_json($body); 1227 | } 1228 | 1229 | sub validate_saml_signature { 1230 | my ($xmlDoc, $public_key_pem) = @_; 1231 | 1232 | my $xpc = XML::LibXML::XPathContext->new($xmlDoc); 1233 | $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion'); 1234 | $xpc->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'); 1235 | $xpc->registerNs('ds', 'http://www.w3.org/2000/09/xmldsig#'); 1236 | 1237 | my ($signature_node) = $xpc->findnodes('//ds:Signature'); 1238 | return 0 unless $signature_node; 1239 | 1240 | my ($signed_info_node) = $xpc->findnodes('./ds:SignedInfo', 1241 | $signature_node); 1242 | my ($signature_method_node) = $xpc->findnodes('./ds:SignatureMethod', 1243 | $signed_info_node); 1244 | my $signature_algorithm = $signature_method_node-> 1245 | getAttribute('Algorithm'); 1246 | 1247 | my $hash_alg; 1248 | if ($signature_algorithm eq 'http://www.w3.org/2000/09/xmldsig#rsa-sha1') { 1249 | $hash_alg = 'SHA1'; 1250 | } elsif ($signature_algorithm eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256') { 1251 | $hash_alg = 'SHA256'; 1252 | } elsif ($signature_algorithm eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384') { 1253 | $hash_alg = 'SHA384'; 1254 | } elsif ($signature_algorithm eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512') { 1255 | $hash_alg = 'SHA512'; 1256 | } else { 1257 | die "Unsupported signature algorithm: $signature_algorithm"; 1258 | } 1259 | 1260 | my ($signature_value_node) = 1261 | $xpc->findnodes('./ds:SignatureValue', $signature_node); 1262 | my $signature_value_base64 = $signature_value_node->textContent; 1263 | my $signature_value = decode_base64($signature_value_base64); 1264 | 1265 | my ($reference_node) = 1266 | $xpc->findnodes('./ds:Reference', $signed_info_node); 1267 | my $id_attr = $reference_node->getAttribute('URI'); 1268 | $id_attr =~ s/^#//; 1269 | 1270 | my $signed_element = 1271 | $xpc->findnodes(sprintf('//*[@ID="%s"]', $id_attr))->[0]; 1272 | my $signed_info_c14n = $signed_info_node->toStringEC14N(); 1273 | 1274 | $signature_node->parentNode->removeChild($signature_node); 1275 | my $c14n_xml = $signed_element->toStringEC14N(); 1276 | 1277 | my $pubkey = 1278 | Crypt::OpenSSL::X509->new_from_string($public_key_pem)->pubkey(); 1279 | my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($pubkey); 1280 | $rsa_pub -> use_pkcs1_padding(); 1281 | 1282 | my $digest; 1283 | if ($hash_alg eq 'SHA1') { 1284 | $digest = sha1($c14n_xml); 1285 | $rsa_pub->use_sha1_hash(); 1286 | } elsif ($hash_alg eq 'SHA256') { 1287 | $digest = sha256($c14n_xml); 1288 | $rsa_pub->use_sha256_hash(); 1289 | } elsif ($hash_alg eq 'SHA384') { 1290 | $digest = sha384($c14n_xml); 1291 | $rsa_pub->use_sha384_hash(); 1292 | } elsif ($hash_alg eq 'SHA512') { 1293 | $digest = sha512($c14n_xml); 1294 | $rsa_pub->use_sha512_hash(); 1295 | } 1296 | 1297 | my $is_valid; 1298 | 1299 | $is_valid = $rsa_pub->verify($signed_info_c14n, $signature_value); 1300 | 1301 | return $is_valid; 1302 | } 1303 | 1304 | sub verify_saml_signature { 1305 | my ($root, $public_key_pem) = @_; 1306 | 1307 | my $signature_result = signature_saml($root, $public_key_pem); 1308 | my $digest_result = digest_saml($root); 1309 | 1310 | return $digest_result && $signature_result; 1311 | } 1312 | 1313 | sub get_hash_algorithm { 1314 | my ($url) = @_; 1315 | 1316 | my %alg_map = ( 1317 | 'http://www.w3.org/2000/09/xmldsig#sha1' => 'SHA1', 1318 | 'http://www.w3.org/2001/04/xmlenc#sha256' => 'SHA256', 1319 | 'http://www.w3.org/2001/04/xmlenc#sha384' => 'SHA384', 1320 | 'http://www.w3.org/2001/04/xmlenc#sha512' => 'SHA512', 1321 | 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' => 'SHA1', 1322 | 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' => 'SHA256', 1323 | 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384' => 'SHA384', 1324 | 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512' => 'SHA512', 1325 | ); 1326 | 1327 | return $alg_map{$url} || die "Unsupported algorithm: $url"; 1328 | } 1329 | 1330 | sub digest_saml { 1331 | my ($signature_node, $produce) = @_; 1332 | 1333 | my $xpc = XML::LibXML::XPathContext->new($signature_node); 1334 | $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion'); 1335 | $xpc->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'); 1336 | $xpc->registerNs('ds', 'http://www.w3.org/2000/09/xmldsig#'); 1337 | $xpc->registerNs('ec', 'http://www.w3.org/2001/10/xml-exc-c14n#'); 1338 | 1339 | my $parent_node = $signature_node->parentNode; 1340 | 1341 | my ($signed_info_node) = 1342 | $xpc->findnodes('./ds:SignedInfo', $signature_node)->[0]; 1343 | my ($reference_node) = 1344 | $xpc->findnodes('./ds:Reference', $signed_info_node)->[0]; 1345 | 1346 | my $id = $parent_node->getAttribute('ID'); 1347 | $reference_node->setAttribute('URI', "#$id"); 1348 | 1349 | my @transforms = 1350 | $xpc->findnodes('./ds:Transforms/ds:Transform', $reference_node); 1351 | my @transform_algs = map { $_->getAttribute('Algorithm') } @transforms; 1352 | 1353 | my $with_comments = ($transform_algs[1] =~ /WithComments/); 1354 | 1355 | my ($inclusive_ns) = 1356 | $xpc->findnodes('./ec:InclusiveNamespaces', $transforms[1]); 1357 | my $prefix_list = $inclusive_ns 1358 | ? [split ' ', $inclusive_ns->getAttribute('PrefixList')] 1359 | : undef; 1360 | 1361 | my $digest_method = 1362 | $xpc->findnodes('./ds:DigestMethod', $reference_node)->[0]; 1363 | my $alg = $digest_method->getAttribute('Algorithm'); 1364 | 1365 | my $hash = get_hash_algorithm($alg); 1366 | 1367 | my $next_sibling = $signature_node->nextSibling(); 1368 | $signature_node->unbindNode(); 1369 | my $parent_node_c14n = 1370 | $parent_node->toStringEC14N($with_comments, undef, $xpc, $prefix_list); 1371 | $parent_node->insertBefore($signature_node, $next_sibling); 1372 | 1373 | my %hash_func_map = ( 1374 | 'SHA1' => sub { return sha1($_[0]); }, 1375 | 'SHA256' => sub { return sha256($_[0]); }, 1376 | 'SHA384' => sub { return sha384($_[0]); }, 1377 | 'SHA512' => sub { return sha512($_[0]); }, 1378 | ); 1379 | 1380 | my $digest; 1381 | if (exists $hash_func_map{$hash}) { 1382 | $digest = $hash_func_map{$hash}->($parent_node_c14n); 1383 | } else { 1384 | die "Unsupported hash algorithm: $hash"; 1385 | } 1386 | 1387 | my $b64_digest = encode_base64($digest, ''); 1388 | 1389 | my ($digest_value_node) = 1390 | $xpc->findnodes('./ds:DigestValue', $reference_node); 1391 | 1392 | if ($produce) { 1393 | $digest_value_node->removeChildNodes(); 1394 | $digest_value_node->appendText($b64_digest); 1395 | return; 1396 | } 1397 | 1398 | my $expected_digest = $digest_value_node->textContent(); 1399 | 1400 | return $expected_digest eq $b64_digest; 1401 | } 1402 | 1403 | sub signature_saml { 1404 | my ($signature_node, $key_data, $produce) = @_; 1405 | 1406 | my $xpc = XML::LibXML::XPathContext->new($signature_node); 1407 | $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion'); 1408 | $xpc->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'); 1409 | $xpc->registerNs('ds', 'http://www.w3.org/2000/09/xmldsig#'); 1410 | 1411 | my ($signature_value_node) = 1412 | $xpc->findnodes('./ds:SignatureValue', $signature_node); 1413 | my $signature_value_base64 = $signature_value_node->textContent; 1414 | my $signature_value = decode_base64($signature_value_base64); 1415 | 1416 | my ($signed_info_node) = 1417 | $xpc->findnodes('./ds:SignedInfo', $signature_node); 1418 | my ($signature_method_node) = 1419 | $xpc->findnodes('./ds:SignatureMethod', $signed_info_node); 1420 | my $alg = $signature_method_node->getAttribute('Algorithm'); 1421 | 1422 | my $hash_alg = get_hash_algorithm($alg); 1423 | 1424 | my $canonicalization_method = $xpc->findnodes('./ds:CanonicalizationMethod', 1425 | $signed_info_node)->[0]->getAttribute('Algorithm'); 1426 | my $with_comments = ($canonicalization_method =~ /WithComments/); 1427 | 1428 | my $signed_info_c14n = $signed_info_node->toStringEC14N($with_comments); 1429 | 1430 | my $rsa = $produce ? Crypt::OpenSSL::RSA->new_private_key($key_data) 1431 | : Crypt::OpenSSL::RSA->new_public_key( 1432 | Crypt::OpenSSL::X509->new_from_string($key_data)->pubkey() 1433 | ); 1434 | 1435 | $rsa->use_pkcs1_padding(); 1436 | 1437 | my %hash_func_map = ( 1438 | 'SHA1' => sub { $_[0]->use_sha1_hash(); }, 1439 | 'SHA256' => sub { $_[0]->use_sha256_hash(); }, 1440 | 'SHA384' => sub { $_[0]->use_sha384_hash(); }, 1441 | 'SHA512' => sub { $_[0]->use_sha512_hash(); }, 1442 | ); 1443 | 1444 | if (exists $hash_func_map{$hash_alg}) { 1445 | $hash_func_map{$hash_alg}->($rsa); 1446 | } else { 1447 | die "Unsupported hash algorithm: $hash_alg"; 1448 | } 1449 | 1450 | my $result; 1451 | if ($produce) { 1452 | my $signature_value = $rsa->sign($signed_info_c14n); 1453 | my $b64_signature_value = encode_base64($signature_value, ''); 1454 | 1455 | my ($signature_value_node) = 1456 | $xpc->findnodes('./ds:SignatureValue', $signature_node); 1457 | $signature_value_node->removeChildNodes(); 1458 | $signature_value_node->appendText($b64_signature_value); 1459 | 1460 | $result = $signature_value_node; 1461 | } else { 1462 | $result = $rsa->verify($signed_info_c14n, $signature_value); 1463 | } 1464 | 1465 | return $result; 1466 | } 1467 | 1468 | sub get_time { 1469 | my $now = DateTime->now; 1470 | my $past_time = $now->clone->subtract(minutes => 5)->strftime('%FT%TZ'); 1471 | my $future_time = $now->add(minutes => 5)->strftime('%FT%TZ'); 1472 | 1473 | return ($past_time, $future_time); 1474 | } 1475 | 1476 | sub is_issue_instant_valid { 1477 | my $issue_instant = shift; 1478 | 1479 | if ($issue_instant =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?Z$/) { 1480 | my ($year, $month, $day, $hour, $minute, $second) = ($1, $2, $3, $4, $5, $6); 1481 | 1482 | my $issue_instant_dt = DateTime->new( 1483 | year => $year, 1484 | month => $month, 1485 | day => $day, 1486 | hour => $hour, 1487 | minute => $minute, 1488 | second => $second, 1489 | time_zone => 'UTC', 1490 | ); 1491 | 1492 | my $current_time = DateTime->now(time_zone => 'UTC'); 1493 | my $min_time = $current_time->clone->subtract(seconds => 5); 1494 | my $max_time = $current_time->clone->add(seconds => 5); 1495 | 1496 | return ($issue_instant_dt >= $min_time) && ($issue_instant_dt <= $max_time); 1497 | } 1498 | 1499 | return 0; 1500 | } 1501 | 1502 | sub read_file { 1503 | my ($files) = @_; 1504 | my $content = ''; 1505 | 1506 | $files = [$files] unless ref $files eq 'ARRAY'; 1507 | 1508 | for my $file (@$files) { 1509 | local $/; 1510 | open my $fh, '<', $file or die "Failed to open $file: $!"; 1511 | my $c = <$fh>; 1512 | close $fh; 1513 | $content .= $c; 1514 | } 1515 | 1516 | return $content; 1517 | } 1518 | 1519 | sub gen_tmpl { 1520 | my ($type) = @_; 1521 | 1522 | my $signature = <<'END_XML'; 1523 | 1524 | 1525 | 1526 | 1527 | 1528 | 1529 | 1530 | 1531 | 1532 | 1533 | 1534 | 1535 | 1536 | 1537 | 1538 | 1539 | 1540 | END_XML 1541 | 1542 | my $response = < 1551 | 1552 | $signature 1553 | 1554 | 1555 | 1556 | 1562 | 1563 | $signature 1564 | 1565 | user1 1568 | 1569 | 1573 | 1574 | 1575 | 1578 | 1579 | 1580 | 1581 | 1582 | 1586 | 1587 | urn:oasis:names:tc:SAML:2.0:ac:classes:Password 1588 | 1589 | 1590 | 1591 | 1594 | 1 1595 | 1596 | 1599 | group1, admins, students 1600 | 1601 | 1604 | user1 1605 | 1606 | 1609 | Alan Alda 1610 | 1611 | 1614 | +31(0)12345678 1615 | 1616 | 1618 | bar 1619 | 1620 | 1621 | 1622 | 1623 | END_XML 1624 | 1625 | my $logout_request = < 1634 | 1635 | $signature 1636 | user1 1639 | _nginx_sessionindex 1640 | 1641 | END_XML 1642 | 1643 | my $logout_response = < 1651 | 1652 | $signature 1653 | 1654 | 1655 | 1656 | 1657 | END_XML 1658 | 1659 | if ($type eq 'Response') { 1660 | return $response; 1661 | } elsif ($type eq 'LogoutRequest') { 1662 | return $logout_request; 1663 | } elsif ($type eq 'LogoutResponse') { 1664 | return $logout_response; 1665 | } else { 1666 | die "unknown type: $type"; 1667 | } 1668 | } 1669 | 1670 | ############################################################################### 1671 | --------------------------------------------------------------------------------