├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── DISCLAIMER.txt ├── Dockerfile ├── LICENSE ├── README.md ├── doc ├── 01_cloudflare_use_cases_haproxy_unbranded.png ├── 01_cloudflare_use_cases_haproxy_unbranded.svg └── Cloudflare_JWT_validation_with_HAProxy.md ├── example ├── docker-compose.yml ├── haproxy │ └── haproxy.cfg └── jwt_test.sh ├── haproxy.cfg └── src ├── base64.lua └── jwtverify.lua /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 19 * * 4' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: [] 23 | # ******************************* WARNING ********************************* 24 | # CodeQL does not support any of the languages detected in your repository. 25 | # ************************************************************************* 26 | # Learn more... 27 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | with: 33 | # We must fetch at least the immediate parents so that if this is 34 | # a pull request then we can checkout the head. 35 | fetch-depth: 2 36 | 37 | # If this run was triggered by a pull request event, then checkout 38 | # the head of the pull request instead of the merge commit. 39 | - run: git checkout HEAD^2 40 | if: ${{ github.event_name == 'pull_request' }} 41 | 42 | # Initializes the CodeQL tools for scanning. 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v1 45 | with: 46 | languages: ${{ matrix.language }} 47 | # If you wish to specify custom queries, you can do so here or in a config file. 48 | # By default, queries listed here will override any specified in a config file. 49 | # Prefix the list here with "+" to use these queries and those in the config file. 50 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 51 | 52 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 53 | # If this step fails, then you should remove it and run the build manually (see below) 54 | - name: Autobuild 55 | uses: github/codeql-action/autobuild@v1 56 | 57 | # ℹ️ Command-line programs to run using the OS shell. 58 | # 📚 https://git.io/JvXDl 59 | 60 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 61 | # and modify them (or add more) to build your code if your project 62 | # uses a compiled language 63 | 64 | #- run: | 65 | # make bootstrap 66 | # make release 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@v1 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | example/certs 3 | example/cloudflare_mock -------------------------------------------------------------------------------- /DISCLAIMER.txt: -------------------------------------------------------------------------------- 1 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM haproxy:2.6-alpine as builder 2 | 3 | USER root 4 | WORKDIR /tmp 5 | 6 | RUN apk add --no-cache build-base gcc musl-dev lua5.3 lua5.3-dev make openssl-dev 7 | 8 | RUN mkdir -p /usr/local/share/lua/5.3 9 | RUN wget https://github.com/haproxytech/haproxy-lua-http/archive/master.tar.gz -O /tmp/haproxy-lua-http.tar.gz && \ 10 | tar -xf /tmp/haproxy-lua-http.tar.gz -C /tmp && \ 11 | cp /tmp/haproxy-lua-http-master/http.lua /usr/local/share/lua/5.3/http.lua 12 | RUN wget https://github.com/rxi/json.lua/archive/v0.1.2.tar.gz -O /tmp/json-lua.tar.gz && \ 13 | tar -xf /tmp/json-lua.tar.gz -C /tmp && \ 14 | cp /tmp/json.lua-*/json.lua /usr/local/share/lua/5.3/json.lua 15 | RUN wget https://github.com/diegonehab/luasocket/archive/master.tar.gz -O /tmp/luasocker.tar.gz && \ 16 | tar -xf /tmp/luasocker.tar.gz -C /tmp && \ 17 | cd /tmp/luasocket-master && \ 18 | make clean all install-both LUAINC=/usr/include/lua5.3 19 | RUN wget https://github.com/wahern/luaossl/archive/rel-20190731.tar.gz -O /tmp/rel.tar.gz && \ 20 | tar -xf /tmp/rel.tar.gz -C /tmp && \ 21 | cd /tmp/luaossl-rel-* && \ 22 | make install 23 | 24 | FROM haproxy:2.6-alpine 25 | 26 | USER root 27 | RUN apk add --no-cache ca-certificates lua5.3 28 | 29 | COPY --from=builder /usr/local/share/lua/5.3 /usr/local/share/lua/5.3 30 | COPY --from=builder /usr/local/lib/lua/5.3 /usr/local/lib/lua/5.3 31 | COPY ./src/base64.lua ./src/jwtverify.lua /usr/local/share/lua/5.3/ 32 | 33 | COPY ./haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg 34 | 35 | USER haproxy 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Name 2 | 3 | haproxy-cloudflare-jwt-validator - JSON Web Token validation for haproxy 4 | 5 | # Erisa Fork description 6 | 7 | This Docker image allows you to simply and easily validate a JWT for a dockerized Cloudflare Access application before passing off requests to the application backend. 8 | 9 | The image is available as `erisamoe/haproxy-cf-jwt` and `ghcr.io/erisa/haproxy-cf-jwt`. 10 | 11 | Currently, only `linux/amd64` is supported. I would like to add more architectures where possible in the near future. 12 | 13 | As well as trying to keep up to date with HAProxy releases, this fork also makes the change to define all required values in environment variables. 14 | 15 | To explain this, here's a `docker-compose.yml` example: 16 | 17 | ```yaml 18 | services: 19 | access: 20 | image: erisamoe/haproxy-cf-jwt 21 | restart: unless-stopped 22 | environment: 23 | - OAUTH_HOST=erisa.cloudflareaccess.com 24 | - AUDIENCE_TAG=1234567890abcde1234567890abcde1234567890abcde 25 | - BACKEND=webserver:80 26 | depends_on: 27 | - webserver 28 | ``` 29 | 30 | The required environment variables are: 31 | - `OAUTH_HOST`: Your Cloudflare Access domain, e.g. `erisa.cloudflareaccess.com`. 32 | - `AUDIENCE_TAG`: The Application Audience (AUD) Tag for your Access application. 33 | - `BACKEND`: The backend webserver to serve requests after authentication. 34 | 35 | Currently only one application and backend per each container instance is supported, you will have to make manual edits to `haproxy.cfg` and mount it as `/usr/local/etc/haproxy/haproxy.cfg` for custom logic and behaviour. 36 | 37 | # Description 38 | 39 | This was tested & developed with HAProxy version 2.3 & Lua version 5.3. 40 | This library provides the ability to validate JWT headers sent by Cloudflare Access. 41 | 42 | Based off of [haproxytech/haproxy-lua-jwt](https://github.com/haproxytech/haproxy-lua-jwt) 43 | 44 | # Installation 45 | 46 | Install the following dependencies: 47 | 48 | * [haproxy-lua-http](https://github.com/haproxytech/haproxy-lua-http) 49 | * [rxi/json](https://github.com/rxi/json.lua) 50 | * [wahern/luaossl](https://github.com/wahern/luaossl) 51 | * [diegonehab/luasocket](https://github.com/diegonehab/luasocket) 52 | 53 | Extract base64.lua & jwtverify.lua to the same directory like so: 54 | 55 | ```shell 56 | git clone git@github.com:kudelskisecurity/haproxy-cloudflare-jwt-validator.git 57 | sudo cp haproxy-cloudflare-jwt-validator/src/* /usr/local/share/lua/5.3 58 | ``` 59 | 60 | # Version 61 | 62 | 0.3.1 63 | 64 | # Usage 65 | 66 | JWT Issuer: `https://test.cloudflareaccess.com` (replace with yours in the config below) 67 | 68 | Add the following settings in your `/etc/haproxy/haproxy.cfg` file: 69 | 70 | Define a HAProxy backend, DNS Resolver, and ENV variables with the following names: 71 | 72 | ``` 73 | global 74 | lua-load /usr/local/share/lua/5.3/jwtverify.lua 75 | setenv OAUTH_HOST test.cloudflareaccess.com 76 | setenv OAUTH_JWKS_URL https://|cloudflare_jwt|/cdn-cgi/access/certs 77 | setenv OAUTH_ISSUER https://"${OAUTH_HOST}" 78 | 79 | backend cloudflare_jwt 80 | mode http 81 | default-server inter 10s rise 2 fall 2 82 | server "${OAUTH_HOST}" "${OAUTH_HOST}":443 check resolvers dnsresolver resolve-prefer ipv4 83 | 84 | resolvers dnsresolver 85 | nameserver dns1 1.1.1.1:53 86 | nameserver dns2 1.0.0.1:53 87 | resolve_retries 3 88 | timeout retry 1s 89 | hold nx 10s 90 | hold valid 10s 91 | ``` 92 | 93 | Obtain your Application Audience (AUD) Tag from Cloudflare and define your backend with JWT validation: 94 | 95 | ``` 96 | backend my_jwt_validated_app 97 | mode http 98 | http-request deny unless { req.hdr(Cf-Access-Jwt-Assertion) -m found } 99 | http-request set-var(txn.audience) str("1234567890abcde1234567890abcde1234567890abcde") 100 | http-request lua.jwtverify 101 | http-request deny unless { var(txn.authorized) -m bool } 102 | server haproxy 127.0.0.1:8080 103 | ``` 104 | 105 | # Docker 106 | 107 | ``` 108 | docker run \ 109 | -v path_to/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg \ 110 | kudelskisecurity/haproxy-cloudflare-jwt-validator:0.3.0 111 | ``` 112 | 113 | # Blogpost 114 | 115 | This work has been publish in [our Kudelskisecurity Research blog](https://research.kudelskisecurity.com/2020/08/04/first-steps-towards-a-zero-trust-architecture/) 116 | -------------------------------------------------------------------------------- /doc/01_cloudflare_use_cases_haproxy_unbranded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Erisa/haproxy-cloudflare-jwt-validator/e54a0375186b96a112d257185b608dac58a5f21e/doc/01_cloudflare_use_cases_haproxy_unbranded.png -------------------------------------------------------------------------------- /doc/Cloudflare_JWT_validation_with_HAProxy.md: -------------------------------------------------------------------------------- 1 | # First steps towards a Zero Trust architecture 2 | 3 | Hybrid and multi-cloud infrastructures are a real challenge in term of security 4 | and user accesses management. Traditional solutions like VPNs are usually not 5 | adapted for such scenarios. They could still work but at the expense of building 6 | a complex (and costly) web of interconnections and where micro-segmentation of 7 | accesses would be complex to manage. 8 | 9 | This blog post is the first one of a series to document our journey towards Zero 10 | Trust at Kudelski Security. Today we will focus on securing the exposition of 11 | internal application through [Cloudflare Access](https://teams.cloudflare.com/access) 12 | leveraging some tools and code that [we just open-sourced](https://github.com/kudelskisecurity/haproxy-cloudflare-jwt-validator). 13 | 14 | # Table of Contents 15 | 16 | - [First steps towards a Zero Trust architecture](#First-steps-towards-a-Zero-Trust-architecture) 17 | - [Zero Trust in two lines](#Zero-Trust-in-two-lines) 18 | - [Cloudflare Access](#Cloudflare-Access) 19 | - [Exposing applications to Cloudflare Access](#Exposing-applications-to-Cloudflare-Access) 20 | - [Step-by-step implementation](#Step-by-step-implementation) 21 | - [What are JSON Web Tokens?](#What-are-JSON-Web-Tokens) 22 | - [What is a JWKs?](#What-is-a-JWKs) 23 | - [Why should we validate JSON Web Tokens?](#Why-should-we-validate-JSON-Web-Tokens?) 24 | - [Demo - Signing & Validating your own JWT Locally](#Demo---Signing-&-Validating-your-own-JWT-Locally) 25 | - [Dependencies](#Dependencies) 26 | - [Run the Test](#Run-the-Test) 27 | - [How it works](#How-it-works) 28 | - [Installing the JWT validation script](#Installing-the-JWT-validation-script) 29 | - [Dependencies](#Dependencies-1) 30 | - [Enabling the plugin](#Enabling-the-plugin) 31 | - [Authentication validation with JWT](#Authentication-validation-with-JWT) 32 | - [Authorization validation with JWT](#Authorization-validation-with-JWT) 33 | - [App-Based Header Authorization](#App-Based-Header-Authorization) 34 | - [HAProxy Based Authorization](#HAProxy-Based-Authorization) 35 | - [Refs](#Refs) 36 | 37 | 38 | ## Zero Trust in two lines 39 | 40 | Behind the buzzword, Zero Trust is not about making a system trusted; it's about 41 | eliminating the trust that we would originally put on the network. So whenever your 42 | users are connecting to your application from the corporate network or from their home 43 | WiFi, they will follow the same authentication and authorization workflow. 44 | 45 | Identity is one of the building-blocks of a Zero Trust Architecture (Identity Based Security) 46 | that's why Zero Trust is tightly linked to Identity and Accesses Management (IAM) and 47 | device-posture-management but that's probably worth another blog post. 48 | 49 | If you want to read more on that topic: 50 | 51 | * [Zero Trust a Global Perspective][1] 52 | * [What is a Zero Trust Architecture?][2] 53 | * [BeyondCorp model][3] 54 | * [BeyondCorp][4] 55 | 56 | 57 | ## Cloudflare Access 58 | 59 | [Cloudflare Access](https://teams.cloudflare.com/access) is a SaaS Identity-aware-Proxy (IaP) 60 | developed by [Cloudflare](https://www.cloudflare.com) that can help companies willing 61 | to switch to a Zero Trust model by providing a platform-agnostic solution that covers 62 | most use-cases. 63 | 64 | 65 | ## Exposing applications to Cloudflare Access 66 | 67 | Because Cloudflare Access is a SaaS product that is not physically linked to 68 | datacenters, we needed to find a way to securely expose some applications 69 | to Cloudflare CDN over the public Internet. There's multiple ways and technologies 70 | to achieve that (including [Argo tunnels](https://www.cloudflare.com/products/argo-tunnel)) 71 | but the solution that we choose and implemented for this particular use-case 72 | leverages HAProxy (a TCP/HTTP load-balancer) which is a product that we were already 73 | familiar with internally. 74 | 75 | We've added some glue on top of HAProxy, augmenting its functionalities with its LUA 76 | engine by cryptographically validating requests coming from Cloudflare Access applications. 77 | (on top of doing Layer 4 network validations). 78 | 79 | ![](01_cloudflare_use_cases_haproxy_unbranded.png "HAProxy JWT Diagram") 80 | 81 | 82 | ## Step-by-step implementation 83 | 84 | This post is a step-by-step guide to implement JSON Web Token (JWT) based 85 | authentication with HAProxy & Cloudflare Access: 86 | 87 | [haproxy-cloudflare-jwt-validator](https://github.com/kudelskisecurity/haproxy-cloudflare-jwt-validator) 88 | is a lua script for HAProxy that validates JWT from a Cloudflare JWKS endpoint. 89 | It’s able to handle authorization and authentication because it validates each 90 | request to make sure that it’s associated with the correct Cloudflare Access 91 | Application. It is additionally able to pass any custom information from the 92 | JSON Web Token (like SAML group information) to HAProxy. It also enables the 93 | security of our legacy applications by adding a layer of strong authentication 94 | (external IdP + MFA) and authorization on top of them. 95 | 96 | Last but not least, this project is about HAProxy in a Cloudflare Access context 97 | but the code and logic can be applied to anything that uses JWT-based authentication. 98 | 99 | 100 | ## What are JSON Web Tokens? 101 | 102 | JSON web tokens are an open standard for transmitting information between 2 103 | parties as a JSON object. These tokens can contain user, group and other 104 | information. They can be trusted because they can be verified since they 105 | are digitally signed. Cloudflare signs their JWT tokens with SHA-256 using 106 | a private & public key pair. 107 | 108 | 109 | ## What is a JWKs? 110 | 111 | A JSON Web Key set is a JSON structure that exposes public keys and certificates 112 | that are used for validating JSON Web Tokens. Cloudflare exposes their JWKs at 113 | `https:///cdn-cgi/access/cert`. Cloudflare 114 | additionally rotates the certificates they use at this endpoint on a regular 115 | basis. 116 | 117 | 118 | ## Why should we validate JSON Web Tokens? 119 | 120 | Cloudflare recommends [whitelisting cloudflare 121 | IPs](https://www.cloudflare.com/ips/) and validating the JWT for the 122 | requests that come through. Only whitelisting IPs & failing to validate 123 | JWT means that an attacker can simply add a DNS entry in their 124 | Cloudflare configuration to bypass your IP whitelist. You need to make 125 | sure to validate the JWT token in order to prevent access from a 126 | non-authorized Cloudflare account and to ensure that nothing goes past 127 | your Access Application rules. 128 | 129 | 130 | ## Demo - Signing & Validating your own JWT Locally 131 | 132 | We have provided an example here for [signing a JSON web token and 133 | validating the token](https://github.com/kudelskisecurity/haproxy-cloudflare-jwt-validator/tree/master/example) 134 | 135 | 136 | ### Dependencies 137 | 138 | The jwt\_test.sh command requires a few dependencies that need to be 139 | installed on your system 140 | 141 | - Docker - 142 | - Docker-compose - 143 | - JQ 144 | - (Debian/Ubuntu) `sudo apt-get install jq` 145 | - (CentOS) `sudo yum install jq` 146 | - (OSX) `brew install jq` 147 | - jwtgen - `npm install -g jwtgen` 148 | 149 | 150 | ### Run the Test 151 | 152 | To run the example... simply run the script ex: 153 | 154 | `bash jwt_test.sh` 155 | 156 | ``` 157 | Generating Private Key & Certificate: 158 | Generating a RSA private key 159 | ................................................................. 160 | ...............++++ 161 | ................................................................. 162 | ............++++ 163 | writing new private key to 'certs/private.key' 164 | ----- 165 | 166 | Adding Certificate to JWKS Endpoint: 167 | done 168 | Starting example_debug_http_listener_1 ... done 169 | Starting example_cloudflare_mock_1 ... done 170 | Starting example_haproxy_cloudflare_jwt_validator_1 ... done 171 | 172 | CURL Response with Bad Cf-Access-Jwt-Assertion header: 173 |

