diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a79bf2e..43f1063e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ deserialization from v6.0.0 (only) has been removed to improve performance. If you are on v6.0.0, either upgrade to a version before this first and allow legacy sessions to expire gracefully or change your `cookie-secret` value and force all sessions to reauthenticate. + +- [#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`. ## Breaking Changes @@ -27,6 +29,7 @@ - [#1142](https://github.com/oauth2-proxy/oauth2-proxy/pull/1142) Add pagewriter to upstream proxy (@JoelSpeed) - [#1181](https://github.com/oauth2-proxy/oauth2-proxy/pull/1181) Fix incorrect `cfg` name in show-debug-on-error flag (@iTaybb) - [#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) +- [#1210](https://github.com/oauth2-proxy/oauth2-proxy/pull/1210) New Keycloak OIDC Provider (@pb82) - [#1244](https://github.com/oauth2-proxy/oauth2-proxy/pull/1244) Update Alpine image version to 3.14 (@ahovgaard) diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 7f44e343..7381e1c0 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -250,6 +250,7 @@ make up the header value | Field | Type | Description | | ----- | ---- | ----------- | | `groups` | _[]string_ | Group enables to restrict login to members of indicated group | +| `roles` | _[]string_ | Role enables to restrict login to users with role (only available when using the keycloak-oidc provider) | ### LoginGovOptions diff --git a/docs/docs/configuration/auth.md b/docs/docs/configuration/auth.md index 0673c295..576c5819 100644 --- a/docs/docs/configuration/auth.md +++ b/docs/docs/configuration/auth.md @@ -146,12 +146,15 @@ If you are using GitHub enterprise, make sure you set the following to the appro ### Keycloak Auth Provider -1. Create new client in your Keycloak with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback' +1. Create new client in your Keycloak realm with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback' 2. Take note of the Secret in the credential tab of the client 3. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'. +:::note this is the legacy Keycloak Auth Prodiver, use `keycloak-oidc` if possible. ::: + Make sure you set the following to the appropriate url: +``` --provider=keycloak --client-id= --client-secret= @@ -161,6 +164,7 @@ Make sure you set the following to the appropriate url: --validate-url="http(s):///auth/realms//protocol/openid-connect/userinfo" --keycloak-group= --keycloak-group= +``` For group based authorization, the optional `--keycloak-group` (legacy) or `--allowed-group` (global standard) flags can be used to specify which groups to limit access to. @@ -172,6 +176,25 @@ Keycloak userinfo endpoint response. 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. +### Keycloak OIDC Auth Provider + +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' +2. Take note of the Secret in the credential tab of the client +3. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'. +4. Create a mapper with **Mapper Type** 'Audience' and **Included Client Audience** and **Included Custom Audience** set to your client name. + +Make sure you set the following to the appropriate url: + +``` + --provider=keycloak-oidc + --client-id= + --client-secret= + --redirect-url=https://myapp.com/oauth2/callback + --oidc-issuer-url=https:///auth//basic + --allowed-role= // Optional, required realm role + --allowed-role=: // Optional, required client role +``` + ### GitLab Auth Provider This 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)). diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index ebf7c05e..7d92d2ec 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -192,6 +192,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/ | `--tls-key-file` | string | path to private key file | | | `--upstream` | string \| list | the http url(s) of the upstream endpoint, file:// paths for static files or `static://` for static response. Routing is based on the path | | | `--allowed-group` | string \| list | restrict logins to members of this group (may be given multiple times) | | +| `--allowed-role` | string \| list | restrict logins to users with this role (may be given multiple times). Only works with the keycloak-oidc provider. | | | `--validate-url` | string | Access token validation endpoint | | | `--version` | n/a | print version string | | | `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`) \[[2](#footnote2)\] | | diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 5a6145e2..cf67dedd 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -658,11 +658,15 @@ func (l *LegacyProvider) convert() (Providers, error) { Token: l.GitHubToken, Users: l.GitHubUsers, } - case "keycloak": + case "keycloak-oidc": provider.KeycloakConfig = KeycloakOptions{ Groups: l.KeycloakGroups, Roles: l.AllowedRoles, } + case "keycloak": + provider.KeycloakConfig = KeycloakOptions{ + Groups: l.KeycloakGroups, + } case "gitlab": provider.GitLabConfig = GitLabOptions{ Group: l.GitLabGroup, diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 4e63c86f..172479fa 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -78,7 +78,9 @@ type Provider struct { type KeycloakOptions struct { // Group enables to restrict login to members of indicated group Groups []string `json:"groups,omitempty"` - Roles []string `json:"roles,omitempty"` + + // Role enables to restrict login to users with role (only available when using the keycloak-oidc provider) + Roles []string `json:"roles,omitempty"` } type AzureOptions struct { diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go index a58e5871..cb1971db 100644 --- a/providers/keycloak_oidc.go +++ b/providers/keycloak_oidc.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" - "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" ) const keycloakOIDCProviderName = "Keycloak OIDC" @@ -31,6 +30,9 @@ var _ Provider = (*KeycloakOIDCProvider)(nil) // Assumes `SetAllowedGroups` is already called on groups and appends to that // with `role:` prefixed roles. func (p *KeycloakOIDCProvider) AddAllowedRoles(roles []string) { + if p.AllowedGroups == nil { + p.AllowedGroups = make(map[string]struct{}) + } for _, role := range roles { p.AllowedGroups[formatRole(role)] = struct{}{} } @@ -41,11 +43,23 @@ func (p *KeycloakOIDCProvider) AddAllowedRoles(roles []string) { func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { err := p.OIDCProvider.EnrichSession(ctx, s) if err != nil { - return err + return fmt.Errorf("could not enrich oidc session: %v", err) } return p.extractRoles(ctx, s) } +// RefreshSession adds role extraction logic to the refresh flow +func (p *KeycloakOIDCProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) { + refreshed, err := p.OIDCProvider.RefreshSession(ctx, s) + + // Refresh could have failed or there was not session to refresh (with no error raised) + if err != nil || !refreshed { + return refreshed, err + } + + return true, p.extractRoles(ctx, s) +} + func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error { claims, err := p.getAccessClaims(ctx, s) if err != nil { @@ -109,7 +123,6 @@ func getClientRoles(claims *accessClaims) []string { for clientName, access := range claims.ResourceAccess { accessMap, ok := access.(map[string]interface{}) if !ok { - logger.Errorf("Unable to parse client roles from claims for client: %v", clientName) continue } diff --git a/providers/keycloak_oidc_test.go b/providers/keycloak_oidc_test.go index 8e9ef07f..686295ea 100644 --- a/providers/keycloak_oidc_test.go +++ b/providers/keycloak_oidc_test.go @@ -1,36 +1,85 @@ package providers import ( + "context" + "encoding/base64" + "fmt" + "net/http/httptest" "net/url" + "github.com/coreos/go-oidc/v3/oidc" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) +const ( + accessTokenHeader = "ewogICJhbGciOiAiUlMyNTYiLAogICJ0eXAiOiAiSldUIgp9" + accessTokenPayload = "eyJyZWFsbV9hY2Nlc3MiOiB7InJvbGVzIjogWyJ3cml0ZSJdfSwgInJlc291cmNlX2FjY2VzcyI6IHsiZGVmYXVsdCI6IHsicm9sZXMiOiBbInJlYWQiXX19fQ" + accessTokenSignature = "dyt0CoTl4WoVjAHI9Q_CwSKhl6d_9rhM3NrXuJttkao" +) + +type DummyKeySet struct{} + +func (DummyKeySet) VerifySignature(_ context.Context, _ string) (payload []byte, err error) { + p, _ := base64.RawURLEncoding.DecodeString(accessTokenPayload) + return p, nil +} + +func getAccessToken() string { + return fmt.Sprintf("%s.%s.%s", accessTokenHeader, accessTokenPayload, accessTokenSignature) +} + +func newTestKeycloakOIDCSetup() (*httptest.Server, *KeycloakOIDCProvider) { + redeemURL, server := newOIDCServer([]byte(fmt.Sprintf(`{"email": "new@thing.com", "expires_in": 300, "access_token": "%v"}`, getAccessToken()))) + provider := newKeycloakOIDCProvider(redeemURL) + return server, provider +} + +func newKeycloakOIDCProvider(serverURL *url.URL) *KeycloakOIDCProvider { + p := NewKeycloakOIDCProvider( + &ProviderData{ + LoginURL: &url.URL{ + Scheme: "https", + Host: "keycloak-oidc.com", + Path: "/oauth/auth"}, + RedeemURL: &url.URL{ + Scheme: "https", + Host: "keycloak-oidc.com", + Path: "/oauth/token"}, + ProfileURL: &url.URL{ + Scheme: "https", + Host: "keycloak-oidc.com", + Path: "/api/v3/user"}, + ValidateURL: &url.URL{ + Scheme: "https", + Host: "keycloak-oidc.com", + Path: "/api/v3/user"}, + Scope: "openid email profile"}) + + if serverURL != nil { + p.RedeemURL.Scheme = serverURL.Scheme + p.RedeemURL.Host = serverURL.Host + } + + keyset := DummyKeySet{} + p.Verifier = oidc.NewVerifier("", keyset, &oidc.Config{ + ClientID: "client", + SkipIssuerCheck: true, + SkipClientIDCheck: true, + SkipExpiryCheck: true, + }) + p.EmailClaim = "email" + p.GroupsClaim = "groups" + return p +} + var _ = Describe("Keycloak OIDC Provider Tests", func() { Context("New Provider Init", func() { - It("uses the passed ProviderData", func() { - p := NewKeycloakOIDCProvider( - &ProviderData{ - LoginURL: &url.URL{ - Scheme: "https", - Host: "keycloak-oidc.com", - Path: "/oauth/auth"}, - RedeemURL: &url.URL{ - Scheme: "https", - Host: "keycloak-oidc.com", - Path: "/oauth/token"}, - ProfileURL: &url.URL{ - Scheme: "https", - Host: "keycloak-oidc.com", - Path: "/api/v3/user"}, - ValidateURL: &url.URL{ - Scheme: "https", - Host: "keycloak-oidc.com", - Path: "/api/v3/user"}, - Scope: "openid email profile"}) + It("creates new keycloak oidc provider with expected defaults", func() { + p := newKeycloakOIDCProvider(nil) providerData := p.Data() - Expect(providerData.ProviderName).To(Equal(keycloakOIDCProviderName)) Expect(providerData.LoginURL.String()).To(Equal("https://keycloak-oidc.com/oauth/auth")) Expect(providerData.RedeemURL.String()).To(Equal("https://keycloak-oidc.com/oauth/token")) @@ -39,4 +88,102 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { Expect(providerData.Scope).To(Equal("openid email profile")) }) }) + + Context("Allowed Roles", func() { + It("should prefix allowed roles and add them to groups", func() { + p := newKeycloakOIDCProvider(nil) + p.AddAllowedRoles([]string{"admin", "editor"}) + Expect(p.AllowedGroups).To(HaveKey("role:admin")) + Expect(p.AllowedGroups).To(HaveKey("role:editor")) + }) + }) + + Context("Enrich Session", func() { + It("should not fail when groups are not assigned", func() { + server, provider := newTestKeycloakOIDCSetup() + url, err := url.Parse(server.URL) + Expect(err).To(BeNil()) + defer server.Close() + + provider.ProfileURL = url + + existingSession := &sessions.SessionState{ + User: "already", + Email: "a@b.com", + Groups: nil, + IDToken: idToken, + AccessToken: getAccessToken(), + RefreshToken: refreshToken, + } + expectedSession := &sessions.SessionState{ + User: "already", + Email: "a@b.com", + Groups: []string{"role:write", "role:default:read"}, + IDToken: idToken, + AccessToken: getAccessToken(), + RefreshToken: refreshToken, + } + + err = provider.EnrichSession(context.Background(), existingSession) + Expect(err).To(BeNil()) + Expect(existingSession).To(Equal(expectedSession)) + }) + + It("should add roles to existing groups", func() { + server, provider := newTestKeycloakOIDCSetup() + url, err := url.Parse(server.URL) + Expect(err).To(BeNil()) + defer server.Close() + + provider.ProfileURL = url + + existingSession := &sessions.SessionState{ + User: "already", + Email: "a@b.com", + Groups: []string{"existing", "group"}, + IDToken: idToken, + AccessToken: getAccessToken(), + RefreshToken: refreshToken, + } + expectedSession := &sessions.SessionState{ + User: "already", + Email: "a@b.com", + Groups: []string{"existing", "group", "role:write", "role:default:read"}, + IDToken: idToken, + AccessToken: getAccessToken(), + RefreshToken: refreshToken, + } + + err = provider.EnrichSession(context.Background(), existingSession) + Expect(err).To(BeNil()) + Expect(existingSession).To(Equal(expectedSession)) + }) + }) + + Context("Refresh Session", func() { + It("should refresh session and extract roles again", func() { + server, provider := newTestKeycloakOIDCSetup() + url, err := url.Parse(server.URL) + Expect(err).To(BeNil()) + defer server.Close() + + provider.ProfileURL = url + + existingSession := &sessions.SessionState{ + User: "already", + Email: "a@b.com", + Groups: nil, + IDToken: idToken, + AccessToken: getAccessToken(), + RefreshToken: refreshToken, + } + + refreshed, err := provider.RefreshSession(context.Background(), existingSession) + Expect(err).To(BeNil()) + Expect(refreshed).To(BeTrue()) + Expect(existingSession.ExpiresOn).ToNot(BeNil()) + Expect(existingSession.CreatedAt).ToNot(BeNil()) + Expect(existingSession.Groups).To(BeEquivalentTo([]string{"role:write", "role:default:read"})) + }) + }) })