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 17cbcbc3..cf67dedd 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -508,6 +508,7 @@ type LegacyProvider struct { ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` + AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"` AcrValues string `flag:"acr-values" cfg:"acr_values"` JWTKey string `flag:"jwt-key" cfg:"jwt_key"` @@ -563,6 +564,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID") flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)") + flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)") return flagSet } @@ -656,6 +658,11 @@ func (l *LegacyProvider) convert() (Providers, error) { Token: l.GitHubToken, Users: l.GitHubUsers, } + case "keycloak-oidc": + provider.KeycloakConfig = KeycloakOptions{ + Groups: l.KeycloakGroups, + Roles: l.AllowedRoles, + } case "keycloak": provider.KeycloakConfig = KeycloakOptions{ Groups: l.KeycloakGroups, diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 12248b3f..172479fa 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -78,6 +78,9 @@ type Provider struct { type KeycloakOptions struct { // Group enables to restrict login to members of indicated group Groups []string `json:"groups,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/pkg/validation/options.go b/pkg/validation/options.go index 1962185d..37c2aa24 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -247,6 +247,11 @@ func parseProviderInfo(o *options.Options, msgs []string) []string { if len(o.Providers[0].KeycloakConfig.Groups) > 0 { p.SetAllowedGroups(o.Providers[0].KeycloakConfig.Groups) } + case *providers.KeycloakOIDCProvider: + if p.Verifier == nil { + msgs = append(msgs, "keycloak-oidc provider requires an oidc issuer URL") + } + p.AddAllowedRoles(o.Providers[0].KeycloakConfig.Roles) case *providers.GoogleProvider: if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" { file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON) diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go new file mode 100644 index 00000000..cb1971db --- /dev/null +++ b/providers/keycloak_oidc.go @@ -0,0 +1,142 @@ +package providers + +import ( + "context" + "fmt" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" +) + +const keycloakOIDCProviderName = "Keycloak OIDC" + +// KeycloakOIDCProvider creates a Keycloak provider based on OIDCProvider +type KeycloakOIDCProvider struct { + *OIDCProvider +} + +// NewKeycloakOIDCProvider makes a KeycloakOIDCProvider using the ProviderData +func NewKeycloakOIDCProvider(p *ProviderData) *KeycloakOIDCProvider { + p.ProviderName = keycloakOIDCProviderName + return &KeycloakOIDCProvider{ + OIDCProvider: &OIDCProvider{ + ProviderData: p, + }, + } +} + +var _ Provider = (*KeycloakOIDCProvider)(nil) + +// AddAllowedRoles sets Keycloak roles that are authorized. +// 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{}{} + } +} + +// EnrichSession is called after Redeem to allow providers to enrich session fields +// such as User, Email, Groups with provider specific API calls. +func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { + err := p.OIDCProvider.EnrichSession(ctx, s) + if err != nil { + 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 { + return err + } + + var roles []string + roles = append(roles, claims.RealmAccess.Roles...) + roles = append(roles, getClientRoles(claims)...) + + // Add to groups list with `role:` prefix to distinguish from groups + for _, role := range roles { + s.Groups = append(s.Groups, formatRole(role)) + } + return nil +} + +type realmAccess struct { + Roles []string `json:"roles"` +} + +type accessClaims struct { + RealmAccess realmAccess `json:"realm_access"` + ResourceAccess map[string]interface{} `json:"resource_access"` +} + +func (p *KeycloakOIDCProvider) getAccessClaims(ctx context.Context, s *sessions.SessionState) (*accessClaims, error) { + // HACK: This isn't an ID Token, but has similar structure & signing + token, err := p.Verifier.Verify(ctx, s.AccessToken) + if err != nil { + return nil, err + } + + var claims *accessClaims + if err = token.Claims(&claims); err != nil { + return nil, err + } + return claims, nil +} + +// getClientRoles extracts client roles from the `resource_access` claim with +// the format `client:role`. +// +// ResourceAccess format: +// "resource_access": { +// "clientA": { +// "roles": [ +// "roleA" +// ] +// }, +// "clientB": { +// "roles": [ +// "roleA", +// "roleB", +// "roleC" +// ] +// } +// } +func getClientRoles(claims *accessClaims) []string { + var clientRoles []string + for clientName, access := range claims.ResourceAccess { + accessMap, ok := access.(map[string]interface{}) + if !ok { + continue + } + + var roles interface{} + if roles, ok = accessMap["roles"]; !ok { + continue + } + for _, role := range roles.([]interface{}) { + clientRoles = append(clientRoles, fmt.Sprintf("%s:%s", clientName, role)) + } + } + return clientRoles +} + +func formatRole(role string) string { + return fmt.Sprintf("role:%s", role) +} diff --git a/providers/keycloak_oidc_test.go b/providers/keycloak_oidc_test.go new file mode 100644 index 00000000..686295ea --- /dev/null +++ b/providers/keycloak_oidc_test.go @@ -0,0 +1,189 @@ +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("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")) + Expect(providerData.ProfileURL.String()).To(Equal("https://keycloak-oidc.com/api/v3/user")) + Expect(providerData.ValidateURL.String()).To(Equal("https://keycloak-oidc.com/api/v3/user")) + 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"})) + }) + }) +}) diff --git a/providers/providers.go b/providers/providers.go index d21409c2..a192f220 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -31,6 +31,8 @@ func New(provider string, p *ProviderData) Provider { return NewGitHubProvider(p) case "keycloak": return NewKeycloakProvider(p) + case "keycloak-oidc": + return NewKeycloakOIDCProvider(p) case "azure": return NewAzureProvider(p) case "adfs":