From 9e5994b959a4ea9868a7aa91f2f209bff43e68a6 Mon Sep 17 00:00:00 2001 From: Thom Seddon Date: Mon, 29 Jun 2020 21:04:42 +0100 Subject: [PATCH] Add Generic OAuth Provider (#138) --- README.md | 34 +++++- internal/auth_test.go | 5 + internal/config.go | 4 +- internal/config_test.go | 5 + internal/provider/generic_oauth.go | 96 ++++++++++++++++ internal/provider/generic_oauth_test.go | 140 ++++++++++++++++++++++++ internal/provider/providers.go | 5 +- 7 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 internal/provider/generic_oauth.go create mode 100644 internal/provider/generic_oauth_test.go diff --git a/README.md b/README.md index 54fd7788..c7b2521d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ Also in the examples directory is [docker-compose-auth-host.yml](https://github. #### 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. @@ -114,6 +116,25 @@ Any provider that supports OpenID Connect 1.0 can be configured via the OIDC con 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 @@ -134,7 +155,7 @@ Application Options: --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] Default provider (default: google) [$DEFAULT_PROVIDER] + --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] @@ -154,6 +175,17 @@ OIDC Provider: --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 ``` diff --git a/internal/auth_test.go b/internal/auth_test.go index 840337c9..14ee1ce6 100644 --- a/internal/auth_test.go +++ b/internal/auth_test.go @@ -316,6 +316,11 @@ func TestMakeState(t *testing.T) { 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) { diff --git a/internal/config.go b/internal/config.go index 8be0ae96..e35a732c 100644 --- a/internal/config.go +++ b/internal/config.go @@ -31,7 +31,7 @@ type Config struct { 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" description:"Default provider"` + 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"` @@ -275,6 +275,8 @@ func (c *Config) GetProvider(name string) (provider.Provider, error) { 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) diff --git a/internal/config_test.go b/internal/config_test.go index 749c9b8f..4740ddfc 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -366,6 +366,11 @@ func TestConfigGetProvider(t *testing.T) { 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) { diff --git a/internal/provider/generic_oauth.go b/internal/provider/generic_oauth.go new file mode 100644 index 00000000..a6bba510 --- /dev/null +++ b/internal/provider/generic_oauth.go @@ -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 +} diff --git a/internal/provider/generic_oauth_test.go b/internal/provider/generic_oauth_test.go new file mode 100644 index 00000000..1c2f2899 --- /dev/null +++ b/internal/provider/generic_oauth_test.go @@ -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) +} diff --git a/internal/provider/providers.go b/internal/provider/providers.go index 5c14466e..ac863df3 100644 --- a/internal/provider/providers.go +++ b/internal/provider/providers.go @@ -9,8 +9,9 @@ import ( // 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"` + 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