diff --git a/CHANGELOG.md b/CHANGELOG.md index 44cd0cf..2c48091 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 e6c5cc6..04e6329 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):///login/oauth/access_token" -validate-url="http(s):///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-secret= + -login-url="http(s):///realms//protocol/openid-connect/auth" + -redeem-url="http(s):///realms/master//openid-connect/auth/token" + -validate-url="http(s):///realms/master//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 a9f1e4a..a4bf378 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 706f6d5..37bbb0b 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 0000000..2715372 --- /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/keycloak_test.go b/providers/keycloak_test.go new file mode 100644 index 0000000..b5d2625 --- /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) +} diff --git a/providers/providers.go b/providers/providers.go index 276fab6..1df2413 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":