git.iko.soy/typeable/oauth2-proxy
on master help sign in
update keycloak oidc provider and add unit tests
commite6223383e5ff68709afe8e47d3e91b499e5802ad authorPeter Braun <pbraun@redhat.com> dateWed 5 May 2021, 16:18 (5 years ago) parentab54de3
8 files changed+221−27
CHANGELOG.md+3 docs/docs/configuration/alpha_config.md+1 docs/docs/configuration/auth.md+24−1 docs/docs/configuration/overview.md+1 pkg/apis/options/legacy_options.go+5−1 pkg/apis/options/providers.go+3−1 providers/keycloak_oidc.go+16−3 providers/keycloak_oidc_test.go+168−21
CHANGELOG.md+3
@@ -8,6 +8,8 @@
88 deserialization from v6.0.0 (only) has been removed to improve performance. If you are on v6.0.0, either upgrade
99 to a version before this first and allow legacy sessions to expire gracefully or change your `cookie-secret`
1010 value and force all sessions to reauthenticate.
11+
12+- [#1210](https://github.com/oauth2-proxy/oauth2-proxy/pull/1210) A new `keycloak-oidc` provider has been added with support for role based authentication. The existing keycloak auth provider will eventually be deprecated and removed. Please switch to the new provider `keycloak-oidc`.
1113
1214## Breaking Changes
1315
@@ -27,6 +29,7 @@
2729- [#1142](https://github.com/oauth2-proxy/oauth2-proxy/pull/1142) Add pagewriter to upstream proxy (@JoelSpeed)
2830- [#1181](https://github.com/oauth2-proxy/oauth2-proxy/pull/1181) Fix incorrect `cfg` name in show-debug-on-error flag (@iTaybb)
2931- [#1207](https://github.com/oauth2-proxy/oauth2-proxy/pull/1207) Fix URI fragment handling on sign-in page, regression introduced in 7.1.0 (@tarvip)
32+- [#1210](https://github.com/oauth2-proxy/oauth2-proxy/pull/1210) New Keycloak OIDC Provider (@pb82)
3033- [#1244](https://github.com/oauth2-proxy/oauth2-proxy/pull/1244) Update Alpine image version to 3.14 (@ahovgaard)
3134
3235
docs/docs/configuration/alpha_config.md+1
@@ -250,6 +250,7 @@ make up the header value
250250| Field | Type | Description |
251251| ----- | ---- | ----------- |
252252| `groups` | _[]string_ | Group enables to restrict login to members of indicated group |
253+| `roles` | _[]string_ | Role enables to restrict login to users with role (only available when using the keycloak-oidc provider) |
253254
254255### LoginGovOptions
255256
docs/docs/configuration/auth.md+24−1
@@ -146,12 +146,15 @@ If you are using GitHub enterprise, make sure you set the following to the appro
146146
147147### Keycloak Auth Provider
148148
149−1. Create new client in your Keycloak with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
149+1. Create new client in your Keycloak realm with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
1501502. Take note of the Secret in the credential tab of the client
1511513. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'.
152152
153+:::note this is the legacy Keycloak Auth Prodiver, use `keycloak-oidc` if possible. :::
154+
153155Make sure you set the following to the appropriate url:
154156
157+```
155158 --provider=keycloak
156159 --client-id=<client you have created>
157160 --client-secret=<your client's secret>
@@ -161,6 +164,7 @@ Make sure you set the following to the appropriate url:
161164 --validate-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/userinfo"
162165 --keycloak-group=<first_allowed_user_group>
163166 --keycloak-group=<second_allowed_user_group>
167+```
164168
165169For group based authorization, the optional `--keycloak-group` (legacy) or `--allowed-group` (global standard)
166170flags can be used to specify which groups to limit access to.
@@ -172,6 +176,25 @@ Keycloak userinfo endpoint response.
172176The group management in keycloak is using a tree. If you create a group named admin in keycloak
173177you should define the 'keycloak-group' value to /admin.
174178
179+### Keycloak OIDC Auth Provider
180+
181+1. Create new client in your Keycloak realm with **Access Type** 'confidental', **Client protocol** 'openid-connect' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
182+2. Take note of the Secret in the credential tab of the client
183+3. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'.
184+4. Create a mapper with **Mapper Type** 'Audience' and **Included Client Audience** and **Included Custom Audience** set to your client name.
185+
186+Make sure you set the following to the appropriate url:
187+
188+```
189+ --provider=keycloak-oidc
190+ --client-id=<your client's id>
191+ --client-secret=<your client's secret>
192+ --redirect-url=https://myapp.com/oauth2/callback
193+ --oidc-issuer-url=https://<keycloak host>/auth/<your realm>/basic
194+ --allowed-role=<realm role name> // Optional, required realm role
195+ --allowed-role=<client id>:<client role name> // Optional, required client role
196+```
197+
175198### GitLab Auth Provider
176199
177200This auth provider has been tested against Gitlab version 12.X. Due to Gitlab API changes, it may not work for version prior to 12.X (see [994](https://github.com/oauth2-proxy/oauth2-proxy/issues/994)).
docs/docs/configuration/overview.md+1
@@ -192,6 +192,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
192192| `--tls-key-file` | string | path to private key file | |
193193| `--upstream` | string \| list | the http url(s) of the upstream endpoint, file:// paths for static files or `static://<status_code>` for static response. Routing is based on the path | |
194194| `--allowed-group` | string \| list | restrict logins to members of this group (may be given multiple times) | |
195+| `--allowed-role` | string \| list | restrict logins to users with this role (may be given multiple times). Only works with the keycloak-oidc provider. | |
195196| `--validate-url` | string | Access token validation endpoint | |
196197| `--version` | n/a | print version string | |
197198| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | |
pkg/apis/options/legacy_options.go+5−1
@@ -658,11 +658,15 @@ func (l *LegacyProvider) convert() (Providers, error) {
658658 Token: l.GitHubToken,
659659 Users: l.GitHubUsers,
660660 }
661− case "keycloak":
661+ case "keycloak-oidc":
662662 provider.KeycloakConfig = KeycloakOptions{
663663 Groups: l.KeycloakGroups,
664664 Roles: l.AllowedRoles,
665665 }
666+ case "keycloak":
667+ provider.KeycloakConfig = KeycloakOptions{
668+ Groups: l.KeycloakGroups,
669+ }
666670 case "gitlab":
667671 provider.GitLabConfig = GitLabOptions{
668672 Group: l.GitLabGroup,
pkg/apis/options/providers.go+3−1
@@ -78,7 +78,9 @@ type Provider struct {
7878type KeycloakOptions struct {
7979 // Group enables to restrict login to members of indicated group
8080 Groups []string `json:"groups,omitempty"`
81− Roles []string `json:"roles,omitempty"`
81+
82+ // Role enables to restrict login to users with role (only available when using the keycloak-oidc provider)
83+ Roles []string `json:"roles,omitempty"`
8284}
8385
8486type AzureOptions struct {
providers/keycloak_oidc.go+16−3
@@ -5,7 +5,6 @@ import (
55 "fmt"
66
77 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
8− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
98)
109
1110const keycloakOIDCProviderName = "Keycloak OIDC"
@@ -31,6 +30,9 @@ var _ Provider = (*KeycloakOIDCProvider)(nil)
3130// Assumes `SetAllowedGroups` is already called on groups and appends to that
3231// with `role:` prefixed roles.
3332func (p *KeycloakOIDCProvider) AddAllowedRoles(roles []string) {
33+ if p.AllowedGroups == nil {
34+ p.AllowedGroups = make(map[string]struct{})
35+ }
3436 for _, role := range roles {
3537 p.AllowedGroups[formatRole(role)] = struct{}{}
3638 }
@@ -41,11 +43,23 @@ func (p *KeycloakOIDCProvider) AddAllowedRoles(roles []string) {
4143func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
4244 err := p.OIDCProvider.EnrichSession(ctx, s)
4345 if err != nil {
44− return err
46+ return fmt.Errorf("could not enrich oidc session: %v", err)
4547 }
4648 return p.extractRoles(ctx, s)
4749}
4850
51+// RefreshSession adds role extraction logic to the refresh flow
52+func (p *KeycloakOIDCProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
53+ refreshed, err := p.OIDCProvider.RefreshSession(ctx, s)
54+
55+ // Refresh could have failed or there was not session to refresh (with no error raised)
56+ if err != nil || !refreshed {
57+ return refreshed, err
58+ }
59+
60+ return true, p.extractRoles(ctx, s)
61+}
62+
4963func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error {
5064 claims, err := p.getAccessClaims(ctx, s)
5165 if err != nil {
@@ -109,7 +123,6 @@ func getClientRoles(claims *accessClaims) []string {
109123 for clientName, access := range claims.ResourceAccess {
110124 accessMap, ok := access.(map[string]interface{})
111125 if !ok {
112− logger.Errorf("Unable to parse client roles from claims for client: %v", clientName)
113126 continue
114127 }
115128
providers/keycloak_oidc_test.go+168−21
@@ -1,36 +1,85 @@
11package providers
22
33import (
4+ "context"
5+ "encoding/base64"
6+ "fmt"
7+ "net/http/httptest"
48 "net/url"
59
10+ "github.com/coreos/go-oidc/v3/oidc"
11+
12+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
613 . "github.com/onsi/ginkgo"
714 . "github.com/onsi/gomega"
815)
916
17+const (
18+ accessTokenHeader = "ewogICJhbGciOiAiUlMyNTYiLAogICJ0eXAiOiAiSldUIgp9"
19+ accessTokenPayload = "eyJyZWFsbV9hY2Nlc3MiOiB7InJvbGVzIjogWyJ3cml0ZSJdfSwgInJlc291cmNlX2FjY2VzcyI6IHsiZGVmYXVsdCI6IHsicm9sZXMiOiBbInJlYWQiXX19fQ"
20+ accessTokenSignature = "dyt0CoTl4WoVjAHI9Q_CwSKhl6d_9rhM3NrXuJttkao"
21+)
22+
23+type DummyKeySet struct{}
24+
25+func (DummyKeySet) VerifySignature(_ context.Context, _ string) (payload []byte, err error) {
26+ p, _ := base64.RawURLEncoding.DecodeString(accessTokenPayload)
27+ return p, nil
28+}
29+
30+func getAccessToken() string {
31+ return fmt.Sprintf("%s.%s.%s", accessTokenHeader, accessTokenPayload, accessTokenSignature)
32+}
33+
34+func newTestKeycloakOIDCSetup() (*httptest.Server, *KeycloakOIDCProvider) {
35+ redeemURL, server := newOIDCServer([]byte(fmt.Sprintf(`{"email": "new@thing.com", "expires_in": 300, "access_token": "%v"}`, getAccessToken())))
36+ provider := newKeycloakOIDCProvider(redeemURL)
37+ return server, provider
38+}
39+
40+func newKeycloakOIDCProvider(serverURL *url.URL) *KeycloakOIDCProvider {
41+ p := NewKeycloakOIDCProvider(
42+ &ProviderData{
43+ LoginURL: &url.URL{
44+ Scheme: "https",
45+ Host: "keycloak-oidc.com",
46+ Path: "/oauth/auth"},
47+ RedeemURL: &url.URL{
48+ Scheme: "https",
49+ Host: "keycloak-oidc.com",
50+ Path: "/oauth/token"},
51+ ProfileURL: &url.URL{
52+ Scheme: "https",
53+ Host: "keycloak-oidc.com",
54+ Path: "/api/v3/user"},
55+ ValidateURL: &url.URL{
56+ Scheme: "https",
57+ Host: "keycloak-oidc.com",
58+ Path: "/api/v3/user"},
59+ Scope: "openid email profile"})
60+
61+ if serverURL != nil {
62+ p.RedeemURL.Scheme = serverURL.Scheme
63+ p.RedeemURL.Host = serverURL.Host
64+ }
65+
66+ keyset := DummyKeySet{}
67+ p.Verifier = oidc.NewVerifier("", keyset, &oidc.Config{
68+ ClientID: "client",
69+ SkipIssuerCheck: true,
70+ SkipClientIDCheck: true,
71+ SkipExpiryCheck: true,
72+ })
73+ p.EmailClaim = "email"
74+ p.GroupsClaim = "groups"
75+ return p
76+}
77+
1078var _ = Describe("Keycloak OIDC Provider Tests", func() {
1179 Context("New Provider Init", func() {
12− It("uses the passed ProviderData", func() {
13− p := NewKeycloakOIDCProvider(
14− &ProviderData{
15− LoginURL: &url.URL{
16− Scheme: "https",
17− Host: "keycloak-oidc.com",
18− Path: "/oauth/auth"},
19− RedeemURL: &url.URL{
20− Scheme: "https",
21− Host: "keycloak-oidc.com",
22− Path: "/oauth/token"},
23− ProfileURL: &url.URL{
24− Scheme: "https",
25− Host: "keycloak-oidc.com",
26− Path: "/api/v3/user"},
27− ValidateURL: &url.URL{
28− Scheme: "https",
29− Host: "keycloak-oidc.com",
30− Path: "/api/v3/user"},
31− Scope: "openid email profile"})
80+ It("creates new keycloak oidc provider with expected defaults", func() {
81+ p := newKeycloakOIDCProvider(nil)
3282 providerData := p.Data()
33−
3483 Expect(providerData.ProviderName).To(Equal(keycloakOIDCProviderName))
3584 Expect(providerData.LoginURL.String()).To(Equal("https://keycloak-oidc.com/oauth/auth"))
3685 Expect(providerData.RedeemURL.String()).To(Equal("https://keycloak-oidc.com/oauth/token"))
@@ -39,4 +88,102 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() {
3988 Expect(providerData.Scope).To(Equal("openid email profile"))
4089 })
4190 })
91+
92+ Context("Allowed Roles", func() {
93+ It("should prefix allowed roles and add them to groups", func() {
94+ p := newKeycloakOIDCProvider(nil)
95+ p.AddAllowedRoles([]string{"admin", "editor"})
96+ Expect(p.AllowedGroups).To(HaveKey("role:admin"))
97+ Expect(p.AllowedGroups).To(HaveKey("role:editor"))
98+ })
99+ })
100+
101+ Context("Enrich Session", func() {
102+ It("should not fail when groups are not assigned", func() {
103+ server, provider := newTestKeycloakOIDCSetup()
104+ url, err := url.Parse(server.URL)
105+ Expect(err).To(BeNil())
106+ defer server.Close()
107+
108+ provider.ProfileURL = url
109+
110+ existingSession := &sessions.SessionState{
111+ User: "already",
112+ Email: "a@b.com",
113+ Groups: nil,
114+ IDToken: idToken,
115+ AccessToken: getAccessToken(),
116+ RefreshToken: refreshToken,
117+ }
118+ expectedSession := &sessions.SessionState{
119+ User: "already",
120+ Email: "a@b.com",
121+ Groups: []string{"role:write", "role:default:read"},
122+ IDToken: idToken,
123+ AccessToken: getAccessToken(),
124+ RefreshToken: refreshToken,
125+ }
126+
127+ err = provider.EnrichSession(context.Background(), existingSession)
128+ Expect(err).To(BeNil())
129+ Expect(existingSession).To(Equal(expectedSession))
130+ })
131+
132+ It("should add roles to existing groups", func() {
133+ server, provider := newTestKeycloakOIDCSetup()
134+ url, err := url.Parse(server.URL)
135+ Expect(err).To(BeNil())
136+ defer server.Close()
137+
138+ provider.ProfileURL = url
139+
140+ existingSession := &sessions.SessionState{
141+ User: "already",
142+ Email: "a@b.com",
143+ Groups: []string{"existing", "group"},
144+ IDToken: idToken,
145+ AccessToken: getAccessToken(),
146+ RefreshToken: refreshToken,
147+ }
148+ expectedSession := &sessions.SessionState{
149+ User: "already",
150+ Email: "a@b.com",
151+ Groups: []string{"existing", "group", "role:write", "role:default:read"},
152+ IDToken: idToken,
153+ AccessToken: getAccessToken(),
154+ RefreshToken: refreshToken,
155+ }
156+
157+ err = provider.EnrichSession(context.Background(), existingSession)
158+ Expect(err).To(BeNil())
159+ Expect(existingSession).To(Equal(expectedSession))
160+ })
161+ })
162+
163+ Context("Refresh Session", func() {
164+ It("should refresh session and extract roles again", func() {
165+ server, provider := newTestKeycloakOIDCSetup()
166+ url, err := url.Parse(server.URL)
167+ Expect(err).To(BeNil())
168+ defer server.Close()
169+
170+ provider.ProfileURL = url
171+
172+ existingSession := &sessions.SessionState{
173+ User: "already",
174+ Email: "a@b.com",
175+ Groups: nil,
176+ IDToken: idToken,
177+ AccessToken: getAccessToken(),
178+ RefreshToken: refreshToken,
179+ }
180+
181+ refreshed, err := provider.RefreshSession(context.Background(), existingSession)
182+ Expect(err).To(BeNil())
183+ Expect(refreshed).To(BeTrue())
184+ Expect(existingSession.ExpiresOn).ToNot(BeNil())
185+ Expect(existingSession.CreatedAt).ToNot(BeNil())
186+ Expect(existingSession.Groups).To(BeEquivalentTo([]string{"role:write", "role:default:read"}))
187+ })
188+ })
42189})