feat: support entra-id allowed app roles
This commit is contained in:
parent
7731437af4
commit
e54d18d08d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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