diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md
index 9e6c2873..0f948652 100644
--- a/docs/docs/configuration/alpha_config.md
+++ b/docs/docs/configuration/alpha_config.md
@@ -398,6 +398,7 @@ character.
| ----- | ---- | ----------- |
| `allowedTenants` | _[]string_ | AllowedTenants is a 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). |
| `federatedTokenAuth` | _bool_ | FederatedTokenAuth enable oAuth2 client authentication with federated token projected
by Entra Workload Identity plugin, instead of client secret. |
+| `roles` | _[]string_ | Role enables to restrict login to users with app role |
### OIDCOptions
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 95fb99bc..f54c8ec0 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 e22278fa..d8f1d402 100644
--- a/pkg/apis/options/legacy_options.go
+++ b/pkg/apis/options/legacy_options.go
@@ -611,7 +611,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
@@ -781,6 +781,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 3a5094da..48585ce0 100644
--- a/pkg/apis/options/providers.go
+++ b/pkg/apis/options/providers.go
@@ -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 {
diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go
index 6b949f45..4420f3a9 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,19 +108,14 @@ 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, fmt.Errorf("couldn't extract access token payload: %w", err)
}
var claims accessClaims
if err := json.Unmarshal(payload, &claims); err != nil {
- return nil, err
+ return nil, fmt.Errorf("unable to unmarshal access claims from access token payload: %w", err)
}
return &claims, nil
}
diff --git a/providers/ms_entra_id.go b/providers/ms_entra_id.go
index df1f38a4..2e2dfca4 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 fmt.Errorf("couldn't extract access token payload: %w", err)
+ }
+
+ var claim rolesClaim
+ if err := json.Unmarshal(payload, &claim); err != nil {
+ return fmt.Errorf("unable to unmarshal roles claim from access token payload: %w", 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..76661cea 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, nil
+}