403 Forbidden

174 | Request forbidden by administrative rules. 175 | 176 | 177 | CURL Response with Valid Cf-Access-Jwt-Assertion header: 178 | { 179 | "path": "/", 180 | "headers": { 181 | "host": "localhost:8080", 182 | "user-agent": "curl/7.58.0", 183 | "accept": "*/*", 184 | "cf-access-jwt-assertion": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1N 185 | iJ9.eyJpYXQiOjE1OTMyMDQ4NTgsImF1ZCI6WyIxMjM0NTY3ODkwYWJjZGUxMjM0NTY3O 186 | DkwYWJjZGUxMjM0NTY3ODkwYWJjZGUiXSwiZW1haWwiOiJyYW5kb20tZW1haWxAZW1haW 187 | wuY29tIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp 188 | 0cnVlLCJpc3MiOiJodHRwOi8vY2xvdWRmbGFyZV9tb2NrIiwibmJmIjoxNTkzMjA0ODU4 189 | LCJleHAiOjM5OTMyMDQ4NTgsInR5cGUiOiJhcHAiLCJpZGVudGl0eV9ub25jZSI6IjExM 190 | TExMTExMTExIiwiY3VzdG9tIjp7fX0.xq1KyxFrOt4-6iAlMqVJ8rORA72OJuZVk9gzd5 191 | nvQ4yqs8MFGTqERp1ggTxF99ieBZ_PDg79jL6NCI_bxxDPCpplX8foUb-XqbS0ppsIrS1 192 | H-NWOsOYMg4KCs7vkj0_g5eforln8aFdCEG1ToA61DqFM2epAxAk5zuHfjwz7aBtk2eNC 193 | RjHgQ692TpwPDdzFsllty4xLfzSbePpEira5pNwBEPFHZnq-ISjpx2d4g6zZUMYMbAX7h 194 | N4RI7NltUxlroQUJDnMIUA7BEVMFsRgqpNpWE3wZbxCprqKXHH9WwhkFebKIQMFyW4KlY 195 | QuDH7htV-uuCaWqwPm4ke4PDY2w3Hjvds5Zne5SAblmkdJBXTx2fDOCLIIv1t0-Pnf_T_ 196 | 3nzmKd_yMQaORRrX9-4lcOWmUoQ1E3pbLcLSQtjvziEFzjcNmcTE1aTtDo6UlLyOEPJpx 197 | b58WrJOrQwo3-jZON1PReCnuZArsfqoE8qPbPTLjMCYkFKNs6WfnXzmLOtTQmVeCMJpG4 198 | QtHVZMDC3GlmDi9RJaribjc0sWbJrPCNqMmdl3dQ3GAspkWDrHW-BWxJ5koCNnToWMEy5 199 | ybG1pTb7Tefd98GamEwhIpENW7z9YF2jPcNeZ8BxmQwna56E0cAQHdTqH-5BaWDDSCLv2 200 | nAy0wzUYMRPKiOVVu1PaZQNU", 201 | "x-forwarded-for": "172.20.0.1", 202 | "connection": "close" 203 | }, 204 | "method": "GET", 205 | "body": "", 206 | "fresh": false, 207 | "hostname": "localhost", 208 | "ip": "172.20.0.1", 209 | "ips": [ 210 | "172.20.0.1" 211 | ], 212 | "protocol": "http", 213 | "query": {}, 214 | "subdomains": [], 215 | "xhr": false, 216 | "os": { 217 | "hostname": "08afc040418e" 218 | }, 219 | "connection": {} 220 | Stopping example_haproxy_cloudflare_jwt_validator_1 ... done 221 | Stopping example_debug_http_listener_1 ... done 222 | Stopping example_cloudflare_mock_1 ... done 223 | ``` 224 | 225 | 226 | ### How it works 227 | 228 | To start off, we generate a private key and a certificate with openssl. 229 | We stick with default values just because this is a demo scenario and it 230 | doesn’t matter for this test. 231 | 232 | ```bash 233 | openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \ 234 | -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com 235 | " \ 236 | -keyout certs/private.key -out certs/certificate.pem 237 | ``` 238 | 239 | We then take the certificate that was generated, and expose it to the 240 | JWKS endpoint (we are running a simple python server that exposes files 241 | just for demo purposes). 242 | 243 | ``` 244 | CERT=$(cat certs/certificate.pem) 245 | jq -n --arg cert "$CERT" '{public_certs: [{kid: "1", cert: $cert} 246 | , {kid: "2", cert: $cert}]}' \ 247 | > cloudflare_mock/cdn-cgi/access/certs && echo "done" 248 | ``` 249 | 250 | We then use a custom claim to generate a JWT token. 251 | 252 | ```bash 253 | CLAIM='{ 254 | "aud": [ 255 | "1234567890abcde1234567890abcde1234567890abcde" 256 | ], 257 | "email": "random-email@email.com", 258 | "sub": "1234567890", 259 | "name": "John Doe", 260 | "admin": true, 261 | "iss": "http://cloudflare_mock", 262 | "iat": 1593204858, 263 | "nbf": 1593204858, 264 | "exp": 3993204858, 265 | "type": "app", 266 | "identity_nonce": "11111111111", 267 | "custom": {} 268 | }' 269 | JWT_TOKEN=$(jwtgen -a RS256 -p certs/private.key --claims "$CLAIM") 270 | curl -H "Cf-Access-Jwt-Assertion: ${JWT_TOKEN}" localhost:8080 271 | ``` 272 | 273 | The jwtverify.lua script then gets triggered via the defined HAProxy 274 | backend. It decodes the token, validates the algorithm, signature, 275 | expiration, issuer, and audience. 276 | 277 | ## Installing the JWT validation script 278 | 279 | ### Dependencies 280 | 281 | The haproxy-cloudflare-jwt-validation lua script requires a few 282 | dependencies that need to be installed on your system; 283 | 284 | Debian/Ubuntu Instructions: 285 | 286 | ```bash 287 | sudo apt install lua5.3 liblua5.3-dev wget make libssl-dev 288 | sudo mkdir -p /usr/local/share/lua/5.3 289 | ``` 290 | 291 | [haproxy-lua-http](https://github.com/haproxytech/haproxy-lua-http): 292 | 293 | ```bash 294 | wget https://github.com/haproxytech/haproxy-lua-http/archive/master.tar.gz 295 | tar -xf master.tar.gz -C /usr/local/share/lua/5.3 296 | cp /usr/local/share/lua/5.3/haproxy-lua-http-master/http.lua /usr/local/share/lua/5.3/http.lua 297 | ``` 298 | 299 | [rxi/json](https://github.com/rxi/json.lua): 300 | 301 | ```bash 302 | wget https://github.com/rxi/json.lua/archive/v0.1.2.tar.gz 303 | tar -xf v0.1.2.tar.gz -C /usr/local/share/lua/5.3 304 | ln -s /usr/local/share/lua/5.3/json.lua-0.1.2/json.lua /usr/local/share/lua/5.3/json.lua 305 | ``` 306 | 307 | [wahern/luaossl](https://github.com/wahern/luaossl): 308 | 309 | ```bash 310 | wget https://github.com/wahern/luaossl/archive/rel-20190731.tar.gz 311 | tar -xf rel-20190731.tar.gz -C /usr/local/share/lua/5.3 312 | cd /usr/local/share/lua/5.3/luaossl-rel-20190731 313 | make install 314 | ``` 315 | 316 | [diegonehab/luasocket](https://github.com/diegonehab/luasocket) 317 | 318 | ```bash 319 | wget https://github.com/diegonehab/luasocket/archive/master.tar.gz 320 | tar -xf master.tar.gz -C /usr/local/share/lua/5.3 321 | cd /usr/local/share/lua/5.3/luasocket-master 322 | make clean all install-both LUAINC=/usr/include/lua5.3 323 | ``` 324 | 325 | Once the dependencies are installed install the latest release of the 326 | plugin: 327 | 328 | 329 | 330 | ```bash 331 | tar -xf haproxy-cloudflare-jwt-validator-${VERSION}.tar.gz -C /usr/local/share/lua/5.3 332 | ln -s /usr/local/share/lua/5.3/haproxy-cloudflare-jwt-validator-${VERSION}/src/base64.lua /usr/local/share/lua/5.3/base64.lua 333 | ln -s /usr/local/share/lua/5.3/haproxy-cloudflare-jwt-validator-${VERSION}/src/jwtverify.lua /usr/local/share/lua/5.3/jwtverify.lua 334 | ``` 335 | 336 | ## Enabling the plugin 337 | 338 | Once the lua script is installed… the plugin can be enabled by enabling 339 | the following configuration options in `/etc/haproxy/haproxy.cfg` 340 | (replace `test.cloudflareaccess.com` with your JWT issuer) 341 | 342 | ``` 343 | global 344 | lua-load /usr/local/share/lua/5.3/jwtverify.lua 345 | setenv OAUTH_HOST test.cloudflareaccess.com 346 | setenv OAUTH_JWKS_URL https://|cloudflare_jwt|/cdn-cgi/access/certs 347 | setenv OAUTH_ISSUER https://"${OAUTH_HOST}" 348 | 349 | backend cloudflare_jwt 350 | mode http 351 | default-server inter 10s rise 2 fall 2 352 | server "${OAUTH_HOST}" "${OAUTH_HOST}":443 check resolvers dnsresolver resolve-prefer ipv4 353 | 354 | resolvers dnsresolver 355 | nameserver dns1 1.1.1.1:53 356 | nameserver dns2 1.0.0.1:53 357 | resolve_retries 3 358 | timeout retry 1s 359 | hold nx 10s 360 | hold valid 10s 361 | ``` 362 | 363 | ## Authentication validation with JWT 364 | 365 | In addition to validating the JWT token with 366 | haproxy-cloudflare-jwt-validator, it’s recommended to block and only 367 | [whitelist cloudflare IPs](https://www.cloudflare.com/ips/) for your 368 | publicly exposed endpoint in your firewall. 369 | 370 | The haproxy-cloudflare-jwt-validator script performs authentication on 371 | your pre-defined backend or frontend. 372 | 373 | For example: 374 | 375 | ``` 376 | backend my_jwt_validated_app_backend 377 | mode http 378 | http-request deny unless { req.hdr(Cf-Access-Jwt-Assertion) -m found } 379 | http-request set-var(txn.audience) str("1234567890abcde1234567890abcde1234567890abcde") 380 | http-request lua.jwtverify 381 | http-request deny unless { var(txn.authorized) -m bool } 382 | server haproxy 127.0.0.1:8080 383 | frontend my_jwt_validated_app_frontend 384 | bind *:80 385 | mode http 386 | use_backend my_jwt_validated_app_backend 387 | ``` 388 | 389 | Using the configuration above… when a HTTP request comes through to port 390 | 80 the following checks are performed: 391 | 392 | - Validates that the `Cf-Access-Jwt-Assertion` Header is set 393 | 394 | - Validates that the Algorithm of the token is RS256 395 | 396 | - Validates that the at least one public key from the JWKS 397 | certificates match the public key for the signed JSON Web Token 398 | 399 | - Validates that the token is not expired 400 | 401 | - Validates that the Issuer of the token matches the predefined issuer 402 | from the `OAUTH_ISSUER` ENV variable 403 | 404 | - Validates that the audience tag from the JWT token matches the 405 | audience tag that we expect for this backend 406 | 407 | Assuming that all of the above checks pass… the request is then allowed. 408 | If any of the above checks fail. HAProxy will respond with a 403 409 | (Unauthorized) error. 410 | 411 | ## Authorization validation with JWT 412 | 413 | App authorization runs under the assumption that you can pass 414 | information from Cloudflare such as group information, permissions, etc 415 | ... as part of the JWT token. 416 | 417 | For example, when you add an SAML Identity Provider to Cloudflare 418 | Access… you can define a list of SAML attributes that will get included 419 | as part of the encoded JSON Web Token. These attributes are passed 420 | through as variables that can be used in headers, HAProxy 421 | authentication, or even other lua scripts. 422 | 423 | When you configure SAML attributes successfully, you will see them 424 | included as part of the decoded JSON Web Token as part of the ‘custom’ 425 | key. Ex: 426 | 427 | ``` 428 | { 429 | ... 430 | "email": "random-email@email.com", 431 | "name": "John Doe", 432 | "custom": { 433 | "http://schemas/groups": [ 434 | "application_admin", 435 | "application_group1", 436 | "application_group2", 437 | "application_group3" 438 | ] 439 | } 440 | } 441 | ``` 442 | 443 | The haproxy-cloudflare-jwt-validate script will take the keys defined in 444 | “custom” and declare haproxy variables with them. (This will strip out 445 | special characters and replace them with an underscore) 446 | 447 | For example: `http://schemas/groups` becomes `txn.http___schemas_groups` 448 | 449 | ### App-Based Header Authorization 450 | 451 | Now that we’ve defined our variable `txn.http___schemas_groups`… you can 452 | then pass this variable through to the headers. Every request that comes 453 | through will pass this header information to the backend by simply using 454 | `set-headers`. 455 | 456 | ``` 457 | backend my_jwt_validated_app_backend 458 | mode http 459 | http-request deny unless { req.hdr(Cf-Access-Jwt-Assertion) -m found } 460 | http-request set-var(txn.audience) str("1234567890abcde1234567890abcde1234567890abcde") 461 | http-request lua.jwtverify 462 | http-request deny unless { var(txn.authorized) -m bool } 463 | http-request set-header custom-groups %[var(txn.http___schemas_groups)] 464 | server haproxy 127.0.0.1:8080 465 | ``` 466 | 467 | Your app can read the headers, create your user based off of these 468 | headers, and assign it group information. 469 | 470 | ### HAProxy Based Authorization 471 | 472 | Another alternative we can do, is validate the presence of a user's group 473 | via HAProxy directly. For example in the following scenario, HAProxy will not grant a 474 | user access unless they belong to the `application_admin` group. 475 | 476 | ``` 477 | backend my_jwt_validated_app_backend 478 | mode http 479 | http-request deny unless { req.hdr(Cf-Access-Jwt-Assertion) -m found } 480 | http-request set-var(txn.audience) str("1234567890abcde1234567890abcde1234567890abcde") 481 | http-request lua.jwtverify 482 | http-request deny unless { var(txn.authorized) -m bool } 483 | http-request deny unless { var(txn.http___schemas_groups) -m sub application_admin } 484 | server haproxy 127.0.0.1:8080 485 | ``` 486 | 487 | 488 | ## References and links 489 | 490 | * [Zero Trust a Global Perspective][1] 491 | * [What is a Zero Trust Architecture?][2] 492 | * [BeyondCorp model][3] 493 | * [BeyondCorp][4] 494 | * [JWT][5] 495 | * [haproxy-lua-jwt][6] 496 | 497 | [1]: https://www.okta.com/blog/2020/05/zero-trust-a-global-perspective 498 | [2]: https://www.paloaltonetworks.com/cyberpedia/what-is-a-zero-trust-architecture 499 | [3]: https://www.beyondcorp.com 500 | [4]: https://cloud.google.com/solutions/beyondcorp-remote-access 501 | [5]: https://jwt.io/introduction 502 | [6]: https://github.com/haproxytech/haproxy-lua-jwt 503 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | haproxy_cloudflare_jwt_validator: 5 | build: ../ 6 | image: haproxy_cloudflare_jwt_validator:latest 7 | ports: 8 | - "8080:8080" 9 | volumes: 10 | - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg 11 | - ../src/jwtverify.lua:/usr/local/share/lua/5.3/jwtverify.lua 12 | - ../src/base64.lua:/usr/local/share/lua/5.3/base64.lua 13 | depends_on: 14 | - debug_http_listener 15 | - cloudflare_mock 16 | 17 | debug_http_listener: 18 | image: mendhak/http-https-echo 19 | 20 | cloudflare_mock: 21 | image: python:2.7-alpine 22 | volumes: 23 | - ./cloudflare_mock/cdn-cgi:/cdn-cgi 24 | expose: 25 | - "80" 26 | ports: 27 | - "8081:80" 28 | command: python -m SimpleHTTPServer 80 -------------------------------------------------------------------------------- /example/haproxy/haproxy.cfg: -------------------------------------------------------------------------------- 1 | # This file managed by Puppet 2 | global 3 | log stdout format raw local0 notice 4 | maxconn 4096 5 | daemon 6 | lua-load /usr/local/share/lua/5.3/jwtverify.lua 7 | setenv OAUTH_HOST cloudflare_mock 8 | setenv OAUTH_JWKS_URL http://|cloudflare_jwt|/cdn-cgi/access/certs 9 | setenv OAUTH_ISSUER http://"${OAUTH_HOST}" 10 | 11 | defaults 12 | log global 13 | mode http 14 | option httplog 15 | option dontlognull 16 | option forwardfor 17 | option http-server-close 18 | stats enable 19 | stats uri /haproxyStats 20 | timeout http-request 10s 21 | timeout queue 1m 22 | timeout connect 10s 23 | timeout client 1m 24 | timeout server 1m 25 | timeout check 10s 26 | 27 | frontend http-in 28 | bind *:8080 29 | mode http 30 | use_backend http-backend 31 | 32 | backend http-backend 33 | mode http 34 | http-request deny unless { req.hdr(Cf-Access-Jwt-Assertion) -m found } 35 | http-request set-var(txn.audience) str("1234567890abcde1234567890abcde1234567890abcde") 36 | http-request lua.jwtverify 37 | http-request deny unless { var(txn.authorized) -m bool } 38 | http-request set-header custom-groups %[var(txn.http___schemas_groups)] 39 | server debug_http_listener debug_http_listener:80 check 40 | 41 | backend cloudflare_jwt 42 | mode http 43 | default-server inter 10s rise 2 fall 2 44 | server "${OAUTH_HOST}" "${OAUTH_HOST}":80 check 45 | -------------------------------------------------------------------------------- /example/jwt_test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mkdir -p cloudflare_mock/cdn-cgi/access 4 | mkdir -p certs 5 | 6 | printf "\nGenerating Private Key & Certificate: \n" 7 | openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \ 8 | -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" \ 9 | -keyout certs/private.key -out certs/certificate.pem 10 | 11 | CERT=$(cat certs/certificate.pem) 12 | 13 | printf "\nAdding Certificate to JWKS Endpoint: \n" 14 | jq -n --arg cert "$CERT" '{public_certs: [{kid: "1", cert: $cert}, {kid: "2", cert: $cert}]}' \ 15 | > cloudflare_mock/cdn-cgi/access/certs && echo "done" 16 | 17 | docker-compose stop 18 | docker-compose up -d 19 | 20 | CLAIM='{ 21 | "aud": [ 22 | "1234567890abcde1234567890abcde1234567890abcde" 23 | ], 24 | "email": "random-email@email.com", 25 | "sub": "1234567890", 26 | "name": "John Doe", 27 | "admin": true, 28 | "iss": "http://cloudflare_mock", 29 | "iat": 1593204858, 30 | "nbf": 1593204858, 31 | "exp": 3993204858, 32 | "type": "app", 33 | "identity_nonce": "11111111111", 34 | "custom": { 35 | "http://schemas/groups": [ 36 | "application_admin", 37 | "application_group1", 38 | "application_group2", 39 | "application_group3" 40 | ] 41 | } 42 | }' 43 | 44 | while ! nc -z localhost 8080; do 45 | sleep 0.1 46 | done 47 | 48 | #wait a couple of seconds for the backends to start for haproxy 49 | sleep 3 50 | 51 | printf "\nCURL Response with Bad Cf-Access-Jwt-Assertion header: \n" 52 | curl -H "Cf-Access-Jwt-Assertion: non-valid-token" localhost:8080 53 | 54 | JWT_TOKEN=$(jwtgen -a RS256 -p certs/private.key --claims "$CLAIM") 55 | 56 | printf "\nCURL Response with Valid Cf-Access-Jwt-Assertion header: \n" 57 | curl -H "Cf-Access-Jwt-Assertion: ${JWT_TOKEN}" localhost:8080 58 | 59 | docker-compose stop 60 | -------------------------------------------------------------------------------- /haproxy.cfg: -------------------------------------------------------------------------------- 1 | global 2 | lua-load /usr/local/share/lua/5.3/jwtverify.lua 3 | setenv OAUTH_JWKS_URL https://|cloudflare_jwt|/cdn-cgi/access/certs 4 | setenv OAUTH_ISSUER https://"${OAUTH_HOST}" 5 | 6 | frontend backendproxy 7 | bind :80 8 | mode http 9 | use_backend jwt-validation 10 | 11 | backend cloudflare_jwt 12 | mode http 13 | default-server inter 10s rise 2 fall 2 14 | server "${OAUTH_HOST}" "${OAUTH_HOST}":443 check resolvers dnsresolver resolve-prefer ipv4 15 | 16 | resolvers dnsresolver 17 | nameserver dns1 1.1.1.1:53 18 | nameserver dns2 1.0.0.1:53 19 | resolve_retries 3 20 | timeout retry 1s 21 | hold nx 10s 22 | hold valid 10s 23 | 24 | backend jwt-validation 25 | mode http 26 | http-request deny unless { req.hdr(Cf-Access-Jwt-Assertion) -m found } 27 | http-request set-var(txn.audience) str("${AUDIENCE_TAG}") 28 | http-request lua.jwtverify 29 | http-request deny unless { var(txn.authorized) -m bool } 30 | server app "${BACKEND}" 31 | -------------------------------------------------------------------------------- /src/base64.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- base64.lua 3 | -- 4 | -- URL safe base64 encoder/decoder 5 | -- 6 | 7 | -- https://github.com/diegonehab/luasocket 8 | local mime = require 'mime' 9 | local _M = {} 10 | 11 | --- base64 encoder 12 | -- 13 | -- @param s String to encode (can be binary data) 14 | -- @return Encoded string 15 | function _M.encode(s) 16 | local u 17 | local padding_len = 2 - ((#s-1) % 3) 18 | 19 | if padding_len > 0 then 20 | u = mime.b64(s):sub(1, - padding_len - 1) 21 | else 22 | u = mime.b64(s) 23 | end 24 | 25 | if u then 26 | return u:gsub('[+]', '-'):gsub('[/]', '_') 27 | else 28 | return nil 29 | end 30 | end 31 | 32 | --- base64 decoder 33 | -- 34 | -- @param s String to decode 35 | -- @return Decoded string (can be binary data) 36 | function _M.decode(s) 37 | local e = s:gsub('[-]', '+'):gsub('[_]', '/') 38 | local u, _ = mime.unb64(e .. string.rep('=', 3 - ((#s - 1) % 4))) 39 | 40 | return u 41 | end 42 | 43 | return _M 44 | -------------------------------------------------------------------------------- /src/jwtverify.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- JWT Validation implementation for HAProxy Lua host 3 | -- This script is a heavily modified version of the following: https://github.com/haproxytech/haproxy-lua-jwt 4 | -- 2020-05-21 - Bojan Zelic - Enabled support for JWKS urls, custom headers, multiple audience tokens 5 | -- Copyright (c) 2019. Adis Nezirovic 6 | -- Copyright (c) 2019. Baptiste Assmann 7 | -- Copyright (c) 2019. Nick Ramirez 8 | -- Copyright (c) 2019. HAProxy Technologies LLC 9 | -- 10 | -- Licensed under the Apache License, Version 2.0 (the "License"); 11 | -- you may not use this file except in compliance with the License. 12 | -- You may obtain a copy of the License at 13 | -- 14 | -- http://www.apache.org/licenses/LICENSE-2.0 15 | -- 16 | -- Unless required by applicable law or agreed to in writing, software 17 | -- distributed under the License is distributed on an "AS IS" BASIS, 18 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | -- See the License for the specific language governing permissions and 20 | -- limitations under the License. 21 | -- 22 | -- Use HAProxy 'lua-load' to load optional configuration file which 23 | -- should contain config table. 24 | -- Default/fallback config 25 | if not config then 26 | config = { 27 | publicKeys = { 28 | keys = {}, 29 | expiresIn = 1000 -- 1 second 30 | }, 31 | max_cache = 24 * 60 * 60, -- 24 hours 32 | issuer = nil, 33 | jwks_url = nil 34 | } 35 | end 36 | 37 | local json = require 'json' 38 | local base64 = require 'base64' 39 | local http = require 'http' 40 | 41 | local openssl = { 42 | pkey = require 'openssl.pkey', 43 | digest = require 'openssl.digest', 44 | x509 = require 'openssl.x509' 45 | } 46 | 47 | local function log_alert(msg) 48 | core.Alert("jwtverify.lua: - "..tostring(msg)) 49 | end 50 | 51 | local function log_info(msg) 52 | core.Info("jwtverify.lua: - "..tostring(msg)) 53 | end 54 | 55 | local function log_debug(msg) 56 | core.Debug("jwtverify.lua: - "..tostring(msg)) 57 | end 58 | 59 | local function log_notice(msg) 60 | core.log(core.notice, "jwtverify.lua: - "..tostring(msg)) 61 | end 62 | 63 | local function dump(o) 64 | if type(o) == 'table' then 65 | local s = '{ ' 66 | for k,v in pairs(o) do 67 | if type(k) ~= 'number' then k = '"'..k..'"' end 68 | s = s .. '['..k..'] = ' .. dump(v) .. ',' 69 | end 70 | return s .. '} ' 71 | else 72 | return tostring(o) 73 | end 74 | end 75 | 76 | local function decodeJwt(authorizationHeader) 77 | local headerFields = core.tokenize(authorizationHeader, " .") 78 | 79 | if #headerFields ~= 3 then 80 | log_debug("Improperly formated Authorization header. Should be followed by 3 token sections.") 81 | return nil 82 | end 83 | 84 | local token = {} 85 | token.header = headerFields[1] 86 | token.headerdecoded = json.decode(base64.decode(token.header)) 87 | 88 | token.payload = headerFields[2] 89 | token.payloaddecoded = json.decode(base64.decode(token.payload)) 90 | 91 | token.signature = headerFields[3] 92 | token.signaturedecoded = base64.decode(token.signature) 93 | 94 | log_debug('Authorization header: ' .. authorizationHeader) 95 | log_debug('Decoded JWT header: ' .. dump(token.headerdecoded)) 96 | log_debug('Decoded JWT payload: ' .. dump(token.payloaddecoded)) 97 | 98 | return token 99 | end 100 | 101 | local function algorithmIsValid(token) 102 | if token.headerdecoded.alg == nil then 103 | log_debug("No 'alg' provided in JWT header.") 104 | return false 105 | elseif token.headerdecoded.alg ~= 'RS256' then 106 | log_debug("RS256 supported. Incorrect alg in JWT: " .. token.headerdecoded.alg) 107 | return false 108 | end 109 | 110 | return true 111 | end 112 | 113 | local function signatureIsValid(token, publicKey) 114 | local digest = openssl.digest.new('SHA256') 115 | digest:update(token.header .. '.' .. token.payload) 116 | 117 | local isVerified = publicKey:verify(token.signaturedecoded, digest) 118 | return isVerified 119 | end 120 | 121 | local function has_value (tab, val) 122 | if tab == val then 123 | return true 124 | end 125 | 126 | for _, value in ipairs(tab) do 127 | if value == val then 128 | return true 129 | end 130 | end 131 | 132 | return false 133 | end 134 | 135 | local function expirationIsValid(token) 136 | return os.difftime(token.payloaddecoded.exp, core.now().sec) > 0 137 | end 138 | 139 | local function issuerIsValid(token, expectedIssuer) 140 | return token.payloaddecoded.iss == expectedIssuer 141 | end 142 | 143 | local function audienceIsValid(token, expectedAudience) 144 | -- audience is sometimes stored as an array of strings 145 | -- sometimes it's stored as a string 146 | return has_value(token.payloaddecoded.aud, expectedAudience) 147 | end 148 | 149 | -- This function loads the JSON from our JWKS url. However because we cannot do DNS lookups in haproxy, We have to 150 | -- use the IP address directly. We depend on a backend that's set in order for Haproxy to resolve an IP address 151 | -- for the JWKS url. 152 | -- If there are any errors (ex: if cloudflare endpoint is down... then we will rely on the last-used public key 153 | local function getJwksData(url) 154 | --check for existence of public keys 155 | 156 | local publicKeys = {} 157 | local expiresIn = 60 * 60 -- 1 hour default 158 | 159 | local be = string.gsub(string.match(url, '|.*|'), '|', '') 160 | local addr 161 | local server_name 162 | for name, server in pairs(core.backends[be].servers) do 163 | local status = server:get_stats()['status'] 164 | if status == "no check" or status:find("UP") == 1 then 165 | addr = server:get_addr() 166 | server_name = name 167 | break 168 | end 169 | end 170 | 171 | if addr == nil or addr == '' then 172 | log_info("No servers available for auth-request backend: '" .. be .. "'") 173 | return { 174 | keys = config.publicKeys.keys, 175 | expiresIn = 1 -- 1 second 176 | } 177 | end 178 | 179 | local ip_url = string.gsub(url, '|'..be..'|', addr) 180 | 181 | log_info('Retrieving JWKS Public Key Data') 182 | 183 | local response, err = http.get{url=ip_url, headers={Host=server_name}} 184 | if not response then 185 | log_alert(err) 186 | return { 187 | keys = config.publicKeys.keys, 188 | expiresIn = 1 -- 1 second 189 | } 190 | end 191 | 192 | if response.status_code ~= 200 then 193 | log_info("JWKS data is not available.") 194 | log_info("status_code: " .. response.status_code or "") 195 | log_info("body: " .. dump(response.content) or "") 196 | log_info("headers: " .. dump(response.headers) or "") 197 | log_info("reason: " .. response.reason or "") 198 | 199 | -- return already set publicKeys if already set 200 | if is_cached then 201 | return { 202 | keys = config.publicKeys.keys, 203 | expiresIn = 60 -- 60 second 204 | } 205 | end 206 | 207 | log_alert("JWKS data is not available") 208 | end 209 | 210 | local JWKS_response = json.decode(response.content) 211 | 212 | for _,v in pairs(JWKS_response.public_certs) do 213 | table.insert(publicKeys,openssl.x509.new(v.cert):getPublicKey()) 214 | log_info("Public Key Cached: " .. v.kid) 215 | end 216 | 217 | local max_age 218 | 219 | if response.headers['cache-control'] then 220 | local has_max_age = string.match(response.headers['cache-control'], "max%-age=%d+") 221 | if has_max_age then 222 | max_age = tonumber(string.gsub(has_max_age, 'max%-age=', ''), 10) 223 | end 224 | end 225 | 226 | if max_age then 227 | expiresIn = math.min(max_age, config.max_cache) 228 | else 229 | log_info('cache-control headers not able to be retrieved from JWKS endpoint') 230 | end 231 | 232 | return { 233 | keys = publicKeys, 234 | expiresIn = expiresIn 235 | } 236 | 237 | end 238 | 239 | function jwtverify(txn) 240 | 241 | local issuer = config.issuer 242 | local audience = txn.get_var(txn, 'txn.audience') 243 | local signature_valid = false 244 | 245 | -- 1. Decode and parse the JWT 246 | local token = decodeJwt(txn.sf:req_hdr("cf-access-jwt-assertion")) 247 | if token == nil then 248 | log_debug("Token could not be decoded.") 249 | goto out 250 | end 251 | 252 | -- 2. Verify the signature algorithm is supported (RS256) 253 | if algorithmIsValid(token) == false then 254 | log_debug("Algorithm not valid.") 255 | goto out 256 | end 257 | 258 | -- 3. Verify the signature with the certificate 259 | for k,pem in pairs(config.publicKeys.keys) do 260 | signature_valid = signature_valid or signatureIsValid(token, pem) 261 | end 262 | 263 | if signature_valid == false then 264 | log_debug("Signature not valid.") 265 | 266 | if not signature_valid then 267 | goto out 268 | end 269 | end 270 | 271 | -- 4. Verify that the token is not expired 272 | if expirationIsValid(token) == false then 273 | log_info("Token is expired.") 274 | goto out 275 | end 276 | 277 | -- 5. Verify the issuer 278 | if issuer ~= nil and issuerIsValid(token, issuer) == false then 279 | log_info("Issuer not valid.") 280 | goto out 281 | end 282 | 283 | -- 6. Verify the audience 284 | if audience ~= nil and audienceIsValid(token, audience) == false then 285 | log_info("Audience not valid.") 286 | goto out 287 | end 288 | 289 | -- 7. Add custom values from payload to variable 290 | if token.payloaddecoded.custom ~= nil then 291 | for name, payload in pairs(token.payloaddecoded.custom) do 292 | local clean_name = name:gsub("%W","_") 293 | local clean_value = payload 294 | if (type(payload) == 'table') then 295 | clean_value = table.concat(payload, ',') 296 | end 297 | 298 | txn.set_var(txn, "txn."..clean_name, clean_value) 299 | log_debug("txn."..clean_name.." is defined from payload") 300 | end 301 | end 302 | 303 | -- 8. Set authorized variable 304 | log_debug("req.authorized = true") 305 | txn.set_var(txn, "txn.authorized", true) 306 | 307 | -- exit 308 | do return end 309 | 310 | ::out:: 311 | log_debug("req.authorized = false") 312 | txn.set_var(txn, "txn.authorized", false) 313 | end 314 | 315 | -- This function runs in the background similarly to a cronjob 316 | -- On a high level it tries to get the public key from our jwks url 317 | -- based on an interval. The interval we use is based on the cache headers as part of the JWKS response 318 | function refresh_jwks() 319 | log_info("Refresh JWKS task initialized") 320 | 321 | while true do 322 | log_info('Refreshing JWKS data') 323 | local status, publicKeys = xpcall(getJwksData, debug.traceback, config.jwks_url) 324 | if status then 325 | config.publicKeys = publicKeys 326 | else 327 | local err = publicKeys 328 | log_alert("Unable to set public keys: "..tostring(err)) 329 | end 330 | 331 | log_info('Getting new Certificate in '..(config.publicKeys.expiresIn)..' seconds - ' 332 | ..os.date('%c', os.time() + config.publicKeys.expiresIn)) 333 | core.sleep(config.publicKeys.expiresIn) 334 | end 335 | end 336 | 337 | -- Called after the configuration is parsed. 338 | -- Loads the OAuth public key for validating the JWT signature. 339 | core.register_init(function() 340 | config.issuer = os.getenv("OAUTH_ISSUER") 341 | config.jwks_url = os.getenv("OAUTH_JWKS_URL") 342 | log_info("JWKS URL: " .. (config.jwks_url or "")) 343 | log_info("Issuer: " .. (config.issuer or "")) 344 | end) 345 | 346 | -- Called on a request. 347 | core.register_action('jwtverify', {'http-req'}, jwtverify, 0) 348 | 349 | -- Task is similar to a cronjob 350 | core.register_task(refresh_jwks) --------------------------------------------------------------------------------