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