Repository: pusher/oauth2_proxy
Pull request #227 (2019-07-28 14:04:36)

Add keycloak provider
Adding integration of keycloak provider based on forgotten PR https://github.com/bitly/oauth2_proxy/pull/366/files 

Solves https://github.com/pusher/oauth2_proxy/issues/127

## How Has This Been Tested?

Tested manually. I set up keycloak and reference it in configuration of oauth2_proxy using this provider.

## 
- [x] My change requires a change to the documentation or CHANGELOG.
- [x] I have updated the documentation/CHANGELOG accordingly.
- [x] I have created a feature (non-master) branch for my PR.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44cd0cf6..2c48091b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## Changes since v4.0.0
 
+- [#227](https://github.com/pusher/oauth2_proxy/pull/227) Add Keycloak provider (@Ofinka)
+
 # v4.0.0
 
 ## Release Highlights
diff --git a/docs/2_auth.md b/docs/2_auth.md
index e6c5cc6f..04e63292 100644
--- a/docs/2_auth.md
+++ b/docs/2_auth.md
@@ -15,6 +15,7 @@ Valid providers are :
 - [Azure](#azure-auth-provider)
 - [Facebook](#facebook-auth-provider)
 - [GitHub](#github-auth-provider)
+- [Keycloak](#keycloak-auth-provider)
 - [GitLab](#gitlab-auth-provider)
 - [LinkedIn](#linkedin-auth-provider)
 - [login.gov](#logingov-provider)
@@ -101,6 +102,20 @@ If you are using GitHub enterprise, make sure you set the following to the appro
     -redeem-url="http(s)://<enterprise github host>/login/oauth/access_token"
     -validate-url="http(s)://<enterprise github host>/api/v3"
 
+### Keycloak Auth Provider
+
+1.  Create new client in your Keycloak with **Access Type** 'confidental'.
+2.  Create a mapper with **Mapper Type** 'Group Membership'.
+
+Make sure you set the following to the appropriate url:
+
+    -provider=keycloak
+    -client-id=<client you have created>
+    -client-secret=<your client's secret>
+    -login-url="http(s)://<keycloak host>/realms/<your realm>/protocol/openid-connect/auth"
+    -redeem-url="http(s)://<keycloak host>/realms/master/<your realm>/openid-connect/auth/token"
+    -validate-url="http(s)://<keycloak host>/realms/master/<your realm>/openid-connect/userinfo"
+
 ### GitLab Auth Provider
 
 Whether you are using GitLab.com or self-hosting GitLab, follow [these steps to add an application](https://docs.gitlab.com/ce/integration/oauth_provider.html). Make sure to enable at least the `openid`, `profile` and `email` scopes.
diff --git a/main.go b/main.go
index a9f1e4a0..a4bf378e 100644
--- a/main.go
+++ b/main.go
@@ -55,6 +55,7 @@ func main() {
 
 	flagSet.Var(&emailDomains, "email-domain", "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
 	flagSet.Var(&whitelistDomains, "whitelist-domain", "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)")
+	flagSet.String("keycloak-group", "", "restrict login to members of this group.")
 	flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
 	flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
 	flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
diff --git a/options.go b/options.go
index 706f6d5e..37bbb0b9 100644
--- a/options.go
+++ b/options.go
@@ -41,6 +41,7 @@ type Options struct {
 	TLSKeyFile      string `flag:"tls-key-file" cfg:"tls_key_file" env:"OAUTH2_PROXY_TLS_KEY_FILE"`
 
 	AuthenticatedEmailsFile  string   `flag:"authenticated-emails-file" cfg:"authenticated_emails_file" env:"OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE"`
+	KeycloakGroup            string   `flag:"keycloak-group" cfg:"keycloak_group" env:"OAUTH2_PROXY_KEYCLOAK_GROUP"`
 	AzureTenant              string   `flag:"azure-tenant" cfg:"azure_tenant" env:"OAUTH2_PROXY_AZURE_TENANT"`
 	BitbucketTeam            string   `flag:"bitbucket-team" cfg:"bitbucket_team" env:"OAUTH2_PROXY_BITBUCKET_TEAM"`
 	BitbucketRepository      string   `flag:"bitbucket-repository" cfg:"bitbucket_repository" env:"OAUTH2_PROXY_BITBUCKET_REPOSITORY"`
@@ -398,6 +399,8 @@ func parseProviderInfo(o *Options, msgs []string) []string {
 		p.Configure(o.AzureTenant)
 	case *providers.GitHubProvider:
 		p.SetOrgTeam(o.GitHubOrg, o.GitHubTeam)
+	case *providers.KeycloakProvider:
+		p.SetGroup(o.KeycloakGroup)
 	case *providers.GoogleProvider:
 		if o.GoogleServiceAccountJSON != "" {
 			file, err := os.Open(o.GoogleServiceAccountJSON)
diff --git a/providers/keycloak.go b/providers/keycloak.go
new file mode 100644
index 00000000..27153726
--- /dev/null
+++ b/providers/keycloak.go
@@ -0,0 +1,86 @@
+package providers
+
+import (
+	"net/http"
+	"net/url"
+
+	"github.com/pusher/oauth2_proxy/pkg/apis/sessions"
+	"github.com/pusher/oauth2_proxy/pkg/logger"
+	"github.com/pusher/oauth2_proxy/pkg/requests"
+)
+
+type KeycloakProvider struct {
+	*ProviderData
+	Group string
+}
+
+func NewKeycloakProvider(p *ProviderData) *KeycloakProvider {
+	p.ProviderName = "Keycloak"
+	if p.LoginURL == nil || p.LoginURL.String() == "" {
+		p.LoginURL = &url.URL{
+			Scheme: "https",
+			Host:   "keycloak.org",
+			Path:   "/oauth/authorize",
+		}
+	}
+	if p.RedeemURL == nil || p.RedeemURL.String() == "" {
+		p.RedeemURL = &url.URL{
+			Scheme: "https",
+			Host:   "keycloak.org",
+			Path:   "/oauth/token",
+		}
+	}
+	if p.ValidateURL == nil || p.ValidateURL.String() == "" {
+		p.ValidateURL = &url.URL{
+			Scheme: "https",
+			Host:   "keycloak.org",
+			Path:   "/api/v3/user",
+		}
+	}
+	if p.Scope == "" {
+		p.Scope = "api"
+	}
+	return &KeycloakProvider{ProviderData: p}
+}
+
+func (p *KeycloakProvider) SetGroup(group string) {
+	p.Group = group
+}
+
+func (p *KeycloakProvider) GetEmailAddress(s *sessions.SessionState) (string, error) {
+
+	req, err := http.NewRequest("GET", p.ValidateURL.String(), nil)
+	req.Header.Set("Authorization", "Bearer "+s.AccessToken)
+	if err != nil {
+		logger.Printf("failed building request %s", err)
+		return "", err
+	}
+	json, err := requests.Request(req)
+	if err != nil {
+		logger.Printf("failed making request %s", err)
+		return "", err
+	}
+
+	if p.Group != "" {
+		var groups, err = json.Get("groups").Array()
+		if err != nil {
+			logger.Printf("groups not found %s", err)
+			return "", err
+		}
+
+		var found = false
+		for i := range groups {
+			if groups[i].(string) == p.Group {
+				found = true
+				break
+			}
+		}
+
+		if found != true {
+			logger.Printf("group not found, access denied")
+			return "", nil
+		}
+	}
+
+	return json.Get("email").String()
+}
diff --git a/providers/providers.go b/providers/providers.go
index 276fab62..1df2413a 100644
--- a/providers/providers.go
+++ b/providers/providers.go
@@ -28,6 +28,8 @@ func New(provider string, p *ProviderData) Provider {
 		return NewFacebookProvider(p)
 	case "github":
 		return NewGitHubProvider(p)
+	case "keycloak":
+		return NewKeycloakProvider(p)
 	case "azure":
 		return NewAzureProvider(p)
 	case "gitlab":


Test patch:
diff --git a/providers/keycloak_test.go b/providers/keycloak_test.go
new file mode 100644
index 00000000..b5d2625b
--- /dev/null
+++ b/providers/keycloak_test.go
@@ -0,0 +1,151 @@
+package providers
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/bmizerany/assert"
+	"github.com/pusher/oauth2_proxy/pkg/apis/sessions"
+)
+
+const imaginaryAccessToken = "imaginary_access_token"
+const bearerAccessToken = "Bearer " + imaginaryAccessToken
+
+func testKeycloakProvider(hostname, group string) *KeycloakProvider {
+	p := NewKeycloakProvider(
+		&ProviderData{
+			ProviderName: "",
+			LoginURL:     &url.URL{},
+			RedeemURL:    &url.URL{},
+			ProfileURL:   &url.URL{},
+			ValidateURL:  &url.URL{},
+			Scope:        ""})
+
+	if group != "" {
+		p.SetGroup(group)
+	}
+
+	if hostname != "" {
+		updateURL(p.Data().LoginURL, hostname)
+		updateURL(p.Data().RedeemURL, hostname)
+		updateURL(p.Data().ProfileURL, hostname)
+		updateURL(p.Data().ValidateURL, hostname)
+	}
+	return p
+}
+
+func testKeycloakBackend(payload string) *httptest.Server {
+	path := "/api/v3/user"
+
+	return httptest.NewServer(http.HandlerFunc(
+		func(w http.ResponseWriter, r *http.Request) {
+			url := r.URL
+			if url.Path != path {
+				w.WriteHeader(404)
+			} else if r.Header.Get("Authorization") != bearerAccessToken {
+				w.WriteHeader(403)
+			} else {
+				w.WriteHeader(200)
+				w.Write([]byte(payload))
+			}
+		}))
+}
+
+func TestKeycloakProviderDefaults(t *testing.T) {
+	p := testKeycloakProvider("", "")
+	assert.NotEqual(t, nil, p)
+	assert.Equal(t, "Keycloak", p.Data().ProviderName)
+	assert.Equal(t, "https://keycloak.org/oauth/authorize",
+		p.Data().LoginURL.String())
+	assert.Equal(t, "https://keycloak.org/oauth/token",
+		p.Data().RedeemURL.String())
+	assert.Equal(t, "https://keycloak.org/api/v3/user",
+		p.Data().ValidateURL.String())
+	assert.Equal(t, "api", p.Data().Scope)
+}
+
+func TestKeycloakProviderOverrides(t *testing.T) {
+	p := NewKeycloakProvider(
+		&ProviderData{
+			LoginURL: &url.URL{
+				Scheme: "https",
+				Host:   "example.com",
+				Path:   "/oauth/auth"},
+			RedeemURL: &url.URL{
+				Scheme: "https",
+				Host:   "example.com",
+				Path:   "/oauth/token"},
+			ValidateURL: &url.URL{
+				Scheme: "https",
+				Host:   "example.com",
+				Path:   "/api/v3/user"},
+			Scope: "profile"})
+	assert.NotEqual(t, nil, p)
+	assert.Equal(t, "Keycloak", p.Data().ProviderName)
+	assert.Equal(t, "https://example.com/oauth/auth",
+		p.Data().LoginURL.String())
+	assert.Equal(t, "https://example.com/oauth/token",
+		p.Data().RedeemURL.String())
+	assert.Equal(t, "https://example.com/api/v3/user",
+		p.Data().ValidateURL.String())
+	assert.Equal(t, "profile", p.Data().Scope)
+}
+
+func TestKeycloakProviderGetEmailAddress(t *testing.T) {
+	b := testKeycloakBackend("{\"email\": \"michael.bland@gsa.gov\"}")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testKeycloakProvider(bURL.Host, "")
+
+	session := &sessions.SessionState{AccessToken: imaginaryAccessToken}
+	email, err := p.GetEmailAddress(session)
+	assert.Equal(t, nil, err)
+	assert.Equal(t, "michael.bland@gsa.gov", email)
+}
+
+func TestKeycloakProviderGetEmailAddressAndGroup(t *testing.T) {
+	b := testKeycloakBackend("{\"email\": \"michael.bland@gsa.gov\", \"groups\": [\"test-grp1\", \"test-grp2\"]}")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testKeycloakProvider(bURL.Host, "test-grp1")
+
+	session := &sessions.SessionState{AccessToken: imaginaryAccessToken}
+	email, err := p.GetEmailAddress(session)
+	assert.Equal(t, nil, err)
+	assert.Equal(t, "michael.bland@gsa.gov", email)
+}
+
+// Note that trying to trigger the "failed building request" case is not
+// practical, since the only way it can fail is if the URL fails to parse.
+func TestKeycloakProviderGetEmailAddressFailedRequest(t *testing.T) {
+	b := testKeycloakBackend("unused payload")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testKeycloakProvider(bURL.Host, "")
+
+	// We'll trigger a request failure by using an unexpected access
+	// token. Alternatively, we could allow the parsing of the payload as
+	// JSON to fail.
+	session := &sessions.SessionState{AccessToken: "unexpected_access_token"}
+	email, err := p.GetEmailAddress(session)
+	assert.NotEqual(t, nil, err)
+	assert.Equal(t, "", email)
+}
+
+func TestKeycloakProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
+	b := testKeycloakBackend("{\"foo\": \"bar\"}")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testKeycloakProvider(bURL.Host, "")
+
+	session := &sessions.SessionState{AccessToken: imaginaryAccessToken}
+	email, err := p.GetEmailAddress(session)
+	assert.NotEqual(t, nil, err)
+	assert.Equal(t, "", email)
+}