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