Skip to content

Commit

Permalink
feat: add support for GitHub as OAuth2 provider (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
clambin authored Apr 21, 2024
1 parent 897d74c commit 8e34f63
Show file tree
Hide file tree
Showing 14 changed files with 352 additions and 156 deletions.
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
- redirect
- provider

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
- redirect
- provider

jobs:
test:
Expand Down
33 changes: 20 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,26 @@ For traefik-simple-auth, a valid cookie:

If an incoming request does not contain a valid session cookie, the user needs to be authenticated:

* We forward the user to Google's login page, so the user can be authenticated;
* When the user has logged in, Google sends the request back to traefik-simple-auth, specifically to the address `<auth-host>/_oauth`;
* We forward the user to the auth provider's login page, so the user can be authenticated;
* When the user has logged in, the provider sends the request back to traefik-simple-auth, specifically to the address `<auth-host>/_oauth`;
* This routes the request to traefik-simple-auth's authCallback handler;
* The handler uses the request to retrieve the authenticated user's email address and see if it is part of the `users` whitelist;
* If so, it creates a new session cookie, and redirects the user to the original destination, with the session cookie;
* This results in the request being sent back to traefik-simple-auth, with the session cookie, so it passes and the request is sent to the final destination.

Given the asynchronous nature of the handshake during the authentication, traefik-simple-auth needs to validate the request
received from Google, to protect against cross-site request forgery (CSRF). The approach is as follows:
received from the auth provider, to protect against cross-site request forgery (CSRF). The approach is as follows:

* When the authCallback handler forwards the user to Google, it passes a random 'state', that it associates with the original request (i.e. the final destination)
* When Google sends the request back to traefik-simple-auth, it passes the same 'state' with the request.
* When the authCallback handler forwards the user to the auth provider, it passes a random 'state', that it associates with the original request (i.e. the final destination)
* When the auth provider sends the request back to traefik-simple-auth, it passes the same 'state' with the request.
* traefik-simple-auth only keeps the state (with the final destination) for 5 minutes, which should be ample time for the user to log in.

## Installation

Container images are available on [ghcr.io](https://ghcr.io/clambin/traefik-simple-auth).

## Configuration
### Google
### Using Google as auth provider

Head to https://console.developers.google.com and create a new project. Create new Credentials and select OAuth Client ID
with "web application" as its application type.
Expand All @@ -78,7 +78,7 @@ Note the Client ID and Client Secret as you will need to configure these for tra
### Traefik
#### Middleware

With your Google credentials defined, set up a `forward-auth` middleware. This causes Traefik to forward each incoming
With your auth credentials defined, set up a `forward-auth` middleware. This causes Traefik to forward each incoming
request for a router configured with this middleware for authentication.

In Kubernetes, this can be done with the following manifest:
Expand All @@ -101,8 +101,8 @@ This created a new middleware `traefik-simple-auth` that forwards incoming reque

#### Ingress

To authenticate a user, traefik-simple-auth redirects the user to their Google login page. Upon successful login, Google
forwards the request to the redirectURL (as configured in section Google). You will therefore need an ingress to route
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 Google). You therefore need an ingress to route
the request to traefik-simple-auth:

```
Expand All @@ -127,22 +127,22 @@ spec:
number: 8080
```

This forwards the Google request back to traefik-simple-auth.
This forwards the request request back to traefik-simple-auth.

### Running traefik-simple-auth

traefik-simple-auth supports the following command-line arguments:

```
Usage:
-addr string
-addr string
The address to listen on for HTTP requests (default ":8080")
-auth-prefix string
prefix to construct the authRedirect URL from the domain (default "auth")
-client-id string
Google OAuth Client ID
OAuth2 Client ID
-client-secret string
Google OAuth Client Secret
OAuth2 Client Secret
-debug
Enable debug mode
-domains string
Expand All @@ -153,10 +153,13 @@ Usage:
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
-users string
Comma-separated list of usernames to login
```

#### Option details
Expand All @@ -169,6 +172,10 @@ Usage:

Prefix used to construct the authorization URL from the domain.

- `provider`

The auth provider to use. Currently, only "google" and "github" are supported.

- `client-id`

The (hex-encoded) Google Client ID, found in the Google Credentials configuration.
Expand Down
6 changes: 4 additions & 2 deletions cmd/traefik-simple-auth/traefik-simple-auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ var (
authPrefix = flag.String("auth-prefix", "auth", "prefix to construct the authRedirect URL from the domain")
domains = flag.String("domains", "", "Comma-separated list of domains to allow access")
users = flag.String("users", "", "Comma-separated list of usernames to login")
clientId = flag.String("client-id", "", "Google OAuth Client ID")
clientSecret = flag.String("client-secret", "", "Google OAuth Client Secret")
provider = flag.String("provider", "google", "The OAuth2 provider to use")
clientId = flag.String("client-id", "", "OAuth2 Client ID")
clientSecret = flag.String("client-secret", "", "OAuth2 Client Secret")
secret = flag.String("secret", "", "Secret to use for authentication")

version string = "change-me"
Expand Down Expand Up @@ -75,6 +76,7 @@ func getConfiguration(l *slog.Logger) server.Config {
InsecureCookie: *insecure,
Domains: strings.Split(*domains, ","),
Users: strings.Split(*users, ","),
Provider: *provider,
ClientID: *clientId,
ClientSecret: *clientSecret,
AuthPrefix: *authPrefix,
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ toolchain go1.22.2
require (
github.com/clambin/go-common/cache v0.4.0
github.com/clambin/go-common/http v0.4.3
github.com/clambin/go-common/set v0.4.3
github.com/clambin/go-common/testutils v0.1.0
github.com/prometheus/client_golang v1.19.0
github.com/stretchr/testify v1.9.0
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clambin/go-common/cache v0.3.0 h1:nLG0wDWnLxCAP6ih5A8zVCInN7YGBWlonBtNTPvJUA4=
github.com/clambin/go-common/cache v0.3.0/go.mod h1:kk8t1usGhQjSOdjE5YKEwWBJigZThxKIl1wYP7hujeE=
github.com/clambin/go-common/cache v0.4.0 h1:PjyWbQye8pHDIDomRfRWsaCeCD4gVjs7ITQJoopUe0E=
github.com/clambin/go-common/cache v0.4.0/go.mod h1:kk8t1usGhQjSOdjE5YKEwWBJigZThxKIl1wYP7hujeE=
github.com/clambin/go-common/http v0.4.3 h1:XRXi7rE4lPGpK4cALfM8ADcVDadgYfpzluM/4irn1E0=
github.com/clambin/go-common/http v0.4.3/go.mod h1:g2LMIgauEx/3wAIYNxrjM2AiKWNbODNlUXUinrWkbPY=
github.com/clambin/go-common/set v0.4.3 h1:Sm9lkAJsh82j40RDpfQIziHyHjwr07+KsQF6vgCVXm4=
github.com/clambin/go-common/set v0.4.3/go.mod h1:Q5GpBoM7B7abNV2Wzys+wQMInBHMoHyh/h0Cn2OmY4A=
github.com/clambin/go-common/testutils v0.1.0 h1:/nGWaOCIhW+Ew1c2NU7GLY/YPb8dp9SV8+MTgWksAgk=
github.com/clambin/go-common/testutils v0.1.0/go.mod h1:bV0j8D4zhNkleCeluFKLBeLQ0L/dqkxbaR/joLn8kzg=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
Expand Down
37 changes: 13 additions & 24 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"github.com/clambin/traefik-simple-auth/pkg/oauth"
"github.com/clambin/traefik-simple-auth/pkg/whitelist"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"log/slog"
"net/http"
"net/url"
Expand All @@ -16,22 +15,18 @@ const OAUTHPath = "/_oauth"

type Server struct {
Config
oauthHandlers map[string]OAuthHandler
oauthHandlers map[string]oauth.Handler
sessionCookieHandler
stateHandler
whitelist.Whitelist
http.Handler
}

type OAuthHandler interface {
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
GetUserEmailAddress(code string) (string, error)
}

type Config struct {
Expiry time.Duration
Secret []byte
InsecureCookie bool
Provider string
Domains Domains
Users []string
ClientID string
Expand All @@ -40,17 +35,11 @@ type Config struct {
}

func New(config Config, l *slog.Logger) *Server {
oauthHandlers := make(map[string]OAuthHandler)
oauthHandlers := make(map[string]oauth.Handler)
for _, domain := range config.Domains {
oauthHandlers[domain] = oauth.Handler{
HTTPClient: http.DefaultClient,
Config: oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: makeAuthURL(config.AuthPrefix, domain),
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
},
var err error
if oauthHandlers[domain], err = oauth.NewHandler(config.Provider, config.ClientID, config.ClientSecret, makeAuthURL(config.AuthPrefix, domain, OAUTHPath), l.With("oauth", config.Provider)); err != nil {
panic("unknown provider: " + config.Provider)
}
}
s := Server{
Expand All @@ -63,7 +52,7 @@ func New(config Config, l *slog.Logger) *Server {
sessions: cache.New[string, sessionCookie](config.Expiry, time.Minute),
},
stateHandler: stateHandler{
// 5 minutes should be enough for the user to log in to Google
// 5 minutes should be enough for the user to log in
cache: cache.New[string, string](5*time.Minute, time.Minute),
},
Whitelist: whitelist.New(config.Users),
Expand All @@ -85,22 +74,22 @@ func (s *Server) authHandler(l *slog.Logger) http.HandlerFunc {

c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
// Client doesn't have a valid cookie. Redirect to Google to authenticate the user.
// Client doesn't have a valid cookie. Redirect to oauth provider to authenticate the user.
// When the user is authenticated, authCallbackHandler generates a new valid cookie.
l.Debug("no cookie found, redirecting ...")
s.redirectToAuth(w, r, l)
return
}
session, err := s.getSessionCookie(c)
if err != nil {
// Client has an invalid cookie. Redirect to Google to authenticate the user.
// Client has an invalid cookie. Redirect to oauth provider to authenticate the user.
// When the user is authenticated, authCallbackHandler generates a new valid cookie.
l.Warn("invalid cookie. redirecting ...", "err", err)
s.redirectToAuth(w, r, l)
return
}
if session.expired() {
// Client has an expired cookie. Redirect to Google to authenticate the user.
// Client has an expired cookie. Redirect to oauth provider to authenticate the user.
// When the user is authenticated, authCallbackHandler generates a new valid cookie.
l.Debug("expired cookie. redirecting ...")
s.redirectToAuth(w, r, l)
Expand Down Expand Up @@ -136,7 +125,7 @@ func (s *Server) redirectToAuth(w http.ResponseWriter, r *http.Request, l *slog.
return
}

// Redirect user to Google to select the account to be used to authenticate the request
// Redirect user to oauth provider to select the account to be used to authenticate the request
authCodeURL := s.oauthHandlers[domain].AuthCodeURL(encodedState, oauth2.SetAuthURLParam("prompt", "select_account"))
l.Debug("redirecting ...", "authCodeURL", authCodeURL)
http.Redirect(w, r, authCodeURL, http.StatusTemporaryRedirect)
Expand Down Expand Up @@ -165,7 +154,7 @@ func (s *Server) authCallbackHandler(l *slog.Logger) http.HandlerFunc {
// Use the "code" in the response to determine the user's email address.
user, err := s.oauthHandlers[domain].GetUserEmailAddress(r.FormValue("code"))
if err != nil {
l.Error("failed to log in to google", "err", err)
l.Error("failed to log in", "err", err)
http.Error(w, "oauth2 failed", http.StatusBadGateway)
return
}
Expand Down Expand Up @@ -225,7 +214,7 @@ func (s *Server) makeCookie(value, domain string) *http.Cookie {
}

// makeAuthURL returns the auth URL for a given domain
func makeAuthURL(authPrefix, domain string) string {
func makeAuthURL(authPrefix, domain, OAUTHPath string) string {
var dot string
if domain != "" && domain[0] != '.' {
dot = "."
Expand Down
32 changes: 18 additions & 14 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"errors"
"github.com/clambin/traefik-simple-auth/pkg/oauth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -79,10 +80,11 @@ func TestServer_authHandler(t *testing.T) {
}

config := Config{
Domains: Domains{"example.com"},
Secret: []byte("secret"),
Expiry: time.Hour,
Users: []string{"foo@example.com"},
Domains: Domains{"example.com"},
Secret: []byte("secret"),
Expiry: time.Hour,
Users: []string{"foo@example.com"},
Provider: "google",
}
s := New(config, slog.Default())

Expand Down Expand Up @@ -137,10 +139,11 @@ func Benchmark_authHandler(b *testing.B) {

func TestServer_authHandler_expiry(t *testing.T) {
config := Config{
Expiry: 500 * time.Millisecond,
Secret: []byte("secret"),
Domains: []string{"example.com"},
Users: []string{"foo@example.com"},
Expiry: 500 * time.Millisecond,
Secret: []byte("secret"),
Domains: []string{"example.com"},
Users: []string{"foo@example.com"},
Provider: "google",
}
s := New(config, slog.Default())
sc := sessionCookie{Email: "foo@example.com", Expiry: time.Now().Add(config.Expiry)}
Expand All @@ -156,7 +159,6 @@ func TestServer_authHandler_expiry(t *testing.T) {
}

func TestServer_redirectToAuth(t *testing.T) {

tests := []struct {
name string
target string
Expand Down Expand Up @@ -187,6 +189,7 @@ func TestServer_redirectToAuth(t *testing.T) {
ClientSecret: "secret",
Domains: Domains{"example.com", ".example.org"},
AuthPrefix: "auth",
Provider: "google",
}
s := New(config, slog.Default())

Expand Down Expand Up @@ -218,9 +221,10 @@ func TestServer_redirectToAuth(t *testing.T) {

func TestServer_LogoutHandler(t *testing.T) {
config := Config{
Secret: []byte("secret"),
Domains: Domains{"example.com"},
Expiry: time.Hour,
Secret: []byte("secret"),
Domains: Domains{"example.com"},
Expiry: time.Hour,
Provider: "google",
}
s := New(config, slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))

Expand Down Expand Up @@ -284,7 +288,7 @@ func TestServer_AuthCallbackHandler(t *testing.T) {
t.Parallel()

l := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
s := New(Config{Users: []string{"foo@example.com"}, Domains: Domains{"example.com"}}, l)
s := New(Config{Users: []string{"foo@example.com"}, Domains: Domains{"example.com"}, Provider: "google"}, l)
s.oauthHandlers["example.com"] = &fakeOauthHandler{email: tt.oauthUser, err: tt.oauthErr}

state := tt.state
Expand Down Expand Up @@ -318,7 +322,7 @@ func makeHTTPRequest(method, host, uri string) *http.Request {
return req
}

var _ OAuthHandler = fakeOauthHandler{}
var _ oauth.Handler = fakeOauthHandler{}

type fakeOauthHandler struct {
email string
Expand Down
Loading

0 comments on commit 8e34f63

Please # to comment.