Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Allow roles to be specified for Keycloak #767

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
- [#750](https://github.com/oauth2-proxy/oauth2-proxy/pull/750) ci: Migrate to Github Actions (@shinebayar-g)
- [#829](https://github.com/oauth2-proxy/oauth2-proxy/pull/820) Rename test directory to testdata (@johejo)
- [#819](https://github.com/oauth2-proxy/oauth2-proxy/pull/819) Improve CI (@johejo)
- [#767](https://github.com/oauth2-proxy/oauth2-proxy/pull/767) Add ability to specify roles when using the Keycloak provider (@khawaga)

# v6.1.1

Expand Down
2 changes: 1 addition & 1 deletion contrib/oauth2-proxy_autocomplete.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ _oauth2_proxy() {
COMPREPLY=( $(compgen -W 'X-Real-IP X-Forwarded-For X-ProxyUser-IP' -- ${cur}) )
return 0
;;
--@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|trusted-ip|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url))
--@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|trusted-ip|keycloak-group|keycloak-role|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|github-user|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url))
return 0
;;
esac
Expand Down
3 changes: 3 additions & 0 deletions docs/versioned_docs/version-6.1.x/configuration/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,12 @@ Make sure you set the following to the appropriate url:
-redeem-url="http(s)://<keycloak host>/realms/<your realm>/protocol/openid-connect/token"
-validate-url="http(s)://<keycloak host>/realms/<your realm>/protocol/openid-connect/userinfo"
-keycloak-group=<user_group>
-keycloak-role=<user_role>

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 can restrict logins to users with specific roles by passing a comma-separated list of roles to 'keycloak-role', the users must have at least one of the specified roles in order to be granted access. Both realm and client roles can be used, client roles should be in the following format 'client:role'.

### GitLab Auth Provider

Whether you are using GitLab.com or self-hosting GitLab, follow [these steps to add an application](https://docs.gitlab.com/ce/integration/oauth_provider.html). Make sure to enable at least the `openid`, `profile` and `email` scopes, and set the redirect url to your application url e.g. https://myapp.com/oauth2/callback.
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Options struct {

AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
KeycloakGroup string `flag:"keycloak-group" cfg:"keycloak_group"`
KeycloakRoles []string `flag:"keycloak-role" cfg:"keycloak_roles"`
AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"`
BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"`
BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"`
Expand Down Expand Up @@ -180,6 +181,7 @@ func NewFlagSet() *pflag.FlagSet {
flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)")
flagSet.String("keycloak-group", "", "restrict login to members of this group.")
flagSet.StringSlice("keycloak-role", []string{}, "restrict logins to members with this role (may be given multiple times)")
flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
Expand Down
2 changes: 2 additions & 0 deletions pkg/validation/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
p.SetUsers(o.GitHubUsers)
case *providers.KeycloakProvider:
p.SetGroup(o.KeycloakGroup)
p.SetRoles(o.KeycloakRoles)
p.SetAllowedGroups(p.PrefixAllowedGroups())
case *providers.GoogleProvider:
if o.GoogleServiceAccountJSON != "" {
file, err := os.Open(o.GoogleServiceAccountJSON)
Expand Down
3 changes: 2 additions & 1 deletion providers/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
)

var authorizedAccessToken = "imaginary_access_token"
// The following is a valid JWT, it can be decoded, edited and re-encoded using https://jwt.io/
var authorizedAccessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkhvbWVyIFNpbXBzb24iLCJlbWFpbCI6IkNodW5reUxvdmVyNTNAYW9sLmNvbSIsImlhdCI6MTUxNjIzOTAyMiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInRlc3QtcmVhbG1yb2xlMSIsInRlc3QtcmVhbG1yb2xlMiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImNsaWVudCI6eyJyb2xlcyI6WyJ0ZXN0LWNsaWVudHJvbGUxIiwidGVzdC1jbGllbnRyb2xlMiJdfX19.DqKfcantBn7B8acd9rl0LK8FL3sVhUcrM_AHztWI2A0"
Comment on lines +11 to +12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What key was used to sign this?

Copy link
Author

@khawaga khawaga Sep 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's using the default values on jwt.io and the default secret which is "your-256-bit-secret" 😅

Should I change it to something else? Or add it as a comment?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm centralizing OIDC logic in this PR and making sample IDTokens more accessible to test cases across providers here: https://github.com/oauth2-proxy/oauth2-proxy/pull/936/files#diff-e719a8f54d2fe3115f6fcaabd056be5b53e86a3f04004d3c53f0b6c5b0409d68R34

Might be of use to this PR if you need valid IDTokens for testing.


func CreateAuthorizedSession() *sessions.SessionState {
return &sessions.SessionState{AccessToken: authorizedAccessToken}
Expand Down
129 changes: 111 additions & 18 deletions providers/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ package providers

import (
"context"
"errors"
"fmt"
"net/url"

"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
"gopkg.in/square/go-jose.v2/jwt"
)

type KeycloakProvider struct {
*ProviderData
Group string
Roles []string
}

var _ Provider = (*KeycloakProvider)(nil)
Expand Down Expand Up @@ -63,37 +67,126 @@ func (p *KeycloakProvider) SetGroup(group string) {
p.Group = group
}

func (p *KeycloakProvider) GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error) {
json, err := requests.New(p.ValidateURL.String()).
func (p *KeycloakProvider) SetRoles(roles []string) {
p.Roles = roles
}

type keycloakUserInfo struct {
Email string `json:"email"`
Groups []string `json:"groups"`
}

func (p *KeycloakProvider) getUserInfo(ctx context.Context, s *sessions.SessionState) (*keycloakUserInfo, error) {
var userInfo keycloakUserInfo
err := requests.New(p.ValidateURL.String()).
WithContext(ctx).
SetHeader("Authorization", "Bearer "+s.AccessToken).
Do().
UnmarshalJSON()
UnmarshalInto(&userInfo)
if err != nil {
logger.Errorf("failed making request %s", err)
return "", err
return nil, fmt.Errorf("error getting user info: %v", err)
}
return &userInfo, nil
}

func getClientRoles(resourceAccess map[string]interface{}) ([]string, error) {
var clientRoles []string
for name, list := range resourceAccess {
scopes, ok := list.(map[string]interface{})
if !ok {
return nil, errors.New("error parsing client roles from claims")
}
if roles, found := scopes["roles"]; found {
for _, r := range roles.([]interface{}) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to handle when this isn't a []interface{} or have fallback code that somehow marshals non-slices if you get a singleton? We have to handle that case in the OIDC side: 48baecb

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I've seen it always returns a slice even when if it only contains a single element, is there any other case that I'm missing?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool - that makes sense that it is uniform since it is all provided by the singular Keycloak. OIDC is all over the place cause identity providers do some crazy stuff with groups since it isn't a totally official claim.

clientRoles = append(clientRoles, fmt.Sprintf("%s:%s", name, r))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will name always be role? Should we make that prefix explicit here instead of from a variable so we don't get surprised later?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait, nevermind - I see the role prefix later. So this has a different name:role structure that represents the full role name?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, so effectively this would end up in allowed groups as role:clientname:clientrole. These are client roles which are namespaced by client, so while the structure is predictable the client name could be anything, hence the additional prefix.

}
}
}
return clientRoles, nil
}

type realmAccess struct {
Roles []string `json:"roles"`
}

type customClaims struct {
RealmAccess realmAccess `json:"realm_access"`
ResourceAccess map[string]interface{} `json:"resource_access"`
}

func (p *KeycloakProvider) getRoles(s *sessions.SessionState) ([]string, error) {
// Decode JWT token without verifying the signature
token, err := jwt.ParseSigned(s.AccessToken)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %v", err)
}
standardClaims := jwt.Claims{}
customClaims := customClaims{}
// Parse claims
if err := token.UnsafeClaimsWithoutVerification(&standardClaims, &customClaims); err != nil {
logger.Printf("failed to parse claims: %s", err)
}
clientRoles, err := getClientRoles(customClaims.ResourceAccess)
if err != nil {
logger.Printf("failed to extract client roles: %s", err)
}
return append(customClaims.RealmAccess.Roles, clientRoles...), nil
Copy link
Member

@NickMeves NickMeves Dec 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels weird to pollute & extend the underlying object we extracted from the token claims.

I understand the performance motivations of reusing the existing slice since it is done at the end of the function anyway, but since these are small & therefore performance is negligible, maybe clarity is more ideal?

Took me a second on first glance to realize the desired output was intended to build the final roles list from RealmAccess + ResourceAccess

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I will change this for better clarity.

}

func (p *KeycloakProvider) addGroupsToSession(s *sessions.SessionState, groups []string) error {
for _, group := range groups {
s.Groups = append(s.Groups, fmt.Sprintf("group:%s", group))
}
return nil
}

func (p *KeycloakProvider) addRolesToSession(s *sessions.SessionState) error {
roles, err := p.getRoles(s)
if err != nil {
return err
}
for _, role := range roles {
s.Groups = append(s.Groups, fmt.Sprintf("role:%s", role))
}
return nil
}

khawaga marked this conversation as resolved.
Show resolved Hide resolved
func (p *KeycloakProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
userInfo, err := p.getUserInfo(ctx, s)
if err != nil {
return err
}

s.Email = userInfo.Email

if p.Group != "" {
var groups, err = json.Get("groups").Array()
err := p.addGroupsToSession(s, userInfo.Groups)
if err != nil {
logger.Printf("groups not found %s", err)
return "", err
return err
}
}

var found = false
for i := range groups {
if groups[i].(string) == p.Group {
found = true
break
}
if len(p.Roles) > 0 {
err := p.addRolesToSession(s)
if err != nil {
return err
}
}

if !found {
logger.Printf("group not found, access denied")
return "", nil
}
return nil
}

// PrefixAllowedGroups returns a list of allowed groups, prefixed by their `kind` value
func (p *KeycloakProvider) PrefixAllowedGroups() (groups []string) {

if p.Group != "" {
groups = append(groups, fmt.Sprintf("group:%s", p.Group))
}

for _, role := range p.Roles {
groups = append(groups, fmt.Sprintf("role:%s", role))
}

return json.Get("email").String()
return groups
}