69 Commits

Author SHA1 Message Date
Thom Seddon
83f06df0f6 Update go-jose (#415) 2026-01-13 18:32:01 +00:00
Thom Seddon
cb5dc1bc9e Upgrade to Go 1.25 + dependency update (#413) 2026-01-13 18:16:28 +00:00
Thom Seddon
26e7995ea1 Update go1.20 -> go1.22 + further update dependcies 2024-05-06 22:19:00 +01:00
Louis DESPLANCHE
20cef15e7b Update go1.13 -> go1.20 + update dependencies + mod tidy + router adaptation to the new traefik version 2023-07-27 14:15:13 +02:00
Thom Seddon
c4317b7503 Allow to be run without middleware + improve request reading consistency (#217)
Prior to this change, the request URI was only ever read from the
X-Forwarded-Uri header which was only set when the container was
accessed via the forwardauth middleware. As such, it was necessary
to apply the treafik-forward-auth middleware to the treafik-forward-auth
container when running auth host mode.
This is a quirk, unnecessary complexity and is a frequent source of
configuration issues.
2021-06-24 21:45:28 +01:00
em0lar / Leo
4ffb6593d5 Add GitHub Actions workflow for creating binaries for releases (#184) (#199)
* Add GitHub Actions workflow for creating binaries for releases
* Add sentence about binary files to README
* Cleanup + nicer way querying GitHub API
2021-02-01 20:28:00 +00:00
hesstobi
6c6f75e80d Make listen port configurable (#230)
Co-authored-by: Tobias Hess <tobias.hess@energiekoppler.com>
2021-02-01 20:10:50 +00:00
Thom Seddon
8be8244b13 Switch to Github Actions for CI (#219) 2021-01-03 13:44:40 +00:00
Thom Seddon
f96a3fb332 Remove double brackets typo in readme (#218) 2020-12-10 21:39:49 +00:00
Thom Seddon
c19f622fbd Create codeql-analysis.yml 2020-10-01 09:29:36 +01:00
Thom Seddon
04f5499f0b Allow override of domains and whitelist in rules (#169)
Co-authored-by: Mathieu Cantin <mcantin@petalmd.com>
Co-authored-by: Pete Shaw <lozlow@users.noreply.github.com>
2020-09-23 14:50:15 +01:00
Thom Seddon
41560feaa7 Support concurrent CSRF cookies by using a prefix of nonce (#187)
* Support concurrent CSRF cookies by using a prefix of nonce.
* Move ValidateState out and make CSRF cookies last 1h
* add tests to check csrf cookie nam + minor tweaks

Co-authored-by: Michal Witkowski <michal@cerberus>
2020-09-23 14:48:04 +01:00
Ricardo Sousa
1743537438 Fix simple-separate-pod url path (#148)
There is a missing slash in the `kubernetes/simple-separate-pod` example link, leading to a 404. This change fixes that url in the README.md file.
2020-07-17 14:14:27 +01:00
Thom Seddon
9e5994b959 Add Generic OAuth Provider (#138) 2020-06-29 21:04:42 +01:00
Thom Seddon
870724c994 Fail if there is an error retrieving the user + extra test (#142)
Previously this would fail, but permit the request, which isn't
normally what you'd want.
2020-06-29 21:02:45 +01:00
Thom Seddon
be2b4ba9f4 Remove unused user fields (#141)
These aren't actually used anywhere and can result in a parse error
if the ID field isn't a string
2020-06-29 21:01:59 +01:00
Thom Seddon
529e28d83b Add FUNDING.yml (#135) 2020-06-26 15:41:33 +01:00
Thom Seddon
2937b04fdb Add support for resource indicator to OIDC provider (#131) 2020-06-11 12:24:51 +01:00
Thom Seddon
fb8b216481 Optionally match emails against *either* whitelist or domains when both are provided (#106)
The previous behaviour would ignore domains if the whitelist parameter was provided, however if both parameters are provided then matching either is more likely the intent.
2020-06-03 14:11:59 +01:00
Thom Seddon
8b3a950162 Add logout endpoint (#107)
Add logout endpoint that clears the auth cookie + optional "logout-redirect" config option, to which, when set, the user will be redirected.
2020-06-03 14:00:47 +01:00
Thom Seddon
655eddeaf9 Add note on using auth host mode with selective auth 2020-05-26 14:55:23 +01:00
Thom Seddon
c63fd738d6 Rename selective auth + fix selective auth examples (#130) 2020-05-26 14:47:14 +01:00
Leland Sindt
00b5d9e031 standardize on 'traefik-forward-auth-secrets' for kubernetes examples (#127) 2020-05-26 14:12:26 +01:00
Thom Seddon
8902cf8735 Use Traefik v2 in README examples and links + use consistent images in examples 2020-05-23 16:42:18 +01:00
Thom Seddon
3345f8ec69 Add traefik v2 swarm examples 2020-05-23 14:43:52 +01:00
Thom Seddon
60604ad3db Always prompt user to select account on google login
This closes #103 and as discussed in that issue, hopefully fixes a
common source of error discussed in #31
2020-05-12 13:50:05 +01:00
Thom Seddon
a668454a11 Warn when using http without insecure cookie
Closes #114
2020-05-12 13:20:51 +01:00
Thom Seddon
eec62eb03a Improve logging detail and consistency
Closes #114
2020-05-11 14:42:53 +01:00
Thom Seddon
7381450015 Improve internal function docs 2020-05-11 14:42:33 +01:00
Thom Seddon
f7a94e7db9 Add traefik v2 kubernetes examples
Ref #72 #89 #92
2020-05-07 15:47:58 +01:00
Thom Seddon
f802a366de Add note on avoiding rules that might break redirect flow
Tracked in #101
2020-05-07 15:28:00 +01:00
Thom Seddon
07f9587bc1 Modify references from Universal Authentication to Global Authentication 2020-05-07 15:22:48 +01:00
Thom Seddon
1ac0ca9732 traefik v1.7 kubernetes doc fixes 2020-04-28 17:15:53 +01:00
Thom Seddon
9abf5645b7 Add kubernetes examples + better document methods of applying authentication
Closes #33
2020-04-24 14:22:29 +01:00
Thom Seddon
3a66191314 Document ARM releases on docker hub
This is now confirmed to be working so fixes #38
2020-04-23 14:35:06 +01:00
Pierre Kisters
c3b4ba8244 Allow multiple cookie domains, domains and whitelists with environment variable (#98)
* comma env-delim for array flags
* tests for env-delim flags
2020-04-14 07:48:55 +01:00
Thiago Pinto
b413c60d42 Update golang arm versions 2020-04-13 18:03:46 +01:00
Sandro Jäckel
e678a33016 Add .git to .dockerignore 2020-04-13 18:01:07 +01:00
Thom Seddon
3652a0b244 Add OIDC docs + examples 2020-02-10 17:09:09 +00:00
Thom Seddon
68c329901a Update go1.12 -> go1.13 + update dependencies + mod tidy 2020-02-10 17:09:09 +00:00
Thom Seddon
ffa5afbf22 Simplify oauth server testing 2020-02-10 17:09:09 +00:00
Thom Seddon
5a9c6adedf Multiple provider support + OIDC provider 2020-02-10 17:09:09 +00:00
Thom Seddon
5dfd4f2878 Add arm builds. Fixes #38 2019-09-30 10:53:01 +01:00
Thom Seddon
a99330e6b2 Fix typos 2019-09-30 10:44:46 +01:00
Sandro
5a676f3068 Fix rules argument (#71) 2019-09-20 16:28:11 +01:00
Thom Seddon
3e6ccc8f45 Redirect to login on cookie expiry + simplify ValidateCookie function
Possible fix for #31
2019-06-13 15:13:52 +01:00
Thom Seddon
3e92400202 Fix backwards compat on "domain" config + remove "domains" config
Fixes #48
2019-06-11 13:14:29 +01:00
Thom Seddon
72fc88a82b Add extra tests for env var backwards compat 2019-06-11 10:08:47 +01:00
Thom Seddon
2c148d3a23 Add releases info to README 2019-06-10 12:19:53 +01:00
Thom Seddon
d33ecc0654 Make rule parsing more robust
- check args length before popping
- ensure rule has name
2019-06-10 11:38:50 +01:00
Thom Seddon
41a3f2a5a9 Fix missing client id/secret log message 2019-06-10 11:24:14 +01:00
Thom Seddon
5a17187855 Fix go-flags dep + formatting 2019-05-13 11:56:43 +01:00
Thom Seddon
e7b567bc92 Fix typos. Inspired by #43 2019-05-13 11:27:31 +01:00
Thom Seddon
a4a34dcd76 Handle unknown ini options 2019-05-07 19:17:42 +01:00
Thom Seddon
d1b12e4ffb Fix host/method rule matching + tests 2019-05-07 14:16:38 +01:00
Thom Seddon
6f3ac5efe5 pre-release logging + docs improvements and fixes 2019-05-07 12:05:47 +01:00
Thom Seddon
b0e4b6333d Use updated go-flags version for env-namespace support
+ fix docs
2019-05-07 10:39:29 +01:00
Thom Seddon
dd13f42ddf Update README + examples for v2 2019-04-24 10:09:35 +01:00
Thom Seddon
8f6e911045 Backwards compatability fixes 2019-04-23 19:16:24 +01:00
Thom Seddon
fa61c84373 Fix, improve + test google provider initiation 2019-04-23 18:48:18 +01:00
Thom Seddon
3cc9cd13e1 Update option descriptions + prefer multiple singular options
Plus backwards compatability for legacy comma separated list options
2019-04-23 18:26:56 +01:00
Thom Seddon
93912f4a6e Overhaul testing to use testify 2019-04-23 17:49:16 +01:00
Thom Seddon
2074bc7727 Import f1ba9b5 2019-04-18 16:37:41 +01:00
Thom Seddon
6968f6181b Add more v2 tests + fixes + improve legacy config parsing 2019-04-18 16:37:41 +01:00
Thom Seddon
5597b7268b Use new rule config + tidy ups 2019-04-18 16:37:41 +01:00
Thom Seddon
e057f2d63a Improve qsdiff error reporting 2019-04-18 16:37:41 +01:00
Thom Seddon
9abe509f66 Refactor progress
- move directory structure
- string based rule definition
- use traefik rule parsing
- drop toml config
- new flag library
- implement go dep
2019-04-18 16:37:55 +01:00
Thom Seddon
d51b93d4b0 Begin refactor + selective auth 2019-04-18 16:37:41 +01:00
Thom Seddon
f1ba9b5ac4 Update default request logger to include original source IP 2019-04-18 16:05:03 +01:00
107 changed files with 6388 additions and 1388 deletions

View File

@@ -1,2 +1,3 @@
example
.travis.yml
.git

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: thomseddon

36
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.25'
- name: Check out code
uses: actions/checkout@v4
- name: Get dependencies
run: |
go get -v -t -d ./...
if [ -f Gopkg.toml ]; then
curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
dep ensure
fi
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...

71
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: '0 10 * * 2'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ['go']
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

57
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Release
on:
release:
types: [published]
jobs:
build:
name: Build release binaries
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.25'
id: go
- name: Build AMD64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -installsuffix nocgo -v -o traefik-forward-auth_amd64 ./cmd
- name: Build ARM
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm GO111MODULE=on go build -a -installsuffix nocgo -v -o traefik-forward-auth_arm ./cmd
- name: Get tag name
run: echo "TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Get artifact details
uses: octokit/request-action@v2.x
id: get_release_details
with:
route: get /repos/${{ github.repository }}/releases/tags/${{ env.TAG }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload AMD64 release asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ fromJson(steps.get_release_details.outputs.data).upload_url }}
asset_path: traefik-forward-auth_amd64
asset_name: traefik-forward-auth_amd64
asset_content_type: application/octet-stream
- name: Upload ARM release asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ fromJson(steps.get_release_details.outputs.data).upload_url }}
asset_path: traefik-forward-auth_arm
asset_name: traefik-forward-auth_arm
asset_content_type: application/octet-stream

View File

@@ -1,8 +0,0 @@
language: go
sudo: false
go:
- "1.10"
install:
- go get github.com/namsral/flag
- go get github.com/sirupsen/logrus
script: go test -v ./...

View File

@@ -1,18 +1,15 @@
FROM golang:1.10-alpine as builder
FROM golang:1.25-alpine as builder
# Setup
RUN mkdir /app
WORKDIR /app
RUN mkdir -p /go/src/github.com/thomseddon/traefik-forward-auth
WORKDIR /go/src/github.com/thomseddon/traefik-forward-auth
# Add libraries
RUN apk add --no-cache git && \
go get "github.com/namsral/flag" && \
go get "github.com/sirupsen/logrus" && \
apk del git
RUN apk add --no-cache git
# Copy & build
ADD . /app/
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /traefik-forward-auth .
ADD . /go/src/github.com/thomseddon/traefik-forward-auth/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -installsuffix nocgo -o /traefik-forward-auth github.com/thomseddon/traefik-forward-auth/cmd
# Copy into scratch container
FROM scratch

18
Dockerfile.arm Normal file
View File

@@ -0,0 +1,18 @@
FROM golang:1.25-alpine as builder
# Setup
RUN mkdir -p /go/src/github.com/thomseddon/traefik-forward-auth
WORKDIR /go/src/github.com/thomseddon/traefik-forward-auth
# Add libraries
RUN apk add --no-cache git
# Copy & build
ADD . /go/src/github.com/thomseddon/traefik-forward-auth/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm GO111MODULE=on go build -a -installsuffix nocgo -o /traefik-forward-auth github.com/thomseddon/traefik-forward-auth/cmd
# Copy into scratch container
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /traefik-forward-auth ./
ENTRYPOINT ["./traefik-forward-auth"]

18
Dockerfile.arm64 Normal file
View File

@@ -0,0 +1,18 @@
FROM golang:1.25-alpine as builder
# Setup
RUN mkdir -p /go/src/github.com/thomseddon/traefik-forward-auth
WORKDIR /go/src/github.com/thomseddon/traefik-forward-auth
# Add libraries
RUN apk add --no-cache git
# Copy & build
ADD . /go/src/github.com/thomseddon/traefik-forward-auth/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GO111MODULE=on go build -a -installsuffix nocgo -o /traefik-forward-auth github.com/thomseddon/traefik-forward-auth/cmd
# Copy into scratch container
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /traefik-forward-auth ./
ENTRYPOINT ["./traefik-forward-auth"]

View File

@@ -1,5 +1,8 @@
format:
gofmt -w -s *.go
gofmt -w -s internal/*.go internal/provider/*.go cmd/*.go
.PHONY: format
test:
go test -v ./...
.PHONY: format test

503
README.md
View File

@@ -1,90 +1,481 @@
# Traefik Forward Auth [![Build Status](https://travis-ci.org/thomseddon/traefik-forward-auth.svg?branch=master)](https://travis-ci.org/thomseddon/traefik-forward-auth) [![Go Report Card](https://goreportcard.com/badge/github.com/thomseddon/traefik-forward-auth)](https://goreportcard.com/badge/github.com/thomseddon/traefik-forward-auth)
# Traefik Forward Auth ![Build Status](https://img.shields.io/github/workflow/status/thomseddon/traefik-forward-auth/CI) [![Go Report Card](https://goreportcard.com/badge/github.com/thomseddon/traefik-forward-auth)](https://goreportcard.com/report/github.com/thomseddon/traefik-forward-auth) ![Docker Pulls](https://img.shields.io/docker/pulls/thomseddon/traefik-forward-auth.svg) [![GitHub release](https://img.shields.io/github/release/thomseddon/traefik-forward-auth.svg)](https://GitHub.com/thomseddon/traefik-forward-auth/releases/)
A minimal forward authentication service that provides Google oauth based login and authentication for the traefik reverse proxy.
A minimal forward authentication service that provides OAuth/SSO login and authentication for the [traefik](https://github.com/containous/traefik) reverse proxy/load balancer.
## Why?
- Seamlessly overlays any http service with a single endpoint (see: `-url-path` in [Configuration](#configuration))
- Seamlessly overlays any http service with a single endpoint (see: `url-path` in [Configuration](#configuration))
- Supports multiple providers including Google and OpenID Connect (supported by Azure, Github, Salesforce etc.)
- Supports multiple domains/subdomains by dynamically generating redirect_uri's
- Allows authentication to be selectively applied/bypassed based on request parameters (see `rules` in [Configuration](#configuration))
- Supports use of centralised authentication host/redirect_uri (see `auth-host` in [Configuration](#configuration))
- Allows authentication to persist across multiple domains (see [Cookie Domains](#cookie-domains))
- Supports extended authentication beyond Google token lifetime (see: `-lifetime` in [Configuration](#configuration))
- Supports extended authentication beyond Google token lifetime (see: `lifetime` in [Configuration](#configuration))
## Quick Start
# Contents
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.
- [Releases](#releases)
- [Usage](#usage)
- [Simple](#simple)
- [Advanced](#advanced)
- [Provider Setup](#provider-setup)
- [Configuration](#configuration)
- [Overview](#overview)
- [Option Details](#option-details)
- [Concepts](#concepts)
- [Forwarded Headers](#forwarded-headers)
- [User Restriction](#user-restriction)
- [Applying Authentication](#applying-authentication)
- [Global Authentication](#global-authentication)
- [Selective Ingress Authentication in Kubernetes](#selective-ingress-authentication-in-kubernetes)
- [Selective Container Authentication in Swarm](#selective-container-authentication-in-swarm)
- [Rules Based Authentication](#rules-based-authentication)
- [Operation Modes](#operation-modes)
- [Overlay Mode](#overlay-mode)
- [Auth Host Mode](#auth-host-mode)
- [Logging Out](#logging-out)
- [Copyright](#copyright)
- [License](#license)
## Configuration
## Releases
The following configuration is supported:
We recommend using the `2` tag on docker hub (`thomseddon/traefik-forward-auth:2`).
You can also use the latest incremental releases found on [docker hub](https://hub.docker.com/r/thomseddon/traefik-forward-auth/tags) and [github](https://github.com/thomseddon/traefik-forward-auth/releases).
|Flag |Type |Description|
|-----------------------|------|-----------|
|-client-id|string|*Google Client ID (required)|
|-client-secret|string|*Google Client Secret (required)|
|-secret|string|*Secret used for signing (required)|
|-config|string|Path to config file|
|-auth-host|string|Central auth login (see below)|
|-cookie-domains|string|Comma separated list of cookie domains (see below)|
|-cookie-name|string|Cookie Name (default "_forward_auth")|
|-cookie-secure|bool|Use secure cookies (default true)|
|-csrf-cookie-name|string|CSRF Cookie Name (default "_forward_auth_csrf")|
|-domain|string|Comma separated list of email domains to allow|
|-whitelist|string|Comma separated list of email addresses to allow|
|-lifetime|int|Session length in seconds (default 43200)|
|-url-path|string|Callback URL (default "_oauth")|
|-prompt|string|Space separated list of [OpenID prompt options](https://developers.google.com/identity/protocols/OpenIDConnect#prompt)|
|-log-level|string|Log level: trace, debug, info, warn, error, fatal, panic (default "warn")|
|-log-format|string|Log format: text, json, pretty (default "text")|
ARM releases are also available on docker hub, just append `-arm` or `-arm64` to your desired released (e.g. `2-arm` or `2.1-arm64`).
Configuration can also be supplied as environment variables (use upper case and swap `-`'s for `_`'s e.g. `-client-id` becomes `CLIENT_ID`)
We also build binary files for usage without docker starting with releases after 2.2.0 You can find these as assets of the specific GitHub release.
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`)
#### Upgrade Guide
## OAuth Configuration
Head to https://console.developers.google.com & make sure you've switched to the correct email account.
Create a new project then search for and select "Credentials" in the search bar. Fill out the "OAuth Consent Screen" tab.
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)
v2 was released in June 2019, whilst this is fully backwards compatible, a number of configuration options were modified, please see the [upgrade guide](https://github.com/thomseddon/traefik-forward-auth/wiki/v2-Upgrade-Guide) to prevent warnings on startup and ensure you are using the current configuration.
## Usage
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).
#### Simple:
## User Restriction
See below for instructions on how to setup your [Provider Setup](#provider-setup).
docker-compose.yml:
```yaml
version: '3'
services:
traefik:
image: traefik:v2.2
command: --providers.docker
ports:
- "8085:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
traefik-forward-auth:
image: thomseddon/traefik-forward-auth:2
environment:
- PROVIDERS_GOOGLE_CLIENT_ID=your-client-id
- PROVIDERS_GOOGLE_CLIENT_SECRET=your-client-secret
- SECRET=something-random
- INSECURE_COOKIE=true # Example assumes no https, do not use in production
labels:
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.address=http://traefik-forward-auth:4181"
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.authResponseHeaders=X-Forwarded-User"
- "traefik.http.services.traefik-forward-auth.loadbalancer.server.port=4181"
whoami:
image: containous/whoami
labels:
- "traefik.http.routers.whoami.rule=Host(`whoami.mycompany.com`)"
- "traefik.http.routers.whoami.middlewares=traefik-forward-auth"
```
#### Advanced:
Please see the examples directory for a more complete [docker-compose.yml](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/swarm/docker-compose.yml) or [kubernetes/simple-separate-pod](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/kubernetes/simple-separate-pod/).
Also in the examples directory is [docker-compose-auth-host.yml](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/swarm/docker-compose-auth-host.yml) and [kubernetes/advanced-separate-pod](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/kubernetes/advanced-separate-pod/) which shows how to configure a central auth host, along with some other options.
#### Provider Setup
Below are some general notes on provider setup, specific instructions and examples for a number of providers can be found on the [Provider Setup](https://github.com/thomseddon/traefik-forward-auth/wiki/Provider-Setup) wiki page.
##### Google
Head to https://console.developers.google.com and make sure you've switched to the correct email account.
Create a new project then search for and select "Credentials" in the search bar. Fill out the "OAuth Consent Screen" tab.
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)
You must set the `providers.google.client-id` and `providers.google.client-secret` config options.
##### OpenID Connect
Any provider that supports OpenID Connect 1.0 can be configured via the OIDC config options below.
You must set the `providers.oidc.issuer-url`, `providers.oidc.client-id` and `providers.oidc.client-secret` config options.
Please see the [Provider Setup](https://github.com/thomseddon/traefik-forward-auth/wiki/Provider-Setup) wiki page for examples.
##### Generic OAuth2
For providers that don't support OpenID Connect, we also have the Generic OAuth2 provider where you can statically configure the OAuth2 and "user" endpoints.
You must set:
- `providers.generic-oauth.auth-url` - URL the client should be sent to authenticate the authenticate
- `providers.generic-oauth.token-url` - URL the service should call to exchange an auth code for an access token
- `providers.generic-oauth.user-url` - URL used to retrieve user info (service makes a GET request)
- `providers.generic-oauth.client-id` - Client ID
- `providers.generic-oauth.client-secret` - Client Secret
You can also set:
- `providers.generic-oauth.scope`- Any scopes that should be included in the request (default: profile, email)
- `providers.generic-oauth.token-style` - How token is presented when querying the User URL. Can be `header` or `query`, defaults to `header`. With `header` the token is provided in an Authorization header, with query the token is provided in the `access_token` query string value.
Please see the [Provider Setup](https://github.com/thomseddon/traefik-forward-auth/wiki/Provider-Setup) wiki page for examples.
## Configuration
### Overview
The following configuration options are supported:
```
Usage:
traefik-forward-auth [OPTIONS]
Application Options:
--log-level=[trace|debug|info|warn|error|fatal|panic] Log level (default: warn) [$LOG_LEVEL]
--log-format=[text|json|pretty] Log format (default: text) [$LOG_FORMAT]
--auth-host= Single host to use when returning from 3rd party auth [$AUTH_HOST]
--config= Path to config file [$CONFIG]
--cookie-domain= Domain to set auth cookie on, can be set multiple times [$COOKIE_DOMAIN]
--insecure-cookie Use insecure cookies [$INSECURE_COOKIE]
--cookie-name= Cookie Name (default: _forward_auth) [$COOKIE_NAME]
--csrf-cookie-name= CSRF Cookie Name (default: _forward_auth_csrf) [$CSRF_COOKIE_NAME]
--default-action=[auth|allow] Default action (default: auth) [$DEFAULT_ACTION]
--default-provider=[google|oidc|generic-oauth] Default provider (default: google) [$DEFAULT_PROVIDER]
--domain= Only allow given email domains, can be set multiple times [$DOMAIN]
--lifetime= Lifetime in seconds (default: 43200) [$LIFETIME]
--logout-redirect= URL to redirect to following logout [$LOGOUT_REDIRECT]
--url-path= Callback URL Path (default: /_oauth) [$URL_PATH]
--secret= Secret used for signing (required) [$SECRET]
--whitelist= Only allow given email addresses, can be set multiple times [$WHITELIST]
--port= Port to listen on (default: 4181) [$PORT]
--rule.<name>.<param>= Rule definitions, param can be: "action", "rule" or "provider"
Google Provider:
--providers.google.client-id= Client ID [$PROVIDERS_GOOGLE_CLIENT_ID]
--providers.google.client-secret= Client Secret [$PROVIDERS_GOOGLE_CLIENT_SECRET]
--providers.google.prompt= Space separated list of OpenID prompt options [$PROVIDERS_GOOGLE_PROMPT]
OIDC Provider:
--providers.oidc.issuer-url= Issuer URL [$PROVIDERS_OIDC_ISSUER_URL]
--providers.oidc.client-id= Client ID [$PROVIDERS_OIDC_CLIENT_ID]
--providers.oidc.client-secret= Client Secret [$PROVIDERS_OIDC_CLIENT_SECRET]
--providers.oidc.resource= Optional resource indicator [$PROVIDERS_OIDC_RESOURCE]
Generic OAuth2 Provider:
--providers.generic-oauth.auth-url= Auth/Login URL [$PROVIDERS_GENERIC_OAUTH_AUTH_URL]
--providers.generic-oauth.token-url= Token URL [$PROVIDERS_GENERIC_OAUTH_TOKEN_URL]
--providers.generic-oauth.user-url= URL used to retrieve user info [$PROVIDERS_GENERIC_OAUTH_USER_URL]
--providers.generic-oauth.client-id= Client ID [$PROVIDERS_GENERIC_OAUTH_CLIENT_ID]
--providers.generic-oauth.client-secret= Client Secret [$PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET]
--providers.generic-oauth.scope= Scopes (default: profile, email) [$PROVIDERS_GENERIC_OAUTH_SCOPE]
--providers.generic-oauth.token-style=[header|query] How token is presented when querying the User URL (default: header)
[$PROVIDERS_GENERIC_OAUTH_TOKEN_STYLE]
--providers.generic-oauth.resource= Optional resource indicator [$PROVIDERS_GENERIC_OAUTH_RESOURCE]
Help Options:
-h, --help Show this help message
```
All options can be supplied in any of the following ways, in the following precedence (first is highest precedence):
1. **Command Arguments/Flags** - As shown above
2. **Environment Variables** - As shown in square brackets above
3. **File**
1. Use INI format (e.g. `url-path = _oauthpath`)
2. Specify the file location via the `--config` flag or `$CONFIG` environment variable
3. Can be specified multiple times, each file will be read in the order they are passed
### Option Details
- `auth-host`
When set, when a user returns from authentication with a 3rd party provider they will always be forwarded to this host. By using one central host, this means you only need to add this `auth-host` as a valid redirect uri to your 3rd party provider.
The host should be specified without protocol or path, for example:
```
--auth-host="auth.example.com"
```
For more details, please also read the [Auth Host Mode](#auth-host-mode), operation mode in the concepts section.
Please Note - this should be considered advanced usage, if you are having problems please try disabling this option and then re-read the [Auth Host Mode](#auth-host-mode) section.
- `config`
Used to specify the path to a configuration file, can be set multiple times, each file will be read in the order they are passed. Options should be set in an INI format, for example:
```
url-path = _oauthpath
```
- `cookie-domain`
When set, if a user successfully completes authentication, then if the host of the original request requiring authentication is a subdomain of a given cookie domain, then the authentication cookie will be set for the higher level cookie domain. This means that a cookie can allow access to multiple subdomains without re-authentication. Can be specificed multiple times.
For example:
```
--cookie-domain="example.com" --cookie-domain="test.org"
```
For example, if the cookie domain `test.com` has been set, and a request comes in on `app1.test.com`, following authentication the auth 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.
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 a different `cookie-name` in each host/cluster or by using the same `cookie-secret` in both instances.
- `insecure-cookie`
If you are not using HTTPS between the client and traefik, you will need to pass the `insecure-cookie` option which will mean the `Secure` attribute on the cookie will not be set.
- `cookie-name`
Set the name of the cookie set following successful authentication.
Default: `_forward_auth`
- `csrf-cookie-name`
Set the name of the temporary CSRF cookie set during authentication.
Default: `_forward_auth_csrf`
- `default-action`
Specifies the behavior when a request does not match any [rules](#rules). Valid options are `auth` or `allow`.
Default: `auth` (i.e. all requests require authentication)
- `default-provider`
Set the default provider to use for authentication, this can be overridden within [rules](#rules). Valid options are currently `google` or `oidc`.
Default: `google`
- `domain`
When set, only users matching a given domain will be permitted to access.
For example, setting `--domain=example.com --domain=test.org` would mean that only users from example.com or test.org will be permitted. So thom@example.com would be allowed but thom@another.com would not.
For more details, please also read [User Restriction](#user-restriction) in the concepts section.
- `lifetime`
How long a successful authentication session should last, in seconds.
Default: `43200` (12 hours)
- `logout-redirect`
When set, users will be redirected to this URL following logout.
- `match-whitelist-or-domain`
When enabled, users will be permitted if they match *either* the `whitelist` or `domain` parameters.
This will be enabled by default in v3, but is disabled by default in v2 to maintain backwards compatibility.
Default: `false`
For more details, please also read [User Restriction](#user-restriction) in the concepts section.
- `url-path`
Customise the path that this service uses to handle the callback following authentication.
Default: `/_oauth`
Please note that when using the default [Overlay Mode](#overlay-mode) requests to this exact path will be intercepted by this service and not forwarded to your application. Use this option (or [Auth Host Mode](#auth-host-mode)) if the default `/_oauth` path will collide with an existing route in your application.
- `secret`
Used to sign cookies authentication, should be a random (e.g. `openssl rand -hex 16`)
- `whitelist`
When set, only specified users will be permitted.
For example, setting `--whitelist=thom@example.com --whitelist=alice@example.com` would mean that only those two exact users will be permitted. So thom@example.com would be allowed but john@example.com would not.
For more details, please also read [User Restriction](#user-restriction) in the concepts section.
- `rule`
Specify selective authentication rules. Rules are specified in the following format: `rule.<name>.<param>=<value>`
- `<name>` can be any string and is only used to group rules together
- `<param>` can be:
- `action` - same usage as [`default-action`](#default-action), supported values:
- `auth` (default)
- `allow`
- `domains` - optional, same usage as [`domain`](#domain)
- `provider` - same usage as [`default-provider`](#default-provider), supported values:
- `google`
- `oidc`
- `rule` - a rule to match a request, this uses traefik's v2 rule parser for which you can find the documentation here: https://docs.traefik.io/v2.0/routing/routers/#rule, supported values are summarised here:
- ``Headers(`key`, `value`)``
- ``HeadersRegexp(`key`, `regexp`)``
- ``Host(`example.com`, ...)``
- ``HostRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``
- ``Method(methods, ...)``
- ``Path(`path`, `/articles/{category}/{id:[0-9]+}`, ...)``
- ``PathPrefix(`/products/`, `/articles/{category}/{id:[0-9]+}`)``
- ``Query(`foo=bar`, `bar=baz`)``
- `whitelist` - optional, same usage as whitelist`](#whitelist)
For example:
```
# Allow requests that being with `/api/public` and contain the `Content-Type` header with a value of `application/json`
rule.1.action = allow
rule.1.rule = PathPrefix(`/api/public`) && Headers(`Content-Type`, `application/json`)
# Allow requests that have the exact path `/public`
rule.two.action = allow
rule.two.rule = Path(`/public`)
# Use OpenID Connect provider (must be configured) for requests that begin with `/github`
rule.oidc.action = auth
rule.oidc.provider = oidc
rule.oidc.rule = PathPrefix(`/github`)
# Allow jane@example.com to `/janes-eyes-only`
rule.two.action = allow
rule.two.rule = Path(`/janes-eyes-only`)
rule.two.whitelist = jane@example.com
```
Note: It is possible to break your redirect flow with rules, please be careful not to create an `allow` rule that matches your redirect_uri unless you know what you're doing. This limitation is being tracked in in #101 and the behaviour will change in future releases.
## Concepts
### User Restriction
You can restrict who can login with the following parameters:
* `-domain` - Use this to limit logins to a specific domain, e.g. test.com only
* `-whitelist` - Use this to only allow specific users to login e.g. thom@test.com only
* `domain` - Use this to limit logins to a specific domain, e.g. test.com only
* `whitelist` - Use this to only allow specific users to login e.g. thom@test.com only
Note, if you pass `whitelist` then only this is checked and `domain` is effectively ignored.
Note, if you pass both `whitelist` and `domain`, then the default behaviour is for only `whitelist` to be used and `domain` will be effectively ignored. You can allow users matching *either* `whitelist` or `domain` by passing the `match-whitelist-or-domain` parameter (this will be the default behaviour in v3). If you set `domains` or `whitelist` on a rule, the global configuration is ignored.
## Cookie Domains
### Forwarded Headers
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.
The authenticated user is set in the `X-Forwarded-User` header, to pass this on add this to the `authResponseHeaders` config option in traefik, as shown below in the [Applying Authentication](#applying-authentication) section.
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.
### Applying Authentication
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.
Authentication can be applied in a variety of ways, either globally across all requests, or selectively to specific containers/ingresses.
## Operation Modes
#### Global Authentication
#### Overlay
This can be achieved by enabling forward authentication for an entire entrypoint, for example, with http only:
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.
```ini
--entryPoints.http.address=:80
--entrypoints.http.http.middlewares=traefik-forward-auth # "default-traefik-forward-auth" on kubernetes
```
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`.
Or https:
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)
```ini
--entryPoints.http.address=:80
--entryPoints.http.http.redirections.entryPoint.to=https
--entryPoints.http.http.redirections.entryPoint.scheme=https
--entryPoints.https.address=:443
--entrypoints.https.http.middlewares=traefik-forward-auth # "default-traefik-forward-auth" on kubernetes
```
#### Auth Host
Note: Traefik prepends the namespace to the name of middleware defined via a kubernetes resource. This is handled automatically when referencing the middleware from another resource in the same namespace (so the namespace does not need to be prepended when referenced). However the full name, including the namespace, must be used when referenced from static configuration (e.g. command arguments or config file), hence you must prepend the namespace to your traefik-forward-auth middleware reference, as shown in the comments above (e.g. `default-traefik-forward-auth` if your middleware is named `traefik-forward-auth` and is defined in the `default` namespace).
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)).
#### Selective Ingress Authentication in Kubernetes
If you choose not to enable forward authentication for a specific entrypoint, you can apply the middleware to selected ingressroutes:
```yaml
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: whoami
labels:
app: whoami
spec:
entryPoints:
- http
routes:
- match: Host(`whoami.example.com`)
kind: Rule
services:
- name: whoami
port: 80
middlewares:
- name: traefik-forward-auth
```
See the examples directory for more examples.
#### Selective Container Authentication in Swarm
You can apply labels to selected containers:
```yaml
whoami:
image: containous/whoami
labels:
- "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
- "traefik.http.routers.whoami.middlewares=traefik-forward-auth"
```
See the examples directory for more examples.
#### Rules Based Authentication
You can also leverage the `rules` config to selectively apply authentication via traefik-forward-auth. For example if you enabled global authentication by enabling forward authentication for an entire entrypoint, you can still exclude some patterns from requiring authentication:
```ini
# Allow requests to 'dash.example.com'
rule.1.action = allow
rule.1.rule = Host(`dash.example.com`)
# Allow requests to `app.example.com/public`
rule.two.action = allow
rule.two.rule = Host(`app.example.com`) && Path(`/public`)
```
### Operation Modes
#### Overlay Mode
Overlay is the default operation mode, in this mode the authorisation endpoint is overlaid onto any domain. By default the `/_oauth` path is used, this can be customised using the `url-path` option.
The user flow will be:
1. Request to `www.myapp.com/home`
2. User redirected to Google login
3. After Google login, user is redirected to `www.myapp.com/_oauth`
4. Token, user and CSRF cookie is validated (this request in intercepted and is never passed to your application)
5. User is redirected to `www.myapp.com/home`
6. Request is allowed
As the hostname in the `redirect_uri` is dynamically generated based on the original 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)
#### Auth Host Mode
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/examples/traefik-v2/swarm/docker-compose-auth-host.yml) or [this kubernetes example](https://github.com/thomseddon/traefik-forward-auth/tree/master/examples/traefik-v2/kubernetes/advanced-separate-pod)).
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.
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`.
@@ -105,6 +496,16 @@ Two criteria must be met for an `auth-host` to be used:
1. Request matches given `cookie-domain`
2. `auth-host` is also subdomain of same `cookie-domain`
Please note: For Auth Host mode to work, you must ensure that requests to your auth-host are routed to the traefik-forward-auth container, as demonstrated with the service labels in the [docker-compose-auth.yml](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/swarm/docker-compose-auth-host.yml) example and the [ingressroute resource](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/kubernetes/advanced-separate-pod/traefik-forward-auth/ingress.yaml) in a kubernetes example.
### Logging Out
The service provides an endpoint to clear a users session and "log them out". The path is created by appending `/logout` to your configured `path` and so with the default settings it will be: `/_oauth/logout`.
You can use the `logout-redirect` config option to redirect users to another URL following logout (note: the user will not have a valid auth cookie after being logged out).
Note: This only clears the auth cookie from the users browser and as this service is stateless, it does not invalidate the cookie against future use. So if the cookie was recorded, for example, it could continue to be used for the duration of the cookie lifetime.
## Copyright
2018 Thom Seddon

31
cmd/main.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"fmt"
"net/http"
internal "github.com/thomseddon/traefik-forward-auth/internal"
)
// Main
func main() {
// Parse options
config := internal.NewGlobalConfig()
// Setup logger
log := internal.NewDefaultLogger()
// Perform config validation
config.Validate()
// Build server
server := internal.NewServer()
// Attach router to default server
http.HandleFunc("/", server.RootHandler)
// Start
log.WithField("config", config).Debug("Starting with config")
log.Infof("Listening on :%d", config.Port)
log.Info(http.ListenAndServe(fmt.Sprintf(":%d", config.Port), nil))
}

View File

@@ -1,48 +0,0 @@
version: '3'
services:
traefik:
image: traefik
command: -c /traefik.toml
# command: -c /traefik.toml --logLevel=DEBUG
ports:
- "8085:80"
- "8086:8080"
networks:
- traefik
volumes:
- ./traefik.toml:/traefik.toml
- /var/run/docker.sock:/var/run/docker.sock
whoami1:
image: emilevauge/whoami
networks:
- traefik
labels:
- "traefik.backend=whoami1"
- "traefik.enable=true"
- "traefik.frontend.rule=Host:whoami.localhost.com"
whoami2:
image: emilevauge/whoami
networks:
- traefik
labels:
- "traefik.backend=whoami2"
- "traefik.enable=true"
- "traefik.frontend.rule=Host:whoami.localhost.org"
traefik-forward-auth:
build: ../
environment:
- CLIENT_ID=test
- CLIENT_SECRET=test
- COOKIE_SECRET=something-random
- COOKIE_SECURE=false
- COOKIE_DOMAINS=localhost.com
- AUTH_URL=http://auth.localhost.com:8085/_oauth
networks:
- traefik
networks:
traefik:

View File

@@ -0,0 +1,10 @@
# Kubernetes
These examples show how to deploy traefik-forward-auth alongside traefik v1.7.
The "seperate pod" examples show traefik-forward-auth in it's own pod and leave the deployment of traefik as an exercise for the user (e.g. if using helm).
The "single pod" examples show traefik and traefik-forward-auth in a single pod.
Please see the README's in each example for more details.

View File

@@ -0,0 +1,16 @@
# Kubernetes - Advanced Separate Pod Example
This is an advanced example of how to deploy traefik-forward-auth in it's own pod. This example is a good starting point for those who already have traefik deployed (e.g. using helm).
This example uses [Selective Authentication](https://github.com/thomseddon/traefik-forward-auth/blob/master/README.md#selective-ingress-authentication-in-kubernetes) to selectively apply forward authentication to each selective ingress, a simple example "whoami" application (deployment, service and ingress) is included for completeness.
This example leverages kustomise to define Secrets and ConfigMaps, example deployment:
```
# Deploy traefik-forward-auth
kubectl apply -k traefik-forward-auth
# Deploy example whoami app
kubectl apply -k whoami
```

View File

@@ -0,0 +1,3 @@
bases:
- traefik-forward-auth
- whoami

View File

@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
spec:
replicas: 1
selector:
matchLabels:
app: traefik-forward-auth
strategy:
type: Recreate
template:
metadata:
labels:
app: traefik-forward-auth
spec:
terminationGracePeriodSeconds: 60
containers:
- image: thomseddon/traefik-forward-auth:2
name: traefik-forward-auth
ports:
- containerPort: 4181
protocol: TCP
env:
- name: CONFIG
value: "/config"
- name: DOMAIN
value: "example.com"
# INSECURE_COOKIE is required unless using https entrypoint
- name: INSECURE_COOKIE
value: "true"
# Remove COOKIE_DOMAIN if not using auth host mode
- name: COOKIE_DOMAIN
value: "example.com"
# Remove AUTH_HOST if not using auth host mode
- name: AUTH_HOST
value: "auth.example.com"
- name: LOG_LEVEL
value: "info"
- name: PROVIDERS_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-id
- name: PROVIDERS_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-secret
- name: SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: secret
volumeMounts:
- name: configs
mountPath: /config
subPath: traefik-forward-auth.ini
volumes:
- name: configs
configMap:
name: configs
- name: traefik-forward-auth-secrets
secret:
secretName: secrets

View File

@@ -0,0 +1,22 @@
#
# NOTE: This is only needed if you are using auth-host mode
#
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
annotations:
kubernetes.io/ingress.class: traefik
ingress.kubernetes.io/auth-type: forward
ingress.kubernetes.io/auth-url: http://traefik-forward-auth:4181
ingress.kubernetes.io/auth-response-headers: X-Forwarded-User
spec:
rules:
- host: auth.example.com
http:
paths:
- backend:
serviceName: traefik-forward-auth
servicePort: auth-http

View File

@@ -0,0 +1,22 @@
commonLabels:
app: traefik-forward-auth
resources:
- deployment.yaml
- service.yaml
- ingress.yaml # Only needed for auth-host mode
#
# Configs
#
configMapGenerator:
- name: configs
files:
- traefik-forward-auth.ini
#
# Secrets
#
secretGenerator:
- name: traefik-forward-auth-secrets
env: traefik-forward-auth.env

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
spec:
type: ClusterIP
selector:
app: traefik-forward-auth
ports:
- name: auth-http
port: 4181
targetPort: 4181

View File

@@ -0,0 +1,3 @@
google-client-id=client-id
google-client-secret=client-secret
secret=something-random

View File

@@ -0,0 +1,8 @@
rule.example_public.action=allow
rule.example_public.rule=Host("stats.example.com") && PathPrefix("/api/public")
rule.example_api.action=allow
rule.example_api.rule=Host("api.example.com") && Headers("X-API-Authorization", "a-long-api-key")
rule.example_api_query.action=allow
rule.example_api_query.rule=Host("api.example.com") && && Query("api_key=a-long-api-key")

View File

@@ -0,0 +1,19 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
labels:
app: whoami
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- image: containous/whoami
name: whoami

View File

@@ -0,0 +1,19 @@
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: whoami
labels:
app: whoami
annotations:
kubernetes.io/ingress.class: traefik
ingress.kubernetes.io/auth-type: forward
ingress.kubernetes.io/auth-url: http://traefik-forward-auth:4181
ingress.kubernetes.io/auth-response-headers: X-Forwarded-User
spec:
rules:
- host: whoami.example.com
http:
paths:
- backend:
serviceName: whoami
servicePort: http

View File

@@ -0,0 +1,7 @@
commonLabels:
app: whoami
resources:
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: whoami
labels:
app: whoami
spec:
type: ClusterIP
ports:
- name: http
port: 80
selector:
app: whoami

View File

@@ -0,0 +1,18 @@
# Kubernetes - Advanced Single Pod Example
This is an advanced example of how to deploy traefik and traefik-forward-auth in a single pod. This example is a good starting point for those who already have a manually defined traefik config (e.g. not using helm).
This example uses [Global Authentication](https://github.com/thomseddon/traefik-forward-auth/blob/master/README.md#global-authentication) to apply authentication for the entire `https` entrypoint.
This example also includes SSL via traefik acme/lesencrypt, auth host mode, exposes the traefik dashboard and leverages kustomise. No special config if required for your applications, but a simple example "whoami" application (deployment, service and ingress) is included for completeness.
Example deployment:
```
# Deploy traefik+traefik-forward-auth
kubectl apply -k traefik
# Deploy whoami app
kubectl apply -k whoami
```

View File

@@ -0,0 +1,3 @@
bases:
- traefik
- whoami

View File

@@ -0,0 +1,8 @@
rule.example_public.action=allow
rule.example_public.rule=Host("stats.example.com") && PathPrefix("/api/public")
rule.example_api.action=allow
rule.example_api.rule=Host("api.example.com") && Headers("X-API-Authorization", "a-long-api-key")
rule.example_api_query.action=allow
rule.example_api_query.rule=Host("api.example.com") && && Query("api_key=a-long-api-key")

View File

@@ -0,0 +1,169 @@
################################################################
# Global configuration
################################################################
# Enable debug mode
#
# Optional
# Default: false
#
# debug = true
# Log level
#
# Optional
# Default: "ERROR"
#
logLevel = "INFO"
# Entrypoints to be used by frontends that do not specify any entrypoint.
# Each frontend can specify its own entrypoints.
#
# Optional
# Default: ["http"]
#
defaultEntryPoints = ["http", "https"]
# If set to true invalid SSL certificates are accepted for backends.
# This disables detection of man-in-the-middle attacks so should only be used on secure backend networks.
#
# Optional
# Default: false
#
insecureSkipVerify = true
################################################################
# Entrypoints configuration
################################################################
# Entrypoints definition
#
# Optional
# Default:
[entryPoints]
[entryPoints.http]
address = ":80"
compress = true
[entryPoints.http.redirect]
entryPoint = "https"
[entryPoints.https]
address = ":443"
compress = true
[entryPoints.https.tls]
[entryPoints.https.auth.forward]
address = "http://127.0.0.1:4181"
authResponseHeaders = ["X-Forwarded-User"]
[entryPoints.traefik]
address = ":8080"
################################################################
# Traefik logs configuration
################################################################
# Traefik logs
# Enabled by default and log to stdout
#
# Optional
#
[traefikLog]
format = "json"
# Sets the filepath for the traefik log. If not specified, stdout will be used.
# Intermediate directories are created if necessary.
#
# Optional
# Default: os.Stdout
#
# filePath = "log/traefik.log"
# Format is either "json" or "common".
#
# Optional
# Default: "common"
#
# format = "common"
################################################################
# Access logs configuration
################################################################
# Enable access logs
# By default it will write to stdout and produce logs in the textual
# Common Log Format (CLF), extended with additional fields.
#
# Optional
#
# [accessLog]
# Sets the file path for the access log. If not specified, stdout will be used.
# Intermediate directories are created if necessary.
#
# Optional
# Default: os.Stdout
#
# filePath = "/path/to/log/log.txt"
# Format is either "json" or "common".
#
# Optional
# Default: "common"
#
# format = "common"
################################################################
# API and dashboard configuration
################################################################
# Enable API and dashboard
[api]
# Name of the related entry point
#
# Optional
# Default: "traefik"
#
# entryPoint = "traefik"
# Enabled Dashboard
#
# Optional
# Default: true
#
# dashboard = false
################################################################
# Ping configuration
################################################################
# Enable ping
[ping]
# Name of the related entry point
#
# Optional
# Default: "traefik"
#
# entryPoint = "traefik"
################################################################
# Docker configuration backend
################################################################
# Enable Kubernetes configuration backend
[kubernetes]
[acme]
KeyType = "RSA4096"
email = "you@example.com"
storage = "/acme/acme.json"
entryPoint = "https"
onHostRule = true
acmeLogging = true
[acme.httpChallenge]
entryPoint = "http"

View File

@@ -0,0 +1,94 @@
#
# Traefik + Traefik Forward Auth Deployment
#
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik
labels:
app: traefik
spec:
replicas: 1
selector:
matchLabels:
app: traefik
strategy:
type: Recreate
template:
metadata:
labels:
app: traefik
spec:
serviceAccountName: traefik
terminationGracePeriodSeconds: 60
containers:
- image: traefik:1.7.12
name: traefik
args:
- --configfile=/config/traefik.toml
ports:
- name: http
containerPort: 80
protocol: TCP
- name: https
containerPort: 443
protocol: TCP
- name: dash
containerPort: 8080
protocol: TCP
volumeMounts:
- mountPath: /config
name: configs
- mountPath: /acme
name: acme
- image: thomseddon/traefik-forward-auth:2
name: traefik-forward-auth
ports:
- containerPort: 4181
protocol: TCP
env:
- name: CONFIG
value: "/config"
- name: DOMAIN
value: "example.com"
# INSECURE_COOKIE is required if not using a https entrypoint
# - name: INSECURE_COOKIE
# value: "true"
# Remove COOKIE_DOMAIN if not using auth host mode
- name: COOKIE_DOMAIN
value: "example.com"
- name: AUTH_HOST
value: "auth.example.com"
- name: LOG_LEVEL
value: "info"
- name: PROVIDERS_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-id
- name: PROVIDERS_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-secret
- name: SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: secret
volumeMounts:
- name: configs
mountPath: /config
subPath: traefik-forward-auth.ini
volumes:
- name: configs
configMap:
name: configs
- name: traefik-forward-auth-secrets
secret:
secretName: secrets
- name: acme
persistentVolumeClaim:
claimName: traefik-acme

View File

@@ -0,0 +1,36 @@
#
# Auth Ingress
#
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: traefik-forward-auth
labels:
app: traefik
spec:
rules:
- host: auth.example.com
http:
paths:
- backend:
serviceName: traefik-forward-auth
servicePort: auth-http
---
#
# Dash Ingress
#
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: traefik-dashboard
labels:
app: traefik
spec:
rules:
- host: traefik.example.com
http:
paths:
- backend:
serviceName: traefik-dashboard
servicePort: dashboard-http

View File

@@ -0,0 +1,28 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: default
commonLabels:
app: traefik
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml
- rbac.yaml
#
# Configs
#
configMapGenerator:
- name: configs
files:
- configs/traefik.toml
- configs/traefik-forward-auth.ini
#
# Secrets
#
secretGenerator:
- name: traefik-forward-auth-secrets
env: secrets/traefik-forward-auth.env

View File

@@ -0,0 +1,17 @@
# Source: traefik/templates/acme-pvc.yaml
#
# PVC
#
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: traefik-acme
labels:
app: traefik
spec:
accessModes:
- "ReadWriteOnce"
resources:
requests:
storage: "1Gi"
storageClassName: "local-traefik-acme"

View File

@@ -0,0 +1,52 @@
#
# RBAC
# Source: traefik/templates/rbac.yaml
#
kind: ServiceAccount
apiVersion: v1
metadata:
name: traefik
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: traefik
rules:
- apiGroups:
- ""
resources:
- pods
- services
- endpoints
- secrets
verbs:
- get
- list
- watch
- apiGroups:
- extensions
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- extensions
resources:
- ingresses/status
verbs:
- update
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: traefik
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: traefik
subjects:
- kind: ServiceAccount
name: traefik
namespace: kube-system

View File

@@ -0,0 +1,3 @@
google-client-id=client-id
google-client-secret=client-secret
secret=something-random

View File

@@ -0,0 +1,58 @@
#
# Traefik Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik
labels:
app: traefik
spec:
# Use NodePort if required
type: LoadBalancer
selector:
app: traefik
ports:
- name: http
port: 80
targetPort: 80
- name: https
port: 443
targetPort: 443
---
#
# Auth Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik-forward-auth
labels:
app: traefik
spec:
type: ClusterIP
selector:
app: traefik
ports:
- name: auth-http
port: 4181
targetPort: 4181
---
#
# Dash Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik-dashboard
labels:
app: traefik
spec:
type: ClusterIP
selector:
app: traefik
ports:
- name: dashboard-http
port: 8080
targetPort: 8080

View File

@@ -0,0 +1,19 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
labels:
app: whoami
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- image: containous/whoami
name: whoami

View File

@@ -0,0 +1,16 @@
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: whoami
labels:
app: whoami
annotations:
kubernetes.io/ingress.class: traefik
spec:
rules:
- host: whoami.example.com
http:
paths:
- backend:
serviceName: whoami
servicePort: http

View File

@@ -0,0 +1,7 @@
commonLabels:
app: whoami
resources:
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: whoami
labels:
app: whoami
spec:
type: ClusterIP
ports:
- name: http
port: 80
selector:
app: whoami

View File

@@ -0,0 +1,43 @@
# Kubernetes - Simple Separate Pod Example
This is a simple example of how to deploy traefik-forward-auth in it's own pod with minimal configuration. This example is a good starting point for those who already have traefik deployed (e.g. using helm).
This example uses annotations to apply authentication to selected ingresses (see `k8s-app.yml`). This means ingresses will not be protected by default, only those with these annotations will require forward authentication. For example:
```
#
# Ingress
#
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: whoami
labels:
app: whoami
annotations:
kubernetes.io/ingress.class: traefik
ingress.kubernetes.io/auth-type: forward
ingress.kubernetes.io/auth-url: http://traefik-forward-auth:4181
ingress.kubernetes.io/auth-response-headers: X-Forwarded-User
spec:
rules:
- host: whoami.example.com
http:
paths:
- backend:
serviceName: whoami
servicePort: http
```
Example deployment:
```
# Deploy traefik-forward-auth
kubectl apply -f k8s-traefik-forward-auth.yml
# Deploy example whoami app
kubectl apply -f k8s-app.yml
```
Please see the advanced examples for more details.

View File

@@ -0,0 +1,62 @@
#
# Example Application Deployment
#
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
labels:
app: whoami
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: containous/whoami
---
#
# Service
#
apiVersion: v1
kind: Service
metadata:
name: whoami
labels:
app: whoami
spec:
ports:
- name: http
port: 80
selector:
app: whoami
---
#
# Ingress
#
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: whoami
labels:
app: whoami
annotations:
kubernetes.io/ingress.class: traefik
ingress.kubernetes.io/auth-type: forward
ingress.kubernetes.io/auth-url: http://traefik-forward-auth:4181
ingress.kubernetes.io/auth-response-headers: X-Forwarded-User
spec:
rules:
- host: whoami.example.com
http:
paths:
- backend:
serviceName: whoami
servicePort: http

View File

@@ -0,0 +1,90 @@
#
# Traefik Forward Auth Deployment
#
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
spec:
replicas: 1
selector:
matchLabels:
app: traefik-forward-auth
strategy:
type: Recreate
template:
metadata:
labels:
app: traefik-forward-auth
spec:
terminationGracePeriodSeconds: 60
containers:
- image: thomseddon/traefik-forward-auth:2
name: traefik-forward-auth
ports:
- containerPort: 4181
protocol: TCP
env:
- name: DOMAIN
value: "example.com"
# INSECURE_COOKIE is required unless using https entrypoint
- name: INSECURE_COOKIE
value: "true"
- name: PROVIDERS_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: traefik-forward-auth-google-client-id
- name: PROVIDERS_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: traefik-forward-auth-google-client-secret
- name: SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: traefik-forward-auth-secret
---
#
# Auth Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
spec:
type: ClusterIP
selector:
app: traefik-forward-auth
ports:
- name: auth-http
port: 4181
targetPort: 4181
---
#
# Secrets
#
# Kubernetes requires secret values to be converted to base64 when defined
# explicitly like this. (use `echo -n 'secret-value' | base64`)
#
# These are here for completeness, in reality you may define these elsewhere,
# for example using kustomize (shown in advanced examples)
#
apiVersion: v1
kind: Secret
metadata:
name: traefik-forward-auth-secrets
labels:
app: traefik-forward-auth
type: Opaque
data:
traefik-forward-auth-google-client-id: base64-client-id
traefik-forward-auth-google-client-secret: base64-client-secret
traefik-forward-auth-secret: base64-something-random

View File

@@ -14,7 +14,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
whoami1:
image: emilevauge/whoami
image: containous/whoami
networks:
- traefik
labels:
@@ -23,17 +23,17 @@ services:
- "traefik.frontend.rule=Host:whoami.yourdomain.com"
traefik-forward-auth:
image: thomseddon/traefik-forward-auth
image: thomseddon/traefik-forward-auth:2
environment:
- CLIENT_ID=your-client-id
- CLIENT_SECRET=your-client-secret
- PROVIDERS_GOOGLE_CLIENT_ID=your-client-id
- PROVIDERS_GOOGLE_CLIENT_SECRET=your-client-secret
- SECRET=something-random
- COOKIE_SECURE=false
- INSECURE_COOKIE=true
- DOMAIN=yourcompany.com
- AUTH_HOST=auth.yourdomain.com
networks:
- traefik
# When using an auth host, adding it here prompts traefik to generate certs
# When using an auth host, the below must be added
labels:
- traefik.enable=true
- traefik.port=4181

View File

@@ -0,0 +1,40 @@
version: '3'
services:
traefik:
image: traefik:1.7
command: -c /traefik.toml
ports:
- "8085:80"
- "8086:8080"
networks:
- traefik
volumes:
- ./traefik.toml:/traefik.toml
- /var/run/docker.sock:/var/run/docker.sock
whoami1:
image: containous/whoami
networks:
- traefik
labels:
- "traefik.backend=whoami"
- "traefik.enable=true"
- "traefik.frontend.rule=Host:whoami.localhost.com"
traefik-forward-auth:
build: thomseddon/traefik-forward-auth:2
environment:
- DEFAULT_PROVIDER=oidc
- PROVIDERS_OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant}
- PROVIDERS_OIDC_CLIENT_ID=your-client-id
- PROVIDERS_OIDC_CLIENT_SECRET=your-client-secret
- SECRET=something-random
- INSECURE_COOKIE=true
- DOMAIN=yourcompany.com
- LOG_LEVEL=debug
networks:
- traefik
networks:
traefik:

View File

@@ -2,7 +2,7 @@ version: '3'
services:
traefik:
image: traefik
image: traefik:1.7
command: -c /traefik.toml --logLevel=DEBUG
ports:
- "8085:80"
@@ -14,7 +14,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
whoami1:
image: emilevauge/whoami
image: containous/whoami
networks:
- traefik
labels:
@@ -23,13 +23,15 @@ services:
- "traefik.frontend.rule=Host:whoami.localhost.com"
traefik-forward-auth:
image: thomseddon/traefik-forward-auth
build: thomseddon/traefik-forward-auth:2
command: ./traefik-forward-auth --rule.1.action=allow --rule.1.rule="Path(`/public`)"
environment:
- CLIENT_ID=your-client-id
- CLIENT_SECRET=your-client-secret
- PROVIDERS_GOOGLE_CLIENT_ID=your-client-id
- PROVIDERS_GOOGLE_CLIENT_SECRET=your-client-secret
- SECRET=something-random
- COOKIE_SECURE=false
- INSECURE_COOKIE=true
- DOMAIN=yourcompany.com
- LOG_LEVEL=debug
networks:
- traefik

View File

@@ -135,3 +135,4 @@
# Enable Docker configuration backend
[docker]
exposedByDefault = false
network = "traefik"

View File

@@ -0,0 +1,39 @@
# Kubernetes - Advanced Separate Pod Example
This is an advanced example of how to deploy traefik-forward-auth in it's own pod. This example is a good starting point for those who already have traefik deployed (e.g. using helm).
This example uses [Selective Authentication](https://github.com/thomseddon/traefik-forward-auth/blob/master/README.md#selective-ingress-authentication-in-kubernetes) to selectively apply forward authentication to each selective ingresses, for example:
```
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: whoami
labels:
app: whoami
spec:
entryPoints:
- https
routes:
- match: Host(`whoami.example.com`)
kind: Rule
services:
- name: whoami
port: 80
middlewares:
- name: traefik-forward-auth
tls:
certresolver: default
```
This example also includes SSL via traefik acme/lesencrypt, auth host mode, and leverages kustomise. A simple example "whoami" application (deployment, service and ingress) is included for completeness.
Example deployment:
```
# Deploy traefik-forward-auth
kubectl apply -k traefik-forward-auth
# Deploy example whoami app
kubectl apply -k whoami
```

View File

@@ -0,0 +1,3 @@
bases:
- traefik-forward-auth
- whoami

View File

@@ -0,0 +1,8 @@
rule.example_public.action=allow
rule.example_public.rule=Host("stats.example.com") && PathPrefix("/api/public")
rule.example_api.action=allow
rule.example_api.rule=Host("api.example.com") && Headers("X-API-Authorization", "a-long-api-key")
rule.example_api_query.action=allow
rule.example_api_query.rule=Host("api.example.com") && && Query("api_key=a-long-api-key")

View File

@@ -0,0 +1,71 @@
#
# Traefik Forward Auth Deployment
#
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
spec:
replicas: 1
selector:
matchLabels:
app: traefik-forward-auth
strategy:
type: Recreate
template:
metadata:
labels:
app: traefik-forward-auth
spec:
serviceAccountName: traefik-ingress-controller
terminationGracePeriodSeconds: 60
containers:
- image: thomseddon/traefik-forward-auth:2
name: traefik-forward-auth
ports:
- containerPort: 4181
protocol: TCP
env:
- name: CONFIG
value: "/config"
- name: DOMAIN
value: "example.com"
# INSECURE_COOKIE is required if not using a https entrypoint
# - name: INSECURE_COOKIE
# value: "true"
# Remove COOKIE_DOMAIN if not using auth host mode
- name: COOKIE_DOMAIN
value: "example.com"
- name: AUTH_HOST
value: "auth.example.com"
- name: LOG_LEVEL
value: "info"
- name: PROVIDERS_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-id
- name: PROVIDERS_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-secret
- name: SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: secret
volumeMounts:
- name: configs
mountPath: /config
subPath: traefik-forward-auth.ini
volumes:
- name: configs
configMap:
name: configs
- name: traefik-forward-auth-secrets
secret:
secretName: traefik-forward-auth-secrets

View File

@@ -0,0 +1,20 @@
#
# Auth Ingress
#
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: traefik-forward-auth
labels:
app: traefik
spec:
entryPoints:
- https
routes:
- match: Host(`auth.example.com`)
kind: Rule
services:
- name: traefik-forward-auth
port: 4181
tls:
certresolver: default

View File

@@ -0,0 +1,26 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: default
commonLabels:
app: traefik-forward-auth
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
- middleware.yaml
#
# Configs
#
configMapGenerator:
- name: configs
files:
- configs/traefik-forward-auth.ini
#
# Secrets
#
secretGenerator:
- name: traefik-forward-auth-secrets
env: secrets/traefik-forward-auth.env

View File

@@ -0,0 +1,9 @@
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: traefik-forward-auth
spec:
forwardAuth:
address: http://traefik-forward-auth:4181
authResponseHeaders:
- X-Forwarded-User

View File

@@ -0,0 +1,3 @@
google-client-id=client-id
google-client-secret=client-secret
secret=something-random

View File

@@ -0,0 +1,17 @@
#
# Auth Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik-forward-auth
labels:
app: traefik
spec:
type: ClusterIP
selector:
app: traefik
ports:
- name: auth-http
port: 4181
targetPort: 4181

View File

@@ -0,0 +1,19 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
labels:
app: whoami
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- image: containous/whoami
name: whoami

View File

@@ -0,0 +1,19 @@
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: whoami
labels:
app: whoami
spec:
entryPoints:
- https
routes:
- match: Host(`whoami.example.com`)
kind: Rule
services:
- name: whoami
port: 80
middlewares:
- name: traefik-forward-auth
tls:
certresolver: default

View File

@@ -0,0 +1,7 @@
commonLabels:
app: whoami
resources:
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: whoami
labels:
app: whoami
spec:
type: ClusterIP
ports:
- name: http
port: 80
selector:
app: whoami

View File

@@ -0,0 +1,18 @@
# Kubernetes - Advanced Single Pod Example
This is an advanced example of how to deploy traefik and traefik-forward-auth in a single pod. This example is a good starting point for those who already have a manually defined traefik config (e.g. not using helm).
This example uses [Global Authentication](https://github.com/thomseddon/traefik-forward-auth/blob/master/README.md#global-authentication) to apply authentication for the entire `https` entrypoint.
This example also includes SSL via traefik acme/lesencrypt, auth host mode, exposes the traefik dashboard and leverages kustomise. No special config if required for your applications, but a simple example "whoami" application (deployment, service and ingress) is included for completeness.
Example deployment:
```
# Deploy traefik+traefik-forward-auth
kubectl apply -k traefik
# Deploy whoami app
kubectl apply -k whoami
```

View File

@@ -0,0 +1,3 @@
bases:
- traefik
- whoami

View File

@@ -0,0 +1,8 @@
rule.example_public.action=allow
rule.example_public.rule=Host("stats.example.com") && PathPrefix("/api/public")
rule.example_api.action=allow
rule.example_api.rule=Host("api.example.com") && Headers("X-API-Authorization", "a-long-api-key")
rule.example_api_query.action=allow
rule.example_api_query.rule=Host("api.example.com") && && Query("api_key=a-long-api-key")

View File

@@ -0,0 +1,103 @@
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: ingressroutes.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: IngressRoute
plural: ingressroutes
singular: ingressroute
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: middlewares.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: Middleware
plural: middlewares
singular: middleware
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: ingressroutetcps.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: IngressRouteTCP
plural: ingressroutetcps
singular: ingressroutetcp
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: ingressrouteudps.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: IngressRouteUDP
plural: ingressrouteudps
singular: ingressrouteudp
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: tlsoptions.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: TLSOption
plural: tlsoptions
singular: tlsoption
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: tlsstores.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: TLSStore
plural: tlsstores
singular: tlsstore
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: traefikservices.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: TraefikService
plural: traefikservices
singular: traefikservice
scope: Namespaced

View File

@@ -0,0 +1,110 @@
#
# Traefik + Traefik Forward Auth Deployment
#
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik
labels:
app: traefik
spec:
replicas: 1
selector:
matchLabels:
app: traefik
strategy:
type: Recreate
template:
metadata:
labels:
app: traefik
spec:
serviceAccountName: traefik-ingress-controller
terminationGracePeriodSeconds: 60
containers:
- image: traefik:2.2
name: traefik
args:
- --api.dashboard
- --accesslog
- --entryPoints.http.address=:80
- --entryPoints.http.http.redirections.entryPoint.to=https
- --entryPoints.http.http.redirections.entryPoint.scheme=https
- --entryPoints.https.address=:443
# We're using "global authentication", so the middleware is defined here on the entrypoint
# When a kubernetescrd middleware is applied globally it should take the form <kubernetes-namespace>-<middleware>
- --entrypoints.https.http.middlewares=default-traefik-forward-auth
- --providers.kubernetescrd
- --log.level=info
- --log.format=json
- --certificatesresolvers.default.acme.email=foo@you.com
- --certificatesresolvers.default.acme.storage=/acme/acme.json
- --certificatesresolvers.default.acme.storage=/acme/acme.json
- --certificatesresolvers.default.acme.httpchallenge.entrypoint=http
# Please note that this is the staging Let's Encrypt server.
# Once you get things working, you should remove that whole line altogether.
- --certificatesresolvers.default.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
ports:
- name: http
containerPort: 80
protocol: TCP
- name: https
containerPort: 443
protocol: TCP
- name: dash
containerPort: 8080
protocol: TCP
volumeMounts:
- mountPath: /acme
name: acme
- image: thomseddon/traefik-forward-auth:2
name: traefik-forward-auth
ports:
- containerPort: 4181
protocol: TCP
env:
- name: CONFIG
value: "/config"
- name: DOMAIN
value: "example.com"
# INSECURE_COOKIE is required if not using a https entrypoint
# - name: INSECURE_COOKIE
# value: "true"
# Remove COOKIE_DOMAIN if not using auth host mode
- name: COOKIE_DOMAIN
value: "example.com"
- name: AUTH_HOST
value: "auth.example.com"
- name: LOG_LEVEL
value: "info"
- name: PROVIDERS_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-id
- name: PROVIDERS_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: google-client-secret
- name: SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: secret
volumeMounts:
- name: configs
mountPath: /config
subPath: traefik-forward-auth.ini
volumes:
- name: configs
configMap:
name: configs
- name: traefik-forward-auth-secrets
secret:
secretName: traefik-forward-auth-secrets
- name: acme
persistentVolumeClaim:
claimName: traefik-acme

View File

@@ -0,0 +1,42 @@
#
# Auth Ingress
#
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: traefik-forward-auth
labels:
app: traefik
spec:
entryPoints:
- https
routes:
- match: Host(`auth.example.com`)
kind: Rule
services:
- name: traefik-forward-auth
port: 4181
tls:
certresolver: default
---
#
# Dash Ingress
#
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: traefik-dashboard
labels:
app: traefik
spec:
entryPoints:
- https
routes:
- match: Host(`traefik.example.com`)
kind: Rule
services:
- name: api@internal
kind: TraefikService
tls:
certresolver: default

View File

@@ -0,0 +1,29 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: default
commonLabels:
app: traefik
resources:
- crds.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- middleware.yaml
- pvc.yaml
- rbac.yaml
#
# Configs
#
configMapGenerator:
- name: configs
files:
- configs/traefik-forward-auth.ini
#
# Secrets
#
secretGenerator:
- name: traefik-forward-auth-secrets
env: secrets/traefik-forward-auth.env

View File

@@ -0,0 +1,9 @@
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: traefik-forward-auth
spec:
forwardAuth:
address: http://127.0.0.1:4181
authResponseHeaders:
- X-Forwarded-User

View File

@@ -0,0 +1,17 @@
# Source: traefik/templates/acme-pvc.yaml
#
# PVC
#
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: traefik-acme
labels:
app: traefik
spec:
accessModes:
- "ReadWriteOnce"
resources:
requests:
storage: "1Gi"
storageClassName: "local-traefik-acme"

View File

@@ -0,0 +1,66 @@
#
# RBAC
#
apiVersion: v1
kind: ServiceAccount
metadata:
namespace: default
name: traefik-ingress-controller
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
name: traefik-ingress-controller
rules:
- apiGroups:
- ""
resources:
- services
- endpoints
- secrets
verbs:
- get
- list
- watch
- apiGroups:
- extensions
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- extensions
resources:
- ingresses/status
verbs:
- update
- apiGroups:
- traefik.containo.us
resources:
- middlewares
- ingressroutes
- traefikservices
- ingressroutetcps
- ingressrouteudps
- tlsoptions
- tlsstores
verbs:
- get
- list
- watch
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
name: traefik-ingress-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: traefik-ingress-controller
subjects:
- kind: ServiceAccount
name: traefik-ingress-controller
namespace: default

View File

@@ -0,0 +1,3 @@
google-client-id=client-id
google-client-secret=client-secret
secret=something-random

View File

@@ -0,0 +1,39 @@
#
# Traefik Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik
labels:
app: traefik
spec:
# Use NodePort if required
type: LoadBalancer
selector:
app: traefik
ports:
- name: http
port: 80
targetPort: 80
- name: https
port: 443
targetPort: 443
---
#
# Auth Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik-forward-auth
labels:
app: traefik
spec:
type: ClusterIP
selector:
app: traefik
ports:
- name: auth-http
port: 4181
targetPort: 4181

View File

@@ -0,0 +1,19 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
labels:
app: whoami
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- image: containous/whoami
name: whoami

View File

@@ -0,0 +1,17 @@
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: whoami
labels:
app: whoami
spec:
entryPoints:
- https
routes:
- match: Host(`whoami.example.com`)
kind: Rule
services:
- name: whoami
port: 80
tls:
certresolver: default

View File

@@ -0,0 +1,7 @@
commonLabels:
app: whoami
resources:
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: whoami
labels:
app: whoami
spec:
type: ClusterIP
ports:
- name: http
port: 80
selector:
app: whoami

View File

@@ -0,0 +1,39 @@
# Kubernetes - Simple Separate Pod Example
This is a simple example of how to deploy traefik-forward-auth in it's own pod with minimal configuration. This example is a good starting point for those who already have traefik deployed (e.g. using helm).
This example uses [Selective Authentication](https://github.com/thomseddon/traefik-forward-auth/blob/master/README.md#selective-ingress-authentication-in-kubernetes) to apply forward authentication to selected ingresses. This means ingresses will not be protected by default. Authentication can be applied by adding the `traefik-forward-auth` middleware, for example:
```
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: whoami
labels:
app: whoami
spec:
entryPoints:
- http
routes:
- match: Host(`whoami.example.com`)
kind: Rule
services:
- name: whoami
port: 80
middlewares:
- name: traefik-forward-auth
```
A minimal application example is provided in `k8s-app.yml`.
Example deployment:
```
# Deploy traefik-forward-auth
kubectl apply -f k8s-traefik-forward-auth.yml
# Deploy example whoami app
kubectl apply -f k8s-app.yml
```
Please see the advanced examples for more details.

View File

@@ -0,0 +1,60 @@
#
# Example Application Deployment
#
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
labels:
app: whoami
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: containous/whoami
---
#
# Service
#
apiVersion: v1
kind: Service
metadata:
name: whoami
labels:
app: whoami
spec:
ports:
- name: http
port: 80
selector:
app: whoami
---
#
# IngressRoute
#
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: whoami
labels:
app: whoami
spec:
entryPoints:
- http
routes:
- match: Host(`whoami.example.com`)
kind: Rule
services:
- name: whoami
port: 80
middlewares:
- name: traefik-forward-auth

View File

@@ -0,0 +1,104 @@
#
# Traefik Forward Auth Deployment
#
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
spec:
replicas: 1
selector:
matchLabels:
app: traefik-forward-auth
strategy:
type: Recreate
template:
metadata:
labels:
app: traefik-forward-auth
spec:
terminationGracePeriodSeconds: 60
containers:
- image: thomseddon/traefik-forward-auth:2
name: traefik-forward-auth
ports:
- containerPort: 4181
protocol: TCP
env:
- name: DOMAIN
value: "example.com"
# INSECURE_COOKIE is required unless using https entrypoint
- name: INSECURE_COOKIE
value: "true"
- name: PROVIDERS_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: traefik-forward-auth-google-client-id
- name: PROVIDERS_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: traefik-forward-auth-google-client-secret
- name: SECRET
valueFrom:
secretKeyRef:
name: traefik-forward-auth-secrets
key: traefik-forward-auth-secret
---
#
# Auth Service
#
apiVersion: v1
kind: Service
metadata:
name: traefik-forward-auth
labels:
app: traefik-forward-auth
spec:
type: ClusterIP
selector:
app: traefik-forward-auth
ports:
- name: auth-http
port: 4181
targetPort: 4181
---
#
# Auth Middleware
#
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: traefik-forward-auth
spec:
forwardAuth:
address: http://traefik-forward-auth:4181
authResponseHeaders:
- X-Forwarded-User
---
#
# Secrets
#
# Kubernetes requires secret values to be converted to base64 when defined
# explicitly like this. (use `echo -n 'secret-value' | base64`)
#
# These are here for completeness, in reality you may define these elsewhere,
# for example using kustomize (shown in advanced examples)
#
apiVersion: v1
kind: Secret
metadata:
name: traefik-forward-auth-secrets
labels:
app: traefik-forward-auth
type: Opaque
data:
traefik-forward-auth-google-client-id: base64-client-id
traefik-forward-auth-google-client-secret: base64-client-secret
traefik-forward-auth-secret: base64-something-random

View File

@@ -0,0 +1,37 @@
version: '3'
services:
traefik:
image: traefik:v2.2
command:
- --providers.docker
# This example uses "global authentication"
- --entryPoints.http.address=:80
- --entrypoints.http.http.middlewares=traefik-forward-auth
ports:
- "8085:80"
- "8086:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
whoami:
image: containous/whoami
labels:
- "traefik.http.routers.whoami.rule=Host(`whoami.localhost.com`)"
traefik-forward-auth:
image: thomseddon/traefik-forward-auth:2
environment:
- PROVIDERS_GOOGLE_CLIENT_ID=your-client-id
- PROVIDERS_GOOGLE_CLIENT_SECRET=your-client-secret
- SECRET=something-random
# INSECURE_COOKIE is required if not using a https entrypoint
- INSECURE_COOKIE=true
- COOKIE_DOMAIN=localhost.com
- AUTH_HOST=auth.localhost.com:8085
- LOG_LEVEL=debug
labels:
- "traefik.http.routers.traefik-forward-auth.rule=Host(`auth.localhost.com`)"
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.address=http://traefik-forward-auth:4181"
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.authResponseHeaders=X-Forwarded-User"
- "traefik.http.services.traefik-forward-auth.loadbalancer.server.port=4181"

View File

@@ -0,0 +1,34 @@
version: '3'
services:
traefik:
image: traefik:v2.2
command: --providers.docker
ports:
- "8085:80"
- "8086:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
whoami:
image: containous/whoami
labels:
- "traefik.http.routers.whoami.rule=Host(`whoami.localhost.com`)"
- "traefik.http.routers.whoami.middlewares=traefik-forward-auth"
traefik-forward-auth:
image: thomseddon/traefik-forward-auth:2
environment:
- DEFAULT_PROVIDER=oidc
- PROVIDERS_OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant}
- PROVIDERS_OIDC_CLIENT_ID=your-client-id
- PROVIDERS_OIDC_CLIENT_SECRET=your-client-secret
- SECRET=something-random
# INSECURE_COOKIE is required if not using a https entrypoint
- INSECURE_COOKIE=true
- LOG_LEVEL=debug
labels:
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.address=http://traefik-forward-auth:4181"
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.authResponseHeaders=X-Forwarded-User"
- "traefik.http.services.traefik-forward-auth.loadbalancer.server.port=4181"

View File

@@ -0,0 +1,32 @@
version: '3'
services:
traefik:
image: traefik:v2.2
command: --providers.docker
ports:
- "8085:80"
- "8086:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
whoami:
image: containous/whoami
labels:
- "traefik.http.routers.whoami.rule=Host(`whoami.localhost.com`)"
# This example uses "Selective Authentication"
- "traefik.http.routers.whoami.middlewares=traefik-forward-auth"
traefik-forward-auth:
image: thomseddon/traefik-forward-auth:2
environment:
- PROVIDERS_GOOGLE_CLIENT_ID=your-client-id
- PROVIDERS_GOOGLE_CLIENT_SECRET=your-client-secret
- SECRET=something-random
# INSECURE_COOKIE is required if not using a https entrypoint
- INSECURE_COOKIE=true
- LOG_LEVEL=debug
labels:
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.address=http://traefik-forward-auth:4181"
- "traefik.http.middlewares.traefik-forward-auth.forwardauth.authResponseHeaders=X-Forwarded-User"
- "traefik.http.services.traefik-forward-auth.loadbalancer.server.port=4181"

View File

@@ -1,390 +0,0 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Forward Auth
type ForwardAuth struct {
Path string
Lifetime time.Duration
Secret []byte
ClientId string
ClientSecret string `json:"-"`
Scope string
LoginURL *url.URL
TokenURL *url.URL
UserURL *url.URL
AuthHost string
CookieName string
CookieDomains []CookieDomain
CSRFCookieName string
CookieSecure bool
Domain []string
Whitelist []string
Prompt string
}
// Request Validation
// Cookie = hash(secret, cookie domain, email, expires)|expires|email
func (f *ForwardAuth) ValidateCookie(r *http.Request, c *http.Cookie) (bool, string, error) {
parts := strings.Split(c.Value, "|")
if len(parts) != 3 {
return false, "", errors.New("Invalid cookie format")
}
mac, err := base64.URLEncoding.DecodeString(parts[0])
if err != nil {
return false, "", errors.New("Unable to decode cookie mac")
}
expectedSignature := f.cookieSignature(r, parts[2], parts[1])
expected, err := base64.URLEncoding.DecodeString(expectedSignature)
if err != nil {
return false, "", errors.New("Unable to generate mac")
}
// Valid token?
if !hmac.Equal(mac, expected) {
return false, "", errors.New("Invalid cookie mac")
}
expires, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return false, "", errors.New("Unable to parse cookie expiry")
}
// Has it expired?
if time.Unix(expires, 0).Before(time.Now()) {
return false, "", errors.New("Cookie has expired")
}
// Looks valid
return true, parts[2], nil
}
// Validate email
func (f *ForwardAuth) ValidateEmail(email string) bool {
found := false
if len(f.Whitelist) > 0 {
for _, whitelist := range f.Whitelist {
if email == whitelist {
found = true
}
}
} else if len(f.Domain) > 0 {
parts := strings.Split(email, "@")
if len(parts) < 2 {
return false
}
for _, domain := range f.Domain {
if domain == parts[1] {
found = true
}
}
} else {
return true
}
return found
}
// OAuth Methods
// Get login url
func (f *ForwardAuth) GetLoginURL(r *http.Request, nonce string) string {
state := fmt.Sprintf("%s:%s", nonce, f.returnUrl(r))
q := url.Values{}
q.Set("client_id", fw.ClientId)
q.Set("response_type", "code")
q.Set("scope", fw.Scope)
if fw.Prompt != "" {
q.Set("prompt", fw.Prompt)
}
q.Set("redirect_uri", f.redirectUri(r))
q.Set("state", state)
var u url.URL
u = *fw.LoginURL
u.RawQuery = q.Encode()
return u.String()
}
// Exchange code for token
type Token struct {
Token string `json:"access_token"`
}
func (f *ForwardAuth) ExchangeCode(r *http.Request, code string) (string, error) {
form := url.Values{}
form.Set("client_id", fw.ClientId)
form.Set("client_secret", fw.ClientSecret)
form.Set("grant_type", "authorization_code")
form.Set("redirect_uri", f.redirectUri(r))
form.Set("code", code)
res, err := http.PostForm(fw.TokenURL.String(), form)
if err != nil {
return "", err
}
var token Token
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&token)
return token.Token, err
}
// Get user with token
type User struct {
Id string `json:"id"`
Email string `json:"email"`
Verified bool `json:"verified_email"`
Hd string `json:"hd"`
}
func (f *ForwardAuth) GetUser(token string) (User, error) {
var user User
client := &http.Client{}
req, err := http.NewRequest("GET", fw.UserURL.String(), nil)
if err != nil {
return user, err
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := client.Do(req)
if err != nil {
return user, err
}
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&user)
return user, err
}
// Utility methods
// Get the redirect base
func (f *ForwardAuth) redirectBase(r *http.Request) string {
proto := r.Header.Get("X-Forwarded-Proto")
host := r.Header.Get("X-Forwarded-Host")
return fmt.Sprintf("%s://%s", proto, host)
}
// Return url
func (f *ForwardAuth) returnUrl(r *http.Request) string {
path := r.Header.Get("X-Forwarded-Uri")
return fmt.Sprintf("%s%s", f.redirectBase(r), path)
}
// Get oauth redirect uri
func (f *ForwardAuth) redirectUri(r *http.Request) string {
if use, _ := f.useAuthDomain(r); use {
proto := r.Header.Get("X-Forwarded-Proto")
return fmt.Sprintf("%s://%s%s", proto, f.AuthHost, f.Path)
}
return fmt.Sprintf("%s%s", f.redirectBase(r), f.Path)
}
// Should we use auth host + what it is
func (f *ForwardAuth) useAuthDomain(r *http.Request) (bool, string) {
if f.AuthHost == "" {
return false, ""
}
// Does the request match a given cookie domain?
reqMatch, reqHost := f.matchCookieDomains(r.Header.Get("X-Forwarded-Host"))
// Do any of the auth hosts match a cookie domain?
authMatch, authHost := f.matchCookieDomains(f.AuthHost)
// We need both to match the same domain
return reqMatch && authMatch && reqHost == authHost, reqHost
}
// Cookie methods
// Create an auth cookie
func (f *ForwardAuth) MakeCookie(r *http.Request, email string) *http.Cookie {
expires := f.cookieExpiry()
mac := f.cookieSignature(r, email, fmt.Sprintf("%d", expires.Unix()))
value := fmt.Sprintf("%s|%d|%s", mac, expires.Unix(), email)
return &http.Cookie{
Name: f.CookieName,
Value: value,
Path: "/",
Domain: f.cookieDomain(r),
HttpOnly: true,
Secure: f.CookieSecure,
Expires: expires,
}
}
// Make a CSRF cookie (used during login only)
func (f *ForwardAuth) MakeCSRFCookie(r *http.Request, nonce string) *http.Cookie {
return &http.Cookie{
Name: f.CSRFCookieName,
Value: nonce,
Path: "/",
Domain: f.csrfCookieDomain(r),
HttpOnly: true,
Secure: f.CookieSecure,
Expires: f.cookieExpiry(),
}
}
// Create a cookie to clear csrf cookie
func (f *ForwardAuth) ClearCSRFCookie(r *http.Request) *http.Cookie {
return &http.Cookie{
Name: f.CSRFCookieName,
Value: "",
Path: "/",
Domain: f.csrfCookieDomain(r),
HttpOnly: true,
Secure: f.CookieSecure,
Expires: time.Now().Local().Add(time.Hour * -1),
}
}
// Validate the csrf cookie against state
func (f *ForwardAuth) ValidateCSRFCookie(c *http.Cookie, state string) (bool, string, error) {
if len(c.Value) != 32 {
return false, "", errors.New("Invalid CSRF cookie value")
}
if len(state) < 34 {
return false, "", errors.New("Invalid CSRF state value")
}
// Check nonce match
if c.Value != state[:32] {
return false, "", errors.New("CSRF cookie does not match state")
}
// Valid, return redirect
return true, state[33:], nil
}
func (f *ForwardAuth) Nonce() (error, string) {
// Make nonce
nonce := make([]byte, 16)
_, err := rand.Read(nonce)
if err != nil {
return err, ""
}
return nil, fmt.Sprintf("%x", nonce)
}
// Cookie domain
func (f *ForwardAuth) cookieDomain(r *http.Request) string {
host := r.Header.Get("X-Forwarded-Host")
// Check if any of the given cookie domains matches
_, domain := f.matchCookieDomains(host)
return domain
}
// Cookie domain
func (f *ForwardAuth) csrfCookieDomain(r *http.Request) string {
var host string
if use, domain := f.useAuthDomain(r); use {
host = domain
} else {
host = r.Header.Get("X-Forwarded-Host")
}
// Remove port
p := strings.Split(host, ":")
return p[0]
}
// Return matching cookie domain if exists
func (f *ForwardAuth) matchCookieDomains(domain string) (bool, string) {
// Remove port
p := strings.Split(domain, ":")
for _, d := range f.CookieDomains {
if d.Match(p[0]) {
return true, d.Domain
}
}
return false, p[0]
}
// Create cookie hmac
func (f *ForwardAuth) cookieSignature(r *http.Request, email, expires string) string {
hash := hmac.New(sha256.New, f.Secret)
hash.Write([]byte(f.cookieDomain(r)))
hash.Write([]byte(email))
hash.Write([]byte(expires))
return base64.URLEncoding.EncodeToString(hash.Sum(nil))
}
// Get cookie expirary
func (f *ForwardAuth) cookieExpiry() time.Time {
return time.Now().Local().Add(f.Lifetime)
}
// Cookie Domain
// Cookie Domain
type CookieDomain struct {
Domain string
DomainLen int
SubDomain string
SubDomainLen int
}
func NewCookieDomain(domain string) *CookieDomain {
return &CookieDomain{
Domain: domain,
DomainLen: len(domain),
SubDomain: fmt.Sprintf(".%s", domain),
SubDomainLen: len(domain) + 1,
}
}
func (c *CookieDomain) Match(host string) bool {
// Exact domain match?
if host == c.Domain {
return true
}
// Subdomain match?
if len(host) >= c.SubDomainLen && host[len(host)-c.SubDomainLen:] == c.SubDomain {
return true
}
return false
}

View File

@@ -1,414 +0,0 @@
package main
import (
// "fmt"
"net/http"
"net/url"
"reflect"
"testing"
"time"
)
func TestValidateCookie(t *testing.T) {
fw = &ForwardAuth{}
r, _ := http.NewRequest("GET", "http://example.com", nil)
c := &http.Cookie{}
// Should require 3 parts
c.Value = ""
valid, _, err := fw.ValidateCookie(r, c)
if valid || err.Error() != "Invalid cookie format" {
t.Error("Should get \"Invalid cookie format\", got:", err)
}
c.Value = "1|2"
valid, _, err = fw.ValidateCookie(r, c)
if valid || err.Error() != "Invalid cookie format" {
t.Error("Should get \"Invalid cookie format\", got:", err)
}
c.Value = "1|2|3|4"
valid, _, err = fw.ValidateCookie(r, c)
if valid || err.Error() != "Invalid cookie format" {
t.Error("Should get \"Invalid cookie format\", got:", err)
}
// Should catch invalid mac
c.Value = "MQ==|2|3"
valid, _, err = fw.ValidateCookie(r, c)
if valid || err.Error() != "Invalid cookie mac" {
t.Error("Should get \"Invalid cookie mac\", got:", err)
}
// Should catch expired
fw.Lifetime = time.Second * time.Duration(-1)
c = fw.MakeCookie(r, "test@test.com")
valid, _, err = fw.ValidateCookie(r, c)
if valid || err.Error() != "Cookie has expired" {
t.Error("Should get \"Cookie has expired\", got:", err)
}
// Should accept valid cookie
fw.Lifetime = time.Second * time.Duration(10)
c = fw.MakeCookie(r, "test@test.com")
valid, email, err := fw.ValidateCookie(r, c)
if !valid {
t.Error("Valid request should return as valid")
}
if err != nil {
t.Error("Valid request should not return error, got:", err)
}
if email != "test@test.com" {
t.Error("Valid request should return user email")
}
}
func TestValidateEmail(t *testing.T) {
fw = &ForwardAuth{}
// Should allow any
if !fw.ValidateEmail("test@test.com") || !fw.ValidateEmail("one@two.com") {
t.Error("Should allow any domain if email domain is not defined")
}
// Should block non matching domain
fw.Domain = []string{"test.com"}
if fw.ValidateEmail("one@two.com") {
t.Error("Should not allow user from another domain")
}
// Should allow matching domain
fw.Domain = []string{"test.com"}
if !fw.ValidateEmail("test@test.com") {
t.Error("Should allow user from allowed domain")
}
// Should block non whitelisted email address
fw.Domain = []string{}
fw.Whitelist = []string{"test@test.com"}
if fw.ValidateEmail("one@two.com") {
t.Error("Should not allow user not in whitelist.")
}
// Should allow matching whitelisted email address
fw.Domain = []string{}
fw.Whitelist = []string{"test@test.com"}
if !fw.ValidateEmail("test@test.com") {
t.Error("Should allow user in whitelist.")
}
}
func TestGetLoginURL(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.com", nil)
r.Header.Add("X-Forwarded-Proto", "http")
r.Header.Add("X-Forwarded-Host", "example.com")
r.Header.Add("X-Forwarded-Uri", "/hello")
fw = &ForwardAuth{
Path: "/_oauth",
ClientId: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
LoginURL: &url.URL{
Scheme: "https",
Host: "test.com",
Path: "/auth",
},
}
// Check url
uri, err := url.Parse(fw.GetLoginURL(r, "nonce"))
if err != nil {
t.Error("Error parsing login url:", err)
}
if uri.Scheme != "https" {
t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme)
}
if uri.Host != "test.com" {
t.Error("Expected login Host to be \"test.com\", got:", uri.Host)
}
if uri.Path != "/auth" {
t.Error("Expected login Path to be \"/auth\", got:", uri.Path)
}
// Check query string
qs := uri.Query()
expectedQs := url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"state": []string{"nonce:http://example.com/hello"},
}
if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string:")
qsDiff(expectedQs, qs)
}
//
// With Auth URL but no matching cookie domain
// - will not use auth host
//
fw = &ForwardAuth{
Path: "/_oauth",
AuthHost: "auth.example.com",
ClientId: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
LoginURL: &url.URL{
Scheme: "https",
Host: "test.com",
Path: "/auth",
},
Prompt: "consent select_account",
}
// Check url
uri, err = url.Parse(fw.GetLoginURL(r, "nonce"))
if err != nil {
t.Error("Error parsing login url:", err)
}
if uri.Scheme != "https" {
t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme)
}
if uri.Host != "test.com" {
t.Error("Expected login Host to be \"test.com\", got:", uri.Host)
}
if uri.Path != "/auth" {
t.Error("Expected login Path to be \"/auth\", got:", uri.Path)
}
// Check query string
qs = uri.Query()
expectedQs = url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"prompt": []string{"consent select_account"},
"state": []string{"nonce:http://example.com/hello"},
}
if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string:")
qsDiff(expectedQs, qs)
}
//
// With correct Auth URL + cookie domain
//
cookieDomain := NewCookieDomain("example.com")
fw = &ForwardAuth{
Path: "/_oauth",
AuthHost: "auth.example.com",
ClientId: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
LoginURL: &url.URL{
Scheme: "https",
Host: "test.com",
Path: "/auth",
},
CookieDomains: []CookieDomain{*cookieDomain},
}
// Check url
uri, err = url.Parse(fw.GetLoginURL(r, "nonce"))
if err != nil {
t.Error("Error parsing login url:", err)
}
if uri.Scheme != "https" {
t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme)
}
if uri.Host != "test.com" {
t.Error("Expected login Host to be \"test.com\", got:", uri.Host)
}
if uri.Path != "/auth" {
t.Error("Expected login Path to be \"/auth\", got:", uri.Path)
}
// Check query string
qs = uri.Query()
expectedQs = url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://auth.example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"state": []string{"nonce:http://example.com/hello"},
}
qsDiff(expectedQs, qs)
if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string:")
qsDiff(expectedQs, qs)
}
//
// With Auth URL + cookie domain, but from different domain
// - will not use auth host
//
r, _ = http.NewRequest("GET", "http://another.com", nil)
r.Header.Add("X-Forwarded-Proto", "http")
r.Header.Add("X-Forwarded-Host", "another.com")
r.Header.Add("X-Forwarded-Uri", "/hello")
// Check url
uri, err = url.Parse(fw.GetLoginURL(r, "nonce"))
if err != nil {
t.Error("Error parsing login url:", err)
}
if uri.Scheme != "https" {
t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme)
}
if uri.Host != "test.com" {
t.Error("Expected login Host to be \"test.com\", got:", uri.Host)
}
if uri.Path != "/auth" {
t.Error("Expected login Path to be \"/auth\", got:", uri.Path)
}
// Check query string
qs = uri.Query()
expectedQs = url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://another.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"state": []string{"nonce:http://another.com/hello"},
}
qsDiff(expectedQs, qs)
if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string:")
qsDiff(expectedQs, qs)
}
}
// TODO
// func TestExchangeCode(t *testing.T) {
// }
// TODO
// func TestGetUser(t *testing.T) {
// }
// TODO? Tested in TestValidateCookie
// func TestMakeCookie(t *testing.T) {
// }
func TestMakeCSRFCookie(t *testing.T) {
r, _ := http.NewRequest("GET", "http://app.example.com", nil)
r.Header.Add("X-Forwarded-Host", "app.example.com")
// No cookie domain or auth url
fw = &ForwardAuth{}
c := fw.MakeCSRFCookie(r, "12345678901234567890123456789012")
if c.Domain != "app.example.com" {
t.Error("Cookie Domain should match request domain, got:", c.Domain)
}
// With cookie domain but no auth url
cookieDomain := NewCookieDomain("example.com")
fw = &ForwardAuth{CookieDomains: []CookieDomain{*cookieDomain}}
c = fw.MakeCSRFCookie(r, "12345678901234567890123456789012")
if c.Domain != "app.example.com" {
t.Error("Cookie Domain should match request domain, got:", c.Domain)
}
// With cookie domain and auth url
fw = &ForwardAuth{
AuthHost: "auth.example.com",
CookieDomains: []CookieDomain{*cookieDomain},
}
c = fw.MakeCSRFCookie(r, "12345678901234567890123456789012")
if c.Domain != "example.com" {
t.Error("Cookie Domain should match request domain, got:", c.Domain)
}
}
func TestClearCSRFCookie(t *testing.T) {
fw = &ForwardAuth{}
r, _ := http.NewRequest("GET", "http://example.com", nil)
c := fw.ClearCSRFCookie(r)
if c.Value != "" {
t.Error("ClearCSRFCookie should create cookie with empty value")
}
}
func TestValidateCSRFCookie(t *testing.T) {
fw = &ForwardAuth{}
c := &http.Cookie{}
// Should require 32 char string
c.Value = ""
valid, _, err := fw.ValidateCSRFCookie(c, "")
if valid || err.Error() != "Invalid CSRF cookie value" {
t.Error("Should get \"Invalid CSRF cookie value\", got:", err)
}
c.Value = "123456789012345678901234567890123"
valid, _, err = fw.ValidateCSRFCookie(c, "")
if valid || err.Error() != "Invalid CSRF cookie value" {
t.Error("Should get \"Invalid CSRF cookie value\", got:", err)
}
// Should require valid state
c.Value = "12345678901234567890123456789012"
valid, _, err = fw.ValidateCSRFCookie(c, "12345678901234567890123456789012:")
if valid || err.Error() != "Invalid CSRF state value" {
t.Error("Should get \"Invalid CSRF state value\", got:", err)
}
// Should allow valid state
c.Value = "12345678901234567890123456789012"
valid, state, err := fw.ValidateCSRFCookie(c, "12345678901234567890123456789012:99")
if !valid {
t.Error("Valid request should return as valid")
}
if err != nil {
t.Error("Valid request should not return error, got:", err)
}
if state != "99" {
t.Error("Valid request should return correct state, got:", state)
}
}
func TestNonce(t *testing.T) {
fw = &ForwardAuth{}
err, nonce1 := fw.Nonce()
if err != nil {
t.Error("Error generation nonce:", err)
}
err, nonce2 := fw.Nonce()
if err != nil {
t.Error("Error generation nonce:", err)
}
if len(nonce1) != 32 || len(nonce2) != 32 {
t.Error("Nonce should be 32 chars")
}
if nonce1 == nonce2 {
t.Error("Nonce should not be equal")
}
}
func TestCookieDomainMatch(t *testing.T) {
cd := NewCookieDomain("example.com")
// Exact should match
if !cd.Match("example.com") {
t.Error("Exact domain should match")
}
// Subdomain should match
if !cd.Match("test.example.com") {
t.Error("Subdomain should match")
}
// Derived domain should not match
if cd.Match("testexample.com") {
t.Error("Derived domain should not match")
}
// Other domain should not match
if cd.Match("test.com") {
t.Error("Other domain should not match")
}
}

45
go.mod Normal file
View File

@@ -0,0 +1,45 @@
module github.com/thomseddon/traefik-forward-auth
go 1.25
toolchain go1.25.0
require (
github.com/coreos/go-oidc v2.4.0+incompatible
github.com/go-jose/go-jose/v4 v4.1.3
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.11.1
github.com/thomseddon/go-flags v1.4.1-0.20190507184247-a3629c504486
github.com/traefik/traefik/v2 v2.11.34
golang.org/x/oauth2 v0.34.0
)
require (
github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gravitational/trace v1.5.1 // indirect
github.com/miekg/dns v1.1.70 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/cachecontrol v0.2.0 // indirect
github.com/traefik/paerser v0.2.2 // indirect
github.com/vulcand/predicate v1.3.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
// Containous forks
replace (
github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e
github.com/go-check/check => github.com/containous/check v0.0.0-20170915194414-ca0bf163426a
github.com/gorilla/mux => github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59
github.com/mailgun/minheap => github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595
)

70
go.sum Normal file
View File

@@ -0,0 +1,70 @@
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd h1:0n+lFLh5zU0l6KSk3KpnDwfbPGAR44aRLgTbCnhRBHU=
github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd/go.mod h1:BbQgeDS5i0tNvypwEoF1oNjOJw8knRAE1DnVvjDstcQ=
github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59 h1:lJUOWjGohYjLKEfAz2nyI/dpzfKNPQLi5GLH7aaOZkw=
github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg=
github.com/coreos/go-oidc v2.4.0+incompatible h1:xjdlhLWXcINyUJgLQ9I76g7osgC2goiL6JDXS6Fegjk=
github.com/coreos/go-oidc v2.4.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-acme/lego/v4 v4.25.2 h1:+D1Q+VnZrD+WJdlkgUEGHFFTcDrwGlE7q24IFtMmHDI=
github.com/go-acme/lego/v4 v4.25.2/go.mod h1:OORYyVNZPaNdIdVYCGSBNRNZDIjhQbPuFxwGDgWj/yM=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU=
github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gravitational/trace v1.5.1 h1:CdSymAjkE1VOef+lsC5x29jX9WbgI0fBtnRqeT4Fh+c=
github.com/gravitational/trace v1.5.1/go.mod h1:sJKfJHIQ7IkG8kvYpFPEr6mj3WDEdZ0YAc7xAD8w7lw=
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k=
github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/thomseddon/go-flags v1.4.1-0.20190507184247-a3629c504486 h1:hk17f4niAl4e6viTj2uf/fpfACa6QPmrtMDAo+1tifE=
github.com/thomseddon/go-flags v1.4.1-0.20190507184247-a3629c504486/go.mod h1:NK9eZpNBmSKVxvyB/MExg6jW0Bo9hQyAuCP+b8MJFow=
github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ=
github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24=
github.com/traefik/traefik/v2 v2.11.34 h1:TSMC60rpQHDaoYcnyiTQtHZimXenFZR+600MhHJEVoY=
github.com/traefik/traefik/v2 v2.11.34/go.mod h1:4IsMkzOastNmRZoIvVSKTGFcJ8arGaOkYuenYDOlDM8=
github.com/vulcand/predicate v1.3.0 h1:jtNe4PHbLJ649dR7Gl+MSAzUhLGtLspAkWlSjoOiXg8=
github.com/vulcand/predicate v1.3.0/go.mod h1:opzv9MetRuMNnuoPeTSWtwzjcXsxQC00/fuWzkPTn4s=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs=
gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

395
internal/auth.go Normal file
View File

@@ -0,0 +1,395 @@
package tfa
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/thomseddon/traefik-forward-auth/internal/provider"
)
// Request Validation
// ValidateCookie verifies that a cookie matches the expected format of:
// Cookie = hash(secret, cookie domain, email, expires)|expires|email
func ValidateCookie(r *http.Request, c *http.Cookie) (string, error) {
parts := strings.Split(c.Value, "|")
if len(parts) != 3 {
return "", errors.New("Invalid cookie format")
}
mac, err := base64.URLEncoding.DecodeString(parts[0])
if err != nil {
return "", errors.New("Unable to decode cookie mac")
}
expectedSignature := cookieSignature(r, parts[2], parts[1])
expected, err := base64.URLEncoding.DecodeString(expectedSignature)
if err != nil {
return "", errors.New("Unable to generate mac")
}
// Valid token?
if !hmac.Equal(mac, expected) {
return "", errors.New("Invalid cookie mac")
}
expires, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", errors.New("Unable to parse cookie expiry")
}
// Has it expired?
if time.Unix(expires, 0).Before(time.Now()) {
return "", errors.New("Cookie has expired")
}
// Looks valid
return parts[2], nil
}
// ValidateEmail checks if the given email address matches either a whitelisted
// email address, as defined by the "whitelist" config parameter. Or is part of
// a permitted domain, as defined by the "domains" config parameter
func ValidateEmail(email, ruleName string) bool {
// Use global config by default
whitelist := config.Whitelist
domains := config.Domains
if rule, ok := config.Rules[ruleName]; ok {
// Override with rule config if found
if len(rule.Whitelist) > 0 || len(rule.Domains) > 0 {
whitelist = rule.Whitelist
domains = rule.Domains
}
}
// Do we have any validation to perform?
if len(whitelist) == 0 && len(domains) == 0 {
return true
}
// Email whitelist validation
if len(whitelist) > 0 {
if ValidateWhitelist(email, whitelist) {
return true
}
// If we're not matching *either*, stop here
if !config.MatchWhitelistOrDomain {
return false
}
}
// Domain validation
if len(domains) > 0 && ValidateDomains(email, domains) {
return true
}
return false
}
// ValidateWhitelist checks if the email is in whitelist
func ValidateWhitelist(email string, whitelist CommaSeparatedList) bool {
for _, whitelist := range whitelist {
if email == whitelist {
return true
}
}
return false
}
// ValidateDomains checks if the email matches a whitelisted domain
func ValidateDomains(email string, domains CommaSeparatedList) bool {
parts := strings.Split(email, "@")
if len(parts) < 2 {
return false
}
for _, domain := range domains {
if domain == parts[1] {
return true
}
}
return false
}
// Utility methods
// Get the redirect base
func redirectBase(r *http.Request) string {
return fmt.Sprintf("%s://%s", r.Header.Get("X-Forwarded-Proto"), r.Host)
}
// Return url
func returnUrl(r *http.Request) string {
return fmt.Sprintf("%s%s", redirectBase(r), r.URL.Path)
}
// Get oauth redirect uri
func redirectUri(r *http.Request) string {
if use, _ := useAuthDomain(r); use {
p := r.Header.Get("X-Forwarded-Proto")
return fmt.Sprintf("%s://%s%s", p, config.AuthHost, config.Path)
}
return fmt.Sprintf("%s%s", redirectBase(r), config.Path)
}
// Should we use auth host + what it is
func useAuthDomain(r *http.Request) (bool, string) {
if config.AuthHost == "" {
return false, ""
}
// Does the request match a given cookie domain?
reqMatch, reqHost := matchCookieDomains(r.Host)
// Do any of the auth hosts match a cookie domain?
authMatch, authHost := matchCookieDomains(config.AuthHost)
// We need both to match the same domain
return reqMatch && authMatch && reqHost == authHost, reqHost
}
// Cookie methods
// MakeCookie creates an auth cookie
func MakeCookie(r *http.Request, email string) *http.Cookie {
expires := cookieExpiry()
mac := cookieSignature(r, email, fmt.Sprintf("%d", expires.Unix()))
value := fmt.Sprintf("%s|%d|%s", mac, expires.Unix(), email)
return &http.Cookie{
Name: config.CookieName,
Value: value,
Path: "/",
Domain: cookieDomain(r),
HttpOnly: true,
Secure: !config.InsecureCookie,
Expires: expires,
}
}
// ClearCookie clears the auth cookie
func ClearCookie(r *http.Request) *http.Cookie {
return &http.Cookie{
Name: config.CookieName,
Value: "",
Path: "/",
Domain: cookieDomain(r),
HttpOnly: true,
Secure: !config.InsecureCookie,
Expires: time.Now().Local().Add(time.Hour * -1),
}
}
func buildCSRFCookieName(nonce string) string {
return config.CSRFCookieName + "_" + nonce[:6]
}
// MakeCSRFCookie makes a csrf cookie (used during login only)
//
// Note, CSRF cookies live shorter than auth cookies, a fixed 1h.
// That's because some CSRF cookies may belong to auth flows that don't complete
// and thus may not get cleared by ClearCookie.
func MakeCSRFCookie(r *http.Request, nonce string) *http.Cookie {
return &http.Cookie{
Name: buildCSRFCookieName(nonce),
Value: nonce,
Path: "/",
Domain: csrfCookieDomain(r),
HttpOnly: true,
Secure: !config.InsecureCookie,
Expires: time.Now().Local().Add(time.Hour * 1),
}
}
// ClearCSRFCookie makes an expired csrf cookie to clear csrf cookie
func ClearCSRFCookie(r *http.Request, c *http.Cookie) *http.Cookie {
return &http.Cookie{
Name: c.Name,
Value: "",
Path: "/",
Domain: csrfCookieDomain(r),
HttpOnly: true,
Secure: !config.InsecureCookie,
Expires: time.Now().Local().Add(time.Hour * -1),
}
}
// FindCSRFCookie extracts the CSRF cookie from the request based on state.
func FindCSRFCookie(r *http.Request, state string) (c *http.Cookie, err error) {
// Check for CSRF cookie
return r.Cookie(buildCSRFCookieName(state))
}
// ValidateCSRFCookie validates the csrf cookie against state
func ValidateCSRFCookie(c *http.Cookie, state string) (valid bool, provider string, redirect string, err error) {
if len(c.Value) != 32 {
return false, "", "", errors.New("Invalid CSRF cookie value")
}
// Check nonce match
if c.Value != state[:32] {
return false, "", "", errors.New("CSRF cookie does not match state")
}
// Extract provider
params := state[33:]
split := strings.Index(params, ":")
if split == -1 {
return false, "", "", errors.New("Invalid CSRF state format")
}
// Valid, return provider and redirect
return true, params[:split], params[split+1:], nil
}
// MakeState generates a state value
func MakeState(r *http.Request, p provider.Provider, nonce string) string {
return fmt.Sprintf("%s:%s:%s", nonce, p.Name(), returnUrl(r))
}
// ValidateState checks whether the state is of right length.
func ValidateState(state string) error {
if len(state) < 34 {
return errors.New("Invalid CSRF state value")
}
return nil
}
// Nonce generates a random nonce
func Nonce() (error, string) {
nonce := make([]byte, 16)
_, err := rand.Read(nonce)
if err != nil {
return err, ""
}
return nil, fmt.Sprintf("%x", nonce)
}
// Cookie domain
func cookieDomain(r *http.Request) string {
// Check if any of the given cookie domains matches
_, domain := matchCookieDomains(r.Host)
return domain
}
// Cookie domain
func csrfCookieDomain(r *http.Request) string {
var host string
if use, domain := useAuthDomain(r); use {
host = domain
} else {
host = r.Host
}
// Remove port
p := strings.Split(host, ":")
return p[0]
}
// Return matching cookie domain if exists
func matchCookieDomains(domain string) (bool, string) {
// Remove port
p := strings.Split(domain, ":")
for _, d := range config.CookieDomains {
if d.Match(p[0]) {
return true, d.Domain
}
}
return false, p[0]
}
// Create cookie hmac
func cookieSignature(r *http.Request, email, expires string) string {
hash := hmac.New(sha256.New, config.Secret)
hash.Write([]byte(cookieDomain(r)))
hash.Write([]byte(email))
hash.Write([]byte(expires))
return base64.URLEncoding.EncodeToString(hash.Sum(nil))
}
// Get cookie expiry
func cookieExpiry() time.Time {
return time.Now().Local().Add(config.Lifetime)
}
// CookieDomain holds cookie domain info
type CookieDomain struct {
Domain string
DomainLen int
SubDomain string
SubDomainLen int
}
// NewCookieDomain creates a new CookieDomain from the given domain string
func NewCookieDomain(domain string) *CookieDomain {
return &CookieDomain{
Domain: domain,
DomainLen: len(domain),
SubDomain: fmt.Sprintf(".%s", domain),
SubDomainLen: len(domain) + 1,
}
}
// Match checks if the given host matches this CookieDomain
func (c *CookieDomain) Match(host string) bool {
// Exact domain match?
if host == c.Domain {
return true
}
// Subdomain match?
if len(host) >= c.SubDomainLen && host[len(host)-c.SubDomainLen:] == c.SubDomain {
return true
}
return false
}
// UnmarshalFlag converts a string to a CookieDomain
func (c *CookieDomain) UnmarshalFlag(value string) error {
*c = *NewCookieDomain(value)
return nil
}
// MarshalFlag converts a CookieDomain to a string
func (c *CookieDomain) MarshalFlag() (string, error) {
return c.Domain, nil
}
// CookieDomains provides legacy sypport for comma separated list of cookie domains
type CookieDomains []CookieDomain
// UnmarshalFlag converts a comma separated list of cookie domains to an array
// of CookieDomains
func (c *CookieDomains) UnmarshalFlag(value string) error {
if len(value) > 0 {
for _, d := range strings.Split(value, ",") {
cookieDomain := NewCookieDomain(d)
*c = append(*c, *cookieDomain)
}
}
return nil
}
// MarshalFlag converts an array of CookieDomain to a comma seperated list
func (c *CookieDomains) MarshalFlag() (string, error) {
var domains []string
for _, d := range *c {
domains = append(domains, d.Domain)
}
return strings.Join(domains, ","), nil
}

454
internal/auth_test.go Normal file
View File

@@ -0,0 +1,454 @@
package tfa
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/thomseddon/traefik-forward-auth/internal/provider"
)
/**
* Tests
*/
func TestAuthValidateCookie(t *testing.T) {
assert := assert.New(t)
config, _ = NewConfig([]string{})
r, _ := http.NewRequest("GET", "http://example.com", nil)
c := &http.Cookie{}
// Should require 3 parts
c.Value = ""
_, err := ValidateCookie(r, c)
if assert.Error(err) {
assert.Equal("Invalid cookie format", err.Error())
}
c.Value = "1|2"
_, err = ValidateCookie(r, c)
if assert.Error(err) {
assert.Equal("Invalid cookie format", err.Error())
}
c.Value = "1|2|3|4"
_, err = ValidateCookie(r, c)
if assert.Error(err) {
assert.Equal("Invalid cookie format", err.Error())
}
// Should catch invalid mac
c.Value = "MQ==|2|3"
_, err = ValidateCookie(r, c)
if assert.Error(err) {
assert.Equal("Invalid cookie mac", err.Error())
}
// Should catch expired
config.Lifetime = time.Second * time.Duration(-1)
c = MakeCookie(r, "test@test.com")
_, err = ValidateCookie(r, c)
if assert.Error(err) {
assert.Equal("Cookie has expired", err.Error())
}
// Should accept valid cookie
config.Lifetime = time.Second * time.Duration(10)
c = MakeCookie(r, "test@test.com")
email, err := ValidateCookie(r, c)
assert.Nil(err, "valid request should not return an error")
assert.Equal("test@test.com", email, "valid request should return user email")
}
func TestAuthValidateEmail(t *testing.T) {
assert := assert.New(t)
config, _ = NewConfig([]string{})
// Should allow any with no whitelist/domain is specified
v := ValidateEmail("test@test.com", "default")
assert.True(v, "should allow any domain if email domain is not defined")
v = ValidateEmail("one@two.com", "default")
assert.True(v, "should allow any domain if email domain is not defined")
// Should allow matching domain
config.Domains = []string{"test.com"}
v = ValidateEmail("one@two.com", "default")
assert.False(v, "should not allow user from another domain")
v = ValidateEmail("test@test.com", "default")
assert.True(v, "should allow user from allowed domain")
// Should allow matching whitelisted email address
config.Domains = []string{}
config.Whitelist = []string{"test@test.com"}
v = ValidateEmail("one@two.com", "default")
assert.False(v, "should not allow user not in whitelist")
v = ValidateEmail("test@test.com", "default")
assert.True(v, "should allow user in whitelist")
// Should allow only matching email address when
// MatchWhitelistOrDomain is disabled
config.Domains = []string{"example.com"}
config.Whitelist = []string{"test@test.com"}
config.MatchWhitelistOrDomain = false
v = ValidateEmail("one@two.com", "default")
assert.False(v, "should not allow user not in either")
v = ValidateEmail("test@example.com", "default")
assert.False(v, "should not allow user from allowed domain")
v = ValidateEmail("test@test.com", "default")
assert.True(v, "should allow user in whitelist")
// Should allow either matching domain or email address when
// MatchWhitelistOrDomain is enabled
config.Domains = []string{"example.com"}
config.Whitelist = []string{"test@test.com"}
config.MatchWhitelistOrDomain = true
v = ValidateEmail("one@two.com", "default")
assert.False(v, "should not allow user not in either")
v = ValidateEmail("test@example.com", "default")
assert.True(v, "should allow user from allowed domain")
v = ValidateEmail("test@test.com", "default")
assert.True(v, "should allow user in whitelist")
// Rule testing
// Should use global whitelist/domain when not specified on rule
config.Domains = []string{"example.com"}
config.Whitelist = []string{"test@test.com"}
config.Rules = map[string]*Rule{"test": NewRule()}
config.MatchWhitelistOrDomain = true
v = ValidateEmail("one@two.com", "test")
assert.False(v, "should not allow user not in either")
v = ValidateEmail("test@example.com", "test")
assert.True(v, "should allow user from allowed global domain")
v = ValidateEmail("test@test.com", "test")
assert.True(v, "should allow user in global whitelist")
// Should allow matching domain in rule
config.Domains = []string{"testglobal.com"}
config.Whitelist = []string{}
rule := NewRule()
config.Rules = map[string]*Rule{"test": rule}
rule.Domains = []string{"testrule.com"}
config.MatchWhitelistOrDomain = false
v = ValidateEmail("one@two.com", "test")
assert.False(v, "should not allow user from another domain")
v = ValidateEmail("one@testglobal.com", "test")
assert.False(v, "should not allow user from global domain")
v = ValidateEmail("test@testrule.com", "test")
assert.True(v, "should allow user from allowed domain")
// Should allow matching whitelist in rule
config.Domains = []string{}
config.Whitelist = []string{"test@testglobal.com"}
rule = NewRule()
config.Rules = map[string]*Rule{"test": rule}
rule.Whitelist = []string{"test@testrule.com"}
config.MatchWhitelistOrDomain = false
v = ValidateEmail("one@two.com", "test")
assert.False(v, "should not allow user from another domain")
v = ValidateEmail("test@testglobal.com", "test")
assert.False(v, "should not allow user from global domain")
v = ValidateEmail("test@testrule.com", "test")
assert.True(v, "should allow user from allowed domain")
// Should allow only matching email address when
// MatchWhitelistOrDomain is disabled
config.Domains = []string{"exampleglobal.com"}
config.Whitelist = []string{"test@testglobal.com"}
rule = NewRule()
config.Rules = map[string]*Rule{"test": rule}
rule.Domains = []string{"examplerule.com"}
rule.Whitelist = []string{"test@testrule.com"}
config.MatchWhitelistOrDomain = false
v = ValidateEmail("one@two.com", "test")
assert.False(v, "should not allow user not in either")
v = ValidateEmail("test@testglobal.com", "test")
assert.False(v, "should not allow user in global whitelist")
v = ValidateEmail("test@exampleglobal.com", "test")
assert.False(v, "should not allow user from global domain")
v = ValidateEmail("test@examplerule.com", "test")
assert.False(v, "should not allow user from allowed domain")
v = ValidateEmail("test@testrule.com", "test")
assert.True(v, "should allow user in whitelist")
// Should allow either matching domain or email address when
// MatchWhitelistOrDomain is enabled
config.Domains = []string{"exampleglobal.com"}
config.Whitelist = []string{"test@testglobal.com"}
rule = NewRule()
config.Rules = map[string]*Rule{"test": rule}
rule.Domains = []string{"examplerule.com"}
rule.Whitelist = []string{"test@testrule.com"}
config.MatchWhitelistOrDomain = true
v = ValidateEmail("one@two.com", "test")
assert.False(v, "should not allow user not in either")
v = ValidateEmail("test@testglobal.com", "test")
assert.False(v, "should not allow user in global whitelist")
v = ValidateEmail("test@exampleglobal.com", "test")
assert.False(v, "should not allow user from global domain")
v = ValidateEmail("test@examplerule.com", "test")
assert.True(v, "should allow user from allowed domain")
v = ValidateEmail("test@testrule.com", "test")
assert.True(v, "should allow user in whitelist")
}
func TestRedirectUri(t *testing.T) {
assert := assert.New(t)
r := httptest.NewRequest("GET", "http://app.example.com/hello", nil)
r.Header.Add("X-Forwarded-Proto", "http")
//
// No Auth Host
//
config, _ = NewConfig([]string{})
uri, err := url.Parse(redirectUri(r))
assert.Nil(err)
assert.Equal("http", uri.Scheme)
assert.Equal("app.example.com", uri.Host)
assert.Equal("/_oauth", uri.Path)
//
// With Auth URL but no matching cookie domain
// - will not use auth host
//
config.AuthHost = "auth.example.com"
uri, err = url.Parse(redirectUri(r))
assert.Nil(err)
assert.Equal("http", uri.Scheme)
assert.Equal("app.example.com", uri.Host)
assert.Equal("/_oauth", uri.Path)
//
// With correct Auth URL + cookie domain
//
config.AuthHost = "auth.example.com"
config.CookieDomains = []CookieDomain{*NewCookieDomain("example.com")}
// Check url
uri, err = url.Parse(redirectUri(r))
assert.Nil(err)
assert.Equal("http", uri.Scheme)
assert.Equal("auth.example.com", uri.Host)
assert.Equal("/_oauth", uri.Path)
//
// With Auth URL + cookie domain, but from different domain
// - will not use auth host
//
r = httptest.NewRequest("GET", "https://another.com/hello", nil)
r.Header.Add("X-Forwarded-Proto", "https")
config.AuthHost = "auth.example.com"
config.CookieDomains = []CookieDomain{*NewCookieDomain("example.com")}
// Check url
uri, err = url.Parse(redirectUri(r))
assert.Nil(err)
assert.Equal("https", uri.Scheme)
assert.Equal("another.com", uri.Host)
assert.Equal("/_oauth", uri.Path)
}
func TestAuthMakeCookie(t *testing.T) {
assert := assert.New(t)
config, _ = NewConfig([]string{})
r, _ := http.NewRequest("GET", "http://app.example.com", nil)
r.Header.Add("X-Forwarded-Host", "app.example.com")
c := MakeCookie(r, "test@example.com")
assert.Equal("_forward_auth", c.Name)
parts := strings.Split(c.Value, "|")
assert.Len(parts, 3, "cookie should be 3 parts")
_, err := ValidateCookie(r, c)
assert.Nil(err, "should generate valid cookie")
assert.Equal("/", c.Path)
assert.Equal("app.example.com", c.Domain)
assert.True(c.Secure)
expires := time.Now().Local().Add(config.Lifetime)
assert.WithinDuration(expires, c.Expires, 10*time.Second)
config.CookieName = "testname"
config.InsecureCookie = true
c = MakeCookie(r, "test@example.com")
assert.Equal("testname", c.Name)
assert.False(c.Secure)
}
func TestAuthMakeCSRFCookie(t *testing.T) {
assert := assert.New(t)
config, _ = NewConfig([]string{})
r, _ := http.NewRequest("GET", "http://app.example.com", nil)
r.Header.Add("X-Forwarded-Host", "app.example.com")
// No cookie domain or auth url
c := MakeCSRFCookie(r, "12345678901234567890123456789012")
assert.Equal("_forward_auth_csrf_123456", c.Name)
assert.Equal("app.example.com", c.Domain)
// With cookie domain but no auth url
config.CookieDomains = []CookieDomain{*NewCookieDomain("example.com")}
c = MakeCSRFCookie(r, "12222278901234567890123456789012")
assert.Equal("_forward_auth_csrf_122222", c.Name)
assert.Equal("app.example.com", c.Domain)
// With cookie domain and auth url
config.AuthHost = "auth.example.com"
config.CookieDomains = []CookieDomain{*NewCookieDomain("example.com")}
c = MakeCSRFCookie(r, "12333378901234567890123456789012")
assert.Equal("_forward_auth_csrf_123333", c.Name)
assert.Equal("example.com", c.Domain)
}
func TestAuthClearCSRFCookie(t *testing.T) {
assert := assert.New(t)
config, _ = NewConfig([]string{})
r, _ := http.NewRequest("GET", "http://example.com", nil)
c := ClearCSRFCookie(r, &http.Cookie{Name: "someCsrfCookie"})
assert.Equal("someCsrfCookie", c.Name)
if c.Value != "" {
t.Error("ClearCSRFCookie should create cookie with empty value")
}
}
func TestAuthValidateCSRFCookie(t *testing.T) {
assert := assert.New(t)
config, _ = NewConfig([]string{})
c := &http.Cookie{}
state := ""
// Should require 32 char string
state = ""
c.Value = ""
valid, _, _, err := ValidateCSRFCookie(c, state)
assert.False(valid)
if assert.Error(err) {
assert.Equal("Invalid CSRF cookie value", err.Error())
}
c.Value = "123456789012345678901234567890123"
valid, _, _, err = ValidateCSRFCookie(c, state)
assert.False(valid)
if assert.Error(err) {
assert.Equal("Invalid CSRF cookie value", err.Error())
}
// Should require provider
state = "12345678901234567890123456789012:99"
c.Value = "12345678901234567890123456789012"
valid, _, _, err = ValidateCSRFCookie(c, state)
assert.False(valid)
if assert.Error(err) {
assert.Equal("Invalid CSRF state format", err.Error())
}
// Should allow valid state
state = "12345678901234567890123456789012:p99:url123"
c.Value = "12345678901234567890123456789012"
valid, provider, redirect, err := ValidateCSRFCookie(c, state)
assert.True(valid, "valid request should return valid")
assert.Nil(err, "valid request should not return an error")
assert.Equal("p99", provider, "valid request should return correct provider")
assert.Equal("url123", redirect, "valid request should return correct redirect")
}
func TestValidateState(t *testing.T) {
assert := assert.New(t)
// Should require valid state
state := "12345678901234567890123456789012:"
err := ValidateState(state)
if assert.Error(err) {
assert.Equal("Invalid CSRF state value", err.Error())
}
// Should pass this state
state = "12345678901234567890123456789012:p99:url123"
err = ValidateState(state)
assert.Nil(err, "valid request should not return an error")
}
func TestMakeState(t *testing.T) {
assert := assert.New(t)
r := httptest.NewRequest("GET", "http://example.com/hello", nil)
r.Header.Add("X-Forwarded-Proto", "http")
// Test with google
p := provider.Google{}
state := MakeState(r, &p, "nonce")
assert.Equal("nonce:google:http://example.com/hello", state)
// Test with OIDC
p2 := provider.OIDC{}
state = MakeState(r, &p2, "nonce")
assert.Equal("nonce:oidc:http://example.com/hello", state)
// Test with Generic OAuth
p3 := provider.GenericOAuth{}
state = MakeState(r, &p3, "nonce")
assert.Equal("nonce:generic-oauth:http://example.com/hello", state)
}
func TestAuthNonce(t *testing.T) {
assert := assert.New(t)
err, nonce1 := Nonce()
assert.Nil(err, "error generating nonce")
assert.Len(nonce1, 32, "length should be 32 chars")
err, nonce2 := Nonce()
assert.Nil(err, "error generating nonce")
assert.Len(nonce2, 32, "length should be 32 chars")
assert.NotEqual(nonce1, nonce2, "nonce should not be equal")
}
func TestAuthCookieDomainMatch(t *testing.T) {
assert := assert.New(t)
cd := NewCookieDomain("example.com")
// Exact should match
assert.True(cd.Match("example.com"), "exact domain should match")
// Subdomain should match
assert.True(cd.Match("test.example.com"), "subdomain should match")
assert.True(cd.Match("twolevels.test.example.com"), "subdomain should match")
assert.True(cd.Match("many.many.levels.test.example.com"), "subdomain should match")
// Derived domain should not match
assert.False(cd.Match("testexample.com"), "derived domain should not match")
// Other domain should not match
assert.False(cd.Match("test.com"), "other domain should not match")
}
func TestAuthCookieDomains(t *testing.T) {
assert := assert.New(t)
cds := CookieDomains{}
err := cds.UnmarshalFlag("one.com,two.org")
assert.Nil(err)
expected := CookieDomains{
CookieDomain{
Domain: "one.com",
DomainLen: 7,
SubDomain: ".one.com",
SubDomainLen: 8,
},
CookieDomain{
Domain: "two.org",
DomainLen: 7,
SubDomain: ".two.org",
SubDomainLen: 8,
},
}
assert.Equal(expected, cds)
marshal, err := cds.MarshalFlag()
assert.Nil(err)
assert.Equal("one.com,two.org", marshal)
}

382
internal/config.go Normal file
View File

@@ -0,0 +1,382 @@
package tfa
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/thomseddon/go-flags"
"github.com/thomseddon/traefik-forward-auth/internal/provider"
)
var config *Config
// Config holds the runtime application config
type Config struct {
LogLevel string `long:"log-level" env:"LOG_LEVEL" default:"warn" choice:"trace" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" description:"Log level"`
LogFormat string `long:"log-format" env:"LOG_FORMAT" default:"text" choice:"text" choice:"json" choice:"pretty" description:"Log format"`
AuthHost string `long:"auth-host" env:"AUTH_HOST" description:"Single host to use when returning from 3rd party auth"`
Config func(s string) error `long:"config" env:"CONFIG" description:"Path to config file" json:"-"`
CookieDomains []CookieDomain `long:"cookie-domain" env:"COOKIE_DOMAIN" env-delim:"," description:"Domain to set auth cookie on, can be set multiple times"`
InsecureCookie bool `long:"insecure-cookie" env:"INSECURE_COOKIE" description:"Use insecure cookies"`
CookieName string `long:"cookie-name" env:"COOKIE_NAME" default:"_forward_auth" description:"Cookie Name"`
CSRFCookieName string `long:"csrf-cookie-name" env:"CSRF_COOKIE_NAME" default:"_forward_auth_csrf" description:"CSRF Cookie Name"`
DefaultAction string `long:"default-action" env:"DEFAULT_ACTION" default:"auth" choice:"auth" choice:"allow" description:"Default action"`
DefaultProvider string `long:"default-provider" env:"DEFAULT_PROVIDER" default:"google" choice:"google" choice:"oidc" choice:"generic-oauth" description:"Default provider"`
Domains CommaSeparatedList `long:"domain" env:"DOMAIN" env-delim:"," description:"Only allow given email domains, can be set multiple times"`
LifetimeString int `long:"lifetime" env:"LIFETIME" default:"43200" description:"Lifetime in seconds"`
LogoutRedirect string `long:"logout-redirect" env:"LOGOUT_REDIRECT" description:"URL to redirect to following logout"`
MatchWhitelistOrDomain bool `long:"match-whitelist-or-domain" env:"MATCH_WHITELIST_OR_DOMAIN" description:"Allow users that match *either* whitelist or domain (enabled by default in v3)"`
Path string `long:"url-path" env:"URL_PATH" default:"/_oauth" description:"Callback URL Path"`
SecretString string `long:"secret" env:"SECRET" description:"Secret used for signing (required)" json:"-"`
Whitelist CommaSeparatedList `long:"whitelist" env:"WHITELIST" env-delim:"," description:"Only allow given email addresses, can be set multiple times"`
Port int `long:"port" env:"PORT" default:"4181" description:"Port to listen on"`
Providers provider.Providers `group:"providers" namespace:"providers" env-namespace:"PROVIDERS"`
Rules map[string]*Rule `long:"rule.<name>.<param>" description:"Rule definitions, param can be: \"action\", \"rule\" or \"provider\""`
// Filled during transformations
Secret []byte `json:"-"`
Lifetime time.Duration
// Legacy
CookieDomainsLegacy CookieDomains `long:"cookie-domains" env:"COOKIE_DOMAINS" description:"DEPRECATED - Use \"cookie-domain\""`
CookieSecretLegacy string `long:"cookie-secret" env:"COOKIE_SECRET" description:"DEPRECATED - Use \"secret\"" json:"-"`
CookieSecureLegacy string `long:"cookie-secure" env:"COOKIE_SECURE" description:"DEPRECATED - Use \"insecure-cookie\""`
ClientIdLegacy string `long:"client-id" env:"CLIENT_ID" description:"DEPRECATED - Use \"providers.google.client-id\""`
ClientSecretLegacy string `long:"client-secret" env:"CLIENT_SECRET" description:"DEPRECATED - Use \"providers.google.client-id\"" json:"-"`
PromptLegacy string `long:"prompt" env:"PROMPT" description:"DEPRECATED - Use \"providers.google.prompt\""`
}
// NewGlobalConfig creates a new global config, parsed from command arguments
func NewGlobalConfig() *Config {
var err error
config, err = NewConfig(os.Args[1:])
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
return config
}
// TODO: move config parsing into new func "NewParsedConfig"
// NewConfig parses and validates provided configuration into a config object
func NewConfig(args []string) (*Config, error) {
c := &Config{
Rules: map[string]*Rule{},
}
err := c.parseFlags(args)
if err != nil {
return c, err
}
// TODO: as log flags have now been parsed maybe we should return here so
// any further errors can be logged via logrus instead of printed?
// TODO: Rename "Validate" method to "Setup" and move all below logic
// Setup
// Set default provider on any rules where it's not specified
for _, rule := range c.Rules {
if rule.Provider == "" {
rule.Provider = c.DefaultProvider
}
}
// Backwards compatability
if c.CookieSecretLegacy != "" && c.SecretString == "" {
fmt.Println("cookie-secret config option is deprecated, please use secret")
c.SecretString = c.CookieSecretLegacy
}
if c.ClientIdLegacy != "" {
c.Providers.Google.ClientID = c.ClientIdLegacy
}
if c.ClientSecretLegacy != "" {
c.Providers.Google.ClientSecret = c.ClientSecretLegacy
}
if c.PromptLegacy != "" {
fmt.Println("prompt config option is deprecated, please use providers.google.prompt")
c.Providers.Google.Prompt = c.PromptLegacy
}
if c.CookieSecureLegacy != "" {
fmt.Println("cookie-secure config option is deprecated, please use insecure-cookie")
secure, err := strconv.ParseBool(c.CookieSecureLegacy)
if err != nil {
return c, err
}
c.InsecureCookie = !secure
}
if len(c.CookieDomainsLegacy) > 0 {
fmt.Println("cookie-domains config option is deprecated, please use cookie-domain")
c.CookieDomains = append(c.CookieDomains, c.CookieDomainsLegacy...)
}
// Transformations
if len(c.Path) > 0 && c.Path[0] != '/' {
c.Path = "/" + c.Path
}
c.Secret = []byte(c.SecretString)
c.Lifetime = time.Second * time.Duration(c.LifetimeString)
return c, nil
}
func (c *Config) parseFlags(args []string) error {
p := flags.NewParser(c, flags.Default|flags.IniUnknownOptionHandler)
p.UnknownOptionHandler = c.parseUnknownFlag
i := flags.NewIniParser(p)
c.Config = func(s string) error {
// Try parsing at as an ini
err := i.ParseFile(s)
// If it fails with a syntax error, try converting legacy to ini
if err != nil && strings.Contains(err.Error(), "malformed key=value") {
converted, convertErr := convertLegacyToIni(s)
if convertErr != nil {
// If conversion fails, return the original error
return err
}
fmt.Println("config format deprecated, please use ini format")
return i.Parse(converted)
}
return err
}
_, err := p.ParseArgs(args)
if err != nil {
return handleFlagError(err)
}
return nil
}
func (c *Config) parseUnknownFlag(option string, arg flags.SplitArgument, args []string) ([]string, error) {
// Parse rules in the format "rule.<name>.<param>"
parts := strings.Split(option, ".")
if len(parts) == 3 && parts[0] == "rule" {
// Ensure there is a name
name := parts[1]
if len(name) == 0 {
return args, errors.New("route name is required")
}
// Get value, or pop the next arg
val, ok := arg.Value()
if !ok && len(args) > 1 {
val = args[0]
args = args[1:]
}
// Check value
if len(val) == 0 {
return args, errors.New("route param value is required")
}
// Unquote if required
if val[0] == '"' {
var err error
val, err = strconv.Unquote(val)
if err != nil {
return args, err
}
}
// Get or create rule
rule, ok := c.Rules[name]
if !ok {
rule = NewRule()
c.Rules[name] = rule
}
// Add param value to rule
switch parts[2] {
case "action":
rule.Action = val
case "rule":
rule.Rule = val
case "provider":
rule.Provider = val
case "whitelist":
list := CommaSeparatedList{}
list.UnmarshalFlag(val)
rule.Whitelist = list
case "domains":
list := CommaSeparatedList{}
list.UnmarshalFlag(val)
rule.Domains = list
default:
return args, fmt.Errorf("invalid route param: %v", option)
}
} else {
return args, fmt.Errorf("unknown flag: %v", option)
}
return args, nil
}
func handleFlagError(err error) error {
flagsErr, ok := err.(*flags.Error)
if ok && flagsErr.Type == flags.ErrHelp {
// Library has just printed cli help
os.Exit(0)
}
return err
}
var legacyFileFormat = regexp.MustCompile(`(?m)^([a-z-]+) (.*)$`)
func convertLegacyToIni(name string) (io.Reader, error) {
b, err := ioutil.ReadFile(name)
if err != nil {
return nil, err
}
return bytes.NewReader(legacyFileFormat.ReplaceAll(b, []byte("$1=$2"))), nil
}
// Validate validates a config object
func (c *Config) Validate() {
// Check for show stopper errors
if len(c.Secret) == 0 {
log.Fatal("\"secret\" option must be set")
}
// Setup default provider
err := c.setupProvider(c.DefaultProvider)
if err != nil {
log.Fatal(err)
}
// Check rules (validates the rule and the rule provider)
for _, rule := range c.Rules {
err = rule.Validate(c)
if err != nil {
log.Fatal(err)
}
}
}
func (c Config) String() string {
jsonConf, _ := json.Marshal(c)
return string(jsonConf)
}
// GetProvider returns the provider of the given name
func (c *Config) GetProvider(name string) (provider.Provider, error) {
switch name {
case "google":
return &c.Providers.Google, nil
case "oidc":
return &c.Providers.OIDC, nil
case "generic-oauth":
return &c.Providers.GenericOAuth, nil
}
return nil, fmt.Errorf("Unknown provider: %s", name)
}
// GetConfiguredProvider returns the provider of the given name, if it has been
// configured. Returns an error if the provider is unknown, or hasn't been configured
func (c *Config) GetConfiguredProvider(name string) (provider.Provider, error) {
// Check the provider has been configured
if !c.providerConfigured(name) {
return nil, fmt.Errorf("Unconfigured provider: %s", name)
}
return c.GetProvider(name)
}
func (c *Config) providerConfigured(name string) bool {
// Check default provider
if name == c.DefaultProvider {
return true
}
// Check rule providers
for _, rule := range c.Rules {
if name == rule.Provider {
return true
}
}
return false
}
func (c *Config) setupProvider(name string) error {
// Check provider exists
p, err := c.GetProvider(name)
if err != nil {
return err
}
// Setup
err = p.Setup()
if err != nil {
return err
}
return nil
}
// Rule holds defined rules
type Rule struct {
Action string
Rule string
Provider string
Whitelist CommaSeparatedList
Domains CommaSeparatedList
}
// NewRule creates a new rule object
func NewRule() *Rule {
return &Rule{
Action: "auth",
}
}
func (r *Rule) formattedRule() string {
// Traefik implements their own "Host" matcher and then offers "HostRegexp"
// to invoke the mux "Host" matcher. This ensures the mux version is used
return strings.ReplaceAll(r.Rule, "Host(", "HostRegexp(")
}
// Validate validates a rule
func (r *Rule) Validate(c *Config) error {
if r.Action != "auth" && r.Action != "allow" {
return errors.New("invalid rule action, must be \"auth\" or \"allow\"")
}
return c.setupProvider(r.Provider)
}
// Legacy support for comma separated lists
// CommaSeparatedList provides legacy support for config values provided as csv
type CommaSeparatedList []string
// UnmarshalFlag converts a comma separated list to an array
func (c *CommaSeparatedList) UnmarshalFlag(value string) error {
*c = append(*c, strings.Split(value, ",")...)
return nil
}
// MarshalFlag converts an array back to a comma separated list
func (c *CommaSeparatedList) MarshalFlag() (string, error) {
return strings.Join(*c, ","), nil
}

411
internal/config_test.go Normal file
View File

@@ -0,0 +1,411 @@
package tfa
import (
// "fmt"
"os"
"testing"
"time"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
/**
* Tests
*/
func TestConfigDefaults(t *testing.T) {
assert := assert.New(t)
c, err := NewConfig([]string{})
assert.Nil(err)
assert.Equal("warn", c.LogLevel)
assert.Equal("text", c.LogFormat)
assert.Equal("", c.AuthHost)
assert.Len(c.CookieDomains, 0)
assert.False(c.InsecureCookie)
assert.Equal("_forward_auth", c.CookieName)
assert.Equal("_forward_auth_csrf", c.CSRFCookieName)
assert.Equal("auth", c.DefaultAction)
assert.Equal("google", c.DefaultProvider)
assert.Len(c.Domains, 0)
assert.Equal(time.Second*time.Duration(43200), c.Lifetime)
assert.Equal("", c.LogoutRedirect)
assert.False(c.MatchWhitelistOrDomain)
assert.Equal("/_oauth", c.Path)
assert.Len(c.Whitelist, 0)
assert.Equal(c.Port, 4181)
assert.Equal("select_account", c.Providers.Google.Prompt)
}
func TestConfigParseArgs(t *testing.T) {
assert := assert.New(t)
c, err := NewConfig([]string{
"--cookie-name=cookiename",
"--csrf-cookie-name", "\"csrfcookiename\"",
"--default-provider", "\"oidc\"",
"--rule.1.action=allow",
"--rule.1.rule=PathPrefix(`/one`)",
"--rule.two.action=auth",
"--rule.two.rule=\"Host(`two.com`) && Path(`/two`)\"",
"--port=8000",
})
require.Nil(t, err)
// Check normal flags
assert.Equal("cookiename", c.CookieName)
assert.Equal("csrfcookiename", c.CSRFCookieName)
assert.Equal("oidc", c.DefaultProvider)
assert.Equal(8000, c.Port)
// Check rules
assert.Equal(map[string]*Rule{
"1": {
Action: "allow",
Rule: "PathPrefix(`/one`)",
Provider: "oidc",
},
"two": {
Action: "auth",
Rule: "Host(`two.com`) && Path(`/two`)",
Provider: "oidc",
},
}, c.Rules)
}
func TestConfigParseUnknownFlags(t *testing.T) {
_, err := NewConfig([]string{
"--unknown=_oauthpath2",
})
if assert.Error(t, err) {
assert.Equal(t, "unknown flag: unknown", err.Error())
}
}
func TestConfigParseRuleError(t *testing.T) {
assert := assert.New(t)
// Rule without name
_, err := NewConfig([]string{
"--rule..action=auth",
})
if assert.Error(err) {
assert.Equal("route name is required", err.Error())
}
// Rule without value
c, err := NewConfig([]string{
"--rule.one.action=",
})
if assert.Error(err) {
assert.Equal("route param value is required", err.Error())
}
// Check rules
assert.Equal(map[string]*Rule{}, c.Rules)
}
func TestConfigFlagBackwardsCompatability(t *testing.T) {
assert := assert.New(t)
c, err := NewConfig([]string{
"--client-id=clientid",
"--client-secret=verysecret",
"--prompt=prompt",
"--cookie-secret=veryverysecret",
"--lifetime=200",
"--cookie-secure=false",
"--cookie-domains=test1.com,example.org",
"--cookie-domain=another1.net",
"--domain=test2.com,example.org",
"--domain=another2.net",
"--whitelist=test3.com,example.org",
"--whitelist=another3.net",
})
require.Nil(t, err)
// The following used to be passed as comma separated list
expected1 := []CookieDomain{
*NewCookieDomain("another1.net"),
*NewCookieDomain("test1.com"),
*NewCookieDomain("example.org"),
}
assert.Equal(expected1, c.CookieDomains, "should read legacy comma separated list cookie-domains")
expected2 := CommaSeparatedList{"test2.com", "example.org", "another2.net"}
assert.Equal(expected2, c.Domains, "should read legacy comma separated list domains")
expected3 := CommaSeparatedList{"test3.com", "example.org", "another3.net"}
assert.Equal(expected3, c.Whitelist, "should read legacy comma separated list whitelist")
// Name changed
assert.Equal([]byte("veryverysecret"), c.Secret)
// Google provider params used to be top level
assert.Equal("clientid", c.ClientIdLegacy)
assert.Equal("clientid", c.Providers.Google.ClientID, "--client-id should set providers.google.client-id")
assert.Equal("verysecret", c.ClientSecretLegacy)
assert.Equal("verysecret", c.Providers.Google.ClientSecret, "--client-secret should set providers.google.client-secret")
assert.Equal("prompt", c.PromptLegacy)
assert.Equal("prompt", c.Providers.Google.Prompt, "--prompt should set providers.google.promot")
// "cookie-secure" used to be a standard go bool flag that could take
// true, TRUE, 1, false, FALSE, 0 etc. values.
// Here we're checking that format is still suppoted
assert.Equal("false", c.CookieSecureLegacy)
assert.True(c.InsecureCookie, "--cookie-secure=false should set insecure-cookie true")
c, err = NewConfig([]string{"--cookie-secure=TRUE"})
assert.Nil(err)
assert.Equal("TRUE", c.CookieSecureLegacy)
assert.False(c.InsecureCookie, "--cookie-secure=TRUE should set insecure-cookie false")
}
func TestConfigParseIni(t *testing.T) {
assert := assert.New(t)
c, err := NewConfig([]string{
"--config=../test/config0",
"--config=../test/config1",
"--csrf-cookie-name=csrfcookiename",
})
require.Nil(t, err)
assert.Equal("inicookiename", c.CookieName, "should be read from ini file")
assert.Equal("csrfcookiename", c.CSRFCookieName, "should be read from ini file")
assert.Equal("/two", c.Path, "variable in second ini file should override first ini file")
assert.Equal(map[string]*Rule{
"1": {
Action: "allow",
Rule: "PathPrefix(`/one`)",
Provider: "google",
},
"two": {
Action: "auth",
Rule: "Host(`two.com`) && Path(`/two`)",
Provider: "google",
},
}, c.Rules)
}
func TestConfigFileBackwardsCompatability(t *testing.T) {
assert := assert.New(t)
c, err := NewConfig([]string{
"--config=../test/config-legacy",
})
require.Nil(t, err)
assert.Equal("/two", c.Path, "variable in legacy config file should be read")
assert.Equal("auth.legacy.com", c.AuthHost, "variable in legacy config file should be read")
}
func TestConfigParseEnvironment(t *testing.T) {
assert := assert.New(t)
os.Setenv("COOKIE_NAME", "env_cookie_name")
os.Setenv("PROVIDERS_GOOGLE_CLIENT_ID", "env_client_id")
os.Setenv("COOKIE_DOMAIN", "test1.com,example.org")
os.Setenv("DOMAIN", "test2.com,example.org")
os.Setenv("WHITELIST", "test3.com,example.org")
c, err := NewConfig([]string{})
assert.Nil(err)
assert.Equal("env_cookie_name", c.CookieName, "variable should be read from environment")
assert.Equal("env_client_id", c.Providers.Google.ClientID, "namespace variable should be read from environment")
assert.Equal([]CookieDomain{
*NewCookieDomain("test1.com"),
*NewCookieDomain("example.org"),
}, c.CookieDomains, "array variable should be read from environment COOKIE_DOMAIN")
assert.Equal(CommaSeparatedList{"test2.com", "example.org"}, c.Domains, "array variable should be read from environment DOMAIN")
assert.Equal(CommaSeparatedList{"test3.com", "example.org"}, c.Whitelist, "array variable should be read from environment WHITELIST")
os.Unsetenv("COOKIE_NAME")
os.Unsetenv("PROVIDERS_GOOGLE_CLIENT_ID")
os.Unsetenv("COOKIE_DOMAIN")
os.Unsetenv("DOMAIN")
os.Unsetenv("WHITELIST")
}
func TestConfigParseEnvironmentBackwardsCompatability(t *testing.T) {
assert := assert.New(t)
vars := map[string]string{
"CLIENT_ID": "clientid",
"CLIENT_SECRET": "verysecret",
"PROMPT": "prompt",
"COOKIE_SECRET": "veryverysecret",
"LIFETIME": "200",
"COOKIE_SECURE": "false",
"COOKIE_DOMAINS": "test1.com,example.org",
"COOKIE_DOMAIN": "another1.net",
"DOMAIN": "test2.com,example.org",
"WHITELIST": "test3.com,example.org",
}
for k, v := range vars {
os.Setenv(k, v)
}
c, err := NewConfig([]string{})
require.Nil(t, err)
// The following used to be passed as comma separated list
expected1 := []CookieDomain{
*NewCookieDomain("another1.net"),
*NewCookieDomain("test1.com"),
*NewCookieDomain("example.org"),
}
assert.Equal(expected1, c.CookieDomains, "should read legacy comma separated list cookie-domains")
expected2 := CommaSeparatedList{"test2.com", "example.org"}
assert.Equal(expected2, c.Domains, "should read legacy comma separated list domains")
expected3 := CommaSeparatedList{"test3.com", "example.org"}
assert.Equal(expected3, c.Whitelist, "should read legacy comma separated list whitelist")
// Name changed
assert.Equal([]byte("veryverysecret"), c.Secret)
// Google provider params used to be top level
assert.Equal("clientid", c.ClientIdLegacy)
assert.Equal("clientid", c.Providers.Google.ClientID, "--client-id should set providers.google.client-id")
assert.Equal("verysecret", c.ClientSecretLegacy)
assert.Equal("verysecret", c.Providers.Google.ClientSecret, "--client-secret should set providers.google.client-secret")
assert.Equal("prompt", c.PromptLegacy)
assert.Equal("prompt", c.Providers.Google.Prompt, "--prompt should set providers.google.promot")
// "cookie-secure" used to be a standard go bool flag that could take
// true, TRUE, 1, false, FALSE, 0 etc. values.
// Here we're checking that format is still supported
assert.Equal("false", c.CookieSecureLegacy)
assert.True(c.InsecureCookie, "--cookie-secure=false should set insecure-cookie true")
c, err = NewConfig([]string{"--cookie-secure=TRUE"})
assert.Nil(err)
assert.Equal("TRUE", c.CookieSecureLegacy)
assert.False(c.InsecureCookie, "--cookie-secure=TRUE should set insecure-cookie false")
for k := range vars {
os.Unsetenv(k)
}
}
func TestConfigTransformation(t *testing.T) {
assert := assert.New(t)
c, err := NewConfig([]string{
"--url-path=_oauthpath",
"--secret=verysecret",
"--lifetime=200",
})
require.Nil(t, err)
assert.Equal("/_oauthpath", c.Path, "path should add slash to front")
assert.Equal("verysecret", c.SecretString)
assert.Equal([]byte("verysecret"), c.Secret, "secret should be converted to byte array")
assert.Equal(200, c.LifetimeString)
assert.Equal(time.Second*time.Duration(200), c.Lifetime, "lifetime should be read and converted to duration")
}
func TestConfigValidate(t *testing.T) {
assert := assert.New(t)
// Install new logger + hook
var hook *test.Hook
log, hook = test.NewNullLogger()
log.ExitFunc = func(code int) {}
// Validate defualt config + rule error
c, _ := NewConfig([]string{
"--rule.1.action=bad",
})
c.Validate()
logs := hook.AllEntries()
assert.Len(logs, 3)
// Should have fatal error requiring secret
assert.Equal("\"secret\" option must be set", logs[0].Message)
assert.Equal(logrus.FatalLevel, logs[0].Level)
// Should also have default provider (google) error
assert.Equal("providers.google.client-id, providers.google.client-secret must be set", logs[1].Message)
assert.Equal(logrus.FatalLevel, logs[1].Level)
// Should validate rule
assert.Equal("invalid rule action, must be \"auth\" or \"allow\"", logs[2].Message)
assert.Equal(logrus.FatalLevel, logs[2].Level)
hook.Reset()
// Validate with invalid providers
c, _ = NewConfig([]string{
"--secret=veryverysecret",
"--providers.google.client-id=id",
"--providers.google.client-secret=secret",
"--rule.1.action=auth",
"--rule.1.provider=bad2",
})
c.Validate()
logs = hook.AllEntries()
assert.Len(logs, 1)
// Should have error for rule provider
assert.Equal("Unknown provider: bad2", logs[0].Message)
assert.Equal(logrus.FatalLevel, logs[0].Level)
}
func TestConfigGetProvider(t *testing.T) {
assert := assert.New(t)
c, _ := NewConfig([]string{})
// Should be able to get "google" provider
p, err := c.GetProvider("google")
assert.Nil(err)
assert.Equal(&c.Providers.Google, p)
// Should be able to get "oidc" provider
p, err = c.GetProvider("oidc")
assert.Nil(err)
assert.Equal(&c.Providers.OIDC, p)
// Should be able to get "generic-oauth" provider
p, err = c.GetProvider("generic-oauth")
assert.Nil(err)
assert.Equal(&c.Providers.GenericOAuth, p)
// Should catch unknown provider
p, err = c.GetProvider("bad")
if assert.Error(err) {
assert.Equal("Unknown provider: bad", err.Error())
}
}
func TestConfigGetConfiguredProvider(t *testing.T) {
assert := assert.New(t)
c, _ := NewConfig([]string{})
// Should be able to get "google" default provider
p, err := c.GetConfiguredProvider("google")
assert.Nil(err)
assert.Equal(&c.Providers.Google, p)
// Should fail to get valid "oidc" provider as it's not configured
p, err = c.GetConfiguredProvider("oidc")
if assert.Error(err) {
assert.Equal("Unconfigured provider: oidc", err.Error())
}
}
func TestConfigCommaSeparatedList(t *testing.T) {
assert := assert.New(t)
list := CommaSeparatedList{}
err := list.UnmarshalFlag("one,two")
assert.Nil(err)
assert.Equal(CommaSeparatedList{"one", "two"}, list, "should parse comma sepearated list")
marshal, err := list.MarshalFlag()
assert.Nil(err)
assert.Equal("one,two", marshal, "should marshal back to comma sepearated list")
}

View File

@@ -1,4 +1,4 @@
package main
package tfa
import (
"os"
@@ -6,13 +6,16 @@ import (
"github.com/sirupsen/logrus"
)
func CreateLogger(logLevel, logFormat string) logrus.FieldLogger {
var log *logrus.Logger
// NewDefaultLogger creates a new logger based on the current configuration
func NewDefaultLogger() *logrus.Logger {
// Setup logger
log := logrus.StandardLogger()
log = logrus.StandardLogger()
logrus.SetOutput(os.Stdout)
// Set logger format
switch logFormat {
switch config.LogFormat {
case "pretty":
break
case "json":
@@ -26,7 +29,7 @@ func CreateLogger(logLevel, logFormat string) logrus.FieldLogger {
}
// Set logger level
switch logLevel {
switch config.LogLevel {
case "trace":
logrus.SetLevel(logrus.TraceLevel)
case "debug":

View File

@@ -0,0 +1,96 @@
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"golang.org/x/oauth2"
)
// GenericOAuth provider
type GenericOAuth struct {
AuthURL string `long:"auth-url" env:"AUTH_URL" description:"Auth/Login URL"`
TokenURL string `long:"token-url" env:"TOKEN_URL" description:"Token URL"`
UserURL string `long:"user-url" env:"USER_URL" description:"URL used to retrieve user info"`
ClientID string `long:"client-id" env:"CLIENT_ID" description:"Client ID"`
ClientSecret string `long:"client-secret" env:"CLIENT_SECRET" description:"Client Secret" json:"-"`
Scopes []string `long:"scope" env:"SCOPE" env-delim:"," default:"profile" default:"email" description:"Scopes"`
TokenStyle string `long:"token-style" env:"TOKEN_STYLE" default:"header" choice:"header" choice:"query" description:"How token is presented when querying the User URL"`
OAuthProvider
}
// Name returns the name of the provider
func (o *GenericOAuth) Name() string {
return "generic-oauth"
}
// Setup performs validation and setup
func (o *GenericOAuth) Setup() error {
// Check parmas
if o.AuthURL == "" || o.TokenURL == "" || o.UserURL == "" || o.ClientID == "" || o.ClientSecret == "" {
return errors.New("providers.generic-oauth.auth-url, providers.generic-oauth.token-url, providers.generic-oauth.user-url, providers.generic-oauth.client-id, providers.generic-oauth.client-secret must be set")
}
// Create oauth2 config
o.Config = &oauth2.Config{
ClientID: o.ClientID,
ClientSecret: o.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: o.AuthURL,
TokenURL: o.TokenURL,
},
Scopes: o.Scopes,
}
o.ctx = context.Background()
return nil
}
// GetLoginURL provides the login url for the given redirect uri and state
func (o *GenericOAuth) GetLoginURL(redirectURI, state string) string {
return o.OAuthGetLoginURL(redirectURI, state)
}
// ExchangeCode exchanges the given redirect uri and code for a token
func (o *GenericOAuth) ExchangeCode(redirectURI, code string) (string, error) {
token, err := o.OAuthExchangeCode(redirectURI, code)
if err != nil {
return "", err
}
return token.AccessToken, nil
}
// GetUser uses the given token and returns a complete provider.User object
func (o *GenericOAuth) GetUser(token string) (User, error) {
var user User
req, err := http.NewRequest("GET", o.UserURL, nil)
if err != nil {
return user, err
}
if o.TokenStyle == "header" {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
} else if o.TokenStyle == "query" {
q := req.URL.Query()
q.Add("access_token", token)
req.URL.RawQuery = q.Encode()
}
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
return user, err
}
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&user)
return user, err
}

View File

@@ -0,0 +1,140 @@
package provider
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
)
// Tests
func TestGenericOAuthName(t *testing.T) {
p := GenericOAuth{}
assert.Equal(t, "generic-oauth", p.Name())
}
func TestGenericOAuthSetup(t *testing.T) {
assert := assert.New(t)
p := GenericOAuth{}
// Check validation
err := p.Setup()
if assert.Error(err) {
assert.Equal("providers.generic-oauth.auth-url, providers.generic-oauth.token-url, providers.generic-oauth.user-url, providers.generic-oauth.client-id, providers.generic-oauth.client-secret must be set", err.Error())
}
// Check setup
p = GenericOAuth{
AuthURL: "https://provider.com/oauth2/auth",
TokenURL: "https://provider.com/oauth2/token",
UserURL: "https://provider.com/oauth2/user",
ClientID: "id",
ClientSecret: "secret",
}
err = p.Setup()
assert.Nil(err)
}
func TestGenericOAuthGetLoginURL(t *testing.T) {
assert := assert.New(t)
p := GenericOAuth{
AuthURL: "https://provider.com/oauth2/auth",
TokenURL: "https://provider.com/oauth2/token",
UserURL: "https://provider.com/oauth2/user",
ClientID: "idtest",
ClientSecret: "secret",
Scopes: []string{"scopetest"},
}
err := p.Setup()
if err != nil {
t.Fatal(err)
}
// Check url
uri, err := url.Parse(p.GetLoginURL("http://example.com/_oauth", "state"))
assert.Nil(err)
assert.Equal("https", uri.Scheme)
assert.Equal("provider.com", uri.Host)
assert.Equal("/oauth2/auth", uri.Path)
// Check query string
qs := uri.Query()
expectedQs := url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"state": []string{"state"},
}
assert.Equal(expectedQs, qs)
}
func TestGenericOAuthExchangeCode(t *testing.T) {
assert := assert.New(t)
// Setup server
expected := url.Values{
"client_id": []string{"idtest"},
"client_secret": []string{"sectest"},
"code": []string{"code"},
"grant_type": []string{"authorization_code"},
"redirect_uri": []string{"http://example.com/_oauth"},
}
server, serverURL := NewOAuthServer(t, map[string]string{
"token": expected.Encode(),
})
defer server.Close()
// Setup provider
p := GenericOAuth{
AuthURL: "https://provider.com/oauth2/auth",
TokenURL: serverURL.String() + "/token",
UserURL: "https://provider.com/oauth2/user",
ClientID: "idtest",
ClientSecret: "sectest",
}
err := p.Setup()
if err != nil {
t.Fatal(err)
}
// We force AuthStyleInParams to prevent the test failure when the
// AuthStyleInHeader is attempted
p.Config.Endpoint.AuthStyle = oauth2.AuthStyleInParams
token, err := p.ExchangeCode("http://example.com/_oauth", "code")
assert.Nil(err)
assert.Equal("123456789", token)
}
func TestGenericOAuthGetUser(t *testing.T) {
assert := assert.New(t)
// Setup server
server, serverURL := NewOAuthServer(t, nil)
defer server.Close()
// Setup provider
p := GenericOAuth{
AuthURL: "https://provider.com/oauth2/auth",
TokenURL: "https://provider.com/oauth2/token",
UserURL: serverURL.String() + "/userinfo",
ClientID: "idtest",
ClientSecret: "sectest",
}
err := p.Setup()
if err != nil {
t.Fatal(err)
}
// We force AuthStyleInParams to prevent the test failure when the
// AuthStyleInHeader is attempted
p.Config.Endpoint.AuthStyle = oauth2.AuthStyleInParams
user, err := p.GetUser("123456789")
assert.Nil(err)
assert.Equal("example@example.com", user.Email)
}

115
internal/provider/google.go Normal file
View File

@@ -0,0 +1,115 @@
package provider
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
)
// Google provider
type Google struct {
ClientID string `long:"client-id" env:"CLIENT_ID" description:"Client ID"`
ClientSecret string `long:"client-secret" env:"CLIENT_SECRET" description:"Client Secret" json:"-"`
Scope string
Prompt string `long:"prompt" env:"PROMPT" default:"select_account" description:"Space separated list of OpenID prompt options"`
LoginURL *url.URL
TokenURL *url.URL
UserURL *url.URL
}
// Name returns the name of the provider
func (g *Google) Name() string {
return "google"
}
// Setup performs validation and setup
func (g *Google) Setup() error {
if g.ClientID == "" || g.ClientSecret == "" {
return errors.New("providers.google.client-id, providers.google.client-secret must be set")
}
// Set static values
g.Scope = "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email"
g.LoginURL = &url.URL{
Scheme: "https",
Host: "accounts.google.com",
Path: "/o/oauth2/auth",
}
g.TokenURL = &url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v3/token",
}
g.UserURL = &url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v2/userinfo",
}
return nil
}
// GetLoginURL provides the login url for the given redirect uri and state
func (g *Google) GetLoginURL(redirectURI, state string) string {
q := url.Values{}
q.Set("client_id", g.ClientID)
q.Set("response_type", "code")
q.Set("scope", g.Scope)
if g.Prompt != "" {
q.Set("prompt", g.Prompt)
}
q.Set("redirect_uri", redirectURI)
q.Set("state", state)
var u url.URL
u = *g.LoginURL
u.RawQuery = q.Encode()
return u.String()
}
// ExchangeCode exchanges the given redirect uri and code for a token
func (g *Google) ExchangeCode(redirectURI, code string) (string, error) {
form := url.Values{}
form.Set("client_id", g.ClientID)
form.Set("client_secret", g.ClientSecret)
form.Set("grant_type", "authorization_code")
form.Set("redirect_uri", redirectURI)
form.Set("code", code)
res, err := http.PostForm(g.TokenURL.String(), form)
if err != nil {
return "", err
}
var token token
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&token)
return token.Token, err
}
// GetUser uses the given token and returns a complete provider.User object
func (g *Google) GetUser(token string) (User, error) {
var user User
client := &http.Client{}
req, err := http.NewRequest("GET", g.UserURL.String(), nil)
if err != nil {
return user, err
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := client.Do(req)
if err != nil {
return user, err
}
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(&user)
return user, err
}

View File

@@ -0,0 +1,148 @@
package provider
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
// Tests
func TestGoogleName(t *testing.T) {
p := Google{}
assert.Equal(t, "google", p.Name())
}
func TestGoogleSetup(t *testing.T) {
assert := assert.New(t)
p := Google{}
// Check validation
err := p.Setup()
if assert.Error(err) {
assert.Equal("providers.google.client-id, providers.google.client-secret must be set", err.Error())
}
// Check setup
p = Google{
ClientID: "id",
ClientSecret: "secret",
}
err = p.Setup()
assert.Nil(err)
assert.Equal("https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", p.Scope)
assert.Equal("", p.Prompt)
assert.Equal(&url.URL{
Scheme: "https",
Host: "accounts.google.com",
Path: "/o/oauth2/auth",
}, p.LoginURL)
assert.Equal(&url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v3/token",
}, p.TokenURL)
assert.Equal(&url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v2/userinfo",
}, p.UserURL)
}
func TestGoogleGetLoginURL(t *testing.T) {
assert := assert.New(t)
p := Google{
ClientID: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
Prompt: "consent select_account",
LoginURL: &url.URL{
Scheme: "https",
Host: "google.com",
Path: "/auth",
},
}
// Check url
uri, err := url.Parse(p.GetLoginURL("http://example.com/_oauth", "state"))
assert.Nil(err)
assert.Equal("https", uri.Scheme)
assert.Equal("google.com", uri.Host)
assert.Equal("/auth", uri.Path)
// Check query string
qs := uri.Query()
expectedQs := url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"prompt": []string{"consent select_account"},
"state": []string{"state"},
}
assert.Equal(expectedQs, qs)
}
func TestGoogleExchangeCode(t *testing.T) {
assert := assert.New(t)
// Setup server
expected := url.Values{
"client_id": []string{"idtest"},
"client_secret": []string{"sectest"},
"code": []string{"code"},
"grant_type": []string{"authorization_code"},
"redirect_uri": []string{"http://example.com/_oauth"},
}
server, serverURL := NewOAuthServer(t, map[string]string{
"token": expected.Encode(),
})
defer server.Close()
// Setup provider
p := Google{
ClientID: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
Prompt: "consent select_account",
TokenURL: &url.URL{
Scheme: serverURL.Scheme,
Host: serverURL.Host,
Path: "/token",
},
}
token, err := p.ExchangeCode("http://example.com/_oauth", "code")
assert.Nil(err)
assert.Equal("123456789", token)
}
func TestGoogleGetUser(t *testing.T) {
assert := assert.New(t)
// Setup server
server, serverURL := NewOAuthServer(t, nil)
defer server.Close()
// Setup provider
p := Google{
ClientID: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
Prompt: "consent select_account",
UserURL: &url.URL{
Scheme: serverURL.Scheme,
Host: serverURL.Host,
Path: "/userinfo",
},
}
user, err := p.GetUser("123456789")
assert.Nil(err)
assert.Equal("example@example.com", user.Email)
}

99
internal/provider/oidc.go Normal file
View File

@@ -0,0 +1,99 @@
package provider
import (
"context"
"errors"
"github.com/coreos/go-oidc"
"golang.org/x/oauth2"
)
// OIDC provider
type OIDC struct {
IssuerURL string `long:"issuer-url" env:"ISSUER_URL" description:"Issuer URL"`
ClientID string `long:"client-id" env:"CLIENT_ID" description:"Client ID"`
ClientSecret string `long:"client-secret" env:"CLIENT_SECRET" description:"Client Secret" json:"-"`
OAuthProvider
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
}
// Name returns the name of the provider
func (o *OIDC) Name() string {
return "oidc"
}
// Setup performs validation and setup
func (o *OIDC) Setup() error {
// Check parms
if o.IssuerURL == "" || o.ClientID == "" || o.ClientSecret == "" {
return errors.New("providers.oidc.issuer-url, providers.oidc.client-id, providers.oidc.client-secret must be set")
}
var err error
o.ctx = context.Background()
// Try to initiate provider
o.provider, err = oidc.NewProvider(o.ctx, o.IssuerURL)
if err != nil {
return err
}
// Create oauth2 config
o.Config = &oauth2.Config{
ClientID: o.ClientID,
ClientSecret: o.ClientSecret,
Endpoint: o.provider.Endpoint(),
// "openid" is a required scope for OpenID Connect flows.
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
// Create OIDC verifier
o.verifier = o.provider.Verifier(&oidc.Config{
ClientID: o.ClientID,
})
return nil
}
// GetLoginURL provides the login url for the given redirect uri and state
func (o *OIDC) GetLoginURL(redirectURI, state string) string {
return o.OAuthGetLoginURL(redirectURI, state)
}
// ExchangeCode exchanges the given redirect uri and code for a token
func (o *OIDC) ExchangeCode(redirectURI, code string) (string, error) {
token, err := o.OAuthExchangeCode(redirectURI, code)
if err != nil {
return "", err
}
// Extract ID token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return "", errors.New("Missing id_token")
}
return rawIDToken, nil
}
// GetUser uses the given token and returns a complete provider.User object
func (o *OIDC) GetUser(token string) (User, error) {
var user User
// Parse & Verify ID Token
idToken, err := o.verifier.Verify(o.ctx, token)
if err != nil {
return user, err
}
// Extract custom claims
if err := idToken.Claims(&user); err != nil {
return user, err
}
return user, nil
}

View File

@@ -0,0 +1,277 @@
package provider
import (
"crypto/rand"
"crypto/rsa"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
jose "github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
)
// Tests
func TestOIDCName(t *testing.T) {
p := OIDC{}
assert.Equal(t, "oidc", p.Name())
}
func TestOIDCSetup(t *testing.T) {
assert := assert.New(t)
p := OIDC{}
err := p.Setup()
if assert.Error(err) {
assert.Equal("providers.oidc.issuer-url, providers.oidc.client-id, providers.oidc.client-secret must be set", err.Error())
}
}
func TestOIDCGetLoginURL(t *testing.T) {
assert := assert.New(t)
provider, server, serverURL, _ := setupOIDCTest(t, nil)
defer server.Close()
// Check url
uri, err := url.Parse(provider.GetLoginURL("http://example.com/_oauth", "state"))
assert.Nil(err)
assert.Equal(serverURL.Scheme, uri.Scheme)
assert.Equal(serverURL.Host, uri.Host)
assert.Equal("/auth", uri.Path)
// Check query string
qs := uri.Query()
expectedQs := url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"openid profile email"},
"state": []string{"state"},
}
assert.Equal(expectedQs, qs)
// Calling the method should not modify the underlying config
assert.Equal("", provider.Config.RedirectURL)
//
// Test with resource config option
//
provider.Resource = "resourcetest"
// Check url
uri, err = url.Parse(provider.GetLoginURL("http://example.com/_oauth", "state"))
assert.Nil(err)
assert.Equal(serverURL.Scheme, uri.Scheme)
assert.Equal(serverURL.Host, uri.Host)
assert.Equal("/auth", uri.Path)
// Check query string
qs = uri.Query()
expectedQs = url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"openid profile email"},
"state": []string{"state"},
"resource": []string{"resourcetest"},
}
assert.Equal(expectedQs, qs)
// Calling the method should not modify the underlying config
assert.Equal("", provider.Config.RedirectURL)
}
func TestOIDCExchangeCode(t *testing.T) {
assert := assert.New(t)
provider, server, _, _ := setupOIDCTest(t, map[string]map[string]string{
"token": {
"code": "code",
"grant_type": "authorization_code",
"redirect_uri": "http://example.com/_oauth",
},
})
defer server.Close()
token, err := provider.ExchangeCode("http://example.com/_oauth", "code")
assert.Nil(err)
assert.Equal("id_123456789", token)
}
func TestOIDCGetUser(t *testing.T) {
assert := assert.New(t)
provider, server, serverURL, key := setupOIDCTest(t, nil)
defer server.Close()
// Generate JWT
token := key.sign(t, []byte(`{
"iss": "`+serverURL.String()+`",
"exp":`+strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)+`,
"aud": "idtest",
"sub": "1",
"email": "example@example.com",
"email_verified": true
}`))
// Get user
user, err := provider.GetUser(token)
assert.Nil(err)
assert.Equal("example@example.com", user.Email)
}
// Utils
// setOIDCTest creates a key, OIDCServer and initilises an OIDC provider
func setupOIDCTest(t *testing.T, bodyValues map[string]map[string]string) (*OIDC, *httptest.Server, *url.URL, *rsaKey) {
// Generate key
key, err := newRSAKey()
if err != nil {
t.Fatal(err)
}
body := make(map[string]string)
if bodyValues != nil {
// URL encode bodyValues into body
for method, values := range bodyValues {
q := url.Values{}
for k, v := range values {
q.Set(k, v)
}
body[method] = q.Encode()
}
}
// Set up oidc server
server, serverURL := NewOIDCServer(t, key, body)
// Setup provider
p := OIDC{
ClientID: "idtest",
ClientSecret: "sectest",
IssuerURL: serverURL.String(),
}
// Initialise config/verifier
err = p.Setup()
if err != nil {
t.Fatal(err)
}
return &p, server, serverURL, key
}
// OIDCServer is used in the OIDC Tests to mock an OIDC server
type OIDCServer struct {
t *testing.T
url *url.URL
body map[string]string // method -> body
key *rsaKey
}
func NewOIDCServer(t *testing.T, key *rsaKey, body map[string]string) (*httptest.Server, *url.URL) {
handler := &OIDCServer{t: t, key: key, body: body}
server := httptest.NewServer(handler)
handler.url, _ = url.Parse(server.URL)
return server, handler.url
}
func (s *OIDCServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, _ := ioutil.ReadAll(r.Body)
if r.URL.Path == "/.well-known/openid-configuration" {
// Open id config
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"issuer":"`+s.url.String()+`",
"authorization_endpoint":"`+s.url.String()+`/auth",
"token_endpoint":"`+s.url.String()+`/token",
"jwks_uri":"`+s.url.String()+`/jwks"
}`)
} else if r.URL.Path == "/token" {
// Token request
// Check body
if b, ok := s.body["token"]; ok {
if b != string(body) {
s.t.Fatal("Unexpected request body, expected", b, "got", string(body))
}
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"access_token":"123456789",
"id_token":"id_123456789"
}`)
} else if r.URL.Path == "/jwks" {
// Key request
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"keys":[`+s.key.publicJWK(s.t)+`]}`)
} else {
s.t.Fatal("Unrecognised request: ", r.URL, string(body))
}
}
// rsaKey is used in the OIDCServer tests to sign and verify requests
type rsaKey struct {
key *rsa.PrivateKey
alg jose.SignatureAlgorithm
jwkPub *jose.JSONWebKey
jwkPriv *jose.JSONWebKey
}
func newRSAKey() (*rsaKey, error) {
key, err := rsa.GenerateKey(rand.Reader, 1028)
if err != nil {
return nil, err
}
return &rsaKey{
key: key,
alg: jose.RS256,
jwkPub: &jose.JSONWebKey{
Key: key.Public(),
Algorithm: string(jose.RS256),
},
jwkPriv: &jose.JSONWebKey{
Key: key,
Algorithm: string(jose.RS256),
},
}, nil
}
func (k *rsaKey) publicJWK(t *testing.T) string {
b, err := k.jwkPub.MarshalJSON()
if err != nil {
t.Fatal(err)
}
return string(b)
}
// sign creates a JWS using the private key from the provided payload.
func (k *rsaKey) sign(t *testing.T, payload []byte) string {
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: k.alg,
Key: k.key,
}, nil)
if err != nil {
t.Fatal(err)
}
jws, err := signer.Sign(payload)
if err != nil {
t.Fatal(err)
}
data, err := jws.CompactSerialize()
if err != nil {
t.Fatal(err)
}
return data
}

View File

@@ -0,0 +1,66 @@
package provider
import (
"context"
// "net/url"
"golang.org/x/oauth2"
)
// Providers contains all the implemented providers
type Providers struct {
Google Google `group:"Google Provider" namespace:"google" env-namespace:"GOOGLE"`
OIDC OIDC `group:"OIDC Provider" namespace:"oidc" env-namespace:"OIDC"`
GenericOAuth GenericOAuth `group:"Generic OAuth2 Provider" namespace:"generic-oauth" env-namespace:"GENERIC_OAUTH"`
}
// Provider is used to authenticate users
type Provider interface {
Name() string
GetLoginURL(redirectURI, state string) string
ExchangeCode(redirectURI, code string) (string, error)
GetUser(token string) (User, error)
Setup() error
}
type token struct {
Token string `json:"access_token"`
}
// User is the authenticated user
type User struct {
Email string `json:"email"`
}
// OAuthProvider is a provider using the oauth2 library
type OAuthProvider struct {
Resource string `long:"resource" env:"RESOURCE" description:"Optional resource indicator"`
Config *oauth2.Config
ctx context.Context
}
// ConfigCopy returns a copy of the oauth2 config with the given redirectURI
// which ensures the underlying config is not modified
func (p *OAuthProvider) ConfigCopy(redirectURI string) oauth2.Config {
config := *p.Config
config.RedirectURL = redirectURI
return config
}
// OAuthGetLoginURL provides a base "GetLoginURL" for proiders using OAauth2
func (p *OAuthProvider) OAuthGetLoginURL(redirectURI, state string) string {
config := p.ConfigCopy(redirectURI)
if p.Resource != "" {
return config.AuthCodeURL(state, oauth2.SetAuthURLParam("resource", p.Resource))
}
return config.AuthCodeURL(state)
}
// OAuthExchangeCode provides a base "ExchangeCode" for proiders using OAauth2
func (p *OAuthProvider) OAuthExchangeCode(redirectURI, code string) (*oauth2.Token, error) {
config := p.ConfigCopy(redirectURI)
return config.Exchange(p.ctx, code)
}

View File

@@ -0,0 +1,48 @@
package provider
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// Utilities
type OAuthServer struct {
t *testing.T
url *url.URL
body map[string]string // method -> body
}
func NewOAuthServer(t *testing.T, body map[string]string) (*httptest.Server, *url.URL) {
handler := &OAuthServer{t: t, body: body}
server := httptest.NewServer(handler)
handler.url, _ = url.Parse(server.URL)
return server, handler.url
}
func (s *OAuthServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, _ := ioutil.ReadAll(r.Body)
// fmt.Println("Got request:", r.URL, r.Method, string(body))
if r.Method == "POST" && r.URL.Path == "/token" {
if s.body["token"] != string(body) {
s.t.Fatal("Unexpected request body, expected", s.body["token"], "got", string(body))
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":"123456789"}`)
} else if r.Method == "GET" && r.URL.Path == "/userinfo" {
fmt.Fprint(w, `{
"id":"1",
"email":"example@example.com",
"verified_email":true,
"hd":"example.com"
}`)
} else {
s.t.Fatal("Unrecognised request: ", r.Method, r.URL, string(body))
}
}

Some files were not shown because too many files have changed in this diff Show More