├── .dockerignore ├── .travis.yml ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── example ├── docker-compose-auth-host.yml ├── docker-compose-dev.yml ├── docker-compose.yml └── traefik.toml ├── forwardauth.go ├── forwardauth_test.go ├── hooks └── build ├── log.go ├── main.go └── main_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | example 2 | .travis.yml 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | go: 4 | - "1.10" 5 | install: 6 | - go get github.com/namsral/flag 7 | - go get github.com/sirupsen/logrus 8 | script: go test -v ./... 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.10-alpine as builder 2 | 3 | # Now we DO need these, for the auto-labeling of the image 4 | ARG BUILD_DATE 5 | ARG VCS_REF 6 | 7 | # Good docker practice, plus we get microbadger badges 8 | LABEL org.label-schema.build-date=$BUILD_DATE \ 9 | org.label-schema.vcs-url="https://github.com/funkypenguin/traefik-forward-auth.git" \ 10 | org.label-schema.vcs-ref=$VCS_REF \ 11 | org.label-schema.schema-version="2.2-r1" 12 | 13 | 14 | # Setup 15 | RUN mkdir /app 16 | WORKDIR /app 17 | 18 | # Add libraries 19 | RUN apk add --no-cache git && \ 20 | go get "github.com/namsral/flag" && \ 21 | go get "github.com/sirupsen/logrus" && \ 22 | apk del git 23 | 24 | # Copy & build 25 | ADD . /app/ 26 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /traefik-forward-auth . 27 | 28 | # Copy into scratch container 29 | FROM alpine 30 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 31 | COPY --from=builder /traefik-forward-auth ./ 32 | ENTRYPOINT ["./traefik-forward-auth"] 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2018] [Thom Seddon] 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. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | format: 3 | gofmt -w -s *.go 4 | 5 | .PHONY: format 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [cookbookurl]: https://geek-cookbook.funkypenguin.co.nz 2 | [kitchenurl]: https://discourse.kitchen.funkypenguin.co.nz 3 | [discordurl]: http://chat.funkypenguin.co.nz 4 | [patreonurl]: https://patreon.com/funkypenguin 5 | [blogurl]: https://www.funkypenguin.co.nz 6 | [hub]: https://hub.docker.com/r/funkypenguin/poor-mans-k8s-lb/ 7 | 8 | [![geek-cookbook](https://raw.githubusercontent.com/funkypenguin/www.funkypenguin.co.nz/master/images/geek-kitchen-banner.png)][cookbookurl] 9 | 10 | # Contents 11 | 12 | 1. [What is funkypenguin/poor-mans-k8s-lb?](#what-is-funkypenguin-poor-mans-k8s-lb) 13 | 2. [Why should I use this?](#why-should-i-use-this) 14 | 3. [How do I use it?](#how-do-i-use-this) 15 | 4. [CHANGELOG](#changelog) 16 | 17 | --- 18 | 19 | This container is maintained by [Funky Penguin's Geek Cookbook][cookbookurl], a collection of "recipes" to run popular applications 20 | on Docker Swarm or Kubernetes, in a cheeky, geek format. 21 | 22 | Got more details at: 23 | * ![Discourse with us!](https://img.shields.io/discourse/https/discourse.geek-kitchen.funkypenguin.co.nz/topics.svg) [Forums][kitchenurl] 24 | * ![Chat with us!](https://img.shields.io/discord/396055506072109067.svg) [Friendly Discord Chat][discordurl] 25 | * ![Geek out with us!](https://img.shields.io/badge/recipies-35+-brightgreen.svg) [Funky Penguin's Geek Cookbook][cookbookurl] 26 | * ![Thank YOU](https://img.shields.io/badge/thank-you-brightgreen.svg) [Patreon][patreonurl] 27 | * ![Read blog!](https://img.shields.io/badge/read-blog-brightgreen.svg) [Blog][blogurl] 28 | 29 | --- 30 | 31 | # What is funkypenguin/traefik-forward-auth ? 32 | 33 | A fork of https://github.com/noelcatt/traefik-forward-auth, which is in turn a fork of https://github.com/thomseddon/traefik-forward-auth. 34 | 35 | Why all the forkery? @thomseddon's version supports only Google OIDC, while @noelcatt's version supports any OIDC, but doesn't have a docker image build pipeline setup. At some point, I hope thaht @thomseddon's version will be extended to support multiple OIDCs, but until then, I'll maintain my own copy. 36 | 37 | [![Build Status](https://travis-ci.org/funkypenguin/traefik-forward-auth.svg?branch=master)](https://travis-ci.org/funkypenguin/traefik-forward-auth) [![Go Report Card](https://goreportcard.com/badge/github.com/funkypenguin/traefik-forward-auth)](https://goreportcard.com/badge/github.com/funkypenguin/traefik-forward-auth) 38 | 39 | 40 | # Why should I use this? 41 | 42 | 43 | # How do I use this? 44 | 45 | # CHANGELOG 46 | 47 | # Upstream README 48 | 49 | 50 | # Traefik Forward Auth [![Build Status](https://travis-ci.org/funkypenguin/traefik-forward-auth.svg?branch=master)](https://travis-ci.org/funkypenguin/traefik-forward-auth) [![Go Report Card](https://goreportcard.com/badge/github.com/funkypenguin/traefik-forward-auth)](https://goreportcard.com/badge/github.com/funkypenguin/traefik-forward-auth) 51 | 52 | A minimal forward authentication service that provides Google oauth based login and authentication for the traefik reverse proxy. 53 | 54 | 55 | ## Why? 56 | 57 | - Seamlessly overlays any http service with a single endpoint (see: `-url-path` in [Configuration](#configuration)) 58 | - Supports multiple domains/subdomains by dynamically generating redirect_uri's 59 | - Allows authentication to persist across multiple domains (see [Cookie Domains](#cookie-domains)) 60 | - Supports extended authentication beyond Google token lifetime (see: `-lifetime` in [Configuration](#configuration)) 61 | 62 | ## Quick Start 63 | 64 | See the (examples) directory for example docker compose and traefik configuration files that demonstrates the forward authentication configuration for traefik and passing required configuration values to traefik-forward-auth. 65 | 66 | ## Configuration 67 | 68 | The following configuration is supported: 69 | 70 | 71 | |Flag |Type |Description| 72 | |-----------------------|------|-----------| 73 | |-client-id|string|*Google Client ID (required)| 74 | |-client-secret|string|*Google Client Secret (required)| 75 | |-secret|string|*Secret used for signing (required)| 76 | |-oidcIssuer|string|*OIDC Issuer URL (required)| 77 | |-config|string|Path to config file| 78 | |-auth-host|string|Central auth login (see below)| 79 | |-cookie-domains|string|Comma separated list of cookie domains (see below)| 80 | |-cookie-name|string|Cookie Name (default "_forward_auth")| 81 | |-cookie-secure|bool|Use secure cookies (default true)| 82 | |-csrf-cookie-name|string|CSRF Cookie Name (default "_forward_auth_csrf")| 83 | |-domain|string|Comma separated list of email domains to allow| 84 | |-whitelist|string|Comma separated list of email addresses to allow| 85 | |-lifetime|int|Session length in seconds (default 43200)| 86 | |-url-path|string|Callback URL (default "_oauth")| 87 | |-prompt|string|Space separated list of [OpenID prompt options](https://developers.google.com/identity/protocols/OpenIDConnect#prompt)| 88 | |-log-level|string|Log level: trace, debug, info, warn, error, fatal, panic (default "warn")| 89 | |-log-format|string|Log format: text, json, pretty (default "text")| 90 | 91 | Configuration can also be supplied as environment variables (use upper case and swap `-`'s for `_`'s e.g. `-client-id` becomes `CLIENT_ID`) 92 | 93 | Configuration can also be supplied via a file, you can specify the location with `-config` flag, the format is `flag value` one per line, e.g. `client-id your-client-id`) 94 | 95 | ## OAuth Configuration 96 | 97 | Head to https://console.developers.google.com & make sure you've switched to the correct email account. 98 | 99 | Create a new project then search for and select "Credentials" in the search bar. Fill out the "OAuth Consent Screen" tab. 100 | 101 | Click, "Create Credentials" > "OAuth client ID". Select "Web Application", fill in the name of your app, skip "Authorized JavaScript origins" and fill "Authorized redirect URIs" with all the domains you will allow authentication from, appended with the `url-path` (e.g. https://app.test.com/_oauth) 102 | 103 | ## Usage 104 | 105 | The authenticated user is set in the `X-Forwarded-User` header, to pass this on add this to the `authResponseHeaders` as shown [here](https://github.com/thomseddon/traefik-forward-auth/blob/master/example/docker-compose-dev.yml). 106 | 107 | ## User Restriction 108 | 109 | You can restrict who can login with the following parameters: 110 | 111 | * `-domain` - Use this to limit logins to a specific domain, e.g. test.com only 112 | * `-whitelist` - Use this to only allow specific users to login e.g. thom@test.com only 113 | 114 | Note, if you pass `whitelist` then only this is checked and `domain` is effectively ignored. 115 | 116 | ## Cookie Domains 117 | 118 | You can supply a comma separated list of cookie domains, if the host of the original request is a subdomain of any given cookie domain, the authentication cookie will set with the given domain. 119 | 120 | For example, if cookie domain is `test.com` and a request comes in on `app1.test.com`, the cookie will be set for the whole `test.com` domain. As such, if another request is forwarded for authentication from `app2.test.com`, the original cookie will be sent and so the request will be allowed without further authentication. 121 | 122 | Beware however, if using cookie domains whilst running multiple instances of traefik/traefik-forward-auth for the same domain, the cookies will clash. You can fix this by using the same `cookie-secret` in both instances, or using a different `cookie-name` on each. 123 | 124 | ## Operation Modes 125 | 126 | #### Overlay 127 | 128 | Overlay is the default operation mode, in this mode the authorisation endpoint is overlayed onto any domain. By default the `/_oauth` path is used, this can be customised using the `-url-path` option. 129 | 130 | If a request comes in for `www.myapp.com/home` then the user will be redirected to the google login, following this they will be sent back to `www.myapp.com/_oauth`, where their token will be validated (this request will not be forwarded to your application). Following successful authoristion, the user will return to their originally requested url of `www.myapp.com/home`. 131 | 132 | As the hostname in the `redirect_uri` is dynamically generated based on the orignal request, every hostname must be permitted in the Google OAuth console (e.g. `www.myappp.com` would need to be added in the above example) 133 | 134 | #### Auth Host 135 | 136 | This is an optional mode of operation that is useful when dealing with a large number of subdomains, it is activated by using the `-auth-host` config option (see [this example docker-compose.yml](https://github.com/thomseddon/traefik-forward-auth/blob/master/example/docker-compose-auth-host.yml)). 137 | 138 | For example, if you have a few applications: `app1.test.com`, `app2.test.com`, `appN.test.com`, adding every domain to Google's console can become laborious. 139 | To utilise an auth host, permit domain level cookies by setting the cookie domain to `test.com` then set the `auth-host` to: `auth.test.com`. 140 | 141 | The user flow will then be: 142 | 143 | 1. Request to `app10.test.com/home/page` 144 | 2. User redirected to Google login 145 | 3. After Google login, user is redirected to `auth.test.com/_oauth` 146 | 4. Token, user and CSRF cookie is validated, auth cookie is set to `test.com` 147 | 5. User is redirected to `app10.test.com/home/page` 148 | 6. Request is allowed 149 | 150 | With this setup, only `auth.test.com` must be permitted in the Google console. 151 | 152 | Two criteria must be met for an `auth-host` to be used: 153 | 154 | 1. Request matches given `cookie-domain` 155 | 2. `auth-host` is also subdomain of same `cookie-domain` 156 | 157 | ## Copyright 158 | 159 | 2018 Thom Seddon 160 | 161 | ## License 162 | 163 | [MIT](https://github.com/thomseddon/traefik-forward-auth/blob/master/LICENSE.md) 164 | -------------------------------------------------------------------------------- /example/docker-compose-auth-host.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | traefik: 5 | image: traefik 6 | command: -c /traefik.toml --logLevel=DEBUG 7 | ports: 8 | - "8085:80" 9 | - "8086:8080" 10 | networks: 11 | - traefik 12 | volumes: 13 | - ./traefik.toml:/traefik.toml 14 | - /var/run/docker.sock:/var/run/docker.sock 15 | 16 | whoami1: 17 | image: emilevauge/whoami 18 | networks: 19 | - traefik 20 | labels: 21 | - "traefik.backend=whoami" 22 | - "traefik.enable=true" 23 | - "traefik.frontend.rule=Host:whoami.yourdomain.com" 24 | 25 | traefik-forward-auth: 26 | image: thomseddon/traefik-forward-auth 27 | environment: 28 | - CLIENT_ID=your-client-id 29 | - CLIENT_SECRET=your-client-secret 30 | - SECRET=something-random 31 | - COOKIE_SECURE=false 32 | - DOMAIN=yourcompany.com 33 | - AUTH_HOST=auth.yourdomain.com 34 | networks: 35 | - traefik 36 | # When using an auth host, adding it here prompts traefik to generate certs 37 | labels: 38 | - traefik.enable=true 39 | - traefik.port=4181 40 | - traefik.backend=traefik-forward-auth 41 | - traefik.frontend.rule=Host:auth.yourdomain.com 42 | 43 | networks: 44 | traefik: 45 | -------------------------------------------------------------------------------- /example/docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | traefik: 5 | image: traefik 6 | command: -c /traefik.toml 7 | # command: -c /traefik.toml --logLevel=DEBUG 8 | ports: 9 | - "8085:80" 10 | - "8086:8080" 11 | networks: 12 | - traefik 13 | volumes: 14 | - ./traefik.toml:/traefik.toml 15 | - /var/run/docker.sock:/var/run/docker.sock 16 | 17 | whoami1: 18 | image: emilevauge/whoami 19 | networks: 20 | - traefik 21 | labels: 22 | - "traefik.backend=whoami1" 23 | - "traefik.enable=true" 24 | - "traefik.frontend.rule=Host:whoami.localhost.com" 25 | 26 | whoami2: 27 | image: emilevauge/whoami 28 | networks: 29 | - traefik 30 | labels: 31 | - "traefik.backend=whoami2" 32 | - "traefik.enable=true" 33 | - "traefik.frontend.rule=Host:whoami.localhost.org" 34 | 35 | traefik-forward-auth: 36 | build: ../ 37 | environment: 38 | - CLIENT_ID=test 39 | - CLIENT_SECRET=test 40 | - COOKIE_SECRET=something-random 41 | - COOKIE_SECURE=false 42 | - COOKIE_DOMAINS=localhost.com 43 | - AUTH_URL=http://auth.localhost.com:8085/_oauth 44 | networks: 45 | - traefik 46 | 47 | networks: 48 | traefik: 49 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | traefik: 5 | image: traefik 6 | command: -c /traefik.toml --logLevel=DEBUG 7 | ports: 8 | - "8085:80" 9 | - "8086:8080" 10 | networks: 11 | - traefik 12 | volumes: 13 | - ./traefik.toml:/traefik.toml 14 | - /var/run/docker.sock:/var/run/docker.sock 15 | 16 | whoami1: 17 | image: emilevauge/whoami 18 | networks: 19 | - traefik 20 | labels: 21 | - "traefik.backend=whoami" 22 | - "traefik.enable=true" 23 | - "traefik.frontend.rule=Host:whoami.localhost.com" 24 | 25 | traefik-forward-auth: 26 | image: thomseddon/traefik-forward-auth 27 | environment: 28 | - CLIENT_ID=your-client-id 29 | - CLIENT_SECRET=your-client-secret 30 | - SECRET=something-random 31 | - COOKIE_SECURE=false 32 | - DOMAIN=yourcompany.com 33 | networks: 34 | - traefik 35 | 36 | networks: 37 | traefik: 38 | -------------------------------------------------------------------------------- /example/traefik.toml: -------------------------------------------------------------------------------- 1 | ################################################################ 2 | # Global configuration 3 | ################################################################ 4 | 5 | # Enable debug mode 6 | # 7 | # Optional 8 | # Default: false 9 | # 10 | # debug = true 11 | 12 | # Log level 13 | # 14 | # Optional 15 | # Default: "ERROR" 16 | # 17 | # logLevel = "DEBUG" 18 | 19 | # Entrypoints to be used by frontends that do not specify any entrypoint. 20 | # Each frontend can specify its own entrypoints. 21 | # 22 | # Optional 23 | # Default: ["http"] 24 | # 25 | # defaultEntryPoints = ["http", "https"] 26 | 27 | ################################################################ 28 | # Entrypoints configuration 29 | ################################################################ 30 | 31 | # Entrypoints definition 32 | # 33 | # Optional 34 | # Default: 35 | [entryPoints] 36 | [entryPoints.http] 37 | address = ":80" 38 | 39 | [entryPoints.http.auth.forward] 40 | address = "http://traefik-forward-auth:4181" 41 | authResponseHeaders = ["X-Forwarded-User"] 42 | 43 | ################################################################ 44 | # Traefik logs configuration 45 | ################################################################ 46 | 47 | # Traefik logs 48 | # Enabled by default and log to stdout 49 | # 50 | # Optional 51 | # 52 | # [traefikLog] 53 | 54 | # Sets the filepath for the traefik log. If not specified, stdout will be used. 55 | # Intermediate directories are created if necessary. 56 | # 57 | # Optional 58 | # Default: os.Stdout 59 | # 60 | # filePath = "log/traefik.log" 61 | 62 | # Format is either "json" or "common". 63 | # 64 | # Optional 65 | # Default: "common" 66 | # 67 | # format = "common" 68 | 69 | ################################################################ 70 | # Access logs configuration 71 | ################################################################ 72 | 73 | # Enable access logs 74 | # By default it will write to stdout and produce logs in the textual 75 | # Common Log Format (CLF), extended with additional fields. 76 | # 77 | # Optional 78 | # 79 | # [accessLog] 80 | 81 | # Sets the file path for the access log. If not specified, stdout will be used. 82 | # Intermediate directories are created if necessary. 83 | # 84 | # Optional 85 | # Default: os.Stdout 86 | # 87 | # filePath = "/path/to/log/log.txt" 88 | 89 | # Format is either "json" or "common". 90 | # 91 | # Optional 92 | # Default: "common" 93 | # 94 | # format = "common" 95 | 96 | ################################################################ 97 | # API and dashboard configuration 98 | ################################################################ 99 | 100 | # Enable API and dashboard 101 | [api] 102 | 103 | # Name of the related entry point 104 | # 105 | # Optional 106 | # Default: "traefik" 107 | # 108 | # entryPoint = "traefik" 109 | 110 | # Enabled Dashboard 111 | # 112 | # Optional 113 | # Default: true 114 | # 115 | # dashboard = false 116 | 117 | ################################################################ 118 | # Ping configuration 119 | ################################################################ 120 | 121 | # Enable ping 122 | [ping] 123 | 124 | # Name of the related entry point 125 | # 126 | # Optional 127 | # Default: "traefik" 128 | # 129 | # entryPoint = "traefik" 130 | 131 | ################################################################ 132 | # Docker configuration backend 133 | ################################################################ 134 | 135 | # Enable Docker configuration backend 136 | [docker] 137 | exposedByDefault = false 138 | -------------------------------------------------------------------------------- /forwardauth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "net/http" 12 | "net/url" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | // Forward Auth 19 | type ForwardAuth struct { 20 | Path string 21 | Lifetime time.Duration 22 | Secret []byte 23 | 24 | ClientId string 25 | ClientSecret string `json:"-"` 26 | Scope string 27 | 28 | LoginURL *url.URL 29 | TokenURL *url.URL 30 | UserURL *url.URL 31 | 32 | AuthHost string 33 | 34 | CookieName string 35 | CookieDomains []CookieDomain 36 | CSRFCookieName string 37 | CookieSecure bool 38 | 39 | Domain []string 40 | Whitelist []string 41 | 42 | Prompt string 43 | } 44 | 45 | // Request Validation 46 | 47 | // Cookie = hash(secret, cookie domain, email, expires)|expires|email 48 | func (f *ForwardAuth) ValidateCookie(r *http.Request, c *http.Cookie) (bool, string, error) { 49 | parts := strings.Split(c.Value, "|") 50 | 51 | if len(parts) != 3 { 52 | return false, "", errors.New("Invalid cookie format") 53 | } 54 | 55 | mac, err := base64.URLEncoding.DecodeString(parts[0]) 56 | if err != nil { 57 | return false, "", errors.New("Unable to decode cookie mac") 58 | } 59 | 60 | expectedSignature := f.cookieSignature(r, parts[2], parts[1]) 61 | expected, err := base64.URLEncoding.DecodeString(expectedSignature) 62 | if err != nil { 63 | return false, "", errors.New("Unable to generate mac") 64 | } 65 | 66 | // Valid token? 67 | if !hmac.Equal(mac, expected) { 68 | return false, "", errors.New("Invalid cookie mac") 69 | } 70 | 71 | expires, err := strconv.ParseInt(parts[1], 10, 64) 72 | if err != nil { 73 | return false, "", errors.New("Unable to parse cookie expiry") 74 | } 75 | 76 | // Has it expired? 77 | if time.Unix(expires, 0).Before(time.Now()) { 78 | return false, "", errors.New("Cookie has expired") 79 | } 80 | 81 | // Looks valid 82 | return true, parts[2], nil 83 | } 84 | 85 | // Validate email 86 | func (f *ForwardAuth) ValidateEmail(email string) bool { 87 | found := false 88 | if len(f.Whitelist) > 0 { 89 | for _, whitelist := range f.Whitelist { 90 | if email == whitelist { 91 | found = true 92 | } 93 | } 94 | } else if len(f.Domain) > 0 { 95 | parts := strings.Split(email, "@") 96 | if len(parts) < 2 { 97 | return false 98 | } 99 | for _, domain := range f.Domain { 100 | if domain == parts[1] { 101 | found = true 102 | } 103 | } 104 | } else { 105 | return true 106 | } 107 | 108 | return found 109 | } 110 | 111 | // OAuth Methods 112 | 113 | // Get login url 114 | func (f *ForwardAuth) GetLoginURL(r *http.Request, nonce string) string { 115 | state := fmt.Sprintf("%s:%s", nonce, f.returnUrl(r)) 116 | 117 | q := url.Values{} 118 | q.Set("client_id", fw.ClientId) 119 | q.Set("response_type", "code") 120 | q.Set("scope", fw.Scope) 121 | if fw.Prompt != "" { 122 | q.Set("prompt", fw.Prompt) 123 | } 124 | q.Set("redirect_uri", f.redirectUri(r)) 125 | q.Set("state", state) 126 | 127 | var u url.URL 128 | u = *fw.LoginURL 129 | u.RawQuery = q.Encode() 130 | 131 | return u.String() 132 | } 133 | 134 | // Exchange code for token 135 | 136 | type Token struct { 137 | Token string `json:"access_token"` 138 | } 139 | 140 | func (f *ForwardAuth) ExchangeCode(r *http.Request, code string) (string, error) { 141 | form := url.Values{} 142 | form.Set("client_id", fw.ClientId) 143 | form.Set("client_secret", fw.ClientSecret) 144 | form.Set("grant_type", "authorization_code") 145 | form.Set("redirect_uri", f.redirectUri(r)) 146 | form.Set("code", code) 147 | 148 | res, err := http.PostForm(fw.TokenURL.String(), form) 149 | if err != nil { 150 | return "", err 151 | } 152 | 153 | var token Token 154 | defer res.Body.Close() 155 | err = json.NewDecoder(res.Body).Decode(&token) 156 | 157 | return token.Token, err 158 | } 159 | 160 | // Get user with token 161 | 162 | type User struct { 163 | Id string `json:"id"` 164 | Email string `json:"email"` 165 | Verified bool `json:"verified_email"` 166 | Hd string `json:"hd"` 167 | } 168 | 169 | func (f *ForwardAuth) GetUser(token string) (User, error) { 170 | var user User 171 | 172 | client := &http.Client{} 173 | req, err := http.NewRequest("GET", fw.UserURL.String(), nil) 174 | if err != nil { 175 | return user, err 176 | } 177 | 178 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 179 | res, err := client.Do(req) 180 | if err != nil { 181 | return user, err 182 | } 183 | 184 | defer res.Body.Close() 185 | err = json.NewDecoder(res.Body).Decode(&user) 186 | 187 | return user, err 188 | } 189 | 190 | // Utility methods 191 | 192 | // Get the redirect base 193 | func (f *ForwardAuth) redirectBase(r *http.Request) string { 194 | proto := r.Header.Get("X-Forwarded-Proto") 195 | host := r.Header.Get("X-Forwarded-Host") 196 | 197 | return fmt.Sprintf("%s://%s", proto, host) 198 | } 199 | 200 | // Return url 201 | func (f *ForwardAuth) returnUrl(r *http.Request) string { 202 | path := r.Header.Get("X-Forwarded-Uri") 203 | 204 | return fmt.Sprintf("%s%s", f.redirectBase(r), path) 205 | } 206 | 207 | // Get oauth redirect uri 208 | func (f *ForwardAuth) redirectUri(r *http.Request) string { 209 | if use, _ := f.useAuthDomain(r); use { 210 | proto := r.Header.Get("X-Forwarded-Proto") 211 | return fmt.Sprintf("%s://%s%s", proto, f.AuthHost, f.Path) 212 | } 213 | 214 | return fmt.Sprintf("%s%s", f.redirectBase(r), f.Path) 215 | } 216 | 217 | // Should we use auth host + what it is 218 | func (f *ForwardAuth) useAuthDomain(r *http.Request) (bool, string) { 219 | if f.AuthHost == "" { 220 | return false, "" 221 | } 222 | 223 | // Does the request match a given cookie domain? 224 | reqMatch, reqHost := f.matchCookieDomains(r.Header.Get("X-Forwarded-Host")) 225 | 226 | // Do any of the auth hosts match a cookie domain? 227 | authMatch, authHost := f.matchCookieDomains(f.AuthHost) 228 | 229 | // We need both to match the same domain 230 | return reqMatch && authMatch && reqHost == authHost, reqHost 231 | } 232 | 233 | // Cookie methods 234 | 235 | // Create an auth cookie 236 | func (f *ForwardAuth) MakeCookie(r *http.Request, email string) *http.Cookie { 237 | expires := f.cookieExpiry() 238 | mac := f.cookieSignature(r, email, fmt.Sprintf("%d", expires.Unix())) 239 | value := fmt.Sprintf("%s|%d|%s", mac, expires.Unix(), email) 240 | 241 | return &http.Cookie{ 242 | Name: f.CookieName, 243 | Value: value, 244 | Path: "/", 245 | Domain: f.cookieDomain(r), 246 | HttpOnly: true, 247 | Secure: f.CookieSecure, 248 | Expires: expires, 249 | } 250 | } 251 | 252 | // Make a CSRF cookie (used during login only) 253 | func (f *ForwardAuth) MakeCSRFCookie(r *http.Request, nonce string) *http.Cookie { 254 | return &http.Cookie{ 255 | Name: f.CSRFCookieName, 256 | Value: nonce, 257 | Path: "/", 258 | Domain: f.csrfCookieDomain(r), 259 | HttpOnly: true, 260 | Secure: f.CookieSecure, 261 | Expires: f.cookieExpiry(), 262 | } 263 | } 264 | 265 | // Create a cookie to clear csrf cookie 266 | func (f *ForwardAuth) ClearCSRFCookie(r *http.Request) *http.Cookie { 267 | return &http.Cookie{ 268 | Name: f.CSRFCookieName, 269 | Value: "", 270 | Path: "/", 271 | Domain: f.csrfCookieDomain(r), 272 | HttpOnly: true, 273 | Secure: f.CookieSecure, 274 | Expires: time.Now().Local().Add(time.Hour * -1), 275 | } 276 | } 277 | 278 | // Validate the csrf cookie against state 279 | func (f *ForwardAuth) ValidateCSRFCookie(c *http.Cookie, state string) (bool, string, error) { 280 | if len(c.Value) != 32 { 281 | return false, "", errors.New("Invalid CSRF cookie value") 282 | } 283 | 284 | if len(state) < 34 { 285 | return false, "", errors.New("Invalid CSRF state value") 286 | } 287 | 288 | // Check nonce match 289 | if c.Value != state[:32] { 290 | return false, "", errors.New("CSRF cookie does not match state") 291 | } 292 | 293 | // Valid, return redirect 294 | return true, state[33:], nil 295 | } 296 | 297 | func (f *ForwardAuth) Nonce() (error, string) { 298 | // Make nonce 299 | nonce := make([]byte, 16) 300 | _, err := rand.Read(nonce) 301 | if err != nil { 302 | return err, "" 303 | } 304 | 305 | return nil, fmt.Sprintf("%x", nonce) 306 | } 307 | 308 | // Cookie domain 309 | func (f *ForwardAuth) cookieDomain(r *http.Request) string { 310 | host := r.Header.Get("X-Forwarded-Host") 311 | 312 | // Check if any of the given cookie domains matches 313 | _, domain := f.matchCookieDomains(host) 314 | return domain 315 | } 316 | 317 | // Cookie domain 318 | func (f *ForwardAuth) csrfCookieDomain(r *http.Request) string { 319 | var host string 320 | if use, domain := f.useAuthDomain(r); use { 321 | host = domain 322 | } else { 323 | host = r.Header.Get("X-Forwarded-Host") 324 | } 325 | 326 | // Remove port 327 | p := strings.Split(host, ":") 328 | return p[0] 329 | } 330 | 331 | // Return matching cookie domain if exists 332 | func (f *ForwardAuth) matchCookieDomains(domain string) (bool, string) { 333 | // Remove port 334 | p := strings.Split(domain, ":") 335 | 336 | for _, d := range f.CookieDomains { 337 | if d.Match(p[0]) { 338 | return true, d.Domain 339 | } 340 | } 341 | 342 | return false, p[0] 343 | } 344 | 345 | // Create cookie hmac 346 | func (f *ForwardAuth) cookieSignature(r *http.Request, email, expires string) string { 347 | hash := hmac.New(sha256.New, f.Secret) 348 | hash.Write([]byte(f.cookieDomain(r))) 349 | hash.Write([]byte(email)) 350 | hash.Write([]byte(expires)) 351 | return base64.URLEncoding.EncodeToString(hash.Sum(nil)) 352 | } 353 | 354 | // Get cookie expirary 355 | func (f *ForwardAuth) cookieExpiry() time.Time { 356 | return time.Now().Local().Add(f.Lifetime) 357 | } 358 | 359 | // Cookie Domain 360 | 361 | // Cookie Domain 362 | type CookieDomain struct { 363 | Domain string 364 | DomainLen int 365 | SubDomain string 366 | SubDomainLen int 367 | } 368 | 369 | func NewCookieDomain(domain string) *CookieDomain { 370 | return &CookieDomain{ 371 | Domain: domain, 372 | DomainLen: len(domain), 373 | SubDomain: fmt.Sprintf(".%s", domain), 374 | SubDomainLen: len(domain) + 1, 375 | } 376 | } 377 | 378 | func (c *CookieDomain) Match(host string) bool { 379 | // Exact domain match? 380 | if host == c.Domain { 381 | return true 382 | } 383 | 384 | // Subdomain match? 385 | if len(host) >= c.SubDomainLen && host[len(host)-c.SubDomainLen:] == c.SubDomain { 386 | return true 387 | } 388 | 389 | return false 390 | } 391 | -------------------------------------------------------------------------------- /forwardauth_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | // "fmt" 5 | "net/http" 6 | "net/url" 7 | "reflect" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestValidateCookie(t *testing.T) { 13 | fw = &ForwardAuth{} 14 | r, _ := http.NewRequest("GET", "http://example.com", nil) 15 | c := &http.Cookie{} 16 | 17 | // Should require 3 parts 18 | c.Value = "" 19 | valid, _, err := fw.ValidateCookie(r, c) 20 | if valid || err.Error() != "Invalid cookie format" { 21 | t.Error("Should get \"Invalid cookie format\", got:", err) 22 | } 23 | c.Value = "1|2" 24 | valid, _, err = fw.ValidateCookie(r, c) 25 | if valid || err.Error() != "Invalid cookie format" { 26 | t.Error("Should get \"Invalid cookie format\", got:", err) 27 | } 28 | c.Value = "1|2|3|4" 29 | valid, _, err = fw.ValidateCookie(r, c) 30 | if valid || err.Error() != "Invalid cookie format" { 31 | t.Error("Should get \"Invalid cookie format\", got:", err) 32 | } 33 | 34 | // Should catch invalid mac 35 | c.Value = "MQ==|2|3" 36 | valid, _, err = fw.ValidateCookie(r, c) 37 | if valid || err.Error() != "Invalid cookie mac" { 38 | t.Error("Should get \"Invalid cookie mac\", got:", err) 39 | } 40 | 41 | // Should catch expired 42 | fw.Lifetime = time.Second * time.Duration(-1) 43 | c = fw.MakeCookie(r, "test@test.com") 44 | valid, _, err = fw.ValidateCookie(r, c) 45 | if valid || err.Error() != "Cookie has expired" { 46 | t.Error("Should get \"Cookie has expired\", got:", err) 47 | } 48 | 49 | // Should accept valid cookie 50 | fw.Lifetime = time.Second * time.Duration(10) 51 | c = fw.MakeCookie(r, "test@test.com") 52 | valid, email, err := fw.ValidateCookie(r, c) 53 | if !valid { 54 | t.Error("Valid request should return as valid") 55 | } 56 | if err != nil { 57 | t.Error("Valid request should not return error, got:", err) 58 | } 59 | if email != "test@test.com" { 60 | t.Error("Valid request should return user email") 61 | } 62 | } 63 | 64 | func TestValidateEmail(t *testing.T) { 65 | fw = &ForwardAuth{} 66 | 67 | // Should allow any 68 | if !fw.ValidateEmail("test@test.com") || !fw.ValidateEmail("one@two.com") { 69 | t.Error("Should allow any domain if email domain is not defined") 70 | } 71 | 72 | // Should block non matching domain 73 | fw.Domain = []string{"test.com"} 74 | if fw.ValidateEmail("one@two.com") { 75 | t.Error("Should not allow user from another domain") 76 | } 77 | 78 | // Should allow matching domain 79 | fw.Domain = []string{"test.com"} 80 | if !fw.ValidateEmail("test@test.com") { 81 | t.Error("Should allow user from allowed domain") 82 | } 83 | 84 | // Should block non whitelisted email address 85 | fw.Domain = []string{} 86 | fw.Whitelist = []string{"test@test.com"} 87 | if fw.ValidateEmail("one@two.com") { 88 | t.Error("Should not allow user not in whitelist.") 89 | } 90 | 91 | // Should allow matching whitelisted email address 92 | fw.Domain = []string{} 93 | fw.Whitelist = []string{"test@test.com"} 94 | if !fw.ValidateEmail("test@test.com") { 95 | t.Error("Should allow user in whitelist.") 96 | } 97 | } 98 | 99 | func TestGetLoginURL(t *testing.T) { 100 | r, _ := http.NewRequest("GET", "http://example.com", nil) 101 | r.Header.Add("X-Forwarded-Proto", "http") 102 | r.Header.Add("X-Forwarded-Host", "example.com") 103 | r.Header.Add("X-Forwarded-Uri", "/hello") 104 | 105 | fw = &ForwardAuth{ 106 | Path: "/_oauth", 107 | ClientId: "idtest", 108 | ClientSecret: "sectest", 109 | Scope: "scopetest", 110 | LoginURL: &url.URL{ 111 | Scheme: "https", 112 | Host: "test.com", 113 | Path: "/auth", 114 | }, 115 | } 116 | 117 | // Check url 118 | uri, err := url.Parse(fw.GetLoginURL(r, "nonce")) 119 | if err != nil { 120 | t.Error("Error parsing login url:", err) 121 | } 122 | if uri.Scheme != "https" { 123 | t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme) 124 | } 125 | if uri.Host != "test.com" { 126 | t.Error("Expected login Host to be \"test.com\", got:", uri.Host) 127 | } 128 | if uri.Path != "/auth" { 129 | t.Error("Expected login Path to be \"/auth\", got:", uri.Path) 130 | } 131 | 132 | // Check query string 133 | qs := uri.Query() 134 | expectedQs := url.Values{ 135 | "client_id": []string{"idtest"}, 136 | "redirect_uri": []string{"http://example.com/_oauth"}, 137 | "response_type": []string{"code"}, 138 | "scope": []string{"scopetest"}, 139 | "state": []string{"nonce:http://example.com/hello"}, 140 | } 141 | if !reflect.DeepEqual(qs, expectedQs) { 142 | t.Error("Incorrect login query string:") 143 | qsDiff(expectedQs, qs) 144 | } 145 | 146 | // 147 | // With Auth URL but no matching cookie domain 148 | // - will not use auth host 149 | // 150 | fw = &ForwardAuth{ 151 | Path: "/_oauth", 152 | AuthHost: "auth.example.com", 153 | ClientId: "idtest", 154 | ClientSecret: "sectest", 155 | Scope: "scopetest", 156 | LoginURL: &url.URL{ 157 | Scheme: "https", 158 | Host: "test.com", 159 | Path: "/auth", 160 | }, 161 | Prompt: "consent select_account", 162 | } 163 | 164 | // Check url 165 | uri, err = url.Parse(fw.GetLoginURL(r, "nonce")) 166 | if err != nil { 167 | t.Error("Error parsing login url:", err) 168 | } 169 | if uri.Scheme != "https" { 170 | t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme) 171 | } 172 | if uri.Host != "test.com" { 173 | t.Error("Expected login Host to be \"test.com\", got:", uri.Host) 174 | } 175 | if uri.Path != "/auth" { 176 | t.Error("Expected login Path to be \"/auth\", got:", uri.Path) 177 | } 178 | 179 | // Check query string 180 | qs = uri.Query() 181 | expectedQs = url.Values{ 182 | "client_id": []string{"idtest"}, 183 | "redirect_uri": []string{"http://example.com/_oauth"}, 184 | "response_type": []string{"code"}, 185 | "scope": []string{"scopetest"}, 186 | "prompt": []string{"consent select_account"}, 187 | "state": []string{"nonce:http://example.com/hello"}, 188 | } 189 | if !reflect.DeepEqual(qs, expectedQs) { 190 | t.Error("Incorrect login query string:") 191 | qsDiff(expectedQs, qs) 192 | } 193 | 194 | // 195 | // With correct Auth URL + cookie domain 196 | // 197 | cookieDomain := NewCookieDomain("example.com") 198 | fw = &ForwardAuth{ 199 | Path: "/_oauth", 200 | AuthHost: "auth.example.com", 201 | ClientId: "idtest", 202 | ClientSecret: "sectest", 203 | Scope: "scopetest", 204 | LoginURL: &url.URL{ 205 | Scheme: "https", 206 | Host: "test.com", 207 | Path: "/auth", 208 | }, 209 | CookieDomains: []CookieDomain{*cookieDomain}, 210 | } 211 | 212 | // Check url 213 | uri, err = url.Parse(fw.GetLoginURL(r, "nonce")) 214 | if err != nil { 215 | t.Error("Error parsing login url:", err) 216 | } 217 | if uri.Scheme != "https" { 218 | t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme) 219 | } 220 | if uri.Host != "test.com" { 221 | t.Error("Expected login Host to be \"test.com\", got:", uri.Host) 222 | } 223 | if uri.Path != "/auth" { 224 | t.Error("Expected login Path to be \"/auth\", got:", uri.Path) 225 | } 226 | 227 | // Check query string 228 | qs = uri.Query() 229 | expectedQs = url.Values{ 230 | "client_id": []string{"idtest"}, 231 | "redirect_uri": []string{"http://auth.example.com/_oauth"}, 232 | "response_type": []string{"code"}, 233 | "scope": []string{"scopetest"}, 234 | "state": []string{"nonce:http://example.com/hello"}, 235 | } 236 | qsDiff(expectedQs, qs) 237 | if !reflect.DeepEqual(qs, expectedQs) { 238 | t.Error("Incorrect login query string:") 239 | qsDiff(expectedQs, qs) 240 | } 241 | 242 | // 243 | // With Auth URL + cookie domain, but from different domain 244 | // - will not use auth host 245 | // 246 | r, _ = http.NewRequest("GET", "http://another.com", nil) 247 | r.Header.Add("X-Forwarded-Proto", "http") 248 | r.Header.Add("X-Forwarded-Host", "another.com") 249 | r.Header.Add("X-Forwarded-Uri", "/hello") 250 | 251 | // Check url 252 | uri, err = url.Parse(fw.GetLoginURL(r, "nonce")) 253 | if err != nil { 254 | t.Error("Error parsing login url:", err) 255 | } 256 | if uri.Scheme != "https" { 257 | t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme) 258 | } 259 | if uri.Host != "test.com" { 260 | t.Error("Expected login Host to be \"test.com\", got:", uri.Host) 261 | } 262 | if uri.Path != "/auth" { 263 | t.Error("Expected login Path to be \"/auth\", got:", uri.Path) 264 | } 265 | 266 | // Check query string 267 | qs = uri.Query() 268 | expectedQs = url.Values{ 269 | "client_id": []string{"idtest"}, 270 | "redirect_uri": []string{"http://another.com/_oauth"}, 271 | "response_type": []string{"code"}, 272 | "scope": []string{"scopetest"}, 273 | "state": []string{"nonce:http://another.com/hello"}, 274 | } 275 | qsDiff(expectedQs, qs) 276 | if !reflect.DeepEqual(qs, expectedQs) { 277 | t.Error("Incorrect login query string:") 278 | qsDiff(expectedQs, qs) 279 | } 280 | } 281 | 282 | // TODO 283 | // func TestExchangeCode(t *testing.T) { 284 | // } 285 | 286 | // TODO 287 | // func TestGetUser(t *testing.T) { 288 | // } 289 | 290 | // TODO? Tested in TestValidateCookie 291 | // func TestMakeCookie(t *testing.T) { 292 | // } 293 | 294 | func TestMakeCSRFCookie(t *testing.T) { 295 | r, _ := http.NewRequest("GET", "http://app.example.com", nil) 296 | r.Header.Add("X-Forwarded-Host", "app.example.com") 297 | 298 | // No cookie domain or auth url 299 | fw = &ForwardAuth{} 300 | c := fw.MakeCSRFCookie(r, "12345678901234567890123456789012") 301 | if c.Domain != "app.example.com" { 302 | t.Error("Cookie Domain should match request domain, got:", c.Domain) 303 | } 304 | 305 | // With cookie domain but no auth url 306 | cookieDomain := NewCookieDomain("example.com") 307 | fw = &ForwardAuth{CookieDomains: []CookieDomain{*cookieDomain}} 308 | c = fw.MakeCSRFCookie(r, "12345678901234567890123456789012") 309 | if c.Domain != "app.example.com" { 310 | t.Error("Cookie Domain should match request domain, got:", c.Domain) 311 | } 312 | 313 | // With cookie domain and auth url 314 | fw = &ForwardAuth{ 315 | AuthHost: "auth.example.com", 316 | CookieDomains: []CookieDomain{*cookieDomain}, 317 | } 318 | c = fw.MakeCSRFCookie(r, "12345678901234567890123456789012") 319 | if c.Domain != "example.com" { 320 | t.Error("Cookie Domain should match request domain, got:", c.Domain) 321 | } 322 | } 323 | 324 | func TestClearCSRFCookie(t *testing.T) { 325 | fw = &ForwardAuth{} 326 | r, _ := http.NewRequest("GET", "http://example.com", nil) 327 | 328 | c := fw.ClearCSRFCookie(r) 329 | if c.Value != "" { 330 | t.Error("ClearCSRFCookie should create cookie with empty value") 331 | } 332 | } 333 | 334 | func TestValidateCSRFCookie(t *testing.T) { 335 | fw = &ForwardAuth{} 336 | c := &http.Cookie{} 337 | 338 | // Should require 32 char string 339 | c.Value = "" 340 | valid, _, err := fw.ValidateCSRFCookie(c, "") 341 | if valid || err.Error() != "Invalid CSRF cookie value" { 342 | t.Error("Should get \"Invalid CSRF cookie value\", got:", err) 343 | } 344 | c.Value = "123456789012345678901234567890123" 345 | valid, _, err = fw.ValidateCSRFCookie(c, "") 346 | if valid || err.Error() != "Invalid CSRF cookie value" { 347 | t.Error("Should get \"Invalid CSRF cookie value\", got:", err) 348 | } 349 | 350 | // Should require valid state 351 | c.Value = "12345678901234567890123456789012" 352 | valid, _, err = fw.ValidateCSRFCookie(c, "12345678901234567890123456789012:") 353 | if valid || err.Error() != "Invalid CSRF state value" { 354 | t.Error("Should get \"Invalid CSRF state value\", got:", err) 355 | } 356 | 357 | // Should allow valid state 358 | c.Value = "12345678901234567890123456789012" 359 | valid, state, err := fw.ValidateCSRFCookie(c, "12345678901234567890123456789012:99") 360 | if !valid { 361 | t.Error("Valid request should return as valid") 362 | } 363 | if err != nil { 364 | t.Error("Valid request should not return error, got:", err) 365 | } 366 | if state != "99" { 367 | t.Error("Valid request should return correct state, got:", state) 368 | } 369 | } 370 | 371 | func TestNonce(t *testing.T) { 372 | fw = &ForwardAuth{} 373 | 374 | err, nonce1 := fw.Nonce() 375 | if err != nil { 376 | t.Error("Error generation nonce:", err) 377 | } 378 | 379 | err, nonce2 := fw.Nonce() 380 | if err != nil { 381 | t.Error("Error generation nonce:", err) 382 | } 383 | 384 | if len(nonce1) != 32 || len(nonce2) != 32 { 385 | t.Error("Nonce should be 32 chars") 386 | } 387 | if nonce1 == nonce2 { 388 | t.Error("Nonce should not be equal") 389 | } 390 | } 391 | 392 | func TestCookieDomainMatch(t *testing.T) { 393 | cd := NewCookieDomain("example.com") 394 | 395 | // Exact should match 396 | if !cd.Match("example.com") { 397 | t.Error("Exact domain should match") 398 | } 399 | 400 | // Subdomain should match 401 | if !cd.Match("test.example.com") { 402 | t.Error("Subdomain should match") 403 | } 404 | 405 | // Derived domain should not match 406 | if cd.Match("testexample.com") { 407 | t.Error("Derived domain should not match") 408 | } 409 | 410 | // Other domain should not match 411 | if cd.Match("test.com") { 412 | t.Error("Other domain should not match") 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /hooks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # $IMAGE_NAME var is injected into the build so the tag is correct. 3 | docker build --build-arg VCS_REF=`git rev-parse --short HEAD` \ 4 | --build-arg BUILD_DATE=`date -u +”%Y-%m-%dT%H:%M:%SZ”` \ 5 | -t $IMAGE_NAME . 6 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func CreateLogger(logLevel, logFormat string) logrus.FieldLogger { 10 | // Setup logger 11 | log := logrus.StandardLogger() 12 | logrus.SetOutput(os.Stdout) 13 | 14 | // Set logger format 15 | switch logFormat { 16 | case "pretty": 17 | break 18 | case "json": 19 | logrus.SetFormatter(&logrus.JSONFormatter{}) 20 | // "text" is the default 21 | default: 22 | logrus.SetFormatter(&logrus.TextFormatter{ 23 | DisableColors: true, 24 | FullTimestamp: true, 25 | }) 26 | } 27 | 28 | // Set logger level 29 | switch logLevel { 30 | case "trace": 31 | logrus.SetLevel(logrus.TraceLevel) 32 | case "debug": 33 | logrus.SetLevel(logrus.DebugLevel) 34 | case "info": 35 | logrus.SetLevel(logrus.InfoLevel) 36 | case "error": 37 | logrus.SetLevel(logrus.ErrorLevel) 38 | case "fatal": 39 | logrus.SetLevel(logrus.FatalLevel) 40 | case "panic": 41 | logrus.SetLevel(logrus.PanicLevel) 42 | // warn is the default 43 | default: 44 | logrus.SetLevel(logrus.WarnLevel) 45 | } 46 | 47 | return log 48 | } 49 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "strings" 11 | "time" 12 | 13 | "github.com/namsral/flag" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // Vars 18 | var fw *ForwardAuth 19 | var log logrus.FieldLogger 20 | 21 | // Primary handler 22 | func handler(w http.ResponseWriter, r *http.Request) { 23 | // Logging setup 24 | logger := log.WithFields(logrus.Fields{ 25 | "SourceIP": r.Header.Get("X-Forwarded-For"), 26 | }) 27 | logger.WithFields(logrus.Fields{ 28 | "Headers": r.Header, 29 | }).Debugf("Handling request") 30 | 31 | // Parse uri 32 | uri, err := url.Parse(r.Header.Get("X-Forwarded-Uri")) 33 | if err != nil { 34 | logger.Errorf("Error parsing X-Forwarded-Uri, %v", err) 35 | http.Error(w, "Service unavailable", 503) 36 | return 37 | } 38 | 39 | // Handle callback 40 | if uri.Path == fw.Path { 41 | logger.Debugf("Passing request to auth callback") 42 | handleCallback(w, r, uri.Query(), logger) 43 | return 44 | } 45 | 46 | // Get auth cookie 47 | c, err := r.Cookie(fw.CookieName) 48 | if err != nil { 49 | // Error indicates no cookie, generate nonce 50 | err, nonce := fw.Nonce() 51 | if err != nil { 52 | logger.Errorf("Error generating nonce, %v", err) 53 | http.Error(w, "Service unavailable", 503) 54 | return 55 | } 56 | 57 | // Set the CSRF cookie 58 | http.SetCookie(w, fw.MakeCSRFCookie(r, nonce)) 59 | logger.Debug("Set CSRF cookie and redirecting to oidc login") 60 | logger.Debug("uri.Path was %s",uri.Path) 61 | logger.Debug("fw.Path was %s",fw.Path) 62 | 63 | // Forward them on 64 | http.Redirect(w, r, fw.GetLoginURL(r, nonce), http.StatusTemporaryRedirect) 65 | 66 | return 67 | } 68 | 69 | // Validate cookie 70 | valid, email, err := fw.ValidateCookie(r, c) 71 | if !valid { 72 | logger.Errorf("Invalid cookie: %v", err) 73 | http.Error(w, "Not authorized", 401) 74 | return 75 | } 76 | 77 | // Validate user 78 | valid = fw.ValidateEmail(email) 79 | if !valid { 80 | logger.WithFields(logrus.Fields{ 81 | "email": email, 82 | }).Errorf("Invalid email") 83 | http.Error(w, "Not authorized", 401) 84 | return 85 | } 86 | 87 | // Valid request 88 | logger.Debugf("Allowing valid request ") 89 | w.Header().Set("X-Forwarded-User", email) 90 | w.WriteHeader(200) 91 | } 92 | 93 | // Authenticate user after they have come back from oidc 94 | func handleCallback(w http.ResponseWriter, r *http.Request, qs url.Values, 95 | logger logrus.FieldLogger) { 96 | // Check for CSRF cookie 97 | csrfCookie, err := r.Cookie(fw.CSRFCookieName) 98 | if err != nil { 99 | logger.Warn("Missing csrf cookie") 100 | http.Error(w, "Not authorized", 401) 101 | return 102 | } 103 | 104 | // Validate state 105 | state := qs.Get("state") 106 | valid, redirect, err := fw.ValidateCSRFCookie(csrfCookie, state) 107 | if !valid { 108 | logger.WithFields(logrus.Fields{ 109 | "csrf": csrfCookie.Value, 110 | "state": state, 111 | }).Warnf("Error validating csrf cookie: %v", err) 112 | http.Error(w, "Not authorized", 401) 113 | return 114 | } 115 | 116 | // Clear CSRF cookie 117 | http.SetCookie(w, fw.ClearCSRFCookie(r)) 118 | 119 | // Exchange code for token 120 | token, err := fw.ExchangeCode(r, qs.Get("code")) 121 | if err != nil { 122 | logger.Errorf("Code exchange failed with: %v", err) 123 | http.Error(w, "Service unavailable", 503) 124 | return 125 | } 126 | 127 | // Get user 128 | user, err := fw.GetUser(token) 129 | if err != nil { 130 | logger.Errorf("Error getting user: %s", err) 131 | return 132 | } 133 | 134 | // Generate cookie 135 | http.SetCookie(w, fw.MakeCookie(r, user.Email)) 136 | logger.WithFields(logrus.Fields{ 137 | "user": user.Email, 138 | }).Infof("Generated auth cookie") 139 | 140 | // Redirect 141 | http.Redirect(w, r, redirect, http.StatusTemporaryRedirect) 142 | } 143 | 144 | func getOidcConfig(oidc string) map[string]interface{} { 145 | uri, err := url.Parse(oidc) 146 | if err != nil { 147 | log.Fatal("failed to parse oidc string") 148 | } 149 | uri.Path = path.Join(uri.Path, "/.well-known/openid-configuration") 150 | res, err := http.Get(uri.String()) 151 | if err != nil { 152 | log.Fatal("failed to get oidc parametere from oidc connect") 153 | } 154 | body, err := ioutil.ReadAll(res.Body) 155 | if err != nil { 156 | log.Fatal("failed to read response body") 157 | } 158 | var result map[string]interface{} 159 | json.Unmarshal(body, &result) 160 | log.Debug(result) 161 | return result 162 | } 163 | 164 | // Main 165 | func main() { 166 | // Parse options 167 | flag.String(flag.DefaultConfigFlagname, "", "Path to config file") 168 | path := flag.String("url-path", "_oauth", "Callback URL") 169 | lifetime := flag.Int("lifetime", 43200, "Session length in seconds") 170 | secret := flag.String("secret", "", "*Secret used for signing (required)") 171 | authHost := flag.String("auth-host", "", "Central auth login") 172 | oidcIssuer := flag.String("oidc-issuer", "", "OIDC Issuer URL (required)") 173 | clientId := flag.String("client-id", "", "Client ID (required)") 174 | clientSecret := flag.String("client-secret", "", "Client Secret (required)") 175 | cookieName := flag.String("cookie-name", "_forward_auth", "Cookie Name") 176 | cSRFCookieName := flag.String("csrf-cookie-name", "_forward_auth_csrf", "CSRF Cookie Name") 177 | cookieDomainList := flag.String("cookie-domains", "", "Comma separated list of cookie domains") //todo 178 | cookieSecret := flag.String("cookie-secret", "", "Deprecated") 179 | cookieSecure := flag.Bool("cookie-secure", true, "Use secure cookies") 180 | domainList := flag.String("domain", "", "Comma separated list of email domains to allow") 181 | emailWhitelist := flag.String("whitelist", "", "Comma separated list of emails to allow") 182 | prompt := flag.String("prompt", "", "Space separated list of OpenID prompt options") 183 | logLevel := flag.String("log-level", "warn", "Log level: trace, debug, info, warn, error, fatal, panic") 184 | logFormat := flag.String("log-format", "text", "Log format: text, json, pretty") 185 | 186 | flag.Parse() 187 | 188 | // Setup logger 189 | log = CreateLogger(*logLevel, *logFormat) 190 | 191 | // Backwards compatibility 192 | if *secret == "" && *cookieSecret != "" { 193 | *secret = *cookieSecret 194 | } 195 | 196 | // Check for show stopper errors 197 | if *clientId == "" || *clientSecret == "" || *secret == "" || *oidcIssuer == "" { 198 | log.Fatal("client-id, client-secret, secret and oidc-issuer must all be set") 199 | } 200 | 201 | var oidcParams = getOidcConfig(*oidcIssuer) 202 | 203 | loginUrl, err := url.Parse((oidcParams["authorization_endpoint"].(string))) 204 | if err != nil { 205 | log.Fatal("unable to parse login url") 206 | } 207 | 208 | tokenUrl, err := url.Parse((oidcParams["token_endpoint"].(string))) 209 | if err != nil { 210 | log.Fatal("unable to parse token url") 211 | } 212 | userUrl, err := url.Parse((oidcParams["userinfo_endpoint"].(string))) 213 | if err != nil { 214 | log.Fatal("unable to parse user url") 215 | } 216 | 217 | // Parse lists 218 | var cookieDomains []CookieDomain 219 | if *cookieDomainList != "" { 220 | for _, d := range strings.Split(*cookieDomainList, ",") { 221 | cookieDomain := NewCookieDomain(d) 222 | cookieDomains = append(cookieDomains, *cookieDomain) 223 | } 224 | } 225 | 226 | var domain []string 227 | if *domainList != "" { 228 | domain = strings.Split(*domainList, ",") 229 | } 230 | var whitelist []string 231 | if *emailWhitelist != "" { 232 | whitelist = strings.Split(*emailWhitelist, ",") 233 | } 234 | 235 | // Setup 236 | fw = &ForwardAuth{ 237 | Path: fmt.Sprintf("/%s", *path), 238 | Lifetime: time.Second * time.Duration(*lifetime), 239 | Secret: []byte(*secret), 240 | AuthHost: *authHost, 241 | 242 | ClientId: *clientId, 243 | ClientSecret: *clientSecret, 244 | Scope: "openid profile email", 245 | 246 | LoginURL: loginUrl, 247 | TokenURL: tokenUrl, 248 | UserURL: userUrl, 249 | 250 | CookieName: *cookieName, 251 | CSRFCookieName: *cSRFCookieName, 252 | CookieDomains: cookieDomains, 253 | CookieSecure: *cookieSecure, 254 | 255 | Domain: domain, 256 | Whitelist: whitelist, 257 | 258 | Prompt: *prompt, 259 | } 260 | 261 | // Attach handler 262 | http.HandleFunc("/", handler) 263 | 264 | // Start 265 | jsonConf, _ := json.Marshal(fw) 266 | log.Debugf("Starting with options: %s", string(jsonConf)) 267 | log.Info("Listening on :4181") 268 | log.Info(http.ListenAndServe(":4181", nil)) 269 | } 270 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | // "reflect" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | /** 16 | * Utilities 17 | */ 18 | 19 | type TokenServerHandler struct{} 20 | 21 | func (t *TokenServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 22 | fmt.Fprint(w, `{"access_token":"123456789"}`) 23 | } 24 | 25 | type UserServerHandler struct{} 26 | 27 | func (t *UserServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | fmt.Fprint(w, `{ 29 | "id":"1", 30 | "email":"example@example.com", 31 | "verified_email":true, 32 | "hd":"example.com" 33 | }`) 34 | } 35 | 36 | func init() { 37 | log = CreateLogger("panic", "") 38 | } 39 | 40 | func httpRequest(r *http.Request, c *http.Cookie) (*http.Response, string) { 41 | w := httptest.NewRecorder() 42 | 43 | // Set cookies on recorder 44 | if c != nil { 45 | http.SetCookie(w, c) 46 | } 47 | 48 | // Copy into request 49 | for _, c := range w.HeaderMap["Set-Cookie"] { 50 | r.Header.Add("Cookie", c) 51 | } 52 | 53 | handler(w, r) 54 | 55 | res := w.Result() 56 | body, _ := ioutil.ReadAll(res.Body) 57 | 58 | return res, string(body) 59 | } 60 | 61 | func newHttpRequest(uri string) *http.Request { 62 | r := httptest.NewRequest("", "http://example.com", nil) 63 | r.Header.Add("X-Forwarded-Uri", uri) 64 | return r 65 | } 66 | 67 | func qsDiff(one, two url.Values) { 68 | for k := range one { 69 | if two.Get(k) == "" { 70 | fmt.Printf("Key missing: %s\n", k) 71 | } 72 | if one.Get(k) != two.Get(k) { 73 | fmt.Printf("Value different for %s: expected: '%s' got: '%s'\n", k, one.Get(k), two.Get(k)) 74 | } 75 | } 76 | for k := range two { 77 | if one.Get(k) == "" { 78 | fmt.Printf("Extra key: %s\n", k) 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * Tests 85 | */ 86 | 87 | func TestHandler(t *testing.T) { 88 | fw = &ForwardAuth{ 89 | Path: "_oauth", 90 | ClientId: "idtest", 91 | ClientSecret: "sectest", 92 | Scope: "scopetest", 93 | LoginURL: &url.URL{ 94 | Scheme: "http", 95 | Host: "test.com", 96 | Path: "/auth", 97 | }, 98 | CookieName: "cookie_test", 99 | Lifetime: time.Second * time.Duration(10), 100 | } 101 | 102 | // Should redirect vanilla request to login url 103 | req := newHttpRequest("foo") 104 | res, _ := httpRequest(req, nil) 105 | if res.StatusCode != 307 { 106 | t.Error("Vanilla request should be redirected with 307, got:", res.StatusCode) 107 | } 108 | fwd, _ := res.Location() 109 | if fwd.Scheme != "http" || fwd.Host != "test.com" || fwd.Path != "/auth" { 110 | t.Error("Vanilla request should be redirected to login url, got:", fwd) 111 | } 112 | 113 | // Should catch invalid cookie 114 | req = newHttpRequest("foo") 115 | 116 | c := fw.MakeCookie(req, "test@example.com") 117 | parts := strings.Split(c.Value, "|") 118 | c.Value = fmt.Sprintf("bad|%s|%s", parts[1], parts[2]) 119 | 120 | res, _ = httpRequest(req, c) 121 | if res.StatusCode != 401 { 122 | t.Error("Request with invalid cookie shound't be authorised", res.StatusCode) 123 | } 124 | 125 | // Should validate email 126 | req = newHttpRequest("foo") 127 | 128 | c = fw.MakeCookie(req, "test@example.com") 129 | fw.Domain = []string{"test.com"} 130 | 131 | res, _ = httpRequest(req, c) 132 | if res.StatusCode != 401 { 133 | t.Error("Request with invalid cookie shound't be authorised", res.StatusCode) 134 | } 135 | 136 | // Should allow valid request email 137 | req = newHttpRequest("foo") 138 | 139 | c = fw.MakeCookie(req, "test@example.com") 140 | fw.Domain = []string{} 141 | 142 | res, _ = httpRequest(req, c) 143 | if res.StatusCode != 200 { 144 | t.Error("Valid request should be allowed, got:", res.StatusCode) 145 | } 146 | 147 | // Should pass through user 148 | users := res.Header["X-Forwarded-User"] 149 | if len(users) != 1 { 150 | t.Error("Valid request missing X-Forwarded-User header") 151 | } else if users[0] != "test@example.com" { 152 | t.Error("X-Forwarded-User should match user, got: ", users) 153 | } 154 | } 155 | 156 | func TestCallback(t *testing.T) { 157 | fw = &ForwardAuth{ 158 | Path: "_oauth", 159 | ClientId: "idtest", 160 | ClientSecret: "sectest", 161 | Scope: "scopetest", 162 | LoginURL: &url.URL{ 163 | Scheme: "http", 164 | Host: "test.com", 165 | Path: "/auth", 166 | }, 167 | CSRFCookieName: "csrf_test", 168 | } 169 | 170 | // Setup token server 171 | tokenServerHandler := &TokenServerHandler{} 172 | tokenServer := httptest.NewServer(tokenServerHandler) 173 | defer tokenServer.Close() 174 | tokenUrl, _ := url.Parse(tokenServer.URL) 175 | fw.TokenURL = tokenUrl 176 | 177 | // Setup user server 178 | userServerHandler := &UserServerHandler{} 179 | userServer := httptest.NewServer(userServerHandler) 180 | defer userServer.Close() 181 | userUrl, _ := url.Parse(userServer.URL) 182 | fw.UserURL = userUrl 183 | 184 | // Should pass auth response request to callback 185 | req := newHttpRequest("_oauth") 186 | res, _ := httpRequest(req, nil) 187 | if res.StatusCode != 401 { 188 | t.Error("Auth callback without cookie shound't be authorised, got:", res.StatusCode) 189 | } 190 | 191 | // Should catch invalid csrf cookie 192 | req = newHttpRequest("_oauth?state=12345678901234567890123456789012:http://redirect") 193 | c := fw.MakeCSRFCookie(req, "nononononononononononononononono") 194 | res, _ = httpRequest(req, c) 195 | if res.StatusCode != 401 { 196 | t.Error("Auth callback with invalid cookie shound't be authorised, got:", res.StatusCode) 197 | } 198 | 199 | // Should redirect valid request 200 | req = newHttpRequest("_oauth?state=12345678901234567890123456789012:http://redirect") 201 | c = fw.MakeCSRFCookie(req, "12345678901234567890123456789012") 202 | res, _ = httpRequest(req, c) 203 | if res.StatusCode != 307 { 204 | t.Error("Valid callback should be allowed, got:", res.StatusCode) 205 | } 206 | fwd, _ := res.Location() 207 | if fwd.Scheme != "http" || fwd.Host != "redirect" || fwd.Path != "" { 208 | t.Error("Valid request should be redirected to return url, got:", fwd) 209 | } 210 | } 211 | --------------------------------------------------------------------------------