git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #953 from grnhse/keycloak-refactor-provider-methods

Refactor Keycloak Provider Methods

commit91b3f5973eae3a4e82a7da7e1ed22e83eaec4f08 authorJoel Speed <Joel.speed@hotmail.co.uk> dateFri 1 Jan 2021, 10:40 (6 years ago) parent89e0a774b28e68
6 files changed+234−170
CHANGELOG.md+5 docs/docs/configuration/auth.md+19−9 pkg/apis/options/options.go+2−2 pkg/validation/options.go+4−1 providers/keycloak.go+24−25 providers/keycloak_test.go+180−133
CHANGELOG.md+5
@@ -4,6 +4,8 @@
44
55## Important Notes
66
7+- [#953](https://github.com/oauth2-proxy/oauth2-proxy/pull/953) Keycloak will now use `--profile-url` if set for the userinfo endpoint
8+ instead of `--validate-url`. `--validate-url` will still work for backwards compatibility.
79- [#936](https://github.com/oauth2-proxy/oauth2-proxy/pull/936) `--user-id-claim` option is deprecated and replaced by `--oidc-email-claim`
810- [#630](https://github.com/oauth2-proxy/oauth2-proxy/pull/630) Gitlab projects needs a Gitlab application with the extra `read_api` enabled
911- [#849](https://github.com/oauth2-proxy/oauth2-proxy/pull/849) `/oauth2/auth` `allowed_groups` querystring parameter can be paired with the `allowed-groups` configuration option.
@@ -33,6 +35,8 @@
3335
3436## Breaking Changes
3537
38+- [#953](https://github.com/oauth2-proxy/oauth2-proxy/pull/953) In config files & envvar configs, `keycloak_group` is now the plural `keycloak_groups`.
39+ Flag configs are still `--keycloak-group` but it can be passed multiple times.
3640- [#911](https://github.com/oauth2-proxy/oauth2-proxy/pull/911) Specifying a non-existent provider will cause OAuth2-Proxy to fail on startup instead of defaulting to "google".
3741- [#797](https://github.com/oauth2-proxy/oauth2-proxy/pull/797) Security changes to Google provider group authorization flow
3842 - If you change the list of allowed groups, existing sessions that now don't have a valid group will be logged out immediately.
@@ -54,6 +58,7 @@
5458
5559## Changes since v6.1.1
5660
61+- [#953](https://github.com/oauth2-proxy/oauth2-proxy/pull/953) Migrate Keycloak to EnrichSession & support multiple groups for authorization (@NickMeves)
5762- [#630](https://github.com/oauth2-proxy/oauth2-proxy/pull/630) Add support for Gitlab project based authentication (@factorysh)
5863- [#907](https://github.com/oauth2-proxy/oauth2-proxy/pull/907) Introduce alpha configuration option to enable testing of structured configuration (@JoelSpeed)
5964- [#938](https://github.com/oauth2-proxy/oauth2-proxy/pull/938) Cleanup missed provider renaming refactor methods (@NickMeves)
docs/docs/configuration/auth.md+19−9
@@ -135,15 +135,25 @@ If you are using GitHub enterprise, make sure you set the following to the appro
135135
136136Make sure you set the following to the appropriate url:
137137
138− -provider=keycloak
139− -client-id=<client you have created>
140− -client-secret=<your client's secret>
141− -login-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/auth"
142− -redeem-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/token"
143− -validate-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/userinfo"
144− -keycloak-group=<user_group>
145−
146−The group management in keycloak is using a tree. If you create a group named admin in keycloak you should define the 'keycloak-group' value to /admin.
138+ --provider=keycloak
139+ --client-id=<client you have created>
140+ --client-secret=<your client's secret>
141+ --login-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/auth"
142+ --redeem-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/token"
143+ --profile-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/userinfo"
144+ --validate-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/userinfo"
145+ --keycloak-group=<first_allowed_user_group>
146+ --keycloak-group=<second_allowed_user_group>
147+
148+For group based authorization, the optional `--keycloak-group` (legacy) or `--allowed-group` (global standard)
149+flags can be used to specify which groups to limit access to.
150+
151+If these are unset but a `groups` mapper is set up above in step (3), the provider will still
152+populate the `X-Forwarded-Groups` header to your upstream server with the `groups` data in the
153+Keycloak userinfo endpoint response.
154+
155+The group management in keycloak is using a tree. If you create a group named admin in keycloak
156+you should define the 'keycloak-group' value to /admin.
147157
148158### GitLab Auth Provider
149159
pkg/apis/options/options.go+2−2
@@ -36,7 +36,7 @@ type Options struct {
3636 TLSKeyFile string `flag:"tls-key-file" cfg:"tls_key_file"`
3737
3838 AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
39− KeycloakGroup string `flag:"keycloak-group" cfg:"keycloak_group"`
39+ KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"`
4040 AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"`
4141 BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
4242 BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
@@ -181,7 +181,7 @@ func NewFlagSet() *pflag.FlagSet {
181181
182182 flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
183183 flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)")
184− flagSet.String("keycloak-group", "", "restrict login to members of this group.")
184+ flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)")
185185 flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
186186 flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
187187 flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
pkg/validation/options.go+4−1
@@ -263,7 +263,10 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
263263 p.SetRepo(o.GitHubRepo, o.GitHubToken)
264264 p.SetUsers(o.GitHubUsers)
265265 case *providers.KeycloakProvider:
266− p.SetGroup(o.KeycloakGroup)
266+ // Backwards compatibility with `--keycloak-group` option
267+ if len(o.KeycloakGroups) > 0 {
268+ p.SetAllowedGroups(o.KeycloakGroups)
269+ }
267270 case *providers.GoogleProvider:
268271 if o.GoogleServiceAccountJSON != "" {
269272 file, err := os.Open(o.GoogleServiceAccountJSON)
providers/keycloak.go+24−25
@@ -2,6 +2,7 @@ package providers
22
33import (
44 "context"
5+ "fmt"
56 "net/url"
67
78 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
@@ -11,7 +12,6 @@ import (
1112
1213type KeycloakProvider struct {
1314 *ProviderData
14− Group string
1515}
1616
1717var _ Provider = (*KeycloakProvider)(nil)
@@ -47,6 +47,7 @@ var (
4747 }
4848)
4949
50+// NewKeycloakProvider creates a KeyCloakProvider using the passed ProviderData
5051func NewKeycloakProvider(p *ProviderData) *KeycloakProvider {
5152 p.setProviderDefaults(providerDefaults{
5253 name: keycloakProviderName,
@@ -59,41 +60,39 @@ func NewKeycloakProvider(p *ProviderData) *KeycloakProvider {
5960 return &KeycloakProvider{ProviderData: p}
6061}
6162
62−func (p *KeycloakProvider) SetGroup(group string) {
63− p.Group = group
64−}
63+// EnrichSession uses the Keycloak userinfo endpoint to populate the session's
64+// email and groups.
65+func (p *KeycloakProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
66+ // Fallback to ValidateURL if ProfileURL not set for legacy compatibility
67+ profileURL := p.ValidateURL.String()
68+ if p.ProfileURL.String() != "" {
69+ profileURL = p.ProfileURL.String()
70+ }
6571
66−func (p *KeycloakProvider) GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error) {
67− json, err := requests.New(p.ValidateURL.String()).
72+ json, err := requests.New(profileURL).
6873 WithContext(ctx).
6974 SetHeader("Authorization", "Bearer "+s.AccessToken).
7075 Do().
7176 UnmarshalJSON()
7277 if err != nil {
73− logger.Errorf("failed making request %s", err)
74− return "", err
78+ logger.Errorf("failed making request %v", err)
79+ return err
7580 }
7681
77− if p.Group != "" {
78− var groups, err = json.Get("groups").Array()
79− if err != nil {
80− logger.Printf("groups not found %s", err)
81− return "", err
82− }
83−
84− var found = false
85− for i := range groups {
86− if groups[i].(string) == p.Group {
87− found = true
88− break
82+ groups, err := json.Get("groups").StringArray()
83+ if err == nil {
84+ for _, group := range groups {
85+ if group != "" {
86+ s.Groups = append(s.Groups, group)
8987 }
9088 }
89+ }
9190
92− if !found {
93− logger.Printf("group not found, access denied")
94− return "", nil
95− }
91+ email, err := json.Get("email").String()
92+ if err != nil {
93+ return fmt.Errorf("unable to extract email from userinfo endpoint: %v", err)
9694 }
95+ s.Email = email
9796
98− return json.Get("email").String()
97+ return nil
9998}
providers/keycloak_test.go+180−133
@@ -2,17 +2,24 @@ package providers
22
33import (
44 "context"
5+ "errors"
6+ "fmt"
57 "net/http"
68 "net/http/httptest"
79 "net/url"
8− "testing"
910
1011 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
12+ . "github.com/onsi/ginkgo"
13+ . "github.com/onsi/ginkgo/extensions/table"
1114 . "github.com/onsi/gomega"
12− "github.com/stretchr/testify/assert"
1315)
1416
15−func testKeycloakProvider(hostname, group string) *KeycloakProvider {
17+const (
18+ keycloakAccessToken = "eyJKeycloak.eyJAccess.Token"
19+ keycloakUserinfoPath = "/api/v3/user"
20+)
21+
22+func testKeycloakProvider(backend *httptest.Server) (*KeycloakProvider, error) {
1623 p := NewKeycloakProvider(
1724 &ProviderData{
1825 ProviderName: "",
@@ -22,142 +29,182 @@ func testKeycloakProvider(hostname, group string) *KeycloakProvider {
2229 ValidateURL: &url.URL{},
2330 Scope: ""})
2431
25− if group != "" {
26− p.SetGroup(group)
27− }
32+ if backend != nil {
33+ bURL, err := url.Parse(backend.URL)
34+ if err != nil {
35+ return nil, err
36+ }
37+ hostname := bURL.Host
2838
29− if hostname != "" {
3039 updateURL(p.Data().LoginURL, hostname)
3140 updateURL(p.Data().RedeemURL, hostname)
3241 updateURL(p.Data().ProfileURL, hostname)
3342 updateURL(p.Data().ValidateURL, hostname)
3443 }
35− return p
36−}
3744
38−func testKeycloakBackend(payload string) *httptest.Server {
39− path := "/api/v3/user"
40−
41− return httptest.NewServer(http.HandlerFunc(
42− func(w http.ResponseWriter, r *http.Request) {
43− url := r.URL
44− if url.Path != path {
45− w.WriteHeader(404)
46− } else if !IsAuthorizedInHeader(r.Header) {
47− w.WriteHeader(403)
48− } else {
49− w.WriteHeader(200)
50− w.Write([]byte(payload))
51− }
52− }))
45+ return p, nil
5346}
5447
55−func TestKeycloakProviderDefaults(t *testing.T) {
56− p := testKeycloakProvider("", "")
57− assert.NotEqual(t, nil, p)
58− assert.Equal(t, "Keycloak", p.Data().ProviderName)
59− assert.Equal(t, "https://keycloak.org/oauth/authorize",
60− p.Data().LoginURL.String())
61− assert.Equal(t, "https://keycloak.org/oauth/token",
62− p.Data().RedeemURL.String())
63− assert.Equal(t, "https://keycloak.org/api/v3/user",
64− p.Data().ValidateURL.String())
65− assert.Equal(t, "api", p.Data().Scope)
66−}
67−
68−func TestNewKeycloakProvider(t *testing.T) {
69− g := NewWithT(t)
70−
71− // Test that defaults are set when calling for a new provider with nothing set
72− providerData := NewKeycloakProvider(&ProviderData{}).Data()
73− g.Expect(providerData.ProviderName).To(Equal("Keycloak"))
74− g.Expect(providerData.LoginURL.String()).To(Equal("https://keycloak.org/oauth/authorize"))
75− g.Expect(providerData.RedeemURL.String()).To(Equal("https://keycloak.org/oauth/token"))
76− g.Expect(providerData.ProfileURL.String()).To(Equal(""))
77− g.Expect(providerData.ValidateURL.String()).To(Equal("https://keycloak.org/api/v3/user"))
78− g.Expect(providerData.Scope).To(Equal("api"))
79−}
80−
81−func TestKeycloakProviderOverrides(t *testing.T) {
82− p := NewKeycloakProvider(
83− &ProviderData{
84− LoginURL: &url.URL{
85− Scheme: "https",
86− Host: "example.com",
87− Path: "/oauth/auth"},
88− RedeemURL: &url.URL{
89− Scheme: "https",
90− Host: "example.com",
91− Path: "/oauth/token"},
92− ValidateURL: &url.URL{
93− Scheme: "https",
94− Host: "example.com",
95− Path: "/api/v3/user"},
96− Scope: "profile"})
97− assert.NotEqual(t, nil, p)
98− assert.Equal(t, "Keycloak", p.Data().ProviderName)
99− assert.Equal(t, "https://example.com/oauth/auth",
100− p.Data().LoginURL.String())
101− assert.Equal(t, "https://example.com/oauth/token",
102− p.Data().RedeemURL.String())
103− assert.Equal(t, "https://example.com/api/v3/user",
104− p.Data().ValidateURL.String())
105− assert.Equal(t, "profile", p.Data().Scope)
106−}
107−
108−func TestKeycloakProviderGetEmailAddress(t *testing.T) {
109− b := testKeycloakBackend("{\"email\": \"michael.bland@gsa.gov\"}")
110− defer b.Close()
111−
112− bURL, _ := url.Parse(b.URL)
113− p := testKeycloakProvider(bURL.Host, "")
114−
115− session := CreateAuthorizedSession()
116− email, err := p.GetEmailAddress(context.Background(), session)
117− assert.Equal(t, nil, err)
118− assert.Equal(t, "michael.bland@gsa.gov", email)
119−}
120−
121−func TestKeycloakProviderGetEmailAddressAndGroup(t *testing.T) {
122− b := testKeycloakBackend("{\"email\": \"michael.bland@gsa.gov\", \"groups\": [\"test-grp1\", \"test-grp2\"]}")
123− defer b.Close()
124−
125− bURL, _ := url.Parse(b.URL)
126− p := testKeycloakProvider(bURL.Host, "test-grp1")
127−
128− session := CreateAuthorizedSession()
129− email, err := p.GetEmailAddress(context.Background(), session)
130− assert.Equal(t, nil, err)
131− assert.Equal(t, "michael.bland@gsa.gov", email)
132−}
133−
134−// Note that trying to trigger the "failed building request" case is not
135−// practical, since the only way it can fail is if the URL fails to parse.
136−func TestKeycloakProviderGetEmailAddressFailedRequest(t *testing.T) {
137− b := testKeycloakBackend("unused payload")
138− defer b.Close()
139−
140− bURL, _ := url.Parse(b.URL)
141− p := testKeycloakProvider(bURL.Host, "")
142−
143− // We'll trigger a request failure by using an unexpected access
144− // token. Alternatively, we could allow the parsing of the payload as
145− // JSON to fail.
146− session := &sessions.SessionState{AccessToken: "unexpected_access_token"}
147− email, err := p.GetEmailAddress(context.Background(), session)
148− assert.NotEqual(t, nil, err)
149− assert.Equal(t, "", email)
150−}
151−
152−func TestKeycloakProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
153− b := testKeycloakBackend("{\"foo\": \"bar\"}")
154− defer b.Close()
155−
156− bURL, _ := url.Parse(b.URL)
157− p := testKeycloakProvider(bURL.Host, "")
158−
159− session := CreateAuthorizedSession()
160− email, err := p.GetEmailAddress(context.Background(), session)
161− assert.NotEqual(t, nil, err)
162− assert.Equal(t, "", email)
163−}
48+var _ = Describe("Keycloak Provider Tests", func() {
49+ Context("New Provider Init", func() {
50+ It("uses defaults", func() {
51+ providerData := NewKeycloakProvider(&ProviderData{}).Data()
52+ Expect(providerData.ProviderName).To(Equal("Keycloak"))
53+ Expect(providerData.LoginURL.String()).To(Equal("https://keycloak.org/oauth/authorize"))
54+ Expect(providerData.RedeemURL.String()).To(Equal("https://keycloak.org/oauth/token"))
55+ Expect(providerData.ProfileURL.String()).To(Equal(""))
56+ Expect(providerData.ValidateURL.String()).To(Equal("https://keycloak.org/api/v3/user"))
57+ Expect(providerData.Scope).To(Equal("api"))
58+ })
59+
60+ It("overrides defaults", func() {
61+ p := NewKeycloakProvider(
62+ &ProviderData{
63+ LoginURL: &url.URL{
64+ Scheme: "https",
65+ Host: "example.com",
66+ Path: "/oauth/auth"},
67+ RedeemURL: &url.URL{
68+ Scheme: "https",
69+ Host: "example.com",
70+ Path: "/oauth/token"},
71+ ProfileURL: &url.URL{
72+ Scheme: "https",
73+ Host: "example.com",
74+ Path: "/api/v3/user"},
75+ ValidateURL: &url.URL{
76+ Scheme: "https",
77+ Host: "example.com",
78+ Path: "/api/v3/user"},
79+ Scope: "profile"})
80+ providerData := p.Data()
81+
82+ Expect(providerData.ProviderName).To(Equal("Keycloak"))
83+ Expect(providerData.LoginURL.String()).To(Equal("https://example.com/oauth/auth"))
84+ Expect(providerData.RedeemURL.String()).To(Equal("https://example.com/oauth/token"))
85+ Expect(providerData.ProfileURL.String()).To(Equal("https://example.com/api/v3/user"))
86+ Expect(providerData.ValidateURL.String()).To(Equal("https://example.com/api/v3/user"))
87+ Expect(providerData.Scope).To(Equal("profile"))
88+ })
89+ })
90+
91+ Context("EnrichSession", func() {
92+ type enrichSessionTableInput struct {
93+ backendHandler http.HandlerFunc
94+ expectedError error
95+ expectedEmail string
96+ expectedGroups []string
97+ }
98+
99+ DescribeTable("should return expected results",
100+ func(in enrichSessionTableInput) {
101+ backend := httptest.NewServer(in.backendHandler)
102+ p, err := testKeycloakProvider(backend)
103+ Expect(err).To(BeNil())
104+
105+ p.ProfileURL, err = url.Parse(
106+ fmt.Sprintf("%s%s", backend.URL, keycloakUserinfoPath),
107+ )
108+ Expect(err).To(BeNil())
109+
110+ session := &sessions.SessionState{AccessToken: keycloakAccessToken}
111+ err = p.EnrichSession(context.Background(), session)
112+
113+ if in.expectedError != nil {
114+ Expect(err).To(Equal(in.expectedError))
115+ } else {
116+ Expect(err).To(BeNil())
117+ }
118+
119+ Expect(session.Email).To(Equal(in.expectedEmail))
120+
121+ if in.expectedGroups != nil {
122+ Expect(session.Groups).To(Equal(in.expectedGroups))
123+ } else {
124+ Expect(session.Groups).To(BeNil())
125+ }
126+ },
127+ Entry("email and multiple groups", enrichSessionTableInput{
128+ backendHandler: func(w http.ResponseWriter, _ *http.Request) {
129+ w.WriteHeader(200)
130+ _, err := w.Write([]byte(`
131+ {
132+ "email": "michael.bland@gsa.gov",
133+ "groups": [
134+ "test-grp1",
135+ "test-grp2"
136+ ]
137+ }
138+ `))
139+ if err != nil {
140+ panic(err)
141+ }
142+ },
143+ expectedError: nil,
144+ expectedEmail: "michael.bland@gsa.gov",
145+ expectedGroups: []string{"test-grp1", "test-grp2"},
146+ }),
147+ Entry("email and single group", enrichSessionTableInput{
148+ backendHandler: func(w http.ResponseWriter, _ *http.Request) {
149+ w.WriteHeader(200)
150+ _, err := w.Write([]byte(`
151+ {
152+ "email": "michael.bland@gsa.gov",
153+ "groups": ["test-grp1"]
154+ }
155+ `))
156+ if err != nil {
157+ panic(err)
158+ }
159+ },
160+ expectedError: nil,
161+ expectedEmail: "michael.bland@gsa.gov",
162+ expectedGroups: []string{"test-grp1"},
163+ }),
164+ Entry("email and no groups", enrichSessionTableInput{
165+ backendHandler: func(w http.ResponseWriter, _ *http.Request) {
166+ w.WriteHeader(200)
167+ _, err := w.Write([]byte(`
168+ {
169+ "email": "michael.bland@gsa.gov"
170+ }
171+ `))
172+ if err != nil {
173+ panic(err)
174+ }
175+ },
176+ expectedError: nil,
177+ expectedEmail: "michael.bland@gsa.gov",
178+ expectedGroups: nil,
179+ }),
180+ Entry("missing email", enrichSessionTableInput{
181+ backendHandler: func(w http.ResponseWriter, _ *http.Request) {
182+ w.WriteHeader(200)
183+ _, err := w.Write([]byte(`
184+ {
185+ "groups": [
186+ "test-grp1",
187+ "test-grp2"
188+ ]
189+ }
190+ `))
191+ if err != nil {
192+ panic(err)
193+ }
194+ },
195+ expectedError: errors.New(
196+ "unable to extract email from userinfo endpoint: type assertion to string failed"),
197+ expectedEmail: "",
198+ expectedGroups: []string{"test-grp1", "test-grp2"},
199+ }),
200+ Entry("request failure", enrichSessionTableInput{
201+ backendHandler: func(w http.ResponseWriter, _ *http.Request) {
202+ w.WriteHeader(500)
203+ },
204+ expectedError: errors.New(`unexpected status "500": `),
205+ expectedEmail: "",
206+ expectedGroups: nil,
207+ }),
208+ )
209+ })
210+})