Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

refactor: stronger evaluation of cmdline args #19

Merged
merged 4 commits into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches:
- main
- sessions
- fuzz

jobs:
test:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches-ignore:
- main
- sessions
- fuzz

jobs:
test:
Expand Down
57 changes: 46 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@

A simple, up-to-date, re-implementation of traefik-forward-auth.

## Contents

- [Goals](#goals)
- [Design](#design)
- [Installation](#installation)
- [Configuration](#configuration)
- [Using Google as auth provider](#using-google-as-auth-provider)
- [Traefik](#traefik)
- [Authenticating access to an ingress](#authenticating-access-to-an-ingress)
- [Running traefik-simple-auth](#running-traefik-simple-auth)
- [Metrics](#metrics)
- [Authors](#authors)
- [License](#license)

## Goals

traefik-simple-auth provides an implementation of Traefik's forwardAuth middleware. Most people typically use Thom Seddon's
[!traefik-forward-auth](https://github.com/thomseddon/traefik-forward-auth?tab=readme-ov-file#configuration), or one of its
[traefik-forward-auth](https://github.com/thomseddon/traefik-forward-auth), or one of its
many forks. However, that implementation hasn't been updated in over 3 years. I wrote traefik-simple-auth with the following goals:

* to learn about Traefik's forwardAuth middleware and the oauth approach that traefik-forward-auth uses;
Expand Down Expand Up @@ -56,7 +70,7 @@ received from the auth provider, to protect against cross-site request forgery (

## Installation

Container images are available on [ghcr.io](https://ghcr.io/clambin/traefik-simple-auth).
Container images are available on [ghcr.io](https://ghcr.io/clambin/traefik-simple-auth). Images are available for linux/amd64, linux/arm and linux/arm64.

## Configuration
### Using Google as auth provider
Expand Down Expand Up @@ -99,7 +113,9 @@ spec:
This created a new middleware `traefik-simple-auth` that forwards incoming requests to `http://traefik-simple-auth:8080`
(the service pointing to traefik-simple-auth) for authentication.

#### Ingress
traefik-simple-auth will add the email address of the authenticated used in the X-Forwarded-User header.

#### Ingress for Authentication

To authenticate a user, traefik-simple-auth redirects the user to the auth provider's login page. Upon successful login,
the provider forwards the request to the redirectURL (as configured in section `Using Google as auth provider`).
Expand All @@ -110,6 +126,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: traefik-simple-auth
namespace: traefik
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: traefik-traefik-simple-auth@kubernetescrd
Expand All @@ -129,6 +146,30 @@ spec:

This forwards the request to traefik-simple-auth.

### Authenticating access to an ingress

To enable traefik-simple-auth to authenticate access to an ingress, add the middleware to its Ingress:

```
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana-external
namespace: monitoring
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: traefik-traefik-simple-auth@kubernetescrd
spec:
rules:
[...]
```

Each access to the ingress causes traefik to first forward the request to the middleware. If the middleware responds
with an HTTP 2xx code (meaning the request has a valid session cookie), traefik honours the request.

Note: traefik prepends the namespace to the name of middleware defined via a kubernetes resource. So, the middleware
`traefik-simple-auth` that was created in the `traefik` namespace becomes `traefik-traefik-simple-auth`.

### Running traefik-simple-auth

traefik-simple-auth supports the following command-line arguments:
Expand All @@ -149,14 +190,12 @@ Usage:
Comma-separated list of domains to allow access
-expiry duration
How long a session remains valid (default 720h0m0s)
-insecure
Use insecure cookies
-prom string
The address to listen on for Prometheus scrape requests (default ":9090")
-provider string
The OAuth2 provider to use (default "google")
-secret string
Secret to use for authentication
Secret to use for authentication (base64-encoded)
-session-cookie-name string
The cookie name to use for authentication (default "traefik-simple-auth")
-users string
Expand Down Expand Up @@ -209,13 +248,9 @@ Usage:

Lifetime of the session cookie, i.e. how long before a user must log back into Google.

- `insecure`

Marks the session cookie as insecure so it can be used over HTTP sessions.

- `secret`

A (hex-encoded) 256-bit secret used to protect the session cookie.
A (base64-encoded) secret used to protect the session cookie.

- `users`

Expand Down
28 changes: 13 additions & 15 deletions internal/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"encoding/base64"
"errors"
"flag"
"fmt"
"github.com/clambin/traefik-simple-auth/pkg/domains"
"github.com/clambin/traefik-simple-auth/pkg/whitelist"
"strings"
"time"
)
Expand All @@ -15,7 +17,6 @@ var (
promAddr = flag.String("prom", ":9090", "The address to listen on for Prometheus scrape requests")
sessionCookieName = flag.String("session-cookie-name", "_traefik_simple_auth", "The cookie name to use for authentication")
expiry = flag.Duration("expiry", 30*24*time.Hour, "How long a session remains valid")
insecure = flag.Bool("insecure", false, "Use insecure cookies")
authPrefix = flag.String("auth-prefix", "auth", "prefix to construct the authRedirect URL from the domain")
domainsString = flag.String("domains", "", "Comma-separated list of domains to allow access")
users = flag.String("users", "", "Comma-separated list of usernames to login")
Expand All @@ -32,24 +33,25 @@ type Configuration struct {
SessionCookieName string
Expiry time.Duration
Secret []byte
InsecureCookie bool
Provider string
Domains domains.Domains
Users []string
Whitelist whitelist.Whitelist
ClientID string
ClientSecret string
AuthPrefix string
}

func GetConfiguration() (Configuration, error) {
if *domainsString == "" {
return Configuration{}, errors.New("must specify at least one domain")
domainList, err := domains.GetDomains(strings.Split(*domainsString, ","))
if err != nil {
return Configuration{}, fmt.Errorf("invalid domain list: %w", err)
}
if len(domainList) == 0 {
return Configuration{}, errors.New("no valid domains")
}
domainList := strings.Split(*domainsString, ",")
for i := range domainList {
if domainList[i] != "" && domainList[i][0] != '.' {
domainList[i] = "." + domainList[i]
}
whiteList, err := whitelist.New(strings.Split(*users, ","))
if err != nil {
return Configuration{}, fmt.Errorf("invalid whitelist: %w", err)
}
secretBytes, err := base64.StdEncoding.DecodeString(*secret)
if err != nil {
Expand All @@ -58,19 +60,15 @@ func GetConfiguration() (Configuration, error) {
if *clientId == "" || *clientSecret == "" {
return Configuration{}, errors.New("must specify both client-id and client-secret")
}
if *users == "" {
return Configuration{}, errors.New("must specify at least one user")
}
return Configuration{
Debug: *debug,
Addr: *addr,
PromAddr: *promAddr,
SessionCookieName: *sessionCookieName,
Expiry: *expiry,
Secret: secretBytes,
InsecureCookie: *insecure,
Domains: domainList,
Users: strings.Split(*users, ","),
Whitelist: whiteList,
Provider: *provider,
ClientID: *clientId,
ClientSecret: *clientSecret,
Expand Down
149 changes: 109 additions & 40 deletions internal/configuration/configuration_test.go
Original file line number Diff line number Diff line change
@@ -1,52 +1,121 @@
package configuration

import (
"encoding/hex"
"github.com/clambin/traefik-simple-auth/pkg/domains"
"github.com/clambin/traefik-simple-auth/pkg/whitelist"
"github.com/stretchr/testify/assert"
"testing"
"time"
)

func TestGetConfiguration(t *testing.T) {
_, err := GetConfiguration()
assert.Error(t, err)
type args struct {
domainsString string
users string
secret string
clientID string
clientSecret string
}
tests := []struct {
name string
args args
wantErr assert.ErrorAssertionFunc
want Configuration
}{
{
name: "empty",
wantErr: assert.Error,
},
{
name: "invalid domains",
args: args{
domainsString: " ",
},
wantErr: assert.Error,
},
{
name: "invalid whitelist",
args: args{
domainsString: "example.com",
users: "9",
},
wantErr: assert.Error,
},
{
name: "missing secret",
args: args{
domainsString: "example.com",
users: "foo@example.com",
},
wantErr: assert.Error,
},
{
name: "invalid secret",
args: args{
domainsString: "example.com",
users: "foo@example.com",
secret: "secret",
},
wantErr: assert.Error,
},
{
name: "missing clientID",
args: args{
domainsString: "example.com",
users: "foo@example.com",
secret: "c2VjcmV0Cg==",
},
wantErr: assert.Error,
},
{
name: "missing clientSecret",
args: args{
domainsString: "example.com",
users: "foo@example.com",
secret: "c2VjcmV0Cg==",
clientID: "123456789",
},
wantErr: assert.Error,
},
{
name: "valid",
args: args{
domainsString: "example.com",
users: "foo@example.com",
secret: "c2VjcmV0Cg==",
clientID: "123456789",
clientSecret: "1234567890",
},
wantErr: assert.NoError,
want: Configuration{
Debug: false,
Addr: ":8080",
PromAddr: ":9090",
SessionCookieName: "_traefik_simple_auth",
Expiry: 30 * 24 * time.Hour,
Secret: []byte("secret\n"),
Provider: "google",
Domains: domains.Domains{".example.com"},
Whitelist: whitelist.Whitelist{"foo@example.com": struct{}{}},
ClientID: "123456789",
ClientSecret: "1234567890",
AuthPrefix: "auth"},
},
}

*domainsString = "example.com"
_, err = GetConfiguration()
assert.Error(t, err)

*secret = "not-a-valid-secret"
_, err = GetConfiguration()
assert.Error(t, err)

*secret = hex.EncodeToString([]byte("secret"))
_, err = GetConfiguration()
assert.Error(t, err)

*clientId = "clientId"
_, err = GetConfiguration()
assert.Error(t, err)

*clientSecret = "clientSecret"
_, err = GetConfiguration()
assert.Error(t, err)

*users = "foo@example.com"
cfg, err := GetConfiguration()
assert.NoError(t, err)

assert.Equal(t, Configuration{
Addr: ":8080",
PromAddr: ":9090",
SessionCookieName: "_traefik_simple_auth",
Expiry: 720 * time.Hour,
Secret: []byte{0xef, 0x7e, 0xb9, 0xeb, 0x7e, 0xf6, 0xeb, 0x9e, 0xf8},
Provider: "google",
Domains: []string{".example.com"},
Users: []string{"foo@example.com"},
ClientID: "clientId",
ClientSecret: "clientSecret",
AuthPrefix: "auth",
}, cfg)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
*domainsString = tt.args.domainsString
*users = tt.args.users
*secret = tt.args.secret
*clientId = tt.args.clientID
*clientSecret = tt.args.clientSecret

cfg, err := GetConfiguration()
tt.wantErr(t, err)
if err == nil {
assert.Equal(t, tt.want, cfg)
}
})
}
}
4 changes: 2 additions & 2 deletions internal/server/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func TestServer_withMetrics(t *testing.T) {
config := configuration.Configuration{
SessionCookieName: "_auth",
Secret: []byte("secret"),
Users: []string{"foo@example.com"},
Whitelist: map[string]struct{}{"foo@example.com": {}},
Domains: []string{"example.com"},
Provider: "google",
}
Expand Down Expand Up @@ -121,7 +121,7 @@ func TestMetrics_Collect_ActiveUsers(t *testing.T) {
config := configuration.Configuration{
SessionCookieName: "_auth",
Secret: []byte("secret"),
Users: []string{"foo@example.com"},
Whitelist: map[string]struct{}{"foo@example.com": {}},
Domains: []string{"example.com"},
Provider: "google",
Expiry: time.Hour,
Expand Down
Loading
Loading