1
0
mirror of https://github.com/oauth2-proxy/oauth2-proxy.git synced 2025-01-10 04:18:14 +02:00

Merge pull request #1210 from pb82/keycloak-oidc-provider

Keycloak oidc provider
This commit is contained in:
Nick Meves 2021-08-07 09:57:03 -07:00 committed by GitHub
commit 526aff8c84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 377 additions and 1 deletions

View File

@ -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 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` 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. 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 ## Breaking Changes
@ -27,6 +29,7 @@
- [#1142](https://github.com/oauth2-proxy/oauth2-proxy/pull/1142) Add pagewriter to upstream proxy (@JoelSpeed) - [#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) - [#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) - [#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) - [#1244](https://github.com/oauth2-proxy/oauth2-proxy/pull/1244) Update Alpine image version to 3.14 (@ahovgaard)

View File

@ -250,6 +250,7 @@ make up the header value
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `groups` | _[]string_ | Group enables to restrict login to members of indicated group | | `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 ### LoginGovOptions

View File

@ -146,12 +146,15 @@ If you are using GitHub enterprise, make sure you set the following to the appro
### Keycloak Auth Provider ### 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 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'. 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: Make sure you set the following to the appropriate url:
```
--provider=keycloak --provider=keycloak
--client-id=<client you have created> --client-id=<client you have created>
--client-secret=<your client's secret> --client-secret=<your client's secret>
@ -161,6 +164,7 @@ Make sure you set the following to the appropriate url:
--validate-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/userinfo" --validate-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/userinfo"
--keycloak-group=<first_allowed_user_group> --keycloak-group=<first_allowed_user_group>
--keycloak-group=<second_allowed_user_group> --keycloak-group=<second_allowed_user_group>
```
For group based authorization, the optional `--keycloak-group` (legacy) or `--allowed-group` (global standard) 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. 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 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. 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=<your client's id>
--client-secret=<your client's secret>
--redirect-url=https://myapp.com/oauth2/callback
--oidc-issuer-url=https://<keycloak host>/auth/<your realm>/basic
--allowed-role=<realm role name> // Optional, required realm role
--allowed-role=<client id>:<client role name> // Optional, required client role
```
### GitLab Auth Provider ### 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)). 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)).

View File

@ -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 | | | `--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://<status_code>` for static response. Routing is based on the path | | | `--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 | |
| `--allowed-group` | string \| list | restrict logins to members of this group (may be given multiple times) | | | `--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 | | | `--validate-url` | string | Access token validation endpoint | |
| `--version` | n/a | print version string | | | `--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`)&nbsp;\[[2](#footnote2)\] | | | `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | |

View File

@ -508,6 +508,7 @@ type LegacyProvider struct {
ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0
UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"`
AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"`
AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"`
AcrValues string `flag:"acr-values" cfg:"acr_values"` AcrValues string `flag:"acr-values" cfg:"acr_values"`
JWTKey string `flag:"jwt-key" cfg:"jwt_key"` 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.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-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 return flagSet
} }
@ -656,6 +658,11 @@ func (l *LegacyProvider) convert() (Providers, error) {
Token: l.GitHubToken, Token: l.GitHubToken,
Users: l.GitHubUsers, Users: l.GitHubUsers,
} }
case "keycloak-oidc":
provider.KeycloakConfig = KeycloakOptions{
Groups: l.KeycloakGroups,
Roles: l.AllowedRoles,
}
case "keycloak": case "keycloak":
provider.KeycloakConfig = KeycloakOptions{ provider.KeycloakConfig = KeycloakOptions{
Groups: l.KeycloakGroups, Groups: l.KeycloakGroups,

View File

@ -78,6 +78,9 @@ type Provider struct {
type KeycloakOptions struct { type KeycloakOptions struct {
// Group enables to restrict login to members of indicated group // Group enables to restrict login to members of indicated group
Groups []string `json:"groups,omitempty"` 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 { type AzureOptions struct {

View File

@ -247,6 +247,11 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
if len(o.Providers[0].KeycloakConfig.Groups) > 0 { if len(o.Providers[0].KeycloakConfig.Groups) > 0 {
p.SetAllowedGroups(o.Providers[0].KeycloakConfig.Groups) 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: case *providers.GoogleProvider:
if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" { if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" {
file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON) file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON)

142
providers/keycloak_oidc.go Normal file
View File

@ -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)
}

View File

@ -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"}))
})
})
})

View File

@ -31,6 +31,8 @@ func New(provider string, p *ProviderData) Provider {
return NewGitHubProvider(p) return NewGitHubProvider(p)
case "keycloak": case "keycloak":
return NewKeycloakProvider(p) return NewKeycloakProvider(p)
case "keycloak-oidc":
return NewKeycloakOIDCProvider(p)
case "azure": case "azure":
return NewAzureProvider(p) return NewAzureProvider(p)
case "adfs": case "adfs":