├── .github ├── dependabot.yml ├── resources │ └── hosts ├── scripts │ ├── build_and_test.sh │ ├── cleanup │ │ └── main.go │ └── prepare │ │ └── main.go └── workflows │ ├── build_and_test.yml │ └── tagged-release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── haproxy-spoe-auth │ └── main.go ├── docker-compose.yml ├── docs ├── configuration.md ├── images │ ├── architecture-oidc.png │ └── sequence-oidc.png ├── ldap.md ├── openidconnect.md └── performance.md ├── go.mod ├── go.sum ├── internal ├── agent │ └── agent.go └── auth │ ├── aes_encryptor.go │ ├── aes_encryptor_test.go │ ├── authenticator.go │ ├── authenticator_ldap.go │ ├── authenticator_oidc.go │ ├── errors.go │ ├── messages.go │ ├── oidc_clients_store.go │ ├── oidc_clients_store_test.go │ ├── signature.go │ ├── signature_hmac_sha256.go │ ├── signature_hmac_sha256_test.go │ └── templates.go ├── resources ├── configuration │ └── config.yml ├── dex │ └── config.yaml ├── haproxy │ ├── haproxy.cfg │ └── spoe-auth.conf ├── ldap │ └── 01-base.ldif ├── nginx │ └── default.conf ├── protected │ ├── index.html │ └── secret.html ├── scripts │ ├── entrypoint.sh │ ├── run-with-debug.sh │ └── run.sh ├── unauthorized │ └── index.html └── unprotected │ └── index.html └── tests ├── actions.go ├── assertions.go ├── const.go ├── ldap_authentication_test.go ├── oidc_authentication_test.go ├── public_test.go └── webdriver.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/resources/hosts: -------------------------------------------------------------------------------- 1 | 127.0.0.1 public.example.com 2 | 127.0.0.1 app1.example.com 3 | 127.0.0.1 app2.example.com 4 | 127.0.0.1 app3.example.com 5 | 127.0.0.1 dex.example.com # An OIDC server implementation -------------------------------------------------------------------------------- /.github/scripts/build_and_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -e 5 | 6 | sudo curl -Lo /usr/bin/docker-compose https://github.com/docker/compose/releases/download/v2.23.3/docker-compose-linux-x86_64 7 | sudo chmod 755 /usr/bin/docker-compose 8 | go run .github/scripts/prepare/main.go 9 | sleep 20 10 | 11 | go test tests/*.go 12 | result=$? 13 | 14 | go run .github/scripts/cleanup/main.go 15 | exit $result -------------------------------------------------------------------------------- /.github/scripts/cleanup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func execute(command string, arg ...string) error { 11 | cmd := exec.Command(command, arg...) 12 | 13 | stdout, err := cmd.StdoutPipe() 14 | if err != nil { 15 | return err 16 | } 17 | 18 | stderr, err := cmd.StderrPipe() 19 | if err != nil { 20 | return err 21 | } 22 | 23 | if err := cmd.Start(); err != nil { 24 | return err 25 | } 26 | 27 | io.Copy(os.Stderr, stderr) 28 | io.Copy(os.Stdout, stdout) 29 | 30 | if err := cmd.Wait(); err != nil { 31 | return err 32 | } 33 | return nil 34 | } 35 | 36 | func cleanup() error { 37 | err := execute("docker-compose", "logs", "spoe") 38 | if err != nil { 39 | return err 40 | } 41 | err = execute("docker-compose", "logs", "haproxy") 42 | if err != nil { 43 | return err 44 | } 45 | err = execute("docker-compose", "logs", "dex") 46 | if err != nil { 47 | return err 48 | } 49 | err = execute("docker-compose", "logs", "ldap") 50 | if err != nil { 51 | return err 52 | } 53 | err = execute("docker-compose", "down", "-v") 54 | if err != nil { 55 | return err 56 | } 57 | return nil 58 | } 59 | 60 | func main() { 61 | err := cleanup() 62 | if err != nil { 63 | log.Fatal(err) 64 | os.Exit(1) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/scripts/prepare/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func execute(command string, arg ...string) error { 11 | cmd := exec.Command(command, arg...) 12 | 13 | stdout, err := cmd.StdoutPipe() 14 | if err != nil { 15 | return err 16 | } 17 | 18 | stderr, err := cmd.StderrPipe() 19 | if err != nil { 20 | return err 21 | } 22 | 23 | if err := cmd.Start(); err != nil { 24 | return err 25 | } 26 | 27 | io.Copy(os.Stderr, stderr) 28 | io.Copy(os.Stdout, stdout) 29 | 30 | if err := cmd.Wait(); err != nil { 31 | return err 32 | } 33 | return nil 34 | } 35 | 36 | func prepare() error { 37 | err := execute("docker-compose", "build") 38 | if err != nil { 39 | return err 40 | } 41 | 42 | err = execute("docker-compose", "pull") 43 | if err != nil { 44 | return err 45 | } 46 | 47 | err = execute("docker-compose", "up", "-d") 48 | if err != nil { 49 | return err 50 | } 51 | return nil 52 | } 53 | 54 | func main() { 55 | err := prepare() 56 | if err != nil { 57 | log.Fatal(err) 58 | os.Exit(1) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Build & Test 4 | 5 | jobs: 6 | build_and_test: 7 | name: Build & Test 8 | runs-on: ubuntu-latest 9 | env: 10 | BROWSER_PATH: /usr/bin/google-chrome 11 | HEADLESS: y 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: nanasess/setup-chromedriver@master 15 | with: 16 | # Optional: do not specify to match Chrome's version 17 | chromedriver-version: '88.0.4324.96' 18 | - run: | 19 | export DISPLAY=:99 20 | chromedriver --url-base=/wd/hub & 21 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional 22 | - run: cat .github/resources/hosts | sudo tee -a /etc/hosts 23 | - run: .github/scripts/build_and_test.sh -------------------------------------------------------------------------------- /.github/workflows/tagged-release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "tagged-release" 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | tagged-release: 11 | name: "Tagged Release" 12 | runs-on: "ubuntu-latest" 13 | defaults: 14 | run: 15 | working-directory: ./cmd/haproxy-spoe-auth/ 16 | 17 | steps: 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ^1.23 22 | 23 | - name: Check out code 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Get dependencies 29 | run: | 30 | go get -v -t -d ./... 31 | 32 | - name: Build 33 | run: go build -v -ldflags "-linkmode external -extldflags -static" . 34 | 35 | - uses: "marvinpinto/action-automatic-releases@latest" 36 | with: 37 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 38 | prerelease: false 39 | files: ./cmd/haproxy-spoe-auth/haproxy-spoe-auth 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | haproxy-ldap-auth 3 | 4 | .vscode/ 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 2 | 3 | # Using root user creates permission issues on the host, particularly with go.sum being regenerated within the container. 4 | RUN useradd -s /bin/bash -m -U dev 5 | 6 | RUN go install github.com/go-delve/delve/cmd/dlv@v1.8.0 7 | RUN go install github.com/cespare/reflex@v0.3.1 8 | 9 | WORKDIR /usr/app 10 | ADD go.mod go.mod 11 | ADD go.sum go.sum 12 | RUN go mod download 13 | RUN chown -R dev /usr/app 14 | 15 | USER dev 16 | 17 | ENTRYPOINT ["/scripts/entrypoint.sh"] 18 | -------------------------------------------------------------------------------- /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 2019 Clément Michaud 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 | # HAProxy SPOE Authentication 2 | 3 | ![Build & Test](https://github.com/criteo/haproxy-spoe-auth/workflows/Build%20&%20Test/badge.svg) 4 | 5 | This project is a an agent allowing HAProxy to to handle authentication requests. 6 | 7 | **WARNING** This project is under heavy development in alpha stage and it might break anytime. 8 | 9 | ## Getting started 10 | 11 | The agent is packaged in a docker-compose for you to quickly test it. You need to make sure 12 | Docker and docker-compose is installed on your machine. Also make sure that port 9080 is 13 | available. 14 | 15 | Now, add the two following lines to your /etc/hosts to fake the domains: 16 | 17 | 127.0.0.1 public.example.com 18 | 127.0.0.1 app1.example.com 19 | 127.0.0.1 app2.example.com 20 | 127.0.0.1 app3.example.com 21 | 127.0.0.1 dex.example.com # An OIDC server implementation 22 | 23 | And then run 24 | 25 | docker-compose up -d 26 | 27 | Now you can test the following commands 28 | 29 | # This a public domain 30 | curl http://public.example.com:9080/ 31 | 32 | # This domain is protected but no credentials are provided, it should return 401. 33 | curl http://app1.example.com:9080/ 34 | 35 | # This domain is protected and credentials are provided, it should return 200. 36 | curl -u "john:password" http://app1.example.com:9080/ 37 | 38 | # This domain is protected and credentials are provided but with a bad password, it should return 401. 39 | curl -u "john:badpassword" http://app1.example.com:9080/ 40 | 41 | # This domain is protected by OpenID Connect. This should redirect you to the authorization server where you can provide the same credentials as above. 42 | # Visit http://app2.example.com:9080/ or http://app3.example.com:9080/ in a browser. They are two different applications 43 | in order to test SSO. Note: Dex seems not to provide this feature though but Okta does for instance. 44 | 45 | # Once authenticated and consent granted, you're redirected to the app. 46 | 47 | # One can also visit http://app2.example.com:9080/secret.html or http://app3.example.com:9080/secret.html to verify the 48 | user is properly redirected as requested before authentication. 49 | 50 | Trying to visit the website protected by LDAP in a browser will display a basic auth form that you should fill 51 | before being granted the rights to visit the page. With OpenID Connect, you should be redirected to the Dex 52 | authentication portal to complete the authentication process. 53 | 54 | The users available in the LDAP are stored in the file [resources/ldap/01-base.ldif](./resources/ldap/01-base.ldif). 55 | 56 | ## Deployment 57 | 58 | The agent should be deployed on the same host than the HAProxy to give the best performance. 59 | 60 | Then you can check the configuration of HAProxy and the SPOE agents available under [resources/haproxy](./resources/haproxy) 61 | 62 | ## Architecture 63 | 64 | The agent communicates with HAProxy leveraging the Stream Processing Offload Engine (SPOE) feature 65 | of HAProxy documented here: https://github.com/haproxy/haproxy/blob/master/doc/SPOE.txt. 66 | 67 | This features allows a bi-directional communication between the agent and HAProxy allowing HAProxy 68 | to forward requests requiring authentication to the agent which itself validates the credentials 69 | against a LDAP server. 70 | 71 | ### LDAP 72 | 73 | Please see the [dedicated section](./docs/ldap.md). 74 | 75 | ### OpenID Connect 76 | 77 | Please see the [dedicated section](./docs/openidconnect.md). 78 | 79 | ## License 80 | 81 | This project is licensed under the Apache 2.0 license. The terms of the license are detailed in LICENSE. 82 | -------------------------------------------------------------------------------- /cmd/haproxy-spoe-auth/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/criteo/haproxy-spoe-auth/internal/agent" 9 | "github.com/criteo/haproxy-spoe-auth/internal/auth" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | func LogLevelFromLogString(level string) logrus.Level { 15 | switch level { 16 | case "info": 17 | return logrus.InfoLevel 18 | case "debug": 19 | return logrus.DebugLevel 20 | default: 21 | return logrus.InfoLevel 22 | } 23 | } 24 | 25 | func main() { 26 | var configFile string 27 | flag.StringVar(&configFile, "config", "", "The path to the configuration file") 28 | dynamicClientInfo := flag.Bool("dynamic-client-info", false, "Dynamically read client information") 29 | flag.Parse() 30 | 31 | if configFile != "" { 32 | viper.SetConfigFile(configFile) 33 | } else { 34 | viper.SetConfigName("config") // name of config file (without extension) 35 | viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name 36 | viper.AddConfigPath(".") // optionally look for config in the working directory 37 | } 38 | err := viper.ReadInConfig() // Find and read the config file 39 | if err != nil { // Handle errors reading the config file 40 | logrus.Panic(fmt.Errorf("fatal error config file: %w", err)) 41 | } 42 | 43 | logrus.SetLevel(LogLevelFromLogString(viper.GetString("server.log_level"))) 44 | 45 | authenticators := map[string]auth.Authenticator{} 46 | 47 | if viper.IsSet("ldap") { 48 | ldapAuthentifier := auth.NewLDAPAuthenticator(auth.LDAPConnectionDetails{ 49 | URI: viper.GetString("ldap.uri"), 50 | Port: viper.GetInt("ldap.port"), 51 | UserDN: viper.GetString("ldap.user_dn"), 52 | Password: viper.GetString("ldap.password"), 53 | BaseDN: viper.GetString("ldap.base_dn"), 54 | UserFilter: viper.GetString("ldap.user_filter"), 55 | VerifyTLS: viper.GetBool("ldap.verify_tls"), 56 | }) 57 | authenticators["try-auth-ldap"] = ldapAuthentifier 58 | } 59 | 60 | if viper.IsSet("oidc") { 61 | var clientsStore auth.OIDCClientsStore 62 | if !*dynamicClientInfo { 63 | // TODO: watch the config file to update the list of clients dynamically 64 | var clientsConfig map[string]auth.OIDCClientConfig 65 | err := viper.UnmarshalKey("oidc.clients", &clientsConfig) 66 | if err != nil { 67 | logrus.Panic(err) 68 | } 69 | clientsStore = auth.NewStaticOIDCClientStore(clientsConfig) 70 | } else { 71 | clientsStore = auth.NewEmptyStaticOIDCClientStore() 72 | } 73 | 74 | oidcAuthenticator := auth.NewOIDCAuthenticator(auth.OIDCAuthenticatorOptions{ 75 | OAuth2AuthenticatorOptions: auth.OAuth2AuthenticatorOptions{ 76 | RedirectCallbackPath: viper.GetString("oidc.oauth2_callback_path"), 77 | LogoutPath: viper.GetString("oidc.oauth2_logout_path"), 78 | HealthCheckPath: viper.GetString("oidc.oauth2_healthcheck_path"), 79 | CallbackAddr: viper.GetString("oidc.callback_addr"), 80 | CookieName: viper.GetString("oidc.cookie_name"), 81 | CookieSecure: viper.GetBool("oidc.cookie_secure"), 82 | CookieTTL: viper.GetDuration("oidc.cookie_ttl_seconds") * time.Second, 83 | SignatureSecret: viper.GetString("oidc.signature_secret"), 84 | ClientsStore: clientsStore, 85 | ReadClientInfoFromMessages: *dynamicClientInfo, 86 | }, 87 | ProviderURL: viper.GetString("oidc.provider_url"), 88 | EncryptionSecret: viper.GetString("oidc.encryption_secret"), 89 | }) 90 | authenticators["try-auth-oidc"] = oidcAuthenticator 91 | } 92 | 93 | agent.StartAgent(viper.GetString("server.addr"), authenticators) 94 | } 95 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | haproxy: 4 | image: haproxy:2.9 5 | volumes: 6 | - ./resources/haproxy:/usr/local/etc/haproxy:ro 7 | ports: 8 | - "9080:9080" 9 | depends_on: 10 | - spoe 11 | - dex 12 | - protected-backend 13 | - unprotected-backend 14 | - unauthorized-backend 15 | networks: 16 | haproxy-spoe-net: 17 | aliases: 18 | - dex.example.com 19 | 20 | protected-backend: 21 | image: nginx:1.21.5 22 | volumes: 23 | - ./resources/protected:/usr/share/nginx/html 24 | - ./resources/nginx/default.conf:/etc/nginx/conf.d/default.conf 25 | networks: 26 | haproxy-spoe-net: 27 | unprotected-backend: 28 | image: nginx:1.21.5 29 | volumes: 30 | - ./resources/unprotected:/usr/share/nginx/html 31 | - ./resources/nginx/default.conf:/etc/nginx/conf.d/default.conf 32 | networks: 33 | haproxy-spoe-net: 34 | unauthorized-backend: 35 | image: nginx:1.21.5 36 | volumes: 37 | - ./resources/unauthorized:/usr/share/nginx/html 38 | - ./resources/nginx/default.conf:/etc/nginx/conf.d/default.conf 39 | networks: 40 | haproxy-spoe-net: 41 | 42 | spoe: 43 | build: . 44 | volumes: 45 | - .:/app 46 | - ./resources/scripts:/scripts 47 | - ./resources/configuration:/configuration 48 | working_dir: /app 49 | command: /scripts/run.sh 50 | security_opt: 51 | - seccomp:unconfined 52 | - apparmor:unconfined 53 | cap_add: 54 | - SYS_PTRACE 55 | depends_on: 56 | - ldap 57 | - dex 58 | networks: 59 | haproxy-spoe-net: 60 | 61 | dex: 62 | image: quay.io/dexidp/dex 63 | command: dex serve /dex/config.yaml 64 | volumes: 65 | - ./resources/dex/config.yaml:/dex/config.yaml 66 | ports: 67 | - "5556:5556" 68 | depends_on: 69 | - ldap 70 | networks: 71 | haproxy-spoe-net: 72 | 73 | ldap: 74 | image: osixia/openldap:1.4.0 75 | environment: 76 | - LDAP_ORGANISATION=MyCompany 77 | - LDAP_DOMAIN=example.com 78 | - LDAP_ADMIN_PASSWORD=password 79 | - LDAP_CONFIG_PASSWORD=password 80 | - LDAP_ADDITIONAL_MODULES=memberof 81 | - LDAP_ADDITIONAL_SCHEMAS=openldap 82 | - LDAP_FORCE_RECONFIGURE=true 83 | - LDAP_TLS_VERIFY_CLIENT=try 84 | command: 85 | - '--copy-service' 86 | - '--loglevel' 87 | - 'debug' 88 | volumes: 89 | - ./resources/ldap:/container/service/slapd/assets/config/bootstrap/ldif/custom 90 | networks: 91 | haproxy-spoe-net: 92 | 93 | 94 | networks: 95 | haproxy-spoe-net: -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The agent is configured through a configuration file provided as argument of the command. 4 | An example configuration file can be found [here](../resources/configuration/config.yml). 5 | 6 | One can run the application with the following command but beware the configuration depends on other components otherwise deployed with docker-compose as described in the [README](../README.md). 7 | 8 | $ go run cmd/haproxy-spoe-auth/main.go -config resources/configuration/config.yml 9 | 10 | The options available in the configuration file are detailed in the file. -------------------------------------------------------------------------------- /docs/images/architecture-oidc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/haproxy-spoe-auth/e38a60470c2c4aaa1362fff9220cfc068b7f19f2/docs/images/architecture-oidc.png -------------------------------------------------------------------------------- /docs/images/sequence-oidc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/haproxy-spoe-auth/e38a60470c2c4aaa1362fff9220cfc068b7f19f2/docs/images/sequence-oidc.png -------------------------------------------------------------------------------- /docs/ldap.md: -------------------------------------------------------------------------------- 1 | # LDAP Authentication 2 | 3 | The following spoa message string will be replaced in ldap search query, when provided 4 | - {user}; represent the user to match 5 | - {group}: represent the group to which belongs the user 6 | An example of user query is provided, but has to be adapated following the LDAP implementation. 7 | 8 | ## TODO 9 | 10 | This agent is currently experimental and under active development. I would not advise to run it in 11 | production yet unless you know what you're doing. 12 | 13 | * Create a pool of reusable connections to the LDAP server(s). 14 | * Create a cache of authenticated users with a TTL to avoid validating every queries against the LDAP server. 15 | * Avoid the search query when binding the user against the LDAP server. 16 | -------------------------------------------------------------------------------- /docs/openidconnect.md: -------------------------------------------------------------------------------- 1 | # OpenID Connect Authentication 2 | 3 | ## Architecture 4 | 5 | The SPOE agent receives authentication request messages when a request is made on the protected endpoint. Either this 6 | request comes without a session cookie or with one previously provided after authentication. 7 | 8 | If the request comes without, the agent sign the original URL and redirects the user to the OpenID Connect server to 9 | perform the authentication workflow with the signed original URL stored in the state. Once the authentication is done, 10 | the server redirects back to the callback URL exposed by the SPOE agent on a different port than the one for the SPOE communication. The API serves '/oauth2/callback' and '/logout' only. 11 | 12 | When the user comes back to the callback endpoint, the OpenID Connect ID token is retrieved, encrypted, set into 13 | the cookie covering the top domain and sent along with an HTML response triggering a redirection to the target URL 14 | on the client side. 15 | 16 | Now the client's browser stores that cookie and it is transmitted with each request to the service. This value is decrypted and the ID token is verified by the agent before approving or denying the request. 17 | 18 | 19 | This workflow is presented in two diagrams: [architecture](./images/architecture-oidc.png) and [sequence](./images/sequence-oidc.png) 20 | 21 | ## Design Decisions 22 | 23 | ### Session management 24 | 25 | The SPOE agent permits authentication and the requirement was that it should be stateless. Therefore, the session is 26 | maintained by encrypting the ID Token returned by the OIDC provider and setting it in a httpOnly cookie. 27 | 28 | The decision to store the ID token in the cookie is also considered a security measure to not rely solely on the 29 | server hosting the SPOE agent to ensure the security of the session. Indeed, to attack the whole session system, the 30 | attacker need to have access to the secret to encrypt the cookie but also to the key signing the ID token. Let say the 31 | attacker gets access to the encryption key, only confidentiality is broken. 32 | 33 | ### ID Token encryption 34 | 35 | The ID token is encrypted with AES-GCM-256. This means that we maintain confidentiality and the message is also 36 | authenticated thanks to the GCM mode of operation. 37 | 38 | ### Session storage 39 | 40 | There is no bullet proof way of storing sessions, each one comes with its own set of pros/cons. However, we want the 41 | system to be generic and so it must not imply any modification from the client code to work. 42 | 43 | #### httpOnly cookie 44 | 45 | Cookies are made for storing sessions. We know that there are many XSS threats related to cookie leaks so that's 46 | why setting the httpOnly cookie reduces the risk by making sure JS should not have access to the cookie. However, 47 | we should keep in mind that some browsers might not implement httpOnly but cookie is the only way to transport the 48 | ID without modification of the code. 49 | 50 | #### LocalStorage 51 | 52 | The session could be stored in local storage but this requires the application to support it. Plus, putting secrets in 53 | the local storage is usually not recommended since there is no mechanism to prevent XSS to leak the token. 54 | 55 | #### Memory 56 | 57 | Memory is probably the safest way to store the information but it has downsides if working with websites with multiple 58 | pages as the secret must be kept between page loads somehow. However, XSS might be more complex to implement than just reading a store. 59 | 60 | ### Signing of the origin URL 61 | 62 | The user being redirected after a successful authentication workflow is exposed to open redirect vulnerability. To 63 | prevent such a threat, the original URL provided by HAProxy is signed by the agent before being transmitted in the 64 | state during the OAuth2 transaction. It is then verified at the end of the workflow to allow or deny the redirection. 65 | That way, it's impossible for hackers to craft a URL to redirect the user to. 66 | 67 | ### Cookie are secure by default 68 | 69 | Cookies are secure by default. There is an option to disable that flag but please make sure about what you do if you 70 | disable the flag. It would expose your users' sessions to leakage on the Internet. This flag is only here for test 71 | purposes. 72 | 73 | ### Cookie TTL 74 | 75 | The cookie has a TTL set to 1h by default. This is the amount of time before the user does a new round trip to the 76 | OAuth2 server. If the server has kept the user logged in, there will be no authentication involved but the user will be 77 | redirected. You need to find the right balance between longevity of the session and security. The more the session lives, 78 | the more there are chances the session has been compromised. 79 | 80 | 81 | ## TODO 82 | 83 | * Add a mechanism to denylist a token. 84 | * Allow the SPOE agent to match a certain list of groups the user belongs to. 85 | * Think about secret rotation. 86 | * Prevent replay attacks by putting some kind of nonce in the state. -------------------------------------------------------------------------------- /docs/performance.md: -------------------------------------------------------------------------------- 1 | # Performance Optimization 2 | 3 | ## Filter Bypass 4 | 5 | The agent is written in Go allowing to set performance expectations pretty high. 6 | 7 | Also, one must take care of the HAProxy configuration when using the agent. The example provided in this 8 | repository allows to split the domains into two categories: the public domains and the ones requiring authentication. 9 | If correctly configured, only domains requiring authentication will trigger a call to the agent hence keeping the 10 | raw performance for public domains. The trick is the condition in the `try-auth` message in the spoe configuration. 11 | 12 | event on-frontend-http-request if { hdr_beg(host) -i app1.example.com } || { hdr_beg(host) -i app2.example.com } 13 | 14 | One can also use a map instead of logical operators if the number of domains becomes too big (https://www.haproxy.com/blog/introduction-to-haproxy-maps/). 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/criteo/haproxy-spoe-auth 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/coreos/go-oidc/v3 v3.14.1 9 | github.com/go-ldap/ldap/v3 v3.4.11 10 | github.com/go-pkgz/expirable-cache/v3 v3.0.0 11 | github.com/negasus/haproxy-spoe-go v1.0.6 12 | github.com/sirupsen/logrus v1.9.3 13 | github.com/spf13/viper v1.20.1 14 | github.com/stretchr/testify v1.10.0 15 | github.com/tebeka/selenium v0.9.9 16 | github.com/tidwall/gjson v1.18.0 17 | github.com/vmihailenco/msgpack/v5 v5.4.1 18 | golang.org/x/oauth2 v0.29.0 19 | ) 20 | 21 | require ( 22 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 23 | github.com/blang/semver v3.5.1+incompatible // indirect 24 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 25 | github.com/fsnotify/fsnotify v1.8.0 // indirect 26 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect 27 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 28 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 29 | github.com/google/uuid v1.6.0 // indirect 30 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 31 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 32 | github.com/sagikazarmark/locafero v0.7.0 // indirect 33 | github.com/sourcegraph/conc v0.3.0 // indirect 34 | github.com/spf13/afero v1.12.0 // indirect 35 | github.com/spf13/cast v1.7.1 // indirect 36 | github.com/spf13/pflag v1.0.6 // indirect 37 | github.com/subosito/gotenv v1.6.0 // indirect 38 | github.com/tidwall/match v1.1.1 // indirect 39 | github.com/tidwall/pretty v1.2.1 // indirect 40 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 41 | go.uber.org/multierr v1.11.0 // indirect 42 | golang.org/x/crypto v0.36.0 // indirect 43 | golang.org/x/sys v0.31.0 // indirect 44 | golang.org/x/text v0.23.0 // indirect 45 | gopkg.in/yaml.v3 v3.0.1 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.41.0/go.mod h1:OauMR7DV8fzvZIl2qg6rkaIhD/vmgk4iwEw/h6ercmg= 5 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= 6 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= 7 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= 9 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 10 | github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e h1:4ZrkT/RzpnROylmoQL57iVUL57wGKTR5O6KpVnbm2tA= 11 | github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= 12 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 13 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 14 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 15 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 16 | github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 17 | github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 18 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 19 | github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= 20 | github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 24 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 26 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 27 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 28 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 29 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= 30 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= 31 | github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= 32 | github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= 33 | github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= 34 | github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= 35 | github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw= 36 | github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec= 37 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 38 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 39 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= 40 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 41 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 42 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 43 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 44 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 45 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 46 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 47 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 48 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 49 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 50 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 51 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 52 | github.com/google/go-github/v27 v27.0.4/go.mod h1:/0Gr8pJ55COkmv+S/yPKCczSkUPIM/LnFyubufRNIS0= 53 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 54 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 55 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 56 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 57 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 58 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 59 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 60 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 61 | github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= 62 | github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 63 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 64 | github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= 65 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 66 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 67 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 68 | github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= 69 | github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= 70 | github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= 71 | github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= 72 | github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= 73 | github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= 74 | github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= 75 | github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= 76 | github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= 77 | github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= 78 | github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= 79 | github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= 80 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 81 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 82 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 83 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 84 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 85 | github.com/negasus/haproxy-spoe-go v1.0.6 h1:uJ5coC6n0p4tI0MbVPna4ztFTpW1P3pzswvjuKAF8X4= 86 | github.com/negasus/haproxy-spoe-go v1.0.6/go.mod h1:ZrBizxtx2EeLN37Jkg9w9g32a1AFCJizA8vg46PaAp4= 87 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 88 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 89 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 90 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 91 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 92 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 93 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 94 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 95 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 96 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 97 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 98 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 99 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 100 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 101 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 102 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 103 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 104 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 105 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 106 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 107 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 108 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 109 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 110 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 111 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 112 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 113 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 114 | github.com/tebeka/selenium v0.9.9 h1:cNziB+etNgyH/7KlNI7RMC1ua5aH1+5wUlFQyzeMh+w= 115 | github.com/tebeka/selenium v0.9.9/go.mod h1:5Fr8+pUvU6B1OiPfkdCKdXZyr5znvVkxuPd0NOdZCQc= 116 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 117 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 118 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 119 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 120 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 121 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 122 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 123 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 124 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 125 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 126 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 127 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 128 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 129 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 130 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 131 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 132 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 133 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 134 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 135 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 136 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 137 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 138 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 139 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 140 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 141 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 142 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 143 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 144 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 145 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 146 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 147 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 148 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 149 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 150 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 151 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 152 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 153 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 154 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 155 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 156 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 157 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 158 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 159 | golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= 160 | golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 161 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 162 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 163 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 164 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 165 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 166 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 167 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 168 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 169 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 170 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 171 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 172 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 173 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 174 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 175 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 176 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 177 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 178 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 179 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 180 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 181 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 182 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 183 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 184 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 185 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 186 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 187 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 188 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 189 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 190 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 191 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 192 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 193 | golang.org/x/tools v0.0.0-20190624190245-7f2218787638/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 194 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 195 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 196 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 197 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 198 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 199 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 200 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 201 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 202 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 203 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 204 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 205 | google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= 206 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 207 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 208 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 209 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 210 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 211 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 212 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 213 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 214 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 215 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 216 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 217 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 218 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 219 | -------------------------------------------------------------------------------- /internal/agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "os" 7 | 8 | "github.com/criteo/haproxy-spoe-auth/internal/auth" 9 | "github.com/negasus/haproxy-spoe-go/action" 10 | "github.com/negasus/haproxy-spoe-go/agent" 11 | "github.com/negasus/haproxy-spoe-go/logger" 12 | "github.com/negasus/haproxy-spoe-go/request" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // NotAuthenticatedMessage SPOE response stating the user is not authenticated 17 | var NotAuthenticatedMessage = action.NewSetVar(action.ScopeSession, "is_authenticated", false) 18 | 19 | // AuthenticatedMessage SPOE response stating the user is authenticated 20 | var AuthenticatedMessage = action.NewSetVar(action.ScopeSession, "is_authenticated", true) 21 | 22 | // StartAgent start the agent 23 | func StartAgent(interfaceAddr string, authenticators map[string]auth.Authenticator) { 24 | agent := agent.New(func(request *request.Request) { 25 | var authenticated bool = false 26 | var hasError bool = false 27 | 28 | for authentifier_name, authentifier := range authenticators { 29 | msg, err := request.Messages.GetByName(authentifier_name) 30 | if err == nil { 31 | logrus.Debugf("new message with name %s received", msg.Name) 32 | 33 | isAuthenticated, replyActions, err := authentifier.Authenticate(msg) 34 | if err != nil { 35 | logrus.Errorf("unable to authenticate user: %v", err) 36 | hasError = true 37 | break 38 | } 39 | request.Actions = append(request.Actions, replyActions...) 40 | 41 | if isAuthenticated { 42 | authenticated = true 43 | } 44 | } 45 | } 46 | 47 | if hasError { 48 | request.Actions = append(request.Actions, auth.BuildHasErrorMessage()) 49 | } else { 50 | if authenticated { 51 | request.Actions = append(request.Actions, AuthenticatedMessage) 52 | } else { 53 | request.Actions = append(request.Actions, NotAuthenticatedMessage) 54 | } 55 | } 56 | }, logger.NewDefaultLog()) 57 | 58 | listener, err := net.Listen("tcp", interfaceAddr) 59 | if err != nil { 60 | log.Printf("error create listener, %v", err) 61 | os.Exit(1) 62 | } 63 | defer listener.Close() 64 | 65 | logrus.Infof("agent starting and listening on %s with %d authenticators", interfaceAddr, len(authenticators)) 66 | if err := agent.Serve(listener); err != nil { 67 | log.Fatal(err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/auth/aes_encryptor.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "fmt" 10 | "io" 11 | "sync" 12 | ) 13 | 14 | // NonceLength the length of the nonce to use 15 | var NonceLength = 12 16 | 17 | // AESEncryptor represent an encryptor leveraging AES-GCM. 18 | // GCM mode operation is used to ensure the encryption is authenticated. 19 | type AESEncryptor struct { 20 | Key []byte 21 | 22 | mutex sync.Mutex 23 | } 24 | 25 | // NewAESEncryptor create an instance of the AESEncryptor 26 | func NewAESEncryptor(secret string) *AESEncryptor { 27 | // SHA256 derives the secret into a 32 bytes key compatible with AES 28 | h := sha256.New() 29 | h.Write([]byte(secret)) 30 | return &AESEncryptor{ 31 | Key: h.Sum(nil), 32 | } 33 | } 34 | 35 | // Encrypt a payload 36 | func (ae *AESEncryptor) Encrypt(message string) (string, error) { 37 | ae.mutex.Lock() 38 | defer ae.mutex.Unlock() 39 | 40 | plaintext := []byte(message) 41 | 42 | block, err := aes.NewCipher(ae.Key) 43 | if err != nil { 44 | return "", fmt.Errorf("unable to create cipher: %v", err) 45 | } 46 | 47 | // Never use more than 2^32 random nonces with a given key because of the risk of a repeat. 48 | nonce := make([]byte, NonceLength) 49 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 50 | return "", fmt.Errorf("unable to generate nonce: %v", err) 51 | } 52 | 53 | aesgcm, err := cipher.NewGCM(block) 54 | if err != nil { 55 | return "", fmt.Errorf("unable to create GCM block cipher") 56 | } 57 | 58 | ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil) 59 | ciphertextAndNonce := append(ciphertext, nonce...) 60 | encmess := base64.StdEncoding.EncodeToString(ciphertextAndNonce) 61 | return encmess, nil 62 | } 63 | 64 | // Decrypt a payload 65 | func (ae *AESEncryptor) Decrypt(securemess string) (string, error) { 66 | ae.mutex.Lock() 67 | defer ae.mutex.Unlock() 68 | 69 | ciphertextAndNonce, err := base64.StdEncoding.DecodeString(securemess) 70 | if err != nil { 71 | return "", fmt.Errorf("unable to b64 decode secure message: %w", err) 72 | } 73 | 74 | block, err := aes.NewCipher(ae.Key) 75 | if err != nil { 76 | return "", err 77 | } 78 | 79 | aesgcm, err := cipher.NewGCM(block) 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | cutIdx := len(ciphertextAndNonce) - NonceLength 85 | nonce := ciphertextAndNonce[cutIdx:] 86 | ciphertext := ciphertextAndNonce[:cutIdx] 87 | 88 | plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) 89 | if err != nil { 90 | panic(err.Error()) 91 | } 92 | 93 | return string(plaintext), nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/auth/aes_encryptor_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestShouldEncrypt(t *testing.T) { 10 | e := NewAESEncryptor("mysecretkey") 11 | 12 | ct, err := e.Encrypt("This is a payload to be encrypted") 13 | assert.NoError(t, err) 14 | 15 | assert.NotEmpty(t, ct) 16 | 17 | decrypted, err := e.Decrypt(ct) 18 | assert.NoError(t, err) 19 | assert.Equal(t, "This is a payload to be encrypted", decrypted) 20 | } 21 | 22 | func TestShouldEncryptScramble(t *testing.T) { 23 | e := NewAESEncryptor("mysecretkey") 24 | 25 | ct1, err := e.Encrypt("This is a payload to be encrypted") 26 | assert.NoError(t, err) 27 | ct2, err := e.Encrypt("This is another payload to be encrypted") 28 | assert.NoError(t, err) 29 | 30 | assert.True(t, ct1 != ct2) 31 | } 32 | -------------------------------------------------------------------------------- /internal/auth/authenticator.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | action "github.com/negasus/haproxy-spoe-go/action" 5 | message "github.com/negasus/haproxy-spoe-go/message" 6 | ) 7 | 8 | // Authenticator the authenticator interface that can be implemented for LDAP, OAuth2, OIDC or whatever else. 9 | type Authenticator interface { 10 | // Check whether the user is authenticated by this authenticator 11 | Authenticate(msg *message.Message) (bool, []action.Action, error) 12 | } 13 | -------------------------------------------------------------------------------- /internal/auth/authenticator_ldap.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/base64" 6 | "fmt" 7 | "strings" 8 | 9 | action "github.com/negasus/haproxy-spoe-go/action" 10 | message "github.com/negasus/haproxy-spoe-go/message" 11 | 12 | "github.com/go-ldap/ldap/v3" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // LDAPConnectionDetails represents the connection details 17 | type LDAPConnectionDetails struct { 18 | URI string 19 | Port int 20 | UserDN string 21 | Password string 22 | BaseDN string 23 | UserFilter string 24 | VerifyTLS bool 25 | } 26 | 27 | // LDAPAuthenticator is the LDAP implementation of the Authenticator interface 28 | type LDAPAuthenticator struct { 29 | connectionDetails LDAPConnectionDetails 30 | } 31 | 32 | // NewLDAPAuthenticator create an instance of a LDAP authenticator 33 | func NewLDAPAuthenticator(options LDAPConnectionDetails) *LDAPAuthenticator { 34 | return &LDAPAuthenticator{ 35 | connectionDetails: options, 36 | } 37 | } 38 | 39 | func verifyCredentials(ldapDetails *LDAPConnectionDetails, username, password, group string) error { 40 | ldap_uri_string := fmt.Sprintf("%s:%d", ldapDetails.URI, ldapDetails.Port) 41 | l, err := ldap.DialURL(ldap_uri_string, 42 | ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: !ldapDetails.VerifyTLS})) 43 | if err != nil { 44 | return err 45 | } 46 | defer l.Close() 47 | 48 | // First bind with a read only user 49 | err = l.Bind(ldapDetails.UserDN, ldapDetails.Password) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | // Search for the given username 55 | searchRequest := ldap.NewSearchRequest( 56 | ldapDetails.BaseDN, 57 | ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false, 58 | strings.Replace(strings.Replace(ldapDetails.UserFilter, "{login}", username, 1), "{group}", group, 1), 59 | []string{"dn"}, 60 | nil, 61 | ) 62 | 63 | sr, err := l.Search(searchRequest) 64 | if err != nil { 65 | return fmt.Errorf("search request failed: %w", err) 66 | } 67 | 68 | if len(sr.Entries) == 0 { 69 | return ErrUserDoesntExist 70 | } 71 | 72 | if len(sr.Entries) > 1 { 73 | return ErrTooManyUsersMatching 74 | } 75 | 76 | userdn := sr.Entries[0].DN 77 | 78 | // Bind as the user to verify their password 79 | err = l.Bind(userdn, password) 80 | if err != nil { 81 | if e, ok := err.(*ldap.Error); ok && e.ResultCode == 49 { // Invalid credentials 82 | return ErrWrongCredentials 83 | } 84 | return fmt.Errorf("unable to bind user: %w", err) 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func parseBasicAuth(auth string) (username, password string, err error) { 91 | if !strings.HasPrefix(auth, "Basic ") { 92 | return "", "", fmt.Errorf("%s prefix not found in authorization header", strings.Trim("Basic ", " ")) 93 | } 94 | c, err := base64.StdEncoding.DecodeString(auth[len("Basic "):]) 95 | if err != nil { 96 | return "", "", err 97 | } 98 | cs := string(c) 99 | s := strings.IndexByte(cs, ':') 100 | if s < 0 { 101 | return "", "", ErrBadAuthorizationValue 102 | } 103 | return cs[:s], cs[s+1:], nil 104 | } 105 | 106 | // Authenticate handle an authentication request coming from HAProxy 107 | func (la *LDAPAuthenticator) Authenticate(msg *message.Message) (bool, []action.Action, error) { 108 | authorization := "" 109 | group := "" 110 | isGroupProvided := false 111 | 112 | authorizationValue, ok := msg.KV.Get("authorization") 113 | if ok { 114 | authorization, ok = authorizationValue.(string) 115 | if !ok { 116 | return false, nil, nil 117 | } 118 | } 119 | if authorization == "" { 120 | logrus.Debug("Authorization header is empty") 121 | return false, nil, nil 122 | } 123 | 124 | authorizedGroupValue, ok := msg.KV.Get("authorized_group") 125 | if ok { 126 | group, ok = authorizedGroupValue.(string) 127 | if !ok { 128 | group = "" 129 | } 130 | isGroupProvided = true 131 | } 132 | if isGroupProvided { 133 | logrus.Debug(fmt.Sprintf("Group is <%s>", group)) 134 | } 135 | 136 | username, password, err := parseBasicAuth(authorization) 137 | 138 | if err != nil { 139 | return false, nil, fmt.Errorf("unable to parse basic auth header") 140 | } 141 | 142 | err = verifyCredentials(&la.connectionDetails, username, password, group) 143 | 144 | if err != nil { 145 | if err == ErrUserDoesntExist { 146 | if isGroupProvided { 147 | logrus.Debugf("user <%s> doesn't exist, or is not a member of group <%s>", username, group) 148 | } else { 149 | logrus.Debugf("user %s does not exist", username) 150 | } 151 | return false, nil, nil 152 | } else if err == ErrWrongCredentials { 153 | logrus.Debug("wrong credentials") 154 | return false, nil, nil 155 | } 156 | return false, nil, err 157 | } 158 | 159 | logrus.Debug("User is authenticated") 160 | return true, []action.Action{AuthenticatedUserMessage(username)}, nil 161 | } 162 | -------------------------------------------------------------------------------- /internal/auth/authenticator_oidc.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/binary" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "strings" 11 | "text/template" 12 | "time" 13 | 14 | "github.com/sirupsen/logrus" 15 | "github.com/vmihailenco/msgpack/v5" 16 | 17 | "github.com/coreos/go-oidc/v3/oidc" 18 | cache "github.com/go-pkgz/expirable-cache/v3" 19 | action "github.com/negasus/haproxy-spoe-go/action" 20 | message "github.com/negasus/haproxy-spoe-go/message" 21 | 22 | "golang.org/x/oauth2" 23 | ) 24 | 25 | // ValidStateDuration is the amount of time before the state is considered expired. This will be replaced 26 | // by an expiration in a JWT token in a future review. 27 | const ValidStateDuration = 30 * time.Second 28 | 29 | // OIDCAuthenticatorOptions options to customize to the OIDC authenticator 30 | type OIDCAuthenticatorOptions struct { 31 | OAuth2AuthenticatorOptions 32 | 33 | // The URL to the OIDC provider exposing the configuration 34 | ProviderURL string 35 | 36 | // This is used to encrypt the ID Token returned by the IdP. 37 | EncryptionSecret string 38 | } 39 | 40 | // OAuth2AuthenticatorOptions options to customize to the OAuth2 authenticator 41 | type OAuth2AuthenticatorOptions struct { 42 | Endpoints oauth2.Endpoint 43 | RedirectCallbackPath string 44 | LogoutPath string 45 | HealthCheckPath string 46 | 47 | // This is used to sign the redirection URL 48 | SignatureSecret string 49 | 50 | CookieName string 51 | CookieSecure bool 52 | CookieTTL time.Duration 53 | 54 | // The addr interface the callback will be exposed on. 55 | CallbackAddr string 56 | 57 | // The object retrieving the OIDC client configuration from the given domain 58 | ClientsStore OIDCClientsStore 59 | 60 | // Indicates whether the client info have to be read from spoe messages 61 | ReadClientInfoFromMessages bool 62 | } 63 | 64 | // State the content of the state 65 | type State struct { 66 | Timestamp time.Time 67 | Signature string 68 | PathAndQueryString string 69 | SSL bool 70 | } 71 | 72 | // OIDCAuthenticator is the OIDC implementation of the Authenticator interface 73 | type OIDCAuthenticator struct { 74 | provider *oidc.Provider 75 | 76 | signatureComputer *HmacSha256Computer 77 | encryptor *AESEncryptor 78 | pkceVerifierCache cache.Cache[string, string] 79 | 80 | options OIDCAuthenticatorOptions 81 | } 82 | 83 | type OAuthArgs struct { 84 | ssl bool 85 | host string 86 | pathq string 87 | clientid string 88 | clientsecret string 89 | redirecturl string 90 | cookie string 91 | tokenClaims []string 92 | } 93 | 94 | // NewOIDCAuthenticator create an instance of an OIDC authenticator 95 | func NewOIDCAuthenticator(options OIDCAuthenticatorOptions) *OIDCAuthenticator { 96 | if len(options.SignatureSecret) < 16 { 97 | logrus.Fatalf("the signature secret should be at least 16 characters, %d provided", len(options.SignatureSecret)) 98 | } 99 | 100 | if options.OAuth2AuthenticatorOptions.ClientsStore == nil { 101 | logrus.Fatal("no client secret provided") 102 | } 103 | 104 | provider, err := oidc.NewProvider(context.Background(), options.ProviderURL) 105 | if err != nil { 106 | logrus.Fatalf("unable to create OIDC provider structure: %v", err) 107 | } 108 | 109 | tmpl, err := template.New("redirect_html").Parse(RedirectPageTemplate) 110 | if err != nil { 111 | logrus.Fatalf("unable to read the html page for redirecting") 112 | } 113 | 114 | errorTmpl, err := template.New("error").Parse(ErrorPageTemplate) 115 | if err != nil { 116 | logrus.Fatalf("unable to read the html page for redirecting") 117 | } 118 | 119 | oa := &OIDCAuthenticator{ 120 | provider: provider, 121 | options: options, 122 | signatureComputer: NewHmacSha256Computer(options.SignatureSecret), 123 | encryptor: NewAESEncryptor(options.EncryptionSecret), 124 | pkceVerifierCache: cache.NewCache[string, string](), 125 | } 126 | 127 | go func() { 128 | http.HandleFunc(options.RedirectCallbackPath, oa.handleOAuth2Callback(tmpl, errorTmpl)) 129 | http.HandleFunc(options.LogoutPath, oa.handleOAuth2Logout()) 130 | logrus.Infof("OIDC API is exposed on %s", options.CallbackAddr) 131 | http.HandleFunc(options.HealthCheckPath, handleHealthCheck) 132 | logrus.Fatalln(http.ListenAndServe(options.CallbackAddr, nil)) 133 | }() 134 | 135 | return oa 136 | } 137 | 138 | func handleHealthCheck(w http.ResponseWriter, r *http.Request) { 139 | _, _ = w.Write([]byte("OK")) 140 | } 141 | 142 | func (oa *OIDCAuthenticator) withOAuth2Config(domain string, callback func(c oauth2.Config) error) error { 143 | clientConfig, err := oa.options.ClientsStore.GetClient(domain) 144 | if err != nil { 145 | return fmt.Errorf("unable to find an oidc client for domain %s", domain) 146 | } 147 | // Configure an OpenID Connect aware OAuth2 client. 148 | oauth2Config := oauth2.Config{ 149 | ClientID: clientConfig.ClientID, 150 | ClientSecret: clientConfig.ClientSecret, 151 | RedirectURL: clientConfig.RedirectURL, 152 | 153 | // Discovery returns the OAuth2 endpoints. 154 | Endpoint: oa.provider.Endpoint(), 155 | 156 | // "openid" is a required scope for OpenID Connect flows. 157 | Scopes: []string{oidc.ScopeOpenID, "email", "profile"}, 158 | } 159 | return callback(oauth2Config) 160 | } 161 | 162 | func (oa *OIDCAuthenticator) verifyIDToken(context context.Context, domain string, rawIDToken string) (*oidc.IDToken, error) { 163 | clientConfig, err := oa.options.ClientsStore.GetClient(domain) 164 | if err != nil { 165 | return nil, fmt.Errorf("unable to find an oidc client for domain %s", domain) 166 | } 167 | verifier := oa.provider.Verifier(&oidc.Config{ClientID: clientConfig.ClientID}) 168 | 169 | // Parse and verify ID Token payload. 170 | idToken, err := verifier.Verify(context, rawIDToken) 171 | if err != nil { 172 | return nil, fmt.Errorf("unable to verify ID Token: %w", err) 173 | } 174 | return idToken, nil 175 | } 176 | 177 | func (oa *OIDCAuthenticator) decryptCookie(cookieValue string, domain string) (*oidc.IDToken, error) { 178 | idToken, err := oa.encryptor.Decrypt(cookieValue) 179 | if err != nil { 180 | return nil, fmt.Errorf("unable to decrypt session cookie: %w", err) 181 | } 182 | 183 | token, err := oa.verifyIDToken(context.Background(), domain, idToken) 184 | return token, err 185 | } 186 | 187 | func extractOAuth2Args(msg *message.Message, readClientInfoFromMessages bool) (OAuthArgs, error) { 188 | var cookie string 189 | var clientid, clientsecret, redirecturl *string 190 | var tokenClaims []string 191 | 192 | // ssl 193 | sslValue, ok := msg.KV.Get("arg_ssl") 194 | if !ok { 195 | return OAuthArgs{ssl: false, host: "", pathq: "", cookie: "", clientid: "", clientsecret: "", redirecturl: ""}, 196 | ErrSSLArgNotFound 197 | } 198 | ssl, ok := sslValue.(bool) 199 | if !ok { 200 | return OAuthArgs{ssl: false, host: "", pathq: "", cookie: "", clientid: "", clientsecret: "", redirecturl: ""}, 201 | fmt.Errorf("SSL is not a bool: %v", sslValue) 202 | } 203 | 204 | // host 205 | hostValue, ok := msg.KV.Get("arg_host") 206 | if !ok { 207 | return OAuthArgs{ssl: false, host: "", pathq: "", cookie: "", clientid: "", clientsecret: "", redirecturl: ""}, 208 | ErrHostArgNotFound 209 | } 210 | host, ok := hostValue.(string) 211 | if !ok { 212 | return OAuthArgs{ssl: false, host: "", pathq: "", cookie: "", clientid: "", clientsecret: "", redirecturl: ""}, 213 | fmt.Errorf("host is not a string: %v", hostValue) 214 | } 215 | 216 | // pathq 217 | pathqValue, ok := msg.KV.Get("arg_pathq") 218 | if !ok { 219 | return OAuthArgs{ssl: false, host: "", pathq: "", cookie: "", clientid: "", clientsecret: "", redirecturl: ""}, 220 | ErrPathqArgNotFound 221 | } 222 | pathq, ok := pathqValue.(string) 223 | if !ok { 224 | return OAuthArgs{ssl: false, host: "", pathq: "", cookie: "", clientid: "", clientsecret: "", redirecturl: ""}, 225 | fmt.Errorf("pathq is not a string: %v", pathqValue) 226 | } 227 | 228 | // cookie 229 | cookieValue, ok := msg.KV.Get("arg_cookie") 230 | if ok { 231 | cookie, _ = cookieValue.(string) 232 | 233 | // Token claims 234 | tokenClaimsValue, ok := msg.KV.Get("arg_token_claims") 235 | if ok { 236 | strV, ok := tokenClaimsValue.(string) 237 | if ok { 238 | tokenClaims = strings.Split(strV, " ") 239 | } 240 | } 241 | } 242 | 243 | if readClientInfoFromMessages { 244 | // client_id 245 | clientidValue, ok := msg.KV.Get("arg_client_id") 246 | if !ok { 247 | logrus.Debugf("clientid is not defined : %v", clientidValue) 248 | } else { 249 | clientidStr, ok := clientidValue.(string) 250 | if !ok { 251 | logrus.Debugf("clientid is not a string: %v", clientidValue) 252 | } else { 253 | clientid = new(string) 254 | *clientid = clientidStr 255 | } 256 | } 257 | 258 | // client_secret 259 | clientsecretValue, ok := msg.KV.Get("arg_client_secret") 260 | if !ok { 261 | logrus.Debugf("clientsecret is not defined : %v", clientsecretValue) 262 | } else { 263 | clientsecretStr, ok := clientsecretValue.(string) 264 | if !ok { 265 | logrus.Debugf("clientsecret is not a string: %v", clientsecretValue) 266 | } else { 267 | clientsecret = new(string) 268 | *clientsecret = clientsecretStr 269 | } 270 | } 271 | 272 | // redirect_url 273 | redirecturlValue, ok := msg.KV.Get("arg_redirect_url") 274 | if !ok { 275 | logrus.Debugf("redirecturl is not defined : %v", redirecturlValue) 276 | } else { 277 | redirecturlStr, ok := redirecturlValue.(string) 278 | if !ok { 279 | logrus.Debugf("redirecturl is not a string: %v", redirecturlValue) 280 | } else { 281 | redirecturl = new(string) 282 | *redirecturl = redirecturlStr 283 | } 284 | } 285 | } 286 | 287 | if clientid == nil || redirecturl == nil { 288 | temp := "" 289 | clientid = &temp 290 | redirecturl = &temp 291 | } 292 | if clientsecret == nil { 293 | temp := "" 294 | clientsecret = &temp 295 | } 296 | return OAuthArgs{ssl: ssl, host: host, pathq: pathq, 297 | cookie: cookie, clientid: *clientid, 298 | clientsecret: *clientsecret, redirecturl: *redirecturl, 299 | tokenClaims: tokenClaims}, 300 | nil 301 | } 302 | 303 | func (oa *OIDCAuthenticator) computeStateSignature(state *State) string { 304 | b := make([]byte, 8) 305 | binary.LittleEndian.PutUint64(b, uint64(state.Timestamp.Unix())) 306 | data := append(b, state.PathAndQueryString...) 307 | var ssl byte = 0 308 | if state.SSL { 309 | ssl = 1 310 | } 311 | data = append(data, ssl) 312 | return oa.signatureComputer.ProduceSignature(data) 313 | } 314 | 315 | func extractDomainFromHost(host string) string { 316 | l := strings.Split(host, ":") 317 | if len(l) < 1 { 318 | return "" 319 | } 320 | return l[0] 321 | } 322 | 323 | // Authenticate treat an authentication request coming from HAProxy 324 | func (oa *OIDCAuthenticator) Authenticate(msg *message.Message) (bool, []action.Action, error) { 325 | oauthArgs, err := extractOAuth2Args(msg, oa.options.ReadClientInfoFromMessages) 326 | if err != nil { 327 | return false, nil, fmt.Errorf("unable to extract origin URL: %v", err) 328 | } 329 | 330 | domain := extractDomainFromHost(oauthArgs.host) 331 | 332 | if oauthArgs.clientid != "" { 333 | oa.options.ClientsStore.AddClient(domain, oauthArgs.clientid, oauthArgs.clientsecret, oauthArgs.redirecturl) 334 | } 335 | 336 | _, err = oa.options.ClientsStore.GetClient(domain) 337 | if err == ErrOIDCClientConfigNotFound { 338 | return false, nil, nil 339 | } else if err != nil { 340 | return false, nil, fmt.Errorf("unable to find an oidc client for domain %s", domain) 341 | } 342 | 343 | // Verify the cookie to make sure the user is authenticated 344 | if oauthArgs.cookie != "" { 345 | idToken, err := oa.decryptCookie(oauthArgs.cookie, domain) 346 | if err != nil { 347 | // CoreOS/go-oidc does not have error types, so the errors are handled using strings 348 | // comparison. 349 | if errors.Is(err, &oidc.TokenExpiredError{}) || strings.Contains(err.Error(), "oidc:") { 350 | authorizationURL, e := oa.buildAuthorizationURL(domain, oauthArgs) 351 | if e != nil { 352 | return false, nil, e 353 | } 354 | 355 | logrus.Infof("Authentication failed, redirecting to OIDC provider %s, reason: %s", authorizationURL, err) 356 | 357 | return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil 358 | } 359 | 360 | return false, nil, err 361 | } 362 | 363 | if len(oauthArgs.tokenClaims) == 0 { 364 | return true, nil, nil 365 | } else { 366 | // Extract token claims. 367 | actions, err := BuildTokenClaimsMessage(idToken, oauthArgs.tokenClaims) 368 | if err != nil { 369 | return false, nil, err 370 | } 371 | 372 | return true, actions, nil 373 | } 374 | 375 | } 376 | 377 | authorizationURL, err := oa.buildAuthorizationURL(domain, oauthArgs) 378 | if err != nil { 379 | return false, nil, err 380 | } 381 | 382 | return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil 383 | } 384 | 385 | func (oa *OIDCAuthenticator) buildAuthorizationURL(domain string, oauthArgs OAuthArgs) (string, error) { 386 | currentTime := time.Now() 387 | 388 | var state State 389 | state.Timestamp = currentTime 390 | state.PathAndQueryString = oauthArgs.pathq 391 | state.SSL = oauthArgs.ssl 392 | state.Signature = oa.computeStateSignature(&state) 393 | 394 | stateBytes, err := msgpack.Marshal(state) 395 | if err != nil { 396 | return "", fmt.Errorf("unable to marshal the state") 397 | } 398 | 399 | var authorizationURL string 400 | pkceVerifier := oauth2.GenerateVerifier() 401 | stateStr := base64.StdEncoding.EncodeToString(stateBytes) 402 | cacheTTL := time.Second * 3600 403 | if oa.options.CookieTTL != 0 { 404 | cacheTTL = oa.options.CookieTTL 405 | } 406 | oa.pkceVerifierCache.Set(stateStr, pkceVerifier, cacheTTL) 407 | err = oa.withOAuth2Config(domain, func(config oauth2.Config) error { 408 | authorizationURL = config.AuthCodeURL(stateStr, oauth2.S256ChallengeOption(pkceVerifier)) 409 | return nil 410 | }) 411 | if err != nil { 412 | return "", fmt.Errorf("unable to build authorize url: %w", err) 413 | } 414 | 415 | return authorizationURL, nil 416 | } 417 | 418 | func (oa *OIDCAuthenticator) handleOAuth2Logout() http.HandlerFunc { 419 | return func(w http.ResponseWriter, r *http.Request) { 420 | c := http.Cookie{ 421 | Name: oa.options.CookieName, 422 | Path: "/", 423 | HttpOnly: true, 424 | Secure: oa.options.CookieSecure, 425 | } 426 | http.SetCookie(w, &c) 427 | 428 | // TODO: make a call to the logout endpoint of the authz server assuming it is implemented. 429 | // RFC is currently in draft state: https://openid.net/specs/openid-connect-session-1_0.html 430 | 431 | fmt.Fprint(w, LogoutPageTemplate) 432 | } 433 | } 434 | 435 | func (oa *OIDCAuthenticator) handleOAuth2Callback(tmpl *template.Template, errorTmpl *template.Template) http.HandlerFunc { 436 | return func(w http.ResponseWriter, r *http.Request) { 437 | stateB64Payload := r.URL.Query().Get("state") 438 | if stateB64Payload == "" { 439 | logrus.Error("cannot extract the state query param") 440 | http.Error(w, "Bad request", http.StatusBadRequest) 441 | return 442 | } 443 | 444 | domain := extractDomainFromHost(r.Host) 445 | 446 | pkceVerifier, ok := oa.pkceVerifierCache.Get(stateB64Payload) 447 | if !ok { 448 | logrus.Error("cannot retrieve pkce verifier") 449 | http.Error(w, "Bad request", http.StatusBadRequest) 450 | return 451 | } 452 | var oauth2Token *oauth2.Token 453 | err := oa.withOAuth2Config(domain, func(config oauth2.Config) error { 454 | token, err := config.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(pkceVerifier)) 455 | oauth2Token = token 456 | return err 457 | }) 458 | if err != nil { 459 | logrus.Errorf("unable to retrieve OAuth2 token: %v", err) 460 | http.Error(w, "Bad request", http.StatusBadRequest) 461 | return 462 | } 463 | 464 | // Extract the ID Token from OAuth2 token. 465 | rawIDToken, ok := oauth2Token.Extra("id_token").(string) 466 | if !ok { 467 | logrus.Errorf("unable to extract the raw id_token: %v", err) 468 | http.Error(w, "Bad request", http.StatusBadRequest) 469 | return 470 | } 471 | 472 | // Parse and verify ID Token payload. 473 | idToken, err := oa.verifyIDToken(r.Context(), domain, rawIDToken) 474 | if err != nil { 475 | logrus.Errorf("unable to verify the ID token: %v", err) 476 | http.Error(w, "Bad request", http.StatusBadRequest) 477 | return 478 | } 479 | 480 | stateJSONPayload, err := base64.StdEncoding.DecodeString(stateB64Payload) 481 | if err != nil { 482 | logrus.Errorf("unable to decode origin URL from state: %v", err) 483 | http.Error(w, "Bad request", http.StatusBadRequest) 484 | return 485 | } 486 | 487 | var state State 488 | err = msgpack.Unmarshal(stateJSONPayload, &state) 489 | if err != nil { 490 | logrus.Errorf("unable to unmarshal the state payload: %v", err) 491 | http.Error(w, "Bad request", http.StatusBadRequest) 492 | return 493 | } 494 | 495 | if state.Timestamp.Add(ValidStateDuration).Before(time.Now()) { 496 | logrus.Errorf("state value has expired: %v", err) 497 | http.Error(w, "Bad request", http.StatusBadRequest) 498 | return 499 | } 500 | 501 | scheme := "https" 502 | if !state.SSL { 503 | scheme = "http" 504 | } 505 | url := fmt.Sprintf("%s://%s%s", scheme, r.Host, state.PathAndQueryString) 506 | logrus.Debugf("target url request by user %s", url) 507 | signature := oa.computeStateSignature(&state) 508 | if signature != state.Signature { 509 | err = errorTmpl.Execute(w, struct{ URL string }{URL: url}) 510 | if err != nil { 511 | logrus.Errorf("unable to render error template: %v", err) 512 | http.Error(w, "Bad request", http.StatusBadRequest) 513 | return 514 | } 515 | return 516 | } 517 | 518 | encryptedIDToken, err := oa.encryptor.Encrypt(rawIDToken) 519 | 520 | if err != nil { 521 | logrus.Errorf("unable to encrypt the ID token: %v", err) 522 | http.Error(w, "Bad request", http.StatusBadRequest) 523 | return 524 | } 525 | 526 | var expiry time.Time 527 | if oa.options.CookieTTL == 0 { 528 | // Align the expiry of the session to the expiry of the ID Token if the options has not been set. 529 | expiry = idToken.Expiry 530 | } else { // otherwise take the value in seconds provided as argument 531 | expiry = time.Now().Add(oa.options.CookieTTL) 532 | } 533 | 534 | cookie := http.Cookie{ 535 | Name: oa.options.CookieName, 536 | Value: encryptedIDToken, 537 | Path: "/", 538 | Expires: expiry, 539 | HttpOnly: true, 540 | Secure: oa.options.CookieSecure, 541 | } 542 | 543 | http.SetCookie(w, &cookie) 544 | 545 | err = tmpl.Execute(w, struct{ URL string }{URL: string(url)}) 546 | if err != nil { 547 | logrus.Errorf("unable to render redirect template: %v", err) 548 | http.Error(w, "Bad request", http.StatusBadRequest) 549 | } 550 | } 551 | } 552 | -------------------------------------------------------------------------------- /internal/auth/errors.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // ErrNoCredential error thrown when no credentials are provided with the request 8 | var ErrNoCredential = errors.New("no credentials provided") 9 | 10 | // ErrBadAuthorizationValue error thrown when the authorization header value is in wrong format 11 | var ErrBadAuthorizationValue = errors.New("bad authorization value provided") 12 | 13 | // ErrWrongCredentials error thrown when credentials provided by user are wrong 14 | var ErrWrongCredentials = errors.New("wrong credentials") 15 | 16 | // ErrUserDoesntExist error thrown when provided user does not exist 17 | var ErrUserDoesntExist = errors.New("user does not exist") 18 | 19 | // ErrTooManyUsersMatching error thrown when too many users are retrieved upon LDAP search 20 | var ErrTooManyUsersMatching = errors.New("there are too many user matching this request") 21 | 22 | // ErrSSLArgNotFound error thrown when the ssl arg has not been provided by HAProxy 23 | var ErrSSLArgNotFound = errors.New("SSL arg not found") 24 | 25 | // ErrSSLArgNotFound error thrown when the host arg has not been provided by HAProxy 26 | var ErrHostArgNotFound = errors.New("host arg not found") 27 | 28 | // ErrSSLArgNotFound error thrown when the pathq arg has not been provided by HAProxy 29 | var ErrPathqArgNotFound = errors.New("pathq arg not found") 30 | 31 | // ErrOIDCClientConfigNotFound error thrown when there is no client config related to a given domain 32 | var ErrOIDCClientConfigNotFound = errors.New("no OIDC client found for this domain") 33 | -------------------------------------------------------------------------------- /internal/auth/messages.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/coreos/go-oidc/v3/oidc" 9 | action "github.com/negasus/haproxy-spoe-go/action" 10 | "github.com/tidwall/gjson" 11 | ) 12 | 13 | // BuildRedirectURLMessage build a message containing the URL the user should be redirected too 14 | func BuildRedirectURLMessage(url string) action.Action { 15 | return action.NewSetVar(action.ScopeSession, "redirect_url", url) 16 | } 17 | 18 | // BuildHasErrorMessage build a message stating an error was thrown in SPOE agent 19 | func BuildHasErrorMessage() action.Action { 20 | return action.NewSetVar(action.ScopeSession, "has_error", true) 21 | } 22 | 23 | // AuthenticatedUserMessage build a message containing the username of the authenticated user 24 | func AuthenticatedUserMessage(username string) action.Action { 25 | return action.NewSetVar(action.ScopeSession, "authenticated_user", username) 26 | } 27 | 28 | func BuildTokenClaimsMessage(idToken *oidc.IDToken, claimsFilter []string) ([]action.Action, error) { 29 | var claimsData json.RawMessage 30 | 31 | if err := idToken.Claims(&claimsData); err != nil { 32 | return nil, fmt.Errorf("unable to load OIDC claims: %w", err) 33 | } 34 | 35 | claimsVals := gjson.ParseBytes(claimsData) 36 | result := make([]action.Action, 0, len(claimsFilter)) 37 | 38 | for i := range claimsFilter { 39 | value := claimsVals.Get(claimsFilter[i]) 40 | 41 | if !value.Exists() { 42 | continue 43 | } 44 | 45 | key := computeSPOEKey(claimsFilter[i]) 46 | result = append(result, action.NewSetVar(action.ScopeSession, key, gjsonToSPOEValue(&value))) 47 | } 48 | 49 | return result, nil 50 | } 51 | 52 | var spoeKeyReplacer = strings.NewReplacer("-", "_", ".", "_") 53 | 54 | func computeSPOEKey(key string) string { 55 | return "token_claim_" + spoeKeyReplacer.Replace(key) 56 | } 57 | 58 | func gjsonToSPOEValue(value *gjson.Result) interface{} { 59 | switch value.Type { 60 | case gjson.Null: 61 | // Null is a null json value 62 | return nil 63 | 64 | case gjson.Number: 65 | // Number is json number 66 | return value.Int() 67 | 68 | case gjson.String: 69 | // String is a json string 70 | return value.String() 71 | 72 | default: 73 | if value.IsArray() { 74 | // Make a comma separated list. 75 | tmp := value.Array() 76 | lastInd := len(tmp) - 1 77 | sb := &strings.Builder{} 78 | 79 | for i := 0; i <= lastInd; i++ { 80 | sb.WriteString(tmp[i].String()) 81 | 82 | if i != lastInd { 83 | sb.WriteRune(',') 84 | } 85 | } 86 | 87 | return sb.String() 88 | } 89 | 90 | // Other types such as True, False, JSON. 91 | return value.String() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/auth/oidc_clients_store.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | ) 7 | 8 | type OIDCClientConfig struct { 9 | ClientID string `mapstructure:"client_id"` 10 | ClientSecret string `mapstructure:"client_secret"` 11 | RedirectURL string `mapstructure:"redirect_url"` 12 | } 13 | 14 | type OIDCClientsStore interface { 15 | // Retrieve the client_id and client_secret based on the domain 16 | GetClient(domain string) (*OIDCClientConfig, error) 17 | AddClient(domain string, clientid string, clientsecret string, redirecturl string) 18 | } 19 | 20 | type StaticOIDCClientsStore struct { 21 | clients map[string]OIDCClientConfig 22 | 23 | mtx sync.RWMutex 24 | } 25 | 26 | func NewStaticOIDCClientStore(config map[string]OIDCClientConfig) *StaticOIDCClientsStore { 27 | return &StaticOIDCClientsStore{clients: config} 28 | } 29 | 30 | func NewEmptyStaticOIDCClientStore() *StaticOIDCClientsStore { 31 | return &StaticOIDCClientsStore{clients: map[string]OIDCClientConfig{}} 32 | } 33 | 34 | func (ocf *StaticOIDCClientsStore) GetClient(domain string) (*OIDCClientConfig, error) { 35 | ocf.mtx.RLock() 36 | defer ocf.mtx.RUnlock() 37 | 38 | if config, ok := ocf.clients[domain]; ok { 39 | return &config, nil 40 | } 41 | return nil, ErrOIDCClientConfigNotFound 42 | } 43 | 44 | func (ocf *StaticOIDCClientsStore) AddClient(domain string, clientid string, clientsecret string, redirecturl string) { 45 | ocf.mtx.Lock() 46 | defer ocf.mtx.Unlock() 47 | 48 | if _, ok := ocf.clients[domain]; !ok { 49 | ocf.clients[strings.Clone(domain)] = OIDCClientConfig{ 50 | ClientID: strings.Clone(clientid), 51 | ClientSecret: strings.Clone(clientsecret), 52 | RedirectURL: strings.Clone(redirecturl), 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/auth/oidc_clients_store_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "strconv" 5 | "sync" 6 | "testing" 7 | ) 8 | 9 | func TestStaticOIDCClientsStoreRace(t *testing.T) { 10 | var wg = &sync.WaitGroup{} 11 | var expectedValue OIDCClientConfig 12 | var store = NewEmptyStaticOIDCClientStore() 13 | const steps = 10000 14 | 15 | // One Goroutine changes the store state while 2 other try to read from it. 16 | wg.Add(1) 17 | go func() { 18 | for i := 0; i < steps; i++ { 19 | // Something comes with requests for a new and a valid domain, 20 | // so it is being added to the store. 21 | expectedValue.ClientID = "client-id" 22 | expectedValue.ClientSecret = "client-secret" 23 | expectedValue.RedirectURL = "https://" + strconv.Itoa(i) + ".example.com/redirect" 24 | domain := strconv.Itoa(i) + ".example.com" 25 | 26 | store.AddClient(domain, expectedValue.ClientID, expectedValue.ClientSecret, expectedValue.RedirectURL) 27 | } 28 | 29 | wg.Done() 30 | }() 31 | 32 | // Read and compare. 33 | var found bool 34 | for i := 0; i < 2; i++ { 35 | wg.Add(1) 36 | go func() { 37 | for i := 0; i < steps; i++ { 38 | _, err := store.GetClient("100000.example.com") 39 | if err == nil { 40 | found = true 41 | break 42 | } 43 | } 44 | 45 | wg.Done() 46 | }() 47 | } 48 | 49 | if found { 50 | t.Fatal("Received a value while should get ErrOIDCClientConfigNotFound") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/auth/signature.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | // SignatureVerifier verify a signature 4 | type SignatureVerifier interface { 5 | VerifySignature(signature []byte) bool 6 | } 7 | 8 | // SignatureProducer produces a signature 9 | type SignatureProducer interface { 10 | ProduceSignature(data []byte) []byte 11 | } 12 | -------------------------------------------------------------------------------- /internal/auth/signature_hmac_sha256.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "sync" 8 | ) 9 | 10 | // HmacSha256Computer represent a producer and verifier of HMAC SHA256 signatures 11 | // SHA256 is prefered over SHA1 for the security margin it implies but both would be ok at this time 12 | // even if SHA1 is known to be vulnerable to collision attacks. 13 | type HmacSha256Computer struct { 14 | secret string 15 | 16 | mutex sync.Mutex 17 | } 18 | 19 | // NewHmacSha256Computer create an instance of HMAC SHA256 computer 20 | func NewHmacSha256Computer(secret string) *HmacSha256Computer { 21 | return &HmacSha256Computer{secret: secret} 22 | } 23 | 24 | // ProduceSignature produce a signature for the given data 25 | func (hsc *HmacSha256Computer) ProduceSignature(data []byte) string { 26 | hsc.mutex.Lock() 27 | defer hsc.mutex.Unlock() 28 | 29 | h := hmac.New(sha256.New, []byte(hsc.secret)) 30 | 31 | // sign the original URL to make sure we don't allow open redirect where one can simply craft any URL 32 | h.Write(data) 33 | return hex.EncodeToString(h.Sum(nil)) 34 | } 35 | -------------------------------------------------------------------------------- /internal/auth/signature_hmac_sha256_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | var ( 10 | DummySecret = "dummy_secret" 11 | DummySecret2 = "another_secret" 12 | ) 13 | 14 | func TestShouldProduceSignature(t *testing.T) { 15 | c := NewHmacSha256Computer(DummySecret) 16 | sig := c.ProduceSignature([]byte("http://my-littly-url.crto.in")) 17 | assert.Equal(t, "33ba08400f391aaaffe1f54f3f2772473b6faa8f4b1b9bab3c71db8c572d4341", sig) 18 | } 19 | 20 | func TestShouldProduceSignatureOfEmptyString(t *testing.T) { 21 | c := NewHmacSha256Computer(DummySecret) 22 | sig := c.ProduceSignature([]byte("")) 23 | assert.Equal(t, "ff61b6bcbd15af6ab2e369a1e92f30c611aae9b859425db70a7e01a0716b510a", sig) 24 | } 25 | 26 | func TestShouldProduceDifferentData(t *testing.T) { 27 | c := NewHmacSha256Computer(DummySecret) 28 | sig1 := c.ProduceSignature([]byte("http://my-littly-url.crto.in")) 29 | sig2 := c.ProduceSignature([]byte("http://my-li")) 30 | assert.True(t, sig1 != sig2, "signatures are equal: %s", sig1) 31 | } 32 | 33 | func TestShouldProduceSameData(t *testing.T) { 34 | c := NewHmacSha256Computer(DummySecret) 35 | sig1 := c.ProduceSignature([]byte("http://my-littly-url.crto.in")) 36 | sig2 := c.ProduceSignature([]byte("http://my-littly-url.crto.in")) 37 | assert.True(t, sig1 == sig2, "signatures are different: %s != %s", sig1, sig2) 38 | } 39 | -------------------------------------------------------------------------------- /internal/auth/templates.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | // RedirectPage is a template used for the final redirection 4 | var RedirectPageTemplate = ` 5 | 6 | Redirection in progress 7 | 8 | 9 | Redirection in progress... 10 | ` 11 | 12 | // ErrorPage is a template used in the case the final redirection cannot happen due to the bad signature of the original URL 13 | var ErrorPageTemplate = ` 14 | Error on redirection 15 | You cannot be redirected to this untrusted url {{.URL}}. 16 | ` 17 | 18 | // LogoutPage is an HTML content stating the user has been logged out successfully 19 | var LogoutPageTemplate = ` 20 | Logout 21 | You have been logged out successfully. 22 | ` 23 | -------------------------------------------------------------------------------- /resources/configuration/config.yml: -------------------------------------------------------------------------------- 1 | server: 2 | # The address the server will listen on 3 | addr: :8081 4 | # The verbosity of the logs: info or debug 5 | log_level: debug 6 | 7 | # If set, the LDAP authenticator is enabled 8 | ldap: 9 | # The URI and port to the ldap server 10 | uri: ldaps://ldap 11 | port: 636 12 | # In case of an ldaps connection, verify the TLS certificate of the server 13 | verify_tls: false 14 | # The DN and password of the user to bind with in order to perform the search query to find the user 15 | user_dn: cn=admin,dc=example,dc=com 16 | password: password 17 | # The base DN used for the search queries 18 | base_dn: dc=example,dc=com 19 | # The filter for the query searching for the user provided 20 | user_filter: "(&(cn={login})(ou:dn:={group}))" 21 | 22 | # If set, the OpenID Connect authenticator is enabled 23 | oidc: 24 | # The URL to the OpenID Connect provider. This is the URL hosting the discovery endpoint 25 | provider_url: http://dex.example.com:9080/dex 26 | # The client_id and client_secret of the app representing the SPOE agent 27 | # The callback the OIDC server will redirect the user to once authentication is done 28 | oauth2_callback_path: /oauth2/callback 29 | # The path to the logout endpoint to redirect the user to. 30 | oauth2_logout_path: /oauth2/logout 31 | # The path the oidc client uses for a healthcheck 32 | oauth2_healthcheck_path: /health 33 | # The SPOE agent will open a dedicated port for the HTTP server handling the callback. This is the address the server listens on 34 | callback_addr: ":5000" 35 | 36 | # Various properties of the cookie holding the ID Token of the user 37 | cookie_name: authsession 38 | cookie_secure: false 39 | # If not set, then uses ID token expire timestamp 40 | cookie_ttl_seconds: 3600 41 | # The secret used to sign the state parameter 42 | signature_secret: myunsecuresecret 43 | # The secret used to encrypt the cookie in order to guarantee the privacy of the data in case of leak 44 | encryption_secret: anotherunsecuresecret 45 | 46 | # A mapping of client credentials per protected domain 47 | clients: 48 | dummy.example.com: 49 | client_id: dummy-client 50 | client_secret: dummy-secret 51 | redirect_url: http://dummy.example.com:9080/oauth2/callback 52 | -------------------------------------------------------------------------------- /resources/dex/config.yaml: -------------------------------------------------------------------------------- 1 | issuer: http://dex.example.com:9080/dex 2 | 3 | storage: 4 | type: sqlite3 5 | config: 6 | file: /tmp/dex.db 7 | 8 | web: 9 | http: 0.0.0.0:5556 10 | 11 | connectors: 12 | - type: ldap 13 | id: ldap 14 | name: LDAP 15 | config: 16 | host: ldap:389 17 | insecureNoSSL: true 18 | bindDN: cn=admin,dc=example,dc=com 19 | bindPW: password 20 | usernamePrompt: SSO Username 21 | 22 | userSearch: 23 | baseDN: dc=example,dc=com 24 | username: cn 25 | idAttr: cn 26 | emailAttr: mail 27 | nameAttr: sn 28 | 29 | staticClients: 30 | - id: app2-client 31 | secret: app2-secret 32 | name: 'Application 2' 33 | redirectURIs: 34 | - 'http://app2.example.com:9080/oauth2/callback' 35 | 36 | - id: app3-client 37 | secret: app3-secret 38 | name: 'Application 3' 39 | redirectURIs: 40 | - 'http://app3.example.com:9080/oauth2/callback' -------------------------------------------------------------------------------- /resources/haproxy/haproxy.cfg: -------------------------------------------------------------------------------- 1 | global 2 | log 127.0.0.1 local0 3 | # log 127.0.0.1 local1 notice 4 | user root 5 | group root 6 | daemon 7 | maxconn 20000 8 | 9 | defaults 10 | log global 11 | mode http 12 | option httplog 13 | option dontlognull 14 | timeout connect 5000 15 | timeout client 50000 16 | timeout server 50000 17 | 18 | frontend haproxynode 19 | bind *:9080 20 | mode http 21 | 22 | # Domains to protect 23 | acl acl_public hdr_beg(host) -i public.example.com 24 | acl acl_app1 hdr_beg(host) -i app1.example.com 25 | 26 | acl acl_app2 hdr_beg(host) -i app2.example.com 27 | http-request set-var(req.oidc_client_id) str(app2-client) if acl_app2 28 | http-request set-var(req.oidc_client_secret) str(app2-secret) if acl_app2 29 | http-request set-var(req.oidc_redirect_url) str(http://app2.example.com:9080/oauth2/callback) if acl_app2 30 | 31 | acl acl_app3 hdr_beg(host) -i app3.example.com 32 | http-request set-var(req.oidc_client_id) str(app3-client) if acl_app3 33 | http-request set-var(req.oidc_client_secret) str(app3-secret) if acl_app3 34 | http-request set-var(req.oidc_redirect_url) str(http://app3.example.com:9080/oauth2/callback) if acl_app3 35 | ## Request extra OpenID token claims, space separated 36 | ## The extra claims will be set as variables with keys: "token_claim_" + {{ claim name }}, 37 | ## where '.' and '-' are replaced with '_'. 38 | ## Nested claims are supported. 39 | http-request set-var(req.oidc_token_claims) str("name roles org-groups resource_access.servicename.roles") if acl_app3 40 | 41 | acl oauth2callback path_beg /oauth2/callback 42 | acl oauth2logout path_beg /oauth2/logout 43 | 44 | acl dex_domain hdr_beg(host) -i dex.example.com 45 | # define the spoe agent 46 | http-request send-spoe-group spoe-auth try-auth-all 47 | filter spoe engine spoe-auth config /usr/local/etc/haproxy/spoe-auth.conf 48 | 49 | # map the spoe response to acl variables 50 | acl authenticated var(sess.auth.is_authenticated) -m bool 51 | acl auth_error var(sess.auth.has_error) -m bool 52 | 53 | use_backend backend_dex if dex_domain 54 | use_backend backend_oauth2 if oauth2callback || oauth2logout 55 | 56 | use_backend backend_error if auth_error 57 | 58 | # app1 returns 401 when it's not authenticated 59 | use_backend backend_unauthorized if acl_app1 ! authenticated 60 | use_backend backend_app if acl_app1 authenticated 61 | 62 | # app2 redirects the user to the OAuth2 server when not authenticated 63 | use_backend backend_redirect if acl_app2 ! authenticated 64 | use_backend backend_app if acl_app2 authenticated 65 | 66 | # Set headers based on OpenID token claims 67 | http-request set-header X-OIDC-Username %[var(sess.auth.token_claim_name)] if acl_app3 authenticated 68 | http-request set-header X-OIDC-Roles %[var(sess.auth.token_claim_roles)] if acl_app3 authenticated 69 | http-request set-header X-OIDC-Groups %[var(sess.auth.token_claim_org_groups)] if acl_app3 authenticated 70 | http-request set-header X-OIDC-Resource-Access %[var(sess.auth.token_claim_resource_access_servicename_roles)] if acl_app3 authenticated 71 | 72 | # app3 redirects the user to the OAuth2 server when not authenticated 73 | use_backend backend_redirect if acl_app3 ! authenticated 74 | use_backend backend_app if acl_app3 authenticated 75 | 76 | # otherwise, simply serve the public domain 77 | default_backend backend_public 78 | 79 | # Public page 80 | backend backend_public 81 | mode http 82 | balance roundrobin 83 | server node-unprotected-app unprotected-backend:80 check 84 | 85 | # Page supposed to be protected 86 | backend backend_app 87 | mode http 88 | balance roundrobin 89 | http-request add-header X-Authorized-User %[var(sess.auth.authenticated_user)] 90 | 91 | server node-protected-app protected-backend:80 check 92 | 93 | # Serve dex application 94 | backend backend_dex 95 | mode http 96 | balance roundrobin 97 | option tcp-check 98 | 99 | server node-dex-app dex:5556 check 100 | 101 | # Page the user is redirected to when unauthorized 102 | backend backend_unauthorized 103 | mode http 104 | balance roundrobin 105 | http-response set-status 401 106 | http-response add-header WWW-Authenticate 'Basic realm="Access the webapp"' 107 | 108 | server node-noauth unauthorized-backend:80 check 109 | 110 | # Return an internal error 111 | backend backend_error 112 | mode http 113 | balance roundrobin 114 | http-response set-status 500 115 | 116 | # Page the user is redirected to when unauthorized 117 | backend backend_redirect 118 | mode http 119 | balance roundrobin 120 | http-request redirect location %[var(sess.auth.redirect_url)] 121 | 122 | # Backend bridging with the SPOE agent 123 | backend backend_spoe-agent 124 | mode tcp 125 | balance roundrobin 126 | option tcp-check 127 | 128 | timeout connect 5s 129 | timeout server 3m 130 | 131 | server node-auth spoe:8081 check 132 | 133 | backend backend_oauth2 134 | mode http 135 | balance roundrobin 136 | option tcp-check 137 | 138 | timeout connect 5s 139 | timeout server 3m 140 | 141 | server node-auth spoe:5000 check 142 | -------------------------------------------------------------------------------- /resources/haproxy/spoe-auth.conf: -------------------------------------------------------------------------------- 1 | [spoe-auth] 2 | spoe-agent auth-agents 3 | option var-prefix auth 4 | 5 | timeout hello 2s 6 | timeout idle 2m 7 | timeout processing 1s 8 | 9 | groups try-auth-all 10 | use-backend backend_spoe-agent 11 | 12 | spoe-group try-auth-all 13 | messages try-auth-ldap 14 | messages try-auth-oidc 15 | 16 | spoe-message try-auth-ldap 17 | args authorization=req.hdr(Authorization) authorized_group=str(users) 18 | event on-frontend-http-request if { hdr_beg(host) -i app1.example.com } || { hdr_beg(host) -i app2.example.com } || { hdr_beg(host) -i app3.example.com } 19 | 20 | spoe-message try-auth-oidc 21 | args arg_ssl=ssl_fc arg_host=req.hdr(Host) arg_pathq=pathq arg_cookie=req.cook(authsession) arg_client_id=var(req.oidc_client_id) arg_client_secret=var(req.oidc_client_secret) arg_redirect_url=var(req.oidc_redirect_url) arg_token_claims=var(req.oidc_token_claims) 22 | event on-frontend-http-request if { hdr_beg(host) -i app1.example.com } || { hdr_beg(host) -i app2.example.com } || { hdr_beg(host) -i app3.example.com } 23 | -------------------------------------------------------------------------------- /resources/ldap/01-base.ldif: -------------------------------------------------------------------------------- 1 | dn: ou=groups,dc=example,dc=com 2 | objectclass: organizationalUnit 3 | objectclass: top 4 | ou: groups 5 | 6 | dn: ou=users,dc=example,dc=com 7 | objectclass: organizationalUnit 8 | objectclass: top 9 | ou: users 10 | 11 | dn: ou=users2,dc=example,dc=com 12 | objectclass: organizationalUnit 13 | objectclass: top 14 | ou: users2 15 | 16 | dn: cn=dev,ou=groups,dc=example,dc=com 17 | cn: dev 18 | member: cn=john,ou=users,dc=example,dc=com 19 | objectclass: groupOfNames 20 | objectclass: top 21 | 22 | dn: cn=admin,ou=groups,dc=example,dc=com 23 | cn: admin 24 | member: cn=john,ou=users,dc=example,dc=com 25 | objectclass: groupOfNames 26 | objectclass: top 27 | 28 | dn: cn=john,ou=users,dc=example,dc=com 29 | cn: john 30 | objectclass: inetOrgPerson 31 | objectclass: top 32 | mail: john.doe@example.com 33 | sn: John Doe 34 | userpassword: {CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/ 35 | 36 | dn: cn=harry,ou=users,dc=example,dc=com 37 | cn: harry 38 | objectclass: inetOrgPerson 39 | objectclass: top 40 | mail: harry.potter@example.com 41 | sn: Harry Potter 42 | userpassword: {CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/ 43 | 44 | dn: cn=barry,ou=users2,dc=example,dc=com 45 | cn: barry 46 | objectclass: inetOrgPerson 47 | objectclass: top 48 | mail: barry.allen@example.com 49 | sn: Barry Allen 50 | userpassword: {CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/ 51 | -------------------------------------------------------------------------------- /resources/nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | 6 | #charset koi8-r; 7 | #access_log /var/log/nginx/host.access.log main; 8 | 9 | location / { 10 | add_header Last-Modified $date_gmt; 11 | add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; 12 | if ($http_X_Authorized_User) { 13 | add_header Request-X-Authorized-User $http_X_Authorized_User; 14 | } 15 | if_modified_since off; 16 | expires off; 17 | etag off; 18 | 19 | root /usr/share/nginx/html; 20 | index index.html index.htm; 21 | } 22 | 23 | #error_page 404 /404.html; 24 | 25 | # redirect server error pages to the static page /50x.html 26 | # 27 | error_page 500 502 503 504 /50x.html; 28 | location = /50x.html { 29 | root /usr/share/nginx/html; 30 | } 31 | 32 | # proxy the PHP scripts to Apache listening on 127.0.0.1:80 33 | # 34 | #location ~ \.php$ { 35 | # proxy_pass http://127.0.0.1; 36 | #} 37 | 38 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 39 | # 40 | #location ~ \.php$ { 41 | # root html; 42 | # fastcgi_pass 127.0.0.1:9000; 43 | # fastcgi_index index.php; 44 | # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; 45 | # include fastcgi_params; 46 | #} 47 | 48 | # deny access to .htaccess files, if Apache's document root 49 | # concurs with nginx's one 50 | # 51 | #location ~ /\.ht { 52 | # deny all; 53 | #} 54 | } -------------------------------------------------------------------------------- /resources/protected/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Protected 4 | 5 | 6 | PROTECTED! 7 | 8 | -------------------------------------------------------------------------------- /resources/protected/secret.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Secret 4 | 5 | 6 | SECRET! 7 | 8 | -------------------------------------------------------------------------------- /resources/scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | 5 | reflex -r '(\.go$|go\.mod|\.sh|\.yaml|\.yml)' -s -- $* -------------------------------------------------------------------------------- /resources/scripts/run-with-debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | dlv --listen 0.0.0.0:2345 --headless=true --output=/tmp/$1 --continue --accept-multiclient debug ${@:2} -------------------------------------------------------------------------------- /resources/scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$DEBUG_ENABLED" -eq "1" ]] 4 | then 5 | echo "Running agent along with debug server" 6 | /scripts/run-with-debug.sh haproxy-spoe-auth cmd/haproxy-spoe-auth/main.go -- \ 7 | -config /configuration/config.yml -dynamic-client-info 8 | else 9 | while true 10 | do 11 | echo "Running agent without debug server" 12 | go run cmd/haproxy-spoe-auth/main.go -config /configuration/config.yml -dynamic-client-info 13 | sleep 2 14 | done 15 | fi -------------------------------------------------------------------------------- /resources/unauthorized/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Unauthorized 4 | 5 | Unauthorized! 6 | -------------------------------------------------------------------------------- /resources/unprotected/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Public 4 | 5 | 6 | Public! 7 | 8 | -------------------------------------------------------------------------------- /tests/actions.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/tebeka/selenium" 9 | ) 10 | 11 | // FillCredentials wait until the credential fields in the dex UI are located 12 | func (ewd *ExtendedWebDriver) FillCredentials(ctx context.Context, t *testing.T, username, password string) { 13 | loginEl := ewd.WaitElementLocatedByID(ctx, t, "login") 14 | passwordEl := ewd.WaitElementLocatedByID(ctx, t, "password") 15 | 16 | submitEl := ewd.WaitElementLocatedByID(ctx, t, "submit-login") 17 | 18 | loginEl.SendKeys(username) 19 | passwordEl.SendKeys(password) 20 | submitEl.Click() 21 | } 22 | 23 | // ClickOnGrantAccess click on the grant access button in the dex approval page 24 | func (ewd *ExtendedWebDriver) ClickOnGrantAccess(ctx context.Context, t *testing.T) { 25 | formsEl := ewd.WaitElementsLocatedByTagName(ctx, t, "form") 26 | assert.Len(t, formsEl, 2) 27 | buttonEl, err := formsEl[0].FindElement(selenium.ByTagName, "button") 28 | assert.NoError(t, err) 29 | buttonEl.Click() 30 | } 31 | -------------------------------------------------------------------------------- /tests/assertions.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/tebeka/selenium" 12 | ) 13 | 14 | // WaitUntilBodyContains wait until the body of the page contains match 15 | func (ewd *ExtendedWebDriver) WaitUntilBodyContains(ctx context.Context, t *testing.T, match string) { 16 | err := ewd.Wait(ctx, func(driver selenium.WebDriver) (bool, error) { 17 | body, err := driver.FindElement(selenium.ByTagName, "body") 18 | 19 | if err != nil { 20 | return false, fmt.Errorf("unable to get current URL: %v", err) 21 | } 22 | bodyContent, err := body.Text() 23 | if err != nil { 24 | return false, fmt.Errorf("unable to retrieve body: %v", err) 25 | } 26 | return strings.Contains(bodyContent, match), nil 27 | }) 28 | 29 | require.NoError(t, err) 30 | } 31 | 32 | // WaitUntilURLIs wait until the URL in the browser bar is the expected one 33 | func (ewd *ExtendedWebDriver) WaitUntilURLIs(ctx context.Context, t *testing.T, url string) { 34 | err := ewd.Wait(ctx, func(driver selenium.WebDriver) (bool, error) { 35 | currentURL, err := driver.CurrentURL() 36 | 37 | if err != nil { 38 | return false, fmt.Errorf("unable to get current URL: %v", err) 39 | } 40 | return currentURL == url, nil 41 | }) 42 | 43 | require.NoError(t, err) 44 | } 45 | 46 | // WaitUntilURLStartsWith wait until the client is redirected to an url starting with a given prefix 47 | func (ewd *ExtendedWebDriver) WaitUntilURLStartsWith(ctx context.Context, t *testing.T, prefix string) { 48 | err := ewd.Wait(ctx, func(driver selenium.WebDriver) (bool, error) { 49 | currentURL, err := driver.CurrentURL() 50 | 51 | if err != nil { 52 | return false, fmt.Errorf("unable to get current URL: %v", err) 53 | } 54 | 55 | return strings.HasPrefix(currentURL, prefix), nil 56 | }) 57 | 58 | require.NoError(t, err) 59 | } 60 | 61 | // WaitUntilRedirectedToDexLogin wait until the client is redirected to dex login portal 62 | func (ewd *ExtendedWebDriver) WaitUntilRedirectedToDexLogin(ctx context.Context, t *testing.T) { 63 | ewd.WaitUntilURLStartsWith(ctx, t, "http://dex.example.com:9080/dex/auth/ldap?req=") 64 | } 65 | 66 | // WaitUntilRedirectedToDexApproval wait until the client is redirected to dex approval page 67 | func (ewd *ExtendedWebDriver) WaitUntilRedirectedToDexApproval(ctx context.Context, t *testing.T) { 68 | ewd.WaitUntilURLStartsWith(ctx, t, "http://dex.example.com:9080/dex/approval?req=") 69 | } 70 | 71 | // WaitUntilDexCredentialsFieldsAreDetetected wait until the credential fields in the dex UI are located 72 | func (ewd *ExtendedWebDriver) WaitUntilDexCredentialsFieldsAreDetetected(ctx context.Context, t *testing.T) { 73 | ewd.WaitElementLocatedByID(ctx, t, "login") 74 | ewd.WaitElementLocatedByID(ctx, t, "password") 75 | } 76 | 77 | // WaitUntilAuthenticatedWithOIDC wait until the authentication workflow has been executed. 78 | // This assert goes from the redirection to dex up to the click on the button to grant access to the information 79 | func (ewd *ExtendedWebDriver) WaitUntilAuthenticatedWithOIDC(ctx context.Context, t *testing.T, username, password string) { 80 | ewd.WaitUntilRedirectedToDexLogin(ctx, t) 81 | ewd.WaitUntilDexCredentialsFieldsAreDetetected(ctx, t) 82 | ewd.FillCredentials(ctx, t, username, password) 83 | ewd.WaitUntilRedirectedToDexApproval(ctx, t) 84 | ewd.ClickOnGrantAccess(ctx, t) 85 | } 86 | 87 | // WaitUntilLoginErrorAppear wait until the error message in dex appears. 88 | func (ewd *ExtendedWebDriver) WaitUntilLoginErrorAppear(ctx context.Context, t *testing.T) { 89 | loginError := ewd.WaitElementLocatedByID(ctx, t, "login-error") 90 | assert.NotNil(t, loginError) 91 | } 92 | -------------------------------------------------------------------------------- /tests/const.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | const ( 4 | // App1URL is the URL for the LDAP use case 5 | App1URL = "http://app1.example.com:9080/" 6 | // App2URL is the URL for the OIDC use case 7 | App2URL = "http://app2.example.com:9080/" 8 | // App3URL is another URL for the OIDC use case 9 | App3URL = "http://app3.example.com:9080/" 10 | // PublicURL is the URL for the unprotected app 11 | PublicURL = "http://public.example.com:9080/" 12 | ) 13 | -------------------------------------------------------------------------------- /tests/ldap_authentication_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestShouldAuthenticateSuccessfullyInLDAP(t *testing.T) { 11 | req, err := http.NewRequest("GET", App1URL, nil) 12 | assert.NoError(t, err) 13 | req.SetBasicAuth("john", "password") 14 | 15 | res, err := http.DefaultClient.Do(req) 16 | assert.NoError(t, err) 17 | assert.Equal(t, "john", res.Header.Get("request-x-authorized-user")) 18 | assert.Equal(t, 200, res.StatusCode) 19 | } 20 | 21 | func TestShouldFailAuthenticationInLDAP(t *testing.T) { 22 | req, err := http.NewRequest("GET", App1URL, nil) 23 | assert.NoError(t, err) 24 | req.SetBasicAuth("john", "badpassword") 25 | 26 | res, err := http.DefaultClient.Do(req) 27 | assert.NoError(t, err) 28 | 29 | assert.Equal(t, 401, res.StatusCode) 30 | } 31 | 32 | func TestShouldFailAuthenticationInLDAPWrongGroup(t *testing.T) { 33 | req, err := http.NewRequest("GET", App1URL, nil) 34 | assert.NoError(t, err) 35 | req.SetBasicAuth("barry", "password") 36 | 37 | res, err := http.DefaultClient.Do(req) 38 | assert.NoError(t, err) 39 | 40 | assert.Equal(t, 401, res.StatusCode) 41 | } 42 | 43 | func TestShouldFailWhenNoCredsProvided(t *testing.T) { 44 | req, err := http.NewRequest("GET", App1URL, nil) 45 | assert.NoError(t, err) 46 | 47 | res, err := http.DefaultClient.Do(req) 48 | assert.NoError(t, err) 49 | 50 | assert.Equal(t, 401, res.StatusCode) 51 | } 52 | -------------------------------------------------------------------------------- /tests/oidc_authentication_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestShouldAuthenticateSuccessfully(t *testing.T) { 13 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 14 | defer cancel() 15 | 16 | assert.NoError(t, WithWebdriver(func(wd ExtendedWebDriver) error { 17 | // In case the cookie is set, we logout the user before running the test. 18 | wd.Get(fmt.Sprintf("%soauth2/logout", App2URL)) 19 | 20 | wd.Get(App2URL) 21 | wd.WaitUntilAuthenticatedWithOIDC(ctx, t, "john", "password") 22 | wd.WaitUntilURLIs(ctx, t, App2URL) 23 | wd.WaitUntilBodyContains(ctx, t, "PROTECTED!") 24 | return nil 25 | })) 26 | } 27 | 28 | func TestShouldVerifyUserRedirectedToInitialURL(t *testing.T) { 29 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 30 | defer cancel() 31 | 32 | assert.NoError(t, WithWebdriver(func(wd ExtendedWebDriver) error { 33 | // In case the cookie is set, we logout the user before running the test. 34 | wd.Get(fmt.Sprintf("%soauth2/logout", App2URL)) 35 | 36 | wd.Get(App2URL) 37 | wd.WaitUntilAuthenticatedWithOIDC(ctx, t, "john", "password") 38 | wd.WaitUntilURLIs(ctx, t, App2URL) 39 | wd.WaitUntilBodyContains(ctx, t, "PROTECTED!") 40 | url := fmt.Sprintf("%ssecret.html", App2URL) 41 | wd.Get(url) 42 | wd.WaitUntilURLIs(ctx, t, url) 43 | wd.WaitUntilBodyContains(ctx, t, "SECRET!") 44 | return nil 45 | })) 46 | } 47 | 48 | func TestShouldKeepUseLoggedIn(t *testing.T) { 49 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 50 | defer cancel() 51 | 52 | assert.NoError(t, WithWebdriver(func(wd ExtendedWebDriver) error { 53 | // In case the cookie is set, we logout the user before running the test. 54 | wd.Get(fmt.Sprintf("%soauth2/logout", App2URL)) 55 | 56 | wd.Get(App2URL) 57 | wd.WaitUntilAuthenticatedWithOIDC(ctx, t, "john", "password") 58 | wd.WaitUntilURLIs(ctx, t, App2URL) 59 | wd.WaitUntilBodyContains(ctx, t, "PROTECTED!") 60 | wd.Get(PublicURL) 61 | wd.WaitUntilURLIs(ctx, t, PublicURL) 62 | wd.WaitUntilBodyContains(ctx, t, "Public!") 63 | // Cookie should be sent and access should be given directly 64 | wd.Get(App2URL) 65 | wd.WaitUntilURLIs(ctx, t, App2URL) 66 | wd.WaitUntilBodyContains(ctx, t, "PROTECTED!") 67 | return nil 68 | })) 69 | } 70 | 71 | func TestShouldFailAuthentication(t *testing.T) { 72 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 73 | defer cancel() 74 | 75 | assert.NoError(t, WithWebdriver(func(wd ExtendedWebDriver) error { 76 | // In case the cookie is set, we logout the user before running the test. 77 | wd.Get(fmt.Sprintf("%soauth2/logout", App2URL)) 78 | 79 | wd.Get(App2URL) 80 | wd.WaitUntilRedirectedToDexLogin(ctx, t) 81 | wd.WaitUntilDexCredentialsFieldsAreDetetected(ctx, t) 82 | wd.FillCredentials(ctx, t, "john", "badpassword") 83 | wd.WaitUntilLoginErrorAppear(ctx, t) 84 | return nil 85 | })) 86 | } 87 | -------------------------------------------------------------------------------- /tests/public_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestShouldAccessPublicPageWithoutCredentials(t *testing.T) { 11 | req, err := http.NewRequest("GET", PublicURL, nil) 12 | assert.NoError(t, err) 13 | 14 | res, err := http.DefaultClient.Do(req) 15 | assert.NoError(t, err) 16 | 17 | assert.Equal(t, 200, res.StatusCode) 18 | } 19 | -------------------------------------------------------------------------------- /tests/webdriver.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/sirupsen/logrus" 16 | "github.com/stretchr/testify/require" 17 | "github.com/tebeka/selenium" 18 | "github.com/tebeka/selenium/chrome" 19 | ) 20 | 21 | const defaultChromeDriverPort = "4444" 22 | 23 | // WebDriverSession binding a selenium service and a webdriver. 24 | type WebDriverSession struct { 25 | service *selenium.Service 26 | WebDriver selenium.WebDriver 27 | } 28 | 29 | // GetWebDriverPort returns the port to initialize the webdriver with. 30 | func GetWebDriverPort() int { 31 | driverPort := os.Getenv("CHROMEDRIVER_PORT") 32 | if driverPort == "" { 33 | driverPort = defaultChromeDriverPort 34 | } 35 | 36 | p, _ := strconv.Atoi(driverPort) 37 | 38 | return p 39 | } 40 | 41 | // StartWebDriverWithProxy create a selenium session. 42 | func StartWebDriverWithProxy(proxy string, port int) (*WebDriverSession, error) { 43 | driverPath := os.Getenv("CHROMEDRIVER_PATH") 44 | if driverPath == "" { 45 | driverPath = "/usr/bin/chromedriver" 46 | } 47 | 48 | if _, err := os.Stat(driverPath); os.IsNotExist(err) { 49 | logrus.Panicf("Driver %s does not exist, make sure you installed chrome driver", driverPath) 50 | } 51 | 52 | service, err := selenium.NewChromeDriverService(driverPath, port) 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | browserPath := os.Getenv("BROWSER_PATH") 59 | if browserPath == "" { 60 | browserPath = "/usr/bin/chromium-browser" 61 | } 62 | 63 | if _, err := os.Stat(browserPath); os.IsNotExist(err) { 64 | logrus.Panicf("Browser %s does not exist, set BROWSER_PATH environment variable to use your own browser or install chromium", driverPath) 65 | } 66 | 67 | chromeCaps := chrome.Capabilities{ 68 | Path: browserPath, 69 | } 70 | 71 | chromeCaps.Args = append(chromeCaps.Args, "--ignore-certificate-errors") 72 | 73 | if os.Getenv("HEADLESS") != "" { 74 | chromeCaps.Args = append(chromeCaps.Args, "--headless") 75 | chromeCaps.Args = append(chromeCaps.Args, "--no-sandbox") 76 | } 77 | 78 | if proxy != "" { 79 | chromeCaps.Args = append(chromeCaps.Args, fmt.Sprintf("--proxy-server=%s", proxy)) 80 | } 81 | 82 | caps := selenium.Capabilities{} 83 | caps.AddChrome(chromeCaps) 84 | 85 | wd, err := selenium.NewRemote(caps, fmt.Sprintf("http://localhost:%d/wd/hub", port)) 86 | if err != nil { 87 | _ = service.Stop() 88 | 89 | log.Fatal(err) 90 | } 91 | 92 | return &WebDriverSession{ 93 | service: service, 94 | WebDriver: wd, 95 | }, nil 96 | } 97 | 98 | // StartWebDriver create a selenium session. 99 | func StartWebDriver() (*WebDriverSession, error) { 100 | return StartWebDriverWithProxy("", GetWebDriverPort()) 101 | } 102 | 103 | // Stop stop the selenium session. 104 | func (wds *WebDriverSession) Stop() error { 105 | var coverage map[string]interface{} 106 | 107 | coverageDir := "../../web/.nyc_output" 108 | time := time.Now() 109 | 110 | resp, err := wds.WebDriver.ExecuteScriptRaw("return JSON.stringify(window.__coverage__)", nil) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | err = json.Unmarshal(resp, &coverage) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | coverageData := fmt.Sprintf("%s", coverage["value"]) 121 | 122 | _ = os.MkdirAll(coverageDir, 0775) 123 | 124 | err = ioutil.WriteFile(fmt.Sprintf("%s/coverage-%d.json", coverageDir, time.Unix()), []byte(coverageData), 0664) //nolint:gosec 125 | if err != nil { 126 | return err 127 | } 128 | 129 | err = wds.WebDriver.Quit() 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return wds.service.Stop() 135 | } 136 | 137 | // ExtendedWebDriver extend the standard WebDriver interface 138 | type ExtendedWebDriver struct { 139 | selenium.WebDriver 140 | } 141 | 142 | // Wait for a condition to be fulfilled. 143 | // Wait wait until condition holds true. 144 | func (ewd *ExtendedWebDriver) Wait(ctx context.Context, condition selenium.Condition) error { 145 | done := make(chan error, 1) 146 | 147 | go func() { 148 | done <- ewd.WebDriver.Wait(condition) 149 | }() 150 | 151 | select { 152 | case <-ctx.Done(): 153 | url, err := ewd.CurrentURL() 154 | if err != nil { 155 | return fmt.Errorf("waiting timeout reached") 156 | } 157 | body, err := ewd.FindElement(selenium.ByTagName, "body") 158 | if err != nil { 159 | return fmt.Errorf("waiting timeout reached: url=%s", url) 160 | } 161 | content, err := body.Text() 162 | if err != nil { 163 | return fmt.Errorf("waiting timeout reached: url=%s", url) 164 | } 165 | return fmt.Errorf("waiting timeout reached: url=%s, body=%s", url, content) 166 | case err := <-done: 167 | return err 168 | } 169 | } 170 | 171 | // WaitElementLocated wait until an element is located 172 | func (ewd *ExtendedWebDriver) WaitElementLocated(ctx context.Context, t *testing.T, by, value string) selenium.WebElement { 173 | var el selenium.WebElement 174 | 175 | err := ewd.Wait(ctx, func(driver selenium.WebDriver) (bool, error) { 176 | var err error 177 | el, err = driver.FindElement(by, value) 178 | 179 | if err != nil { 180 | if strings.Contains(err.Error(), "no such element") { 181 | return false, nil 182 | } 183 | return false, err 184 | } 185 | 186 | return el != nil, nil 187 | }) 188 | 189 | require.NoError(t, err) 190 | require.NotNil(t, el) 191 | 192 | return el 193 | } 194 | 195 | // WaitElementsLocated wait until multiple elements are located 196 | func (ewd *ExtendedWebDriver) WaitElementsLocated(ctx context.Context, t *testing.T, by, value string) []selenium.WebElement { 197 | var el []selenium.WebElement 198 | 199 | err := ewd.Wait(ctx, func(driver selenium.WebDriver) (bool, error) { 200 | var err error 201 | el, err = driver.FindElements(by, value) 202 | 203 | if err != nil { 204 | if strings.Contains(err.Error(), "no such element") { 205 | return false, nil 206 | } 207 | return false, err 208 | } 209 | 210 | return el != nil, nil 211 | }) 212 | 213 | require.NoError(t, err) 214 | require.NotNil(t, el) 215 | 216 | return el 217 | } 218 | 219 | // WaitElementLocatedByID wait an element is located by id. 220 | func (ewd *ExtendedWebDriver) WaitElementLocatedByID(ctx context.Context, t *testing.T, id string) selenium.WebElement { 221 | return ewd.WaitElementLocated(ctx, t, selenium.ByID, id) 222 | } 223 | 224 | // WaitElementLocatedByTagName wait an element is located by tag name. 225 | func (ewd *ExtendedWebDriver) WaitElementLocatedByTagName(ctx context.Context, t *testing.T, tagName string) selenium.WebElement { 226 | return ewd.WaitElementLocated(ctx, t, selenium.ByTagName, tagName) 227 | } 228 | 229 | // WaitElementsLocatedByTagName wait elements is located by tag name. 230 | func (ewd *ExtendedWebDriver) WaitElementsLocatedByTagName(ctx context.Context, t *testing.T, tagName string) []selenium.WebElement { 231 | return ewd.WaitElementsLocated(ctx, t, selenium.ByTagName, tagName) 232 | } 233 | 234 | // WithWebdriver run some actions against a webdriver. 235 | func WithWebdriver(fn func(webdriver ExtendedWebDriver) error) error { 236 | wds, err := StartWebDriver() 237 | 238 | if err != nil { 239 | return err 240 | } 241 | 242 | defer wds.Stop() //nolint:errcheck // TODO: Legacy code, consider refactoring time permitting. 243 | 244 | ewd := ExtendedWebDriver{ 245 | WebDriver: wds.WebDriver, 246 | } 247 | 248 | return fn(ewd) 249 | } 250 | --------------------------------------------------------------------------------