feat: support entra-id allowed app roles

This commit is contained in:
Long Wu 2025-06-28 08:55:25 +00:00
parent 7731437af4
commit e54d18d08d
8 changed files with 140 additions and 14 deletions

View File

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

View File

@ -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.

View File

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

View File

@ -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 {

View File

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

View File

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

View File

@ -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 := &registeredClaimsWithRoles{}
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 := &registeredClaimsWithRoles{}
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)
}

View File

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