Merge e54d18d08d into 110d51d1d7
				
					
				
			This commit is contained in:
		
						commit
						4643e9aba4
					
				|  | @ -7,7 +7,7 @@ title: Keycloak OIDC | |||
| 
 | ||||
| | Flag             | Toml Field      | Type           | Description                                                                                                        | Default | | ||||
| | ---------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------ | ------- | | ||||
| | `--allowed-role` | `allowed_roles` | string \| list | restrict logins to users with this role (may be given multiple times). Only works with the keycloak-oidc provider. |         | | ||||
| | `--allowed-role` | `allowed_roles` | string \| list | Restrict logins to users with this role (may be given multiple times). Works with the keycloak-oidc and ms-entra-id provider. |         | | ||||
| 
 | ||||
| ## Usage | ||||
| 
 | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ The provider is OIDC-compliant, so all the OIDC parameters are honored. Addition | |||
| | --------------------------- | -------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | ||||
| | `--entra-id-allowed-tenant` | `entra_id_allowed_tenants` | string \| list | List of allowed tenants. In case of multi-tenant apps, incoming tokens are issued by different issuers and OIDC issuer verification needs to be disabled. When not specified, all tenants are allowed. Redundant for single-tenant apps (regular ID token validation matches the issuer). |         | | ||||
| | `--entra-id-federated-token-auth` | `entra_id_federated_token_auth` | boolean | Enable oAuth2 client authentication with federated token projected by Entra Workload Identity plugin, instead of client secret.   | false | | ||||
| | `--allowed-role` | `allowed_roles` | string \| list | Restrict logins to users with this app role (may be given multiple times). Works with the keycloak-oidc and ms-entra-id provider. |         | | ||||
| 
 | ||||
| ## Configure App registration | ||||
| To begin, create an App registration, set a redirect URI, and generate a secret. All account types are supported, including single-tenant, multi-tenant, multi-tenant with Microsoft accounts, and Microsoft accounts only. | ||||
|  |  | |||
|  | @ -609,7 +609,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { | |||
| 
 | ||||
| 	flagSet.String("user-id-claim", 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)") | ||||
| 	flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc, ms-entra-id) restrict logins to members of these roles (may be given multiple times)") | ||||
| 	flagSet.String("backend-logout-url", "", "url to perform a backend logout, {id_token} can be used as placeholder for the id_token") | ||||
| 
 | ||||
| 	return flagSet | ||||
|  | @ -775,6 +775,7 @@ func (l *LegacyProvider) convert() (Providers, error) { | |||
| 		provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ | ||||
| 			AllowedTenants:     l.EntraIDAllowedTenants, | ||||
| 			FederatedTokenAuth: l.EntraIDFederatedTokenAuth, | ||||
| 			Roles:              l.AllowedRoles, | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -182,6 +182,9 @@ type MicrosoftEntraIDOptions struct { | |||
| 	// FederatedTokenAuth enable oAuth2 client authentication with federated token projected
 | ||||
| 	// by Entra Workload Identity plugin, instead of client secret.
 | ||||
| 	FederatedTokenAuth bool `json:"federatedTokenAuth,omitempty"` | ||||
| 
 | ||||
| 	// Role enables to restrict login to users with app role
 | ||||
| 	Roles []string `json:"roles,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type ADFSOptions struct { | ||||
|  |  | |||
|  | @ -2,10 +2,8 @@ package providers | |||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | ||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||
|  | @ -110,14 +108,9 @@ type accessClaims struct { | |||
| } | ||||
| 
 | ||||
| func (p *KeycloakOIDCProvider) getAccessClaims(s *sessions.SessionState) (*accessClaims, error) { | ||||
| 	parts := strings.Split(s.AccessToken, ".") | ||||
| 	if len(parts) < 2 { | ||||
| 		return nil, fmt.Errorf("malformed access token, expected 3 parts got %d", len(parts)) | ||||
| 	} | ||||
| 
 | ||||
| 	payload, err := base64.RawURLEncoding.DecodeString(parts[1]) | ||||
| 	payload, err := extractAccessTokenPayload(s) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("malformed access token, couldn't extract jwt payload: %v", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	var claims accessClaims | ||||
|  |  | |||
|  | @ -47,13 +47,16 @@ func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *Micros | |||
| 		name: microsoftEntraIDProviderName, | ||||
| 	}) | ||||
| 
 | ||||
| 	return &MicrosoftEntraIDProvider{ | ||||
| 	provider := &MicrosoftEntraIDProvider{ | ||||
| 		OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig), | ||||
| 
 | ||||
| 		multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants, | ||||
| 		federatedTokenAuth:        opts.MicrosoftEntraIDConfig.FederatedTokenAuth, | ||||
| 		microsoftGraphURL:         microsoftGraphURL, | ||||
| 	} | ||||
| 	provider.addAllowedRoles(opts.MicrosoftEntraIDConfig.Roles) | ||||
| 
 | ||||
| 	return provider | ||||
| } | ||||
| 
 | ||||
| // EnrichSession checks for group overage after calling generic EnrichSession
 | ||||
|  | @ -74,7 +77,7 @@ func (p *MicrosoftEntraIDProvider) EnrichSession(ctx context.Context, session *s | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| 	return p.extractRoles(session) | ||||
| } | ||||
| 
 | ||||
| // ValidateSession checks for allowed tenants (e.g. for multi-tenant apps) and passes through to generic ValidateSession
 | ||||
|  | @ -154,7 +157,7 @@ func (p *MicrosoftEntraIDProvider) RefreshSession(ctx context.Context, s *sessio | |||
| 		return false, fmt.Errorf("unable to redeem refresh token: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return true, nil | ||||
| 	return true, p.extractRoles(s) | ||||
| } | ||||
| 
 | ||||
| // redeemRefreshTokenWithFederatedToken uses a RefreshToken and federated credentials with the RedeemURL to refresh the
 | ||||
|  | @ -320,3 +323,45 @@ func (p *MicrosoftEntraIDProvider) fetchToken(ctx context.Context, params url.Va | |||
| 
 | ||||
| 	return token.WithExtra(rawResponse), nil | ||||
| } | ||||
| 
 | ||||
| // addAllowedRoles sets app roles that are authorized.
 | ||||
| // Assumes `SetAllowedGroups` is already called on groups and appends to that
 | ||||
| // with `role:` prefixed roles.
 | ||||
| func (p *MicrosoftEntraIDProvider) addAllowedRoles(roles []string) { | ||||
| 	if p.AllowedGroups == nil { | ||||
| 		p.AllowedGroups = make(map[string]struct{}) | ||||
| 	} | ||||
| 	for _, role := range roles { | ||||
| 		p.AllowedGroups[formatAppRole(role)] = struct{}{} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| type rolesClaim struct { | ||||
| 	Roles []string `json:"roles"` | ||||
| } | ||||
| 
 | ||||
| func (p *MicrosoftEntraIDProvider) extractRoles(s *sessions.SessionState) error { | ||||
| 	// Get 'roles' claim from access token payload
 | ||||
| 	payload, err := extractAccessTokenPayload(s) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	var claim rolesClaim | ||||
| 	if err := json.Unmarshal(payload, &claim); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	var roles []string | ||||
| 	roles = append(roles, claim.Roles...) | ||||
| 
 | ||||
| 	// Add to groups list with `role:` prefix to distinguish from groups
 | ||||
| 	for _, role := range roles { | ||||
| 		s.Groups = append(s.Groups, formatAppRole(role)) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func formatAppRole(role string) string { | ||||
| 	return fmt.Sprintf("role:%s", role) | ||||
| } | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import ( | |||
| 	"github.com/coreos/go-oidc/v3/oidc" | ||||
| 	"github.com/golang-jwt/jwt/v5" | ||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | ||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 
 | ||||
| 	. "github.com/onsi/gomega" | ||||
|  | @ -183,3 +184,67 @@ type mockedVerifier struct { | |||
| func (v *mockedVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) { | ||||
| 	return nil, nil | ||||
| } | ||||
| 
 | ||||
| type registeredClaimsWithRoles struct { | ||||
| 	// the `roles` (Roles) claim. See https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-app-roles-in-apps#usage-scenario-of-app-roles
 | ||||
| 	Roles []string `json:"roles,omitempty"` | ||||
| 
 | ||||
| 	jwt.RegisteredClaims | ||||
| } | ||||
| 
 | ||||
| func TestAzureEntraOIDCProviderValidateSessionAllowedRoles(t *testing.T) { | ||||
| 	// Create multi-tenant Azure Entra provider with allowed roles
 | ||||
| 	provider := NewMicrosoftEntraIDProvider( | ||||
| 		&ProviderData{ | ||||
| 			Verifier: &mockedVerifier{}, | ||||
| 		}, | ||||
| 		options.Provider{ | ||||
| 			OIDCConfig: options.OIDCOptions{ | ||||
| 				IssuerURL:                      "https://login.microsoftonline.com/common/v2.0", | ||||
| 				InsecureSkipIssuerVerification: true, | ||||
| 				InsecureSkipNonce:              true, | ||||
| 			}, | ||||
| 			MicrosoftEntraIDConfig: options.MicrosoftEntraIDOptions{ | ||||
| 				Roles: []string{"Owner", "Contributor"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	) | ||||
| 
 | ||||
| 	// Check for access token don't have allowed roles
 | ||||
| 	key, _ := rsa.GenerateKey(rand.Reader, 2048) | ||||
| 
 | ||||
| 	claimsWithEmptyRoles := ®isteredClaimsWithRoles{} | ||||
| 	claimsWithEmptyRoles.Issuer = "https://login.microsoftonline.com/85d7d600-7804-4d92-8d43-9c33c21c130c/v2.0" | ||||
| 	claimsWithEmptyRoles.Roles = nil | ||||
| 
 | ||||
| 	idToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claimsWithEmptyRoles) | ||||
| 	unauthorizedRoleJWT, err := idToken.SignedString(key) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	session := &sessions.SessionState{ | ||||
| 		AccessToken: "unauthorized_token", | ||||
| 		Groups:      nil, | ||||
| 	} | ||||
| 	session.IDToken = unauthorizedRoleJWT | ||||
| 
 | ||||
| 	authorized, _ := provider.Authorize(context.Background(), session) | ||||
| 	assert.False(t, authorized) | ||||
| 
 | ||||
| 	// Check for access token has one of allowed roles
 | ||||
| 	claimsWithAuthorizedRole := ®isteredClaimsWithRoles{} | ||||
| 	claimsWithAuthorizedRole.Issuer = "https://login.microsoftonline.com/85d7d600-7804-4d92-8d43-9c33c21c130c/v2.0" | ||||
| 	claimsWithAuthorizedRole.Roles = []string{"Owner"} | ||||
| 
 | ||||
| 	idToken = jwt.NewWithClaims(jwt.SigningMethodRS256, claimsWithAuthorizedRole) | ||||
| 	validJWT, err := idToken.SignedString(key) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	session = &sessions.SessionState{ | ||||
| 		AccessToken: "authorized_token", | ||||
| 		Groups:      []string{formatAppRole("Owner")}, | ||||
| 	} | ||||
| 	session.IDToken = validJWT | ||||
| 
 | ||||
| 	authorized, _ = provider.Authorize(context.Background(), session) | ||||
| 	assert.True(t, authorized) | ||||
| } | ||||
|  |  | |||
|  | @ -1,11 +1,14 @@ | |||
| package providers | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
| 
 | ||||
|  | @ -74,3 +77,18 @@ func formatGroup(rawGroup interface{}) (string, error) { | |||
| 	} | ||||
| 	return string(jsonGroup), nil | ||||
| } | ||||
| 
 | ||||
| // extractAccessTokenPayload extracts the access token payload (JSON string in bytes)
 | ||||
| func extractAccessTokenPayload(s *sessions.SessionState) ([]byte, error) { | ||||
| 	parts := strings.Split(s.AccessToken, ".") | ||||
| 	if len(parts) < 2 { | ||||
| 		return nil, fmt.Errorf("malformed access token, expected 3 parts got %d", len(parts)) | ||||
| 	} | ||||
| 
 | ||||
| 	payload, err := base64.RawURLEncoding.DecodeString(parts[1]) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("malformed access token, couldn't extract jwt payload: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return payload, err | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue