Merge e54d18d08d into 110d51d1d7
				
					
				
			This commit is contained in:
		
						commit
						4643e9aba4
					
				|  | @ -7,7 +7,7 @@ title: Keycloak OIDC | ||||||
| 
 | 
 | ||||||
| | Flag             | Toml Field      | Type           | Description                                                                                                        | Default | | | 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 | ## 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-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 | | | `--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 | ## 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. | 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.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-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") | 	flagSet.String("backend-logout-url", "", "url to perform a backend logout, {id_token} can be used as placeholder for the id_token") | ||||||
| 
 | 
 | ||||||
| 	return flagSet | 	return flagSet | ||||||
|  | @ -775,6 +775,7 @@ func (l *LegacyProvider) convert() (Providers, error) { | ||||||
| 		provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ | 		provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ | ||||||
| 			AllowedTenants:     l.EntraIDAllowedTenants, | 			AllowedTenants:     l.EntraIDAllowedTenants, | ||||||
| 			FederatedTokenAuth: l.EntraIDFederatedTokenAuth, | 			FederatedTokenAuth: l.EntraIDFederatedTokenAuth, | ||||||
|  | 			Roles:              l.AllowedRoles, | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -182,6 +182,9 @@ type MicrosoftEntraIDOptions struct { | ||||||
| 	// FederatedTokenAuth enable oAuth2 client authentication with federated token projected
 | 	// FederatedTokenAuth enable oAuth2 client authentication with federated token projected
 | ||||||
| 	// by Entra Workload Identity plugin, instead of client secret.
 | 	// by Entra Workload Identity plugin, instead of client secret.
 | ||||||
| 	FederatedTokenAuth bool `json:"federatedTokenAuth,omitempty"` | 	FederatedTokenAuth bool `json:"federatedTokenAuth,omitempty"` | ||||||
|  | 
 | ||||||
|  | 	// Role enables to restrict login to users with app role
 | ||||||
|  | 	Roles []string `json:"roles,omitempty"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type ADFSOptions struct { | type ADFSOptions struct { | ||||||
|  |  | ||||||
|  | @ -2,10 +2,8 @@ package providers | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"encoding/base64" |  | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"strings" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | 	"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) { | func (p *KeycloakOIDCProvider) getAccessClaims(s *sessions.SessionState) (*accessClaims, error) { | ||||||
| 	parts := strings.Split(s.AccessToken, ".") | 	payload, err := extractAccessTokenPayload(s) | ||||||
| 	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 { | 	if err != nil { | ||||||
| 		return nil, fmt.Errorf("malformed access token, couldn't extract jwt payload: %v", err) | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var claims accessClaims | 	var claims accessClaims | ||||||
|  |  | ||||||
|  | @ -47,13 +47,16 @@ func NewMicrosoftEntraIDProvider(p *ProviderData, opts options.Provider) *Micros | ||||||
| 		name: microsoftEntraIDProviderName, | 		name: microsoftEntraIDProviderName, | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	return &MicrosoftEntraIDProvider{ | 	provider := &MicrosoftEntraIDProvider{ | ||||||
| 		OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig), | 		OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig), | ||||||
| 
 | 
 | ||||||
| 		multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants, | 		multiTenantAllowedTenants: opts.MicrosoftEntraIDConfig.AllowedTenants, | ||||||
| 		federatedTokenAuth:        opts.MicrosoftEntraIDConfig.FederatedTokenAuth, | 		federatedTokenAuth:        opts.MicrosoftEntraIDConfig.FederatedTokenAuth, | ||||||
| 		microsoftGraphURL:         microsoftGraphURL, | 		microsoftGraphURL:         microsoftGraphURL, | ||||||
| 	} | 	} | ||||||
|  | 	provider.addAllowedRoles(opts.MicrosoftEntraIDConfig.Roles) | ||||||
|  | 
 | ||||||
|  | 	return provider | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // EnrichSession checks for group overage after calling generic EnrichSession
 | // 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
 | // 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 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
 | // 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 | 	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/coreos/go-oidc/v3/oidc" | ||||||
| 	"github.com/golang-jwt/jwt/v5" | 	"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/options" | ||||||
|  | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| 
 | 
 | ||||||
| 	. "github.com/onsi/gomega" | 	. "github.com/onsi/gomega" | ||||||
|  | @ -183,3 +184,67 @@ type mockedVerifier struct { | ||||||
| func (v *mockedVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) { | func (v *mockedVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) { | ||||||
| 	return nil, nil | 	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 | package providers | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"encoding/base64" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/url" | 	"net/url" | ||||||
|  | 	"strings" | ||||||
| 
 | 
 | ||||||
|  | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||||
| 	"golang.org/x/oauth2" | 	"golang.org/x/oauth2" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -74,3 +77,18 @@ func formatGroup(rawGroup interface{}) (string, error) { | ||||||
| 	} | 	} | ||||||
| 	return string(jsonGroup), nil | 	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