From e54d18d08dd9fcffc481b17c3beb7196dd1bb60a Mon Sep 17 00:00:00 2001 From: Long Wu Date: Sat, 28 Jun 2025 08:55:25 +0000 Subject: [PATCH] feat: support entra-id allowed app roles --- .../configuration/providers/keycloak_oidc.md | 2 +- .../configuration/providers/ms_entra_id.md | 1 + pkg/apis/options/legacy_options.go | 3 +- pkg/apis/options/providers.go | 3 + providers/keycloak_oidc.go | 11 +--- providers/ms_entra_id.go | 51 ++++++++++++++- providers/ms_entra_id_test.go | 65 +++++++++++++++++++ providers/util.go | 18 +++++ 8 files changed, 140 insertions(+), 14 deletions(-) diff --git a/docs/docs/configuration/providers/keycloak_oidc.md b/docs/docs/configuration/providers/keycloak_oidc.md index b29096e3..bbfffb27 100644 --- a/docs/docs/configuration/providers/keycloak_oidc.md +++ b/docs/docs/configuration/providers/keycloak_oidc.md @@ -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 diff --git a/docs/docs/configuration/providers/ms_entra_id.md b/docs/docs/configuration/providers/ms_entra_id.md index c5d9594e..7c42c52d 100644 --- a/docs/docs/configuration/providers/ms_entra_id.md +++ b/docs/docs/configuration/providers/ms_entra_id.md @@ -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. diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index a2c5f4e3..99f217a5 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -604,7 +604,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 @@ -770,6 +770,7 @@ func (l *LegacyProvider) convert() (Providers, error) { provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ AllowedTenants: l.EntraIDAllowedTenants, FederatedTokenAuth: l.EntraIDFederatedTokenAuth, + Roles: l.AllowedRoles, } } diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 280b1ce0..74b588ec 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -176,6 +176,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 { diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go index 6b949f45..9ef7f3dd 100644 --- a/providers/keycloak_oidc.go +++ b/providers/keycloak_oidc.go @@ -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 diff --git a/providers/ms_entra_id.go b/providers/ms_entra_id.go index df1f38a4..ffa543d5 100644 --- a/providers/ms_entra_id.go +++ b/providers/ms_entra_id.go @@ -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) +} diff --git a/providers/ms_entra_id_test.go b/providers/ms_entra_id_test.go index dfd1ef99..7c799fe8 100644 --- a/providers/ms_entra_id_test.go +++ b/providers/ms_entra_id_test.go @@ -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) +} diff --git a/providers/util.go b/providers/util.go index 115c29c7..66afb5af 100644 --- a/providers/util.go +++ b/providers/util.go @@ -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 +}