├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── Dockerfile.release ├── LICENSE.txt ├── README.md ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go ├── server ├── anonymous.go ├── config.go ├── proxy.go ├── proxy_websocket.go ├── request_tracker_cookie.go └── server.go └── test └── grafana-public-dashboards ├── README.md ├── dashboards └── public.json ├── docker-compose.yml └── provisioning └── dashboards └── files.yml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | #github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 3 | custom: 4 | - https://www.buymeacoffee.com/itzg 5 | - https://paypal.me/itzg 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | groups: 9 | updates: 10 | patterns: 11 | - "*" 12 | update-types: 13 | - patch 14 | - minor 15 | - package-ecosystem: "gomod" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | groups: 20 | patches: 21 | patterns: 22 | - "*" 23 | update-types: 24 | - patch 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+-*" 8 | 9 | jobs: 10 | release: 11 | uses: itzg/github-workflows/.github/workflows/go-with-releaser-image.yml@main 12 | with: 13 | go-version: "1.23.6" 14 | secrets: 15 | image-registry-username: ${{ secrets.DOCKERHUB_USERNAME }} 16 | image-registry-password: ${{ secrets.DOCKERHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | uses: itzg/github-workflows/.github/workflows/go-test.yml@main 14 | with: 15 | go-version: "1.23.6" 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*metadata.xml 2 | *.cert 3 | *.key 4 | 5 | /dist/ 6 | /saml-auth-proxy 7 | /saml-auth-proxy.exe 8 | /*.iml 9 | /.idea -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: saml-auth-proxy 2 | gomod: 3 | proxy: true 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - windows 10 | - darwin 11 | goarch: 12 | - amd64 13 | - arm 14 | archives: 15 | - format: binary 16 | dockers: 17 | - image_templates: 18 | - "itzg/{{.ProjectName}}:latest" 19 | - "itzg/{{.ProjectName}}:{{ .Tag }}" 20 | dockerfile: Dockerfile.release 21 | use: buildx 22 | checksum: 23 | name_template: 'checksums.txt' 24 | snapshot: 25 | name_template: "snapshot-{{ .ShortCommit }}" 26 | changelog: 27 | sort: asc 28 | filters: 29 | exclude: 30 | - '^docs:' 31 | - '^misc:' 32 | - '^ci:' 33 | - '^test:' 34 | release: 35 | prerelease: auto 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine AS builder 2 | 3 | WORKDIR /app 4 | COPY . /app 5 | RUN go build 6 | 7 | 8 | FROM alpine 9 | 10 | RUN apk add --no-cache -U \ 11 | ca-certificates 12 | 13 | COPY --from=builder /app/saml-auth-proxy /usr/bin 14 | ENTRYPOINT ["/usr/bin/saml-auth-proxy"] -------------------------------------------------------------------------------- /Dockerfile.release: -------------------------------------------------------------------------------- 1 | FROM alpine:3.9 2 | 3 | RUN apk add --no-cache -U \ 4 | ca-certificates 5 | 6 | COPY saml-auth-proxy /usr/bin 7 | ENTRYPOINT ["/usr/bin/saml-auth-proxy"] -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Geoff Bourne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Test](https://github.com/itzg/saml-auth-proxy/actions/workflows/test.yml/badge.svg)](https://github.com/itzg/saml-auth-proxy/actions/workflows/test.yml) 2 | [![](https://img.shields.io/github/release/itzg/saml-auth-proxy.svg?style=flat)](https://github.com/itzg/saml-auth-proxy/releases/latest) 3 | [![](https://img.shields.io/docker/pulls/itzg/saml-auth-proxy.svg?style=flat)](https://hub.docker.com/r/itzg/saml-auth-proxy) 4 | 5 | Provides a SAML SP authentication proxy for backend web services 6 | 7 | ## Usage 8 | 9 | ```text 10 | -allow-idp-initiated 11 | If set, allows for IdP initiated authentication flow (env SAML_PROXY_ALLOW_IDP_INITIATED) 12 | -attribute-header-mappings attribute=header 13 | Comma separated list of attribute=header pairs mapping SAML IdP response attributes to forwarded request header (env SAML_PROXY_ATTRIBUTE_HEADER_MAPPINGS) 14 | -attribute-header-wildcard string 15 | Maps all SAML attributes with this option as a prefix, slashes in attribute names will be replaced by dashes (env SAML_PROXY_ATTRIBUTE_HEADER_WILDCARD) 16 | -auth-verify bool 17 | Enables verify path endpoint for forward auth and trusts X-Forwarded headers (env SAML_PROXY_AUTH_VERIFY) 18 | -auth-verify-path string 19 | Path under BaseUrl that will respond with a 200 when authenticated (env SAML_PROXY_AUTH_VERIFY_PATH) (default "/_verify") 20 | -authorize-attribute attribute 21 | Enables authorization and specifies the attribute to check for authorized values (env SAML_PROXY_AUTHORIZE_ATTRIBUTE) 22 | -authorize-values values 23 | If enabled, comma separated list of values that must be present in the authorize attribute (env SAML_PROXY_AUTHORIZE_VALUES) 24 | -backend-url URL 25 | URL of the backend being proxied (env SAML_PROXY_BACKEND_URL) 26 | -base-url URL 27 | External URL of this proxy (env SAML_PROXY_BASE_URL) 28 | -bind host:port 29 | host:port to bind for serving HTTP (env SAML_PROXY_BIND) (default ":8080") 30 | -cookie-domain string 31 | Overrides the domain set on the session cookie. By default the BaseUrl host is used. (env SAML_PROXY_COOKIE_DOMAIN) 32 | -cookie-max-age duration 33 | Specifies the amount of time the authentication token will remain valid (env SAML_PROXY_COOKIE_MAX_AGE) (default 2h0m0s) 34 | -cookie-name string 35 | Name of the cookie that tracks session token (env SAML_PROXY_COOKIE_NAME) (default "token") 36 | -entity-id string 37 | Entity ID of this service provider (env SAML_PROXY_ENTITY_ID) 38 | -idp-ca-path path 39 | Optional path to a CA certificate PEM file for the IdP (env SAML_PROXY_IDP_CA_PATH) 40 | -idp-metadata-url URL 41 | URL of the IdP's metadata XML, can be a local file by specifying the file:// scheme (env SAML_PROXY_IDP_METADATA_URL) 42 | -initiate-session-path path 43 | If set, initiates a SAML authentication flow only when a user visits this path. This will allow anonymous users to access to the backend. (env SAML_PROXY_INITIATE_SESSION_PATH) 44 | -name-id-format string 45 | One of unspecified, transient, email, or persistent to use a standard format or give a full URN of the name ID format (env SAML_PROXY_NAME_ID_FORMAT) (default "transient") 46 | -idp-metadata-url URL 47 | URL of the IdP's metadata XML, can be a local file by specifying the file:// scheme (env SAML_PROXY_IDP_METADATA_URL) 48 | -name-id-format string 49 | One of unspecified, transient, email, or persistent to use a standard format or give a full URN of the name ID format (env SAML_PROXY_NAME_ID_FORMAT) (default "transient") 50 | -name-id-mapping header 51 | Name of the request header to convey the SAML nameID/subject (env SAML_PROXY_NAME_ID_MAPPING) 52 | -new-auth-webhook-url URL 53 | URL of webhook that will get POST'ed when a new authentication is processed (env SAML_PROXY_NEW_AUTH_WEBHOOK_URL) 54 | -sign-requests 55 | If set, enables SAML request signing (env SAML_PROXY_SIGN_REQUESTS) 56 | -sp-cert-path path 57 | The path to the X509 public certificate PEM file for this SP (env SAML_PROXY_SP_CERT_PATH) (default "saml-auth-proxy.cert") 58 | -sp-key-path path 59 | The path to the X509 private key PEM file for this SP (env SAML_PROXY_SP_KEY_PATH) (default "saml-auth-proxy.key") 60 | -static-relay-state string 61 | A fixed RelayState value, such as a short URL. Will be trimmed to 80 characters to conform with SAML. The default generates random bytes that are Base64 62 | encoded. (env SAML_PROXY_STATIC_RELAY_STATE) 63 | -version 64 | show version and exit 65 | ``` 66 | 67 | The snake-case values, such as `SAML_PROXY_BACKEND_URL`, are the equivalent environment variables that can be set instead of passing configuration via the command-line. 68 | 69 | The command-line argument usage renders with only a single leading dash, but GNU-style double-dashes can be used also, such as `--sp-key-path`. 70 | 71 | ## Authorization 72 | 73 | The proxy has support for not only authenticating users via a SAML IdP, but can also further authorize access by evaluating the attributes included in the SAML response assertion. 74 | 75 | The authorization is configured with the combination of `--authorize-attribute` and `--authorize-values`. 76 | 77 | **NOTE** the attribute is case sensitive, so be sure to specify that parameter exactly as it appears in the `Name` attribute of the `` element. 78 | 79 | The values are a comma separated list of authorized values and since the assertion attributes can contain more than one value also, the authorization performs an "intersection" matching any one of the expected values with any one of the assertion attribute values. That allows for matching user IDs where the assertion has a single value but you want to allow one or more users to be authorized. It also allows for matching group names where each user may be belong to more than one group and you may want to also authorize any number of groups. 80 | 81 | The proxy also has [support for Traefik forward auth](https://doc.traefik.io/traefik/middlewares/http/forwardauth) and [the caddy variant](https://caddyserver.com/docs/caddyfile/directives/forward_auth). The `--auth-verify` and `--auth-verify-path` parameters can be used to enable a verify endpoint that will respond with a 204 when the user is authenticated. 82 | 83 | **WARNING** the `--auth-verify` option trusts the `X-Forwarded-*` headers and should only be used when the proxy is behind a gateway; one that clears and sets those headers. 84 | 85 | ## Note for AJAX/Fetch Operations 86 | 87 | If the web application being protected behind this proxy makes AJAX/Fetch calls, then be sure 88 | to enable "same-origin" access for the credentials of those calls, 89 | as described [here](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials). 90 | 91 | With that configuration in place, the AJAX/Fetch calls will leverage the same `token` cookie 92 | provided in response to the first authenticated page retrieval via the proxy. 93 | 94 | When the user is authorized, the proxied request header `X-Authorized-Using` will be populated with the `attribute=value` that was matched, such as 95 | 96 | ``` 97 | X-Authorized-Using: UserID=user1 98 | ``` 99 | 100 | ## Health Endpoint 101 | 102 | The proxy itself provides a health endpoint at `/_health` that can be used to confirm the proxy is healthy/ready independent of the SAML processing. It returns a status code of 200 and a `text/plain` body with "OK". 103 | 104 | ## Building 105 | 106 | With Go 1.11 or newer: 107 | 108 | ``` 109 | go build 110 | ``` 111 | 112 | ## Trying it out 113 | 114 | The following procedure will enable you to try out the proxy running locally and using 115 | Grafana as a backend to proxy with authentication. It will use [SSOCircle](https://www.ssocircle.com) 116 | as a SAML IdP. 117 | 118 | Start the supplied Grafana and Web Debug Server using Docker Compose: 119 | 120 | ```bash 121 | docker compose up -d 122 | ``` 123 | 124 | Create a domain name that resolves to 127.0.0.1 and use that as the `BASE_FQDN` in the following 125 | operations; 126 | 127 | Generate the SP certificate and key material by running: 128 | 129 | ```bash 130 | # IMPORTANT: set this 131 | BASE_FQDN=... 132 | openssl req -x509 -newkey rsa:2048 -keyout saml-auth-proxy.key -out saml-auth-proxy.cert -days 365 -nodes -subj "/CN=${BASE_FQDN}" 133 | ``` 134 | 135 | Start saml-auth-proxy using: 136 | 137 | ```bash 138 | ./saml-auth-proxy \ 139 | --base-url http://${BASE_FQDN}:8080 \ 140 | --backend-url http://localhost:3000 \ 141 | --idp-metadata-url=https://idp.ssocircle.com/meta-idp.xml \ 142 | --attribute-header-mappings UserID=x-webauth-user 143 | ``` 144 | 145 | Generate your SP's SAML metadata by accessing the built-in metadata endpoint: 146 | 147 | ```bash 148 | curl http://localhost:8080/saml/metadata > saml-sp-metadata.xml 149 | ``` 150 | 151 | or with PowerShell 152 | ```ps 153 | Invoke-RestMethod -Uri http://localhost:8080/saml/metadata -OutFile .\saml-sp-metadata.xml 154 | ``` 155 | 156 | You can upload the file `saml-sp-metadata.xml` file at 157 | [SSOCircle's Manage SP Meta Data](https://idp.ssocircle.com/sso/hos/ManageSPMetadata.jsp). 158 | 159 | **Note** you will also be selecting the attributes that will be included in the assertion in the SAML authentication response, such as: 160 | - `FirstName` 161 | - `LastName` 162 | - `EmailAddress` 163 | - `UserID` 164 | 165 | To try out authorization you would add the following arguments referencing something like `UserID` and one or more expected SAMLTest user's values: 166 | 167 | ``` 168 | --authorize-attribute UserID \ 169 | --authorize-values user1,user2 170 | ``` 171 | 172 | Now you can open your browser and navigate to `http://${BASE_FQDN}:8080`. You will be redirected via SAMLTest's login page and then be returned with access to Grafana. 173 | 174 | Force a logout from the IdP by going to 175 | 176 | ## Troubleshooting 177 | 178 | ### ERROR: failed to decrypt response 179 | 180 | If the SAML redirect results in a "Forbidden" white-page and the saml-auth-proxy outputs a log like the following, then be sure to double check that the subject/CN of the generated certificate matches the FQDN of the deployed endpoint. 181 | 182 | ``` 183 | ERROR: failed to decrypt response: crypto/rsa: decryption error 184 | ``` 185 | 186 | After correcting the certificate and key, be sure to regenerate the metadata and provide that to the ADFS/SAML IdP owner. 187 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | grafana: 5 | image: grafana/grafana 6 | ports: 7 | - "3000:3000" 8 | environment: 9 | GF_SECURITY_ADMIN_PASSWORD: notsecret 10 | GF_AUTH_PROXY_ENABLED: "true" 11 | GF_AUTH_PROXY_HEADER_NAME: X-WEBAUTH-USER 12 | volumes: 13 | - grafana:/var/lib/grafana 14 | web-debug-server: 15 | image: itzg/web-debug-server:1.2.3 16 | ports: 17 | - "8081:8080" 18 | 19 | volumes: 20 | grafana: {} -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itzg/saml-auth-proxy 2 | 3 | require ( 4 | github.com/crewjam/saml v0.4.14 5 | github.com/itzg/go-flagsfiller v1.15.0 6 | github.com/itzg/zapconfigs v0.1.0 7 | github.com/patrickmn/go-cache v2.1.0+incompatible 8 | go.uber.org/zap v1.27.0 9 | golang.org/x/sync v0.11.0 10 | ) 11 | 12 | require ( 13 | github.com/beevik/etree v1.4.1 // indirect 14 | github.com/crewjam/httperr v0.2.0 // indirect 15 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 16 | github.com/gorilla/websocket v1.5.3 // indirect 17 | github.com/iancoleman/strcase v0.3.0 // indirect 18 | github.com/jonboulle/clockwork v0.4.0 // indirect 19 | github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect 20 | github.com/pkg/errors v0.9.1 // indirect 21 | github.com/russellhaering/goxmldsig v1.4.0 // indirect 22 | go.uber.org/multierr v1.11.0 // indirect 23 | golang.org/x/crypto v0.31.0 // indirect 24 | ) 25 | 26 | go 1.22 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= 3 | github.com/beevik/etree v1.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI= 4 | github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= 7 | github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= 8 | github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= 9 | github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 14 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 15 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 16 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 17 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 18 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 19 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 20 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 21 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 22 | github.com/itzg/go-flagsfiller v1.15.0 h1:xspqfbiifTo1qnCpExtfkMN5fSfueB0nMsOsazcTETw= 23 | github.com/itzg/go-flagsfiller v1.15.0/go.mod h1:nR3jrF1gVJ7ZUfSews6/oPbXjBTG3ziIHfLaXstmxjE= 24 | github.com/itzg/zapconfigs v0.1.0 h1:Gokocm8VaTNnZjvIiVA5NEhzZ1v7lEyXY/AbeBmq6YQ= 25 | github.com/itzg/zapconfigs v0.1.0/go.mod h1:y4dArgRUOFbGRkUNJ8XSSw98FGn03wtkvMPy+OSA5Rc= 26 | github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 27 | github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= 28 | github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= 29 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 30 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 31 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 32 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 33 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 34 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 35 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 | github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= 37 | github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= 38 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 39 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 40 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 41 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 42 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 43 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 44 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 47 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 48 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 49 | github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= 50 | github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= 51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 52 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 53 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 54 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 55 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 56 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 57 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 58 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 59 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 60 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 61 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 62 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 63 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 64 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 65 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 66 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 67 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 68 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 69 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 70 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 71 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 72 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 73 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 74 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 75 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 76 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 77 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 78 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 79 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 80 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 82 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 83 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 84 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 85 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 86 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 87 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 89 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 90 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 91 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 92 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 94 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 95 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 96 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 97 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 98 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 99 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "syscall" 13 | 14 | "github.com/itzg/go-flagsfiller" 15 | "github.com/itzg/saml-auth-proxy/server" 16 | "github.com/itzg/zapconfigs" 17 | "go.uber.org/zap" 18 | "golang.org/x/sync/errgroup" 19 | ) 20 | 21 | var ( 22 | version = "dev" 23 | commit = "HEAD" 24 | ) 25 | 26 | func main() { 27 | var serverConfig server.Config 28 | 29 | filler := flagsfiller.New(flagsfiller.WithEnv("SamlProxy")) 30 | err := filler.Fill(flag.CommandLine, &serverConfig) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | flag.Parse() 36 | 37 | if serverConfig.Version { 38 | fmt.Printf("%s %s (%s)\n", os.Args[0], version, commit) 39 | os.Exit(0) 40 | } 41 | 42 | var logger *zap.Logger 43 | if serverConfig.Debug { 44 | logger = zapconfigs.NewDebugLogger() 45 | } else { 46 | logger = zapconfigs.NewDefaultLogger() 47 | } 48 | defer logger.Sync() 49 | 50 | checkRequired(serverConfig.BaseUrl, "base-url") 51 | checkRequired(serverConfig.BackendUrl, "backend-url") 52 | checkRequired(serverConfig.IdpMetadataUrl, "idp-metadata-url") 53 | 54 | ctx, cancel := context.WithCancel(context.Background()) 55 | 56 | go func() { 57 | c := make(chan os.Signal, 1) // we need to reserve to buffer size 1, so the notifier are not blocked 58 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 59 | 60 | <-c 61 | cancel() 62 | }() 63 | 64 | var bindType, bind = httpBinding(serverConfig.Bind) 65 | 66 | listener, err := net.Listen(bindType, bind) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | 71 | g, gCtx := errgroup.WithContext(ctx) 72 | g.Go(func() error { 73 | return server.Start(ctx, listener, logger, &serverConfig) 74 | }) 75 | 76 | g.Go(func() error { 77 | <-gCtx.Done() 78 | return listener.Close() 79 | }) 80 | 81 | if err := g.Wait(); err != nil { 82 | fmt.Printf("exit reason: %s \n", err) 83 | } 84 | } 85 | 86 | func checkRequired(value string, name string) { 87 | if value == "" { 88 | _, _ = fmt.Fprintf(os.Stderr, "%s is required\n", name) 89 | flag.Usage() 90 | os.Exit(2) 91 | } 92 | } 93 | 94 | func httpBinding(bind string) (string, string) { 95 | 96 | if strings.HasPrefix(bind, "unix:") { 97 | return "unix", strings.TrimLeft(bind, "unix:") 98 | } else { 99 | return "tcp", bind 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /server/anonymous.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "github.com/crewjam/saml" 6 | "github.com/crewjam/saml/samlsp" 7 | "go.uber.org/zap" 8 | "net/http" 9 | ) 10 | 11 | type AnonymousSession struct { 12 | } 13 | 14 | func IsAnonymousSession(session samlsp.Session) bool { 15 | _, isAnonymous := session.(AnonymousSession) 16 | return isAnonymous 17 | } 18 | 19 | // InitAnonymousSessionProvider will initially provide AnonymousSession instances when requested; however, 20 | // once the given initiateSessionPath is intercepted, then remaining session access is delegated to the 21 | // given delegateSessionProvider. 22 | type InitAnonymousSessionProvider struct { 23 | delegateSessionProvider samlsp.SessionProvider 24 | initiateSessionPath string 25 | logger *zap.Logger 26 | } 27 | 28 | func NewInitAnonymousSessionProvider(logger *zap.Logger, initiateSessionPath string, delegateSessionProvider samlsp.SessionProvider) *InitAnonymousSessionProvider { 29 | return &InitAnonymousSessionProvider{ 30 | delegateSessionProvider: delegateSessionProvider, 31 | initiateSessionPath: initiateSessionPath, 32 | logger: logger.With(zap.String("scope", "InitAnonymousSessionProvider")), 33 | } 34 | } 35 | 36 | func (p *InitAnonymousSessionProvider) CreateSession(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) error { 37 | return p.delegateSessionProvider.CreateSession(w, r, assertion) 38 | } 39 | 40 | func (p *InitAnonymousSessionProvider) DeleteSession(w http.ResponseWriter, r *http.Request) error { 41 | return p.delegateSessionProvider.DeleteSession(w, r) 42 | } 43 | 44 | func (p *InitAnonymousSessionProvider) GetSession(r *http.Request) (samlsp.Session, error) { 45 | session, err := p.delegateSessionProvider.GetSession(r) 46 | if err != nil { 47 | if errors.Is(err, samlsp.ErrNoSession) { 48 | if r.URL.Path == p.initiateSessionPath { 49 | p.logger.Debug("Intercepted initiate session path", zap.String("path", r.URL.Path)) 50 | return nil, samlsp.ErrNoSession 51 | } 52 | p.logger.Debug("Auth has not been initiated, returning anonymous session", zap.String("path", r.URL.Path)) 53 | return AnonymousSession{}, nil 54 | } else { 55 | return nil, err 56 | } 57 | } else { 58 | return session, nil 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "time" 4 | 5 | type Config struct { 6 | Version bool `usage:"show version and exit" env:""` 7 | Bind string `default:":8080" usage:"[host:port] to bind for serving HTTP"` 8 | BaseUrl string `usage:"External [URL] of this proxy"` 9 | BackendUrl string `usage:"[URL] of the backend being proxied"` 10 | EntityID string `usage:"Entity ID of this service provider"` 11 | IdpMetadataUrl string `usage:"[URL] of the IdP's metadata XML, can be a local file by specifying the file:// scheme"` 12 | IdpCaPath string `usage:"Optional [path] to a CA certificate PEM file for the IdP"` 13 | NameIdFormat string `usage:"One of unspecified, transient, email, or persistent to use a standard format or give a full URN of the name ID format" default:"transient"` 14 | SpKeyPath string `default:"saml-auth-proxy.key" usage:"The [path] to the X509 private key PEM file for this SP"` 15 | SpCertPath string `default:"saml-auth-proxy.cert" usage:"The [path] to the X509 public certificate PEM file for this SP"` 16 | NameIdMapping string `usage:"Name of the request [header] to convey the SAML nameID/subject"` 17 | AttributeHeaderMappings map[string]string `usage:"Comma separated list of [attribute=header] pairs mapping SAML IdP response attributes to forwarded request header"` 18 | AttributeHeaderWildcard string `usage:"Maps all SAML attributes with this option as a prefix, slashes in attribute names will be replaced by dashes"` 19 | NewAuthWebhookUrl string `usage:"[URL] of webhook that will get POST'ed when a new authentication is processed"` 20 | AuthorizeAttribute string `usage:"Enables authorization and specifies the [attribute] to check for authorized values"` 21 | AuthorizeValues []string `usage:"If enabled, comma separated list of [values] that must be present in the authorize attribute"` 22 | CookieName string `usage:"Name of the cookie that tracks session token" default:"token"` 23 | CookieMaxAge time.Duration `usage:"Specifies the amount of time the authentication token will remain valid" default:"2h"` 24 | CookieDomain string `usage:"Overrides the domain set on the session cookie. By default the BaseUrl host is used."` 25 | AllowIdpInitiated bool `usage:"If set, allows for IdP initiated authentication flow"` 26 | AuthVerify bool `usage:"Enables verify path endpoint for forward auth and trusts X-Forwarded headers"` 27 | AuthVerifyPath string `default:"/_verify" usage:"Path under BaseUrl that will respond with a 200 when authenticated"` 28 | Debug bool `usage:"Enable debug logs"` 29 | StaticRelayState string `usage:"A fixed RelayState value, such as a short URL. Will be trimmed to 80 characters to conform with SAML. The default generates random bytes that are Base64 encoded."` 30 | InitiateSessionPath string `usage:"If set, initiates a SAML authentication flow only when a user visits this path. This will allow anonymous users to access to the backend."` 31 | SignRequests bool `usage:"If set, enables SAML request signing"` 32 | } 33 | -------------------------------------------------------------------------------- /server/proxy.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "github.com/gorilla/websocket" 15 | "go.uber.org/zap" 16 | 17 | "github.com/crewjam/saml/samlsp" 18 | "github.com/patrickmn/go-cache" 19 | ) 20 | 21 | const ( 22 | newTokenCacheExpiration = 5 * time.Second 23 | newTokenCacheCleanupInterval = 1 * time.Minute 24 | ) 25 | 26 | const ( 27 | HeaderAuthorizedUsing = "X-Authorized-Using" 28 | HeaderForwardedProto = "X-Forwarded-Proto" 29 | HeaderForwardedFor = "X-Forwarded-For" 30 | HeaderForwardedHost = "X-Forwarded-Host" 31 | HeaderForwardedURI = "X-Forwarded-Uri" 32 | ) 33 | 34 | type Proxy struct { 35 | config *Config 36 | backendUrl *url.URL 37 | client *http.Client 38 | newTokenCache *cache.Cache 39 | logger *zap.Logger 40 | upgrader websocket.Upgrader 41 | } 42 | 43 | func NewProxy(logger *zap.Logger, cfg *Config) (*Proxy, error) { 44 | backendUrl, err := url.Parse(cfg.BackendUrl) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to parse backend URL: %w", err) 47 | } 48 | 49 | client := &http.Client{ 50 | // don't follow redirects 51 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 52 | return http.ErrUseLastResponse 53 | }, 54 | } 55 | 56 | proxy := &Proxy{ 57 | config: cfg, 58 | client: client, 59 | backendUrl: backendUrl, 60 | newTokenCache: cache.New(newTokenCacheExpiration, newTokenCacheCleanupInterval), 61 | logger: logger, 62 | upgrader: websocket.Upgrader{ 63 | ReadBufferSize: 1024, 64 | WriteBufferSize: 1024, 65 | CheckOrigin: func(r *http.Request) bool { 66 | return true 67 | }, 68 | }, 69 | } 70 | 71 | return proxy, nil 72 | } 73 | 74 | func (p *Proxy) health(respOutWriter http.ResponseWriter, _ *http.Request) { 75 | respOutWriter.Header().Set("Content-Type", "text/plain") 76 | respOutWriter.WriteHeader(200) 77 | _, err := respOutWriter.Write([]byte("OK")) 78 | if err != nil { 79 | p.logger. 80 | With(zap.Error(err)). 81 | Error("failed to write health response body") 82 | } 83 | } 84 | 85 | func (p *Proxy) handler(respOutWriter http.ResponseWriter, reqIn *http.Request) { 86 | // Check if this is a WebSocket upgrade request 87 | if websocket.IsWebSocketUpgrade(reqIn) { 88 | p.handleWebSocket(respOutWriter, reqIn) 89 | return 90 | } 91 | 92 | session := samlsp.SessionFromContext(reqIn.Context()) 93 | 94 | var reqOut *http.Request 95 | if IsAnonymousSession(session) { 96 | reqOut = p.setupRequest(respOutWriter, reqIn) 97 | if reqOut == nil { 98 | return 99 | } 100 | 101 | } else { 102 | sessionClaims, ok := session.(samlsp.JWTSessionClaims) 103 | if !ok { 104 | p.logger.Error("session is not expected type") 105 | respOutWriter.WriteHeader(http.StatusInternalServerError) 106 | return 107 | } 108 | 109 | authUsing, authorized := p.authorized(&sessionClaims) 110 | if !authorized { 111 | p.logger.Debug("Responding Unauthorized") 112 | respOutWriter.WriteHeader(http.StatusUnauthorized) 113 | return 114 | } 115 | 116 | reqOut = p.setupRequest(respOutWriter, reqIn) 117 | if reqOut == nil { 118 | return 119 | } 120 | 121 | p.checkForNewAuth(&sessionClaims) 122 | 123 | p.addHeaders(sessionClaims, reqOut.Header) 124 | 125 | if p.config.NameIdMapping != "" { 126 | reqOut.Header.Set(p.config.NameIdMapping, 127 | sessionClaims.Subject) 128 | } 129 | 130 | if authUsing != "" { 131 | reqOut.Header.Set(HeaderAuthorizedUsing, authUsing) 132 | } 133 | } 134 | 135 | reqOut.Header.Set(HeaderForwardedHost, reqIn.Host) 136 | remoteHost, _, err := net.SplitHostPort(reqIn.RemoteAddr) 137 | if err == nil { 138 | reqOut.Header.Add(HeaderForwardedFor, remoteHost) 139 | } else { 140 | p.logger. 141 | With(zap.Error(err)). 142 | With(zap.String("remoteAddr", reqIn.RemoteAddr)). 143 | Error("unable to parse host and port") 144 | } 145 | protoParts := strings.Split(reqIn.Proto, "/") 146 | reqOut.Header.Set(HeaderForwardedProto, strings.ToLower(protoParts[0])) 147 | 148 | respIn, err := p.client.Do(reqOut) 149 | if err != nil { 150 | respOutWriter.WriteHeader(http.StatusBadGateway) 151 | _, _ = respOutWriter.Write([]byte(err.Error())) 152 | return 153 | } 154 | defer respIn.Body.Close() 155 | copyHeaders(respOutWriter.Header(), respIn.Header) 156 | respOutWriter.WriteHeader(respIn.StatusCode) 157 | _, err = io.Copy(respOutWriter, respIn.Body) 158 | if err != nil { 159 | p.logger. 160 | With(zap.Error(err)). 161 | Error("failed to transfer backend response body") 162 | } 163 | } 164 | 165 | func (p *Proxy) setupRequest(respOutWriter http.ResponseWriter, reqIn *http.Request) *http.Request { 166 | resolved, err := p.backendUrl.Parse(reqIn.URL.Path) 167 | if err != nil { 168 | p.logger. 169 | With(zap.String("urlPath", reqIn.URL.Path)). 170 | With(zap.Error(err)). 171 | Error("failed to resolve backend URL") 172 | 173 | respOutWriter.WriteHeader(500) 174 | _, _ = respOutWriter.Write([]byte(fmt.Sprintf("Failed to resolve backend URL: %s", err.Error()))) 175 | return nil 176 | } 177 | resolved.RawQuery = reqIn.URL.RawQuery 178 | 179 | reqOut, err := http.NewRequest(reqIn.Method, resolved.String(), reqIn.Body) 180 | if err != nil { 181 | p.logger. 182 | With(zap.String("method", reqIn.Method)). 183 | With(zap.Any("url", reqIn.URL)). 184 | With(zap.Error(err)). 185 | Error("unable to create new request") 186 | respOutWriter.WriteHeader(http.StatusInternalServerError) 187 | return nil 188 | } 189 | 190 | copyHeaders(reqOut.Header, reqIn.Header) 191 | 192 | reqOut.Header.Del("Cookie") 193 | cookies := reqIn.Cookies() 194 | for _, cookie := range cookies { 195 | if cookie.Name != p.config.CookieName { 196 | reqOut.AddCookie(cookie) 197 | } 198 | } 199 | return reqOut 200 | } 201 | 202 | func (p *Proxy) addHeaders(sessionClaims samlsp.JWTSessionClaims, headers http.Header) { 203 | if p.config.AttributeHeaderMappings != nil { 204 | for attr, hdr := range p.config.AttributeHeaderMappings { 205 | if values, ok := sessionClaims.GetAttributes()[attr]; ok { 206 | for _, value := range values { 207 | headers.Add(hdr, value) 208 | } 209 | } 210 | } 211 | } 212 | 213 | if p.config.AttributeHeaderWildcard != "" { 214 | for attr, values := range sessionClaims.GetAttributes() { 215 | for _, value := range values { 216 | if uri, err := url.Parse(attr); err == nil { 217 | attr = strings.Trim(strings.Replace(uri.Path, "/", "-", -1), "-") 218 | } 219 | headers.Add(p.config.AttributeHeaderWildcard+attr, value) 220 | } 221 | } 222 | } 223 | } 224 | 225 | func (p *Proxy) checkForNewAuth(sessionClaims *samlsp.JWTSessionClaims) { 226 | if p.config.NewAuthWebhookUrl != "" && sessionClaims.IssuedAt >= time.Now().Unix()-1 { 227 | err := p.newTokenCache.Add(sessionClaims.Id, sessionClaims, cache.DefaultExpiration) 228 | if err == nil { 229 | p.logger. 230 | With(zap.Any("sessionClaims", sessionClaims)). 231 | Info("Issued new authentication token") 232 | 233 | var postBody bytes.Buffer 234 | encoder := json.NewEncoder(&postBody) 235 | err := encoder.Encode(sessionClaims.GetAttributes()) 236 | if err == nil { 237 | _, err := http.Post(p.config.NewAuthWebhookUrl, "application/json", &postBody) 238 | if err != nil { 239 | p.logger. 240 | With(zap.Error(err)). 241 | Error("unable to post new auth webhook") 242 | } 243 | } else { 244 | p.logger. 245 | With(zap.Error(err)). 246 | Error("unable to encode auth token attributes") 247 | } 248 | } 249 | } 250 | } 251 | 252 | // authorized returns a boolean indication if the request is authorized. 253 | // The initial string return value is an attribute=value pair that was used to authorize the request. 254 | // If authorization was not configured the returned string is empty. 255 | func (p *Proxy) authorized(sessionClaims *samlsp.JWTSessionClaims) (string, bool) { 256 | if p.config.AuthorizeAttribute != "" { 257 | values, exists := sessionClaims.GetAttributes()[p.config.AuthorizeAttribute] 258 | if !exists { 259 | p.logger.Debug("AuthorizeAttribute not present in session claims") 260 | return "", false 261 | } 262 | 263 | for _, value := range values { 264 | for _, expected := range p.config.AuthorizeValues { 265 | if value == expected { 266 | return fmt.Sprintf("%s=%s", p.config.AuthorizeAttribute, value), true 267 | } 268 | } 269 | } 270 | 271 | p.logger. 272 | With(zap.Strings("values", values)). 273 | Debug("AuthorizeAttribute did not match required value") 274 | return "", false 275 | } else { 276 | return "", true 277 | } 278 | } 279 | 280 | func copyHeaders(dst http.Header, src http.Header) { 281 | for k, values := range src { 282 | for _, value := range values { 283 | dst.Add(k, value) 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /server/proxy_websocket.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gorilla/websocket" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // List of headers to exclude 14 | var websocketExcludeHeaders = map[string]bool{ 15 | "sec-websocket-key": true, 16 | "sec-websocket-version": true, 17 | "sec-websocket-accept": true, 18 | "sec-websocket-protocol": true, 19 | "sec-websocket-extensions": true, 20 | "upgrade": true, 21 | "connection": true, 22 | } 23 | 24 | func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { 25 | // Create backend URL for WebSocket connection 26 | backendURL := *p.backendUrl 27 | if backendURL.Scheme == "http" { 28 | backendURL.Scheme = "ws" 29 | } else if backendURL.Scheme == "https" { 30 | backendURL.Scheme = "wss" 31 | } 32 | backendURL.Path = r.URL.Path 33 | backendURL.RawQuery = r.URL.RawQuery 34 | 35 | // Try a quick probe first with a short timeout 36 | probeDialer := websocket.Dialer{ 37 | HandshakeTimeout: 2 * time.Second, 38 | } 39 | 40 | // Create clean header set for the probe 41 | probeHeader := http.Header{} 42 | for k, v := range r.Header { 43 | headerName := strings.ToLower(k) 44 | if !websocketExcludeHeaders[headerName] { 45 | probeHeader[k] = v 46 | } 47 | } 48 | 49 | // Try to connect to check if WebSocket is supported 50 | probeConn, probeResp, err := probeDialer.Dial(backendURL.String(), probeHeader) 51 | if err != nil { 52 | if probeResp != nil && probeResp.Body != nil { 53 | probeResp.Body.Close() 54 | } 55 | // Log the failure and return - this will cause the main handler to fall back to HTTP 56 | p.logger.Debug("WebSocket not supported by backend, falling back to HTTP", 57 | zap.String("path", r.URL.Path), 58 | zap.Error(err)) 59 | return 60 | } 61 | probeConn.Close() 62 | 63 | // If we get here, WebSocket is supported - proceed with the actual connection 64 | clientConn, err := p.upgrader.Upgrade(w, r, nil) 65 | if err != nil { 66 | p.logger.Error("Failed to upgrade client connection", zap.Error(err)) 67 | return 68 | } 69 | defer clientConn.Close() 70 | 71 | // Connect to the backend WebSocket server 72 | dialer := websocket.Dialer{ 73 | HandshakeTimeout: 10 * time.Second, 74 | } 75 | 76 | // Create clean header set for the backend connection 77 | requestHeader := http.Header{} 78 | for k, v := range r.Header { 79 | headerName := strings.ToLower(k) 80 | if !websocketExcludeHeaders[headerName] { 81 | requestHeader[k] = v 82 | } 83 | } 84 | 85 | // Add forwarded headers 86 | requestHeader.Set(HeaderForwardedHost, r.Host) 87 | if remoteHost, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { 88 | requestHeader.Set(HeaderForwardedFor, remoteHost) 89 | } 90 | requestHeader.Set(HeaderForwardedProto, "ws") 91 | 92 | backendConn, resp, err := dialer.Dial(backendURL.String(), requestHeader) 93 | if err != nil { 94 | p.logger.Error("Failed to connect to backend", 95 | zap.Error(err), 96 | zap.String("backend_url", backendURL.String())) 97 | return 98 | } 99 | defer backendConn.Close() 100 | if resp != nil && resp.Body != nil { 101 | defer resp.Body.Close() 102 | } 103 | 104 | // Bidirectional copy of messages 105 | errorChan := make(chan error, 2) 106 | 107 | // Copy messages from client to backend 108 | go func() { 109 | for { 110 | messageType, message, err := clientConn.ReadMessage() 111 | if err != nil { 112 | errorChan <- err 113 | return 114 | } 115 | err = backendConn.WriteMessage(messageType, message) 116 | if err != nil { 117 | errorChan <- err 118 | return 119 | } 120 | } 121 | }() 122 | 123 | // Copy messages from backend to client 124 | go func() { 125 | for { 126 | messageType, message, err := backendConn.ReadMessage() 127 | if err != nil { 128 | errorChan <- err 129 | return 130 | } 131 | err = clientConn.WriteMessage(messageType, message) 132 | if err != nil { 133 | errorChan <- err 134 | return 135 | } 136 | } 137 | }() 138 | 139 | // Wait for error or connection close 140 | err = <-errorChan 141 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 142 | p.logger.Error("WebSocket error", zap.Error(err)) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /server/request_tracker_cookie.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/crewjam/saml" 11 | "github.com/crewjam/saml/samlsp" 12 | ) 13 | 14 | // Extends samlsp.CookieRequestTracker to add CookieDomain configuration. 15 | type CookieRequestTracker struct { 16 | samlsp.CookieRequestTracker 17 | 18 | CookieDomain string 19 | StaticRelayState string 20 | TrustForwardedHeaders bool 21 | } 22 | 23 | func minOfInts(x, y int) int { 24 | if x < y { 25 | return x 26 | } else { 27 | return y 28 | } 29 | } 30 | 31 | // Source: https://github.com/crewjam/saml/blob/5e0ffd290abf0be7dfd4f8279e03a963071544eb/samlsp/request_tracker_cookie.go#L28-58 32 | // Changes: 33 | // - Adds host in request URI 34 | // - Adds CookieDomain config in http.SetCookie 35 | // - Handles X-Forwarded headers 36 | func (t CookieRequestTracker) TrackRequest(w http.ResponseWriter, r *http.Request, samlRequestID string) (string, error) { 37 | var redirectURI *url.URL 38 | if t.TrustForwardedHeaders && r.Header.Get(HeaderForwardedProto) != "" && r.Header.Get(HeaderForwardedHost) != "" && r.Header.Get(HeaderForwardedURI) != "" { 39 | // When X-Forwarded headers exist, use it 40 | redirectURI, _ = url.Parse(fmt.Sprintf("%s://%s%s", r.Header.Get(HeaderForwardedProto), r.Header.Get(HeaderForwardedHost), r.Header.Get(HeaderForwardedURI))) 41 | } else { 42 | redirectURI, _ = url.Parse(r.URL.String()) // Clone 43 | redirectURI.Host = r.Host 44 | } 45 | 46 | trackedRequest := samlsp.TrackedRequest{ 47 | Index: base64.RawURLEncoding.EncodeToString(randomBytes(42)), 48 | SAMLRequestID: samlRequestID, 49 | URI: redirectURI.String(), 50 | } 51 | 52 | if t.StaticRelayState != "" { 53 | trackedRequest.Index = t.StaticRelayState[0:minOfInts(80, len(t.StaticRelayState))] 54 | } else if t.RelayStateFunc != nil { 55 | relayState := t.RelayStateFunc(w, r) 56 | if relayState != "" { 57 | trackedRequest.Index = relayState 58 | } 59 | } 60 | 61 | signedTrackedRequest, err := t.Codec.Encode(trackedRequest) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | http.SetCookie(w, &http.Cookie{ 67 | Name: t.NamePrefix + trackedRequest.Index, 68 | Value: signedTrackedRequest, 69 | MaxAge: int(t.MaxAge.Seconds()), 70 | Domain: t.CookieDomain, 71 | HttpOnly: true, 72 | SameSite: t.SameSite, 73 | Secure: t.ServiceProvider.AcsURL.Scheme == "https", 74 | Path: t.ServiceProvider.AcsURL.Path, 75 | }) 76 | 77 | return trackedRequest.Index, nil 78 | } 79 | 80 | // Source: https://github.com/crewjam/saml/blob/5e0ffd290abf0be7dfd4f8279e03a963071544eb/samlsp/util.go#L9-L16 81 | func randomBytes(n int) []byte { 82 | rv := make([]byte, n) 83 | 84 | if _, err := io.ReadFull(saml.RandReader, rv); err != nil { 85 | panic(err) 86 | } 87 | return rv 88 | } 89 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "encoding/xml" 9 | "fmt" 10 | "log" 11 | "net" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "time" 16 | 17 | "go.uber.org/zap" 18 | 19 | "github.com/crewjam/saml" 20 | "github.com/crewjam/saml/samlsp" 21 | ) 22 | 23 | const fetchMetadataTimeout = 30 * time.Second 24 | 25 | func Start(ctx context.Context, listener net.Listener, logger *zap.Logger, cfg *Config) error { 26 | keyPair, err := tls.LoadX509KeyPair(cfg.SpCertPath, cfg.SpKeyPath) 27 | if err != nil { 28 | return fmt.Errorf("failed to load SP key and certificate: %w", err) 29 | } 30 | 31 | keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) 32 | if err != nil { 33 | return fmt.Errorf("failed to parse SP certificate: %w", err) 34 | } 35 | 36 | idpMetadataUrl, err := url.Parse(cfg.IdpMetadataUrl) 37 | if err != nil { 38 | return fmt.Errorf("failed to parse IdP metdata URL: %w", err) 39 | } 40 | 41 | rootUrl, err := url.Parse(cfg.BaseUrl) 42 | if err != nil { 43 | return fmt.Errorf("failed to parse base URL: %w", err) 44 | } 45 | 46 | httpClient, err := setupHttpClient(cfg.IdpCaPath) 47 | if err != nil { 48 | return fmt.Errorf("failed to setup HTTP client: %w", err) 49 | } 50 | 51 | samlOpts := samlsp.Options{ 52 | URL: *rootUrl, 53 | Key: keyPair.PrivateKey.(*rsa.PrivateKey), 54 | Certificate: keyPair.Leaf, 55 | AllowIDPInitiated: cfg.AllowIdpInitiated, 56 | SignRequest: cfg.SignRequests, 57 | } 58 | if cfg.EntityID != "" { 59 | samlOpts.EntityID = cfg.EntityID 60 | } 61 | 62 | samlOpts.IDPMetadata, err = fetchMetadata(ctx, httpClient, idpMetadataUrl) 63 | if err != nil { 64 | return fmt.Errorf("failed to fetch/load IdP metadata: %w", err) 65 | } 66 | 67 | middleware, err := samlsp.New(samlOpts) 68 | if err != nil { 69 | return fmt.Errorf("failed to initialize SP: %w", err) 70 | } 71 | 72 | switch cfg.NameIdFormat { 73 | case "unspecified": 74 | middleware.ServiceProvider.AuthnNameIDFormat = saml.UnspecifiedNameIDFormat 75 | case "transient": 76 | middleware.ServiceProvider.AuthnNameIDFormat = saml.TransientNameIDFormat 77 | case "email": 78 | middleware.ServiceProvider.AuthnNameIDFormat = saml.EmailAddressNameIDFormat 79 | case "persistent": 80 | middleware.ServiceProvider.AuthnNameIDFormat = saml.PersistentNameIDFormat 81 | default: 82 | middleware.ServiceProvider.AuthnNameIDFormat = saml.NameIDFormat(cfg.NameIdFormat) 83 | } 84 | 85 | var cookieDomain = cfg.CookieDomain 86 | if cookieDomain == "" { 87 | cookieDomain = rootUrl.Hostname() 88 | } 89 | middleware.RequestTracker = CookieRequestTracker{ 90 | CookieRequestTracker: samlsp.DefaultRequestTracker(samlsp.Options{ 91 | URL: *rootUrl, 92 | Key: keyPair.PrivateKey.(*rsa.PrivateKey), 93 | }, &middleware.ServiceProvider), 94 | CookieDomain: cookieDomain, 95 | StaticRelayState: cfg.StaticRelayState, 96 | TrustForwardedHeaders: cfg.AuthVerify, 97 | } 98 | cookieSessionProvider := samlsp.DefaultSessionProvider(samlOpts) 99 | cookieSessionProvider.Name = cfg.CookieName 100 | cookieSessionProvider.Domain = cookieDomain 101 | cookieSessionProvider.MaxAge = cfg.CookieMaxAge 102 | 103 | if cfg.InitiateSessionPath != "" { 104 | middleware.Session = NewInitAnonymousSessionProvider(logger, cfg.InitiateSessionPath, cookieSessionProvider) 105 | } else { 106 | middleware.Session = cookieSessionProvider 107 | } 108 | 109 | proxy, err := NewProxy(logger, cfg) 110 | if err != nil { 111 | return fmt.Errorf("failed to create proxy: %w", err) 112 | } 113 | 114 | app := http.HandlerFunc(proxy.handler) 115 | if cfg.AuthVerify { 116 | http.Handle(cfg.AuthVerifyPath, authVerify(middleware)) 117 | } 118 | 119 | http.Handle("/saml/sign_in", http.HandlerFunc(middleware.HandleStartAuthFlow)) 120 | http.Handle("/saml/", middleware) 121 | http.Handle("/_health", http.HandlerFunc(proxy.health)) 122 | http.Handle("/", middleware.RequireAccount(app)) 123 | 124 | logger. 125 | With(zap.String("baseUrl", cfg.BaseUrl)). 126 | With(zap.String("backendUrl", cfg.BackendUrl)). 127 | With(zap.String("binding", cfg.Bind)). 128 | Info("Serving requests") 129 | return http.Serve(listener, nil) 130 | } 131 | 132 | func fetchMetadata(ctx context.Context, client *http.Client, idpMetadataUrl *url.URL) (*saml.EntityDescriptor, error) { 133 | if idpMetadataUrl.Scheme == "file" { 134 | data, err := os.ReadFile(idpMetadataUrl.Path) 135 | if err != nil { 136 | return nil, fmt.Errorf("failed to read IdP metadata file.: %w", err) 137 | } 138 | idpMetadata := &saml.EntityDescriptor{} 139 | err = xml.Unmarshal(data, idpMetadata) 140 | if err != nil { 141 | return nil, fmt.Errorf("failed to unmarshal IdP metadata XML.: %w", err) 142 | } 143 | return idpMetadata, nil 144 | } else { 145 | reqCtx, _ := context.WithTimeout(ctx, fetchMetadataTimeout) 146 | return samlsp.FetchMetadata(reqCtx, client, *idpMetadataUrl) 147 | } 148 | } 149 | 150 | func setupHttpClient(idpCaFile string) (*http.Client, error) { 151 | if idpCaFile == "" { 152 | return http.DefaultClient, nil 153 | } 154 | 155 | rootCAs, _ := x509.SystemCertPool() 156 | if rootCAs == nil { 157 | rootCAs = x509.NewCertPool() 158 | } 159 | 160 | certs, err := os.ReadFile(idpCaFile) 161 | if err != nil { 162 | return nil, fmt.Errorf("failed to read IdP CA file: %w", err) 163 | } 164 | 165 | if ok := rootCAs.AppendCertsFromPEM(certs); !ok { 166 | log.Println("INF No certs appended, using system certs only") 167 | } 168 | 169 | config := &tls.Config{ 170 | RootCAs: rootCAs, 171 | } 172 | 173 | tr := &http.Transport{TLSClientConfig: config} 174 | client := &http.Client{Transport: tr} 175 | 176 | return client, nil 177 | } 178 | 179 | func authVerify(middleware *samlsp.Middleware) http.Handler { 180 | 181 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 182 | 183 | session, err := middleware.Session.GetSession(r) 184 | 185 | if session != nil { 186 | w.WriteHeader(204) 187 | return 188 | } 189 | 190 | if err == samlsp.ErrNoSession { 191 | w.WriteHeader(401) 192 | return 193 | } 194 | 195 | }) 196 | } 197 | -------------------------------------------------------------------------------- /test/grafana-public-dashboards/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Setup 3 | 4 | Create the proxy's cert and key files [like in the README](../../README.md#trying-it-out) 5 | 6 | Bring up the services setting the `BASE_URL` to the publicly resolvable URL of your service: 7 | 8 | ```shell 9 | BASE_URL=... docker compose up -d --build 10 | ``` 11 | 12 | Export and upload the IDP metadata [like in the README](../../README.md#trying-it-out) 13 | 14 | Access Grafana via the proxy at 15 | 16 | Login as Rick via `samltest.idp` since the test configures that user as admin. 17 | 18 | Go to the pre-provisioned dashboard at the path `/d/c6f2205a-a683-417f-b177-b916085d5519/public?orgId=1`, [make it public](https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/#make-a-dashboard-public), and copy the public dashboard link. 19 | 20 | Open an incognito tab (or equivalent) and confirm access to the public dashboard without login. Go to some other path like `/` and confirm that you are redirected to login via SAML auth. -------------------------------------------------------------------------------- /test/grafana-public-dashboards/dashboards/public.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "editable": true, 19 | "fiscalYearStartMonth": 0, 20 | "graphTooltip": 0, 21 | "id": 1, 22 | "links": [], 23 | "liveNow": false, 24 | "panels": [ 25 | { 26 | "datasource": { 27 | "type": "datasource", 28 | "uid": "grafana" 29 | }, 30 | "gridPos": { 31 | "h": 13, 32 | "w": 24, 33 | "x": 0, 34 | "y": 0 35 | }, 36 | "id": 1, 37 | "options": { 38 | "code": { 39 | "language": "plaintext", 40 | "showLineNumbers": false, 41 | "showMiniMap": false 42 | }, 43 | "content": "# Welcome\n\nThis is a public dashboard", 44 | "mode": "markdown" 45 | }, 46 | "pluginVersion": "10.0.3", 47 | "title": "Panel Title", 48 | "type": "text" 49 | } 50 | ], 51 | "refresh": "", 52 | "schemaVersion": 38, 53 | "style": "dark", 54 | "tags": [], 55 | "templating": { 56 | "list": [] 57 | }, 58 | "time": { 59 | "from": "now-6h", 60 | "to": "now" 61 | }, 62 | "timepicker": {}, 63 | "timezone": "", 64 | "title": "Public", 65 | "uid": "c6f2205a-a683-417f-b177-b916085d5519", 66 | "version": 2, 67 | "weekStart": "" 68 | } -------------------------------------------------------------------------------- /test/grafana-public-dashboards/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | proxy: 5 | build: 6 | context: ../.. 7 | environment: 8 | SAML_PROXY_DEBUG: true 9 | SAML_PROXY_IDP_METADATA_URL: https://samltest.id/saml/idp 10 | SAML_PROXY_BASE_URL: ${BASE_URL} 11 | # SAML_PROXY_BACKEND_URL: http://web-debug-server:8080 12 | SAML_PROXY_BACKEND_URL: http://grafana:3000 13 | SAML_PROXY_SP_KEY_PATH: /run/secrets/samlsp-key 14 | SAML_PROXY_SP_CERT_PATH: /run/secrets/samlsp-cert 15 | SAML_PROXY_ATTRIBUTE_HEADER_MAPPINGS: uid=x-webauth-user 16 | SAML_PROXY_INITIATE_SESSION_PATH: /login 17 | ports: 18 | - "8080:8080" 19 | secrets: 20 | - samlsp-key 21 | - samlsp-cert 22 | grafana: 23 | image: grafana/grafana 24 | ports: 25 | - "3000:3000" 26 | environment: 27 | GF_SERVER_ROOT_URL: ${BASE_URL} 28 | GF_SECURITY_ADMIN_USER: rick 29 | GF_AUTH_PROXY_ENABLED: "true" 30 | GF_AUTH_PROXY_HEADER_NAME: X-WEBAUTH-USER 31 | GF_FEATURE_TOGGLES_ENABLE: publicDashboards 32 | volumes: 33 | - grafana:/var/lib/grafana 34 | - ./provisioning:/etc/grafana/provisioning 35 | - ./dashboards:/var/lib/grafana/dashboards:ro 36 | web-debug-server: 37 | image: itzg/web-debug-server:1.2.3 38 | ports: 39 | - "8081:8080" 40 | 41 | volumes: 42 | grafana: {} 43 | 44 | secrets: 45 | samlsp-key: 46 | file: saml-auth-proxy.key 47 | samlsp-cert: 48 | file: saml-auth-proxy.cert -------------------------------------------------------------------------------- /test/grafana-public-dashboards/provisioning/dashboards/files.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | # an unique provider name. Required 5 | - name: 'files' 6 | # Org id. Default to 1 7 | orgId: 1 8 | # name of the dashboard folder. 9 | folder: '' 10 | # folder UID. will be automatically generated if not specified 11 | folderUid: '' 12 | # provider type. Default to 'file' 13 | type: file 14 | # disable dashboard deletion 15 | disableDeletion: false 16 | # how often Grafana will scan for changed dashboards 17 | updateIntervalSeconds: 10 18 | # allow updating provisioned dashboards from the UI 19 | allowUiUpdates: false 20 | options: 21 | # path to dashboard files on disk. Required when using the 'file' type 22 | path: /var/lib/grafana/dashboards 23 | # use folder names from filesystem to create folders in Grafana 24 | foldersFromFilesStructure: true 25 | --------------------------------------------------------------------------------