diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/kas/auth_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/kas/auth_test.go index 85177fda298..596f02b52f3 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/v2/kas/auth_test.go +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/kas/auth_test.go @@ -1700,6 +1700,1004 @@ func TestAdaptAuthConfig(t *testing.T) { }, shouldError: true, }, + { + name: "valid discovery URL - different host from issuer URL", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://issuer.example.com", + DiscoveryURL: "https://discovery.example.com/.well-known/openid-configuration", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://issuer.example.com#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{}, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://issuer.example.com", + DiscoveryURL: "https://discovery.example.com/.well-known/openid-configuration", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "valid discovery URL - different path from issuer URL", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://example.com/issuer", + DiscoveryURL: "https://example.com/discovery/.well-known/openid-configuration", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://example.com/issuer#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{}, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://example.com/issuer", + DiscoveryURL: "https://example.com/discovery/.well-known/openid-configuration", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "valid userValidationRule - single expression", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://test.com#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{}, + UserValidationRules: []UserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + }, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + }, + UserValidationRules: []configv1.TokenUserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "valid userValidationRule - multiple expressions ANDed together", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://test.com#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{}, + UserValidationRules: []UserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must have at least one group", + }, + { + Expression: "user.username.contains('@')", + Message: "username must be an email address", + }, + }, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + }, + UserValidationRules: []configv1.TokenUserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must have at least one group", + }, + { + Expression: "user.username.contains('@')", + Message: "username must be an email address", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "valid claimValidationRule with CEL - multiple expressions", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://test.com#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{ + { + Expression: "has(claims.email) && claims.email.endsWith('@example.com')", + Message: "email must be from example.com domain", + }, + { + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + }, + ClaimValidationRules: []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "has(claims.email) && claims.email.endsWith('@example.com')", + Message: "email must be from example.com domain", + }, + }, + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "full feature parity - discoveryURL, CEL claim mappings, claim validation, and user validation", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://issuer.example.com", + DiscoveryURL: "https://discovery.example.com/.well-known/openid-configuration", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"my-app"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Expression: "claims.email.split('@')[0]", + }, + Groups: PrefixedClaimOrExpression{ + Expression: "type(claims.groups) == list ? claims.groups : []", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{ + { + Expression: "has(claims.email) && claims.email.endsWith('@example.com')", + Message: "email must be from example.com domain", + }, + { + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + UserValidationRules: []UserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must have at least one group", + }, + }, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://issuer.example.com", + DiscoveryURL: "https://discovery.example.com/.well-known/openid-configuration", + Audiences: []configv1.TokenAudience{"my-app"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + Expression: "claims.email.split('@')[0]", + }, + Groups: configv1.PrefixedClaimMapping{ + TokenClaimMapping: configv1.TokenClaimMapping{ + Expression: "type(claims.groups) == list ? claims.groups : []", + }, + }, + }, + ClaimValidationRules: []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "has(claims.email) && claims.email.endsWith('@example.com')", + Message: "email must be from example.com domain", + }, + }, + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + }, + UserValidationRules: []configv1.TokenUserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must have at least one group", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "claimValidationRule with CEL - empty expression, error", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + }, + ClaimValidationRules: []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "", // empty expression + Message: "validation failed", + }, + }, + }, + }, + }, + }, + shouldError: true, + }, + { + name: "username expression with complex CEL - extracting from nested claims", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Expression: "has(claims.preferred_username) ? claims.preferred_username : claims.sub", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{}, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + Expression: "has(claims.preferred_username) ? claims.preferred_username : claims.sub", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "groups expression with complex CEL - conditional based on claim type", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://test.com#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + Expression: "claims.?groups.orValue([])", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{}, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + Groups: configv1.PrefixedClaimMapping{ + TokenClaimMapping: configv1.TokenClaimMapping{ + Expression: "claims.?groups.orValue([])", + }, + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "multiple claimValidationRules - CEL type with complex expressions", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://test.com#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{ + { + Expression: "has(claims.email) && claims.email.contains('@')", + Message: "token must have valid email claim", + }, + { + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + { + Expression: "has(claims.groups) && type(claims.groups) == list", + Message: "groups claim must be a list", + }, + }, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + }, + ClaimValidationRules: []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "has(claims.email) && claims.email.contains('@')", + Message: "token must have valid email claim", + }, + }, + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "has(claims.groups) && type(claims.groups) == list", + Message: "groups claim must be a list", + }, + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "CEL expression username and groups with filtering - omitting prefix/prefixPolicy", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Expression: "claims.email.split('@')[0]", + }, + Groups: PrefixedClaimOrExpression{ + Expression: "claims.?groups.orValue(dyn([])).filter(g, g.startsWith('ocp-'))", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{ + { + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + UserValidationRules: []UserValidationRule{ + { + Expression: "user.username.size() > 5", + Message: "username must be longer than 5 characters", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must belong to at least one group after filtering", + }, + }, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + // Omitting prefixPolicy when using expression - should be allowed + Expression: "claims.email.split('@')[0]", + }, + Groups: configv1.PrefixedClaimMapping{ + TokenClaimMapping: configv1.TokenClaimMapping{ + // Omitting prefix when using expression - should be allowed + Expression: "claims.?groups.orValue(dyn([])).filter(g, g.startsWith('ocp-'))", + }, + }, + }, + ClaimValidationRules: []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + }, + UserValidationRules: []configv1.TokenUserValidationRule{ + { + Expression: "user.username.size() > 5", + Message: "username must be longer than 5 characters", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must belong to at least one group after filtering", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "combined claim and user validation with CEL expressions", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Expression: "claims.email.split('@')[0]", + }, + Groups: PrefixedClaimOrExpression{ + Expression: "claims.?groups.orValue(dyn([])).filter(g, g.startsWith('ocp-'))", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{ + { + Expression: "has(claims.email) && claims.email.contains('@')", + Message: "token must have valid email claim", + }, + { + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + UserValidationRules: []UserValidationRule{ + { + Expression: "user.username.size() > 5", + Message: "mapped username must be longer than 5 characters", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must have at least one group after filtering", + }, + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + }, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + Expression: "claims.email.split('@')[0]", + }, + Groups: configv1.PrefixedClaimMapping{ + TokenClaimMapping: configv1.TokenClaimMapping{ + Expression: "claims.?groups.orValue(dyn([])).filter(g, g.startsWith('ocp-'))", + }, + }, + }, + ClaimValidationRules: []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "has(claims.email) && claims.email.contains('@')", + Message: "token must have valid email claim", + }, + }, + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + }, + UserValidationRules: []configv1.TokenUserValidationRule{ + { + Expression: "user.username.size() > 5", + Message: "mapped username must be longer than 5 characters", + }, + { + Expression: "user.groups.size() > 0", + Message: "user must have at least one group after filtering", + }, + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "username expression with conditional logic and fallback", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Expression: "has(claims.preferred_username) && claims.preferred_username != '' ? claims.preferred_username : claims.email.split('@')[0]", + }, + Groups: PrefixedClaimOrExpression{ + Prefix: ptr.To(""), + Claim: "", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{ + { + Expression: "claims.email_verified == true", + Message: "email must be verified when used for username", + }, + }, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + Expression: "has(claims.preferred_username) && claims.preferred_username != '' ? claims.preferred_username : claims.email.split('@')[0]", + }, + }, + ClaimValidationRules: []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: "claims.email_verified == true", + Message: "email must be verified when used for username", + }, + }, + }, + }, + }, + }, + shouldError: false, + }, + { + name: "groups expression with map and filter operations - using orValue for type safety", + client: fake.NewClientBuilder().Build(), + featureGates: []featuregate.Feature{ + featuregates.ExternalOIDCWithUpstreamParity, + }, + expectedAuthenticationConfiguration: &AuthenticationConfiguration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiserver.config.k8s.io/v1alpha1", + Kind: "AuthenticationConfiguration", + }, + JWT: []JWTAuthenticator{ + { + Issuer: Issuer{ + URL: "https://test.com", + AudienceMatchPolicy: AudienceMatchPolicyMatchAny, + Audiences: []string{"one", "two"}, + }, + ClaimMappings: ClaimMappings{ + Username: PrefixedClaimOrExpression{ + Prefix: ptr.To("https://test.com#"), + Claim: "username", + }, + Groups: PrefixedClaimOrExpression{ + // Use optional access (?) and dyn([]) to handle optional 'roles' claim and provide type-safe default for filter/map + Expression: "claims.?roles.orValue(dyn([])).filter(r, r.startsWith('openshift-')).map(r, r.substring(10))", + }, + UID: ClaimOrExpression{Claim: "sub"}, + Extra: []ExtraMapping{}, + }, + ClaimValidationRules: []ClaimValidationRule{}, + UserValidationRules: []UserValidationRule{}, + }, + }, + }, + hcpAuthenticationSpec: &configv1.AuthenticationSpec{ + OIDCProviders: []configv1.OIDCProvider{ + { + Name: "test", + Issuer: configv1.TokenIssuer{ + URL: "https://test.com", + Audiences: []configv1.TokenAudience{"one", "two"}, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + PrefixPolicy: configv1.NoOpinion, + Claim: "username", + }, + Groups: configv1.PrefixedClaimMapping{ + TokenClaimMapping: configv1.TokenClaimMapping{ + // Use optional access (?) and dyn([]) to handle optional 'roles' claim and provide type-safe default for filter/map + Expression: "claims.?roles.orValue(dyn([])).filter(r, r.startsWith('openshift-')).map(r, r.substring(10))", + }, + }, + }, + }, + }, + }, + shouldError: false, + }, } for _, tc := range testCases { diff --git a/hypershift-operator/main.go b/hypershift-operator/main.go index 7f54779aeba..b71eee36c3e 100644 --- a/hypershift-operator/main.go +++ b/hypershift-operator/main.go @@ -24,6 +24,7 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" awsutil "github.com/openshift/hypershift/cmd/infra/aws/util" + cpofeaturegate "github.com/openshift/hypershift/control-plane-operator/featuregates" pkiconfig "github.com/openshift/hypershift/control-plane-pki-operator/config" etcdrecovery "github.com/openshift/hypershift/etcd-recovery" "github.com/openshift/hypershift/hypershift-operator/controllers/auditlogpersistence" @@ -205,6 +206,9 @@ func NewStartCommand() *cobra.Command { featuregate.ConfigureFeatureSet(featureSet) featuregate.Gate().AddFlag(cmd.Flags()) + // Configure feature set from CPO (needed to propagate feature gates like TechPreviewNoUpgrade) + cpofeaturegate.ConfigureFeatureSet(featureSet) + cmd.Run = func(cmd *cobra.Command, args []string) { ctx, cancel := context.WithCancel(ctrl.SetupSignalHandler()) defer cancel() diff --git a/test/e2e/external_oidc_test.go b/test/e2e/external_oidc_test.go index 3f186089655..647f9b3b8b1 100644 --- a/test/e2e/external_oidc_test.go +++ b/test/e2e/external_oidc_test.go @@ -5,6 +5,8 @@ package e2e import ( "context" "os" + "slices" + "strings" "testing" . "github.com/onsi/gomega" @@ -13,7 +15,6 @@ import ( configv1client "github.com/openshift/client-go/config/clientset/versioned" hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" e2eutil "github.com/openshift/hypershift/test/e2e/util" - kauthnv1 "k8s.io/api/authentication/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kauthnv1typedclient "k8s.io/client-go/kubernetes/typed/authentication/v1" @@ -57,32 +58,39 @@ func TestExternalOIDC(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) t.Logf("selfSubjectReview %+v", selfSubjectReview) + // Setup Keycloak admin client + kc, err := e2eutil.SetupKeycloakAdminClientFromCluster(ctx, t, mgtClient, clusterOpts.ExtOIDCConfig) + if err != nil { + t.Skipf("Could not setup Keycloak admin client: %v", err) + } + t.Run("[OCPFeatureGate:ExternalOIDC] test keycloak external OIDC", func(t *testing.T) { // No gates exist for ExternalOIDC as it has already been enabled by default. - g := NewWithT(t) t.Logf("begin to test external OIDC %s", globalOpts.ExternalOIDCProvider) - g.Expect(hostedCluster.Spec.Configuration).NotTo(BeNil()) - g.Expect(hostedCluster.Spec.Configuration.Authentication).NotTo(BeNil()) - g.Expect(hostedCluster.Spec.Configuration.Authentication.OIDCProviders).NotTo(BeEmpty()) - clientCfg := e2eutil.WaitForGuestRestConfig(t, ctx, mgtClient, hostedCluster) e2eutil.ChangeClientForKeycloakExtOIDC(t, ctx, clientCfg, clusterOpts.ExtOIDCConfig) t.Logf("successfully get oidc user client") }) if featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUIDAndExtraClaimMappings) { - t.Run("[OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] Test external OIDC userInfo username", func(t *testing.T) { - g := NewWithT(t) - t.Logf("begin to test external OIDC with external OIDC userInfo username") - g.Expect(selfSubjectReview.Status.UserInfo.Username).NotTo(BeEmpty()) - g.Expect(selfSubjectReview.Status.UserInfo.Username).Should(ContainSubstring(clusterOpts.ExtOIDCConfig.UserPrefix)) - }) - t.Run("[OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] Test external OIDC userInfo Groups", func(t *testing.T) { - g := NewWithT(t) - t.Logf("begin to test external OIDC userInfo Groups") - g.Expect(selfSubjectReview.Status.UserInfo.Groups).NotTo(BeEmpty()) - g.Expect(selfSubjectReview.Status.UserInfo.Groups).Should(ContainElements(ContainSubstring(clusterOpts.ExtOIDCConfig.GroupPrefix))) - }) + // Since this username/group behavior differs between ExternalODICWithUIDandExtraClaimMappings and + // ExternalOIDCWithUpstreamParity feature gates, we should put this test behind a feature + // gate check. + if !featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUpstreamParity) { + t.Run("[OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] Test external OIDC userInfo username", func(t *testing.T) { + g := NewWithT(t) + t.Logf("begin to test external OIDC with external OIDC userInfo username") + g.Expect(selfSubjectReview.Status.UserInfo.Username).NotTo(BeEmpty()) + g.Expect(selfSubjectReview.Status.UserInfo.Username).Should(ContainSubstring(clusterOpts.ExtOIDCConfig.UserPrefix)) + }) + + t.Run("[OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] Test external OIDC userInfo Groups", func(t *testing.T) { + g := NewWithT(t) + t.Logf("begin to test external OIDC userInfo Groups") + g.Expect(selfSubjectReview.Status.UserInfo.Groups).NotTo(BeEmpty()) + g.Expect(selfSubjectReview.Status.UserInfo.Groups).Should(ContainElements(ContainSubstring(clusterOpts.ExtOIDCConfig.GroupPrefix))) + }) + } t.Run("[OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] Test external OIDC userInfo UID", func(t *testing.T) { g := NewWithT(t) @@ -109,5 +117,380 @@ func TestExternalOIDC(t *testing.T) { g.Expect(err).To(HaveOccurred()) }) } + + if featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUpstreamParity) { + t.Run("[OCPFeatureGate:ExternalOIDCWithUpstreamParity] Test CEL username expression mapping", func(t *testing.T) { + g := NewWithT(t) + t.Logf("begin to test CEL username expression mapping") + + // Setup: Create test resources with automatic cleanup + testResources, err := e2eutil.NewTestResources(ctx, kc, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred()) + defer testResources.Cleanup(ctx, t) + + // Setup: Create authenticated test user with group + testUser, err := testResources.SetupAuthenticatedUserWithGroup(ctx, t, "cel-test-user", "cel-test-group", clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred()) + + // Verify: Username is email prefix (before @) + expectedUsername := strings.Split(testUser.Email, "@")[0] + g.Expect(testUser.SelfSubjectReview.Status.UserInfo.Username).Should(Equal(expectedUsername), + "username should be email prefix from CEL expression: claims.email.split('@')[0]") + g.Expect(testUser.SelfSubjectReview.Status.UserInfo.Username).NotTo(ContainSubstring("@"), + "username should not contain @ symbol") + t.Logf("CEL username expression correctly mapped '%s' to '%s'", testUser.Email, testUser.SelfSubjectReview.Status.UserInfo.Username) + + // Edge case test: preferred_username vs email-derived username mismatch + // Tests that CEL expression uses claims.email, not claims.preferred_username + t.Logf("Edge case test: Creating user with preferred_username different from email local part") + preferredUsername := "cel-preferred-" + e2eutil.GenerateRandomPassword(8) + actualEmail := "cel-email-" + e2eutil.GenerateRandomPassword(8) + "@test.example.com" + preferredPassword := e2eutil.GenerateRandomPassword(16) + + // In Keycloak, the 'username' field becomes the 'preferred_username' claim + // So we create a user where username != email local part + _, err = testResources.CreateTestUser(ctx, t, preferredUsername, actualEmail, preferredPassword) + g.Expect(err).NotTo(HaveOccurred()) + t.Logf("Created user: preferred_username='%s', email='%s'", preferredUsername, actualEmail) + + // Authenticate as this user + preferredAuthConfig := *clusterOpts.ExtOIDCConfig + preferredAuthConfig.TestUsers = preferredUsername + ":" + preferredPassword + preferredKubeConfig := e2eutil.ChangeUserForKeycloakExtOIDC(t, ctx, clientCfg, &preferredAuthConfig) + preferredAuthClient, err := kauthnv1typedclient.NewForConfig(preferredKubeConfig) + g.Expect(err).NotTo(HaveOccurred()) + + preferredReview, err := preferredAuthClient.SelfSubjectReviews().Create(ctx, &kauthnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + + // Verify: K8s username should come from email claim, NOT preferred_username claim + expectedUsername = strings.Split(actualEmail, "@")[0] + g.Expect(preferredReview.Status.UserInfo.Username).Should(Equal(expectedUsername), + "username should be derived from email claim via CEL expression, not from preferred_username claim") + g.Expect(preferredReview.Status.UserInfo.Username).NotTo(Equal(preferredUsername), + "username should NOT equal preferred_username when they differ") + t.Logf("✓ CEL expression correctly used email claim over preferred_username: K8s username='%s' (from email='%s'), preferred_username='%s'", + preferredReview.Status.UserInfo.Username, actualEmail, preferredUsername) + + // Verify: Groups are mapped without prefix + g.Expect(testUser.SelfSubjectReview.Status.UserInfo.Groups).NotTo(BeEmpty(), + "user should have groups from Keycloak") + hasTestGroup := false + for _, group := range testUser.SelfSubjectReview.Status.UserInfo.Groups { + if group == testUser.GroupName { + hasTestGroup = true + } + g.Expect(group).NotTo(HavePrefix(clusterOpts.ExtOIDCConfig.GroupPrefix), + "groups should not have prefix when using CEL expression") + } + g.Expect(hasTestGroup).To(BeTrue(), "user should be member of test group: %s", testUser.GroupName) + t.Logf("CEL groups expression correctly mapped groups: %v", testUser.SelfSubjectReview.Status.UserInfo.Groups) + }) + + t.Run("[OCPFeatureGate:ExternalOIDCWithUpstreamParity] Test CEL groups expression mapping", func(t *testing.T) { + g := NewWithT(t) + t.Logf("begin to test CEL groups expression mapping") + + // Setup: Create test resources with automatic cleanup + testResources, err := e2eutil.NewTestResources(ctx, kc, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred()) + defer testResources.Cleanup(ctx, t) + + // Verify: Groups expression is configured + g.Expect(hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].ClaimMappings.Groups.Expression).NotTo(BeEmpty()) + t.Logf("CEL groups expression configured: %s", hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].ClaimMappings.Groups.Expression) + + // Setup: Create authenticated test user with group + testUser, err := testResources.SetupAuthenticatedUserWithGroup(ctx, t, "cel-groups-test-user", "cel-groups-test-group", clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred()) + + // Verify: User has groups from Keycloak + g.Expect(testUser.SelfSubjectReview.Status.UserInfo.Groups).NotTo(BeEmpty(), + "user should have groups from Keycloak") + + // Verify: Groups are mapped without prefix (CEL expression removes prefix) + for _, group := range testUser.SelfSubjectReview.Status.UserInfo.Groups { + g.Expect(group).NotTo(HavePrefix(clusterOpts.ExtOIDCConfig.GroupPrefix), + "groups should not have prefix when using CEL expression") + } + + // Verify: User is member of the test group + hasTestGroup := slices.Contains(testUser.SelfSubjectReview.Status.UserInfo.Groups, testUser.GroupName) + g.Expect(hasTestGroup).To(BeTrue(), "user should be member of test group: %s", testUser.GroupName) + t.Logf("CEL groups expression successfully mapped groups without prefix: %v", testUser.SelfSubjectReview.Status.UserInfo.Groups) + + // Negative test: User without groups should still authenticate + // The CEL expression claims.?groups.orValue([]) handles missing groups claim gracefully + t.Logf("Negative test: Creating user without group membership") + noGroupUsername := "cel-no-groups-" + e2eutil.GenerateRandomPassword(8) + noGroupEmail := noGroupUsername + "@test.example.com" + noGroupPassword := e2eutil.GenerateRandomPassword(16) + _, err = testResources.CreateTestUser(ctx, t, noGroupUsername, noGroupEmail, noGroupPassword) + g.Expect(err).NotTo(HaveOccurred()) + + // Authenticate user without groups - should SUCCEED with empty groups + noGroupAuthConfig := *clusterOpts.ExtOIDCConfig + noGroupAuthConfig.TestUsers = noGroupUsername + ":" + noGroupPassword + noGroupKubeConfig := e2eutil.ChangeUserForKeycloakExtOIDC(t, ctx, clientCfg, &noGroupAuthConfig) + noGroupAuthClient, err := kauthnv1typedclient.NewForConfig(noGroupKubeConfig) + g.Expect(err).NotTo(HaveOccurred()) + + noGroupReview, err := noGroupAuthClient.SelfSubjectReviews().Create(ctx, &kauthnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + g.Expect(err).NotTo(HaveOccurred(), "authentication should succeed even when user has no groups") + t.Logf("✓ User without groups authenticated successfully with groups: %v", noGroupReview.Status.UserInfo.Groups) + }) + + t.Run("[OCPFeatureGate:ExternalOIDCWithUpstreamParity] Test claim validation rules", func(t *testing.T) { + g := NewWithT(t) + t.Logf("begin to test claim validation rules") + + // Setup: Create test resources with automatic cleanup + testResources, err := e2eutil.NewTestResources(ctx, kc, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred()) + defer testResources.Cleanup(ctx, t) + + // Verify: Claim validation rules are configured + g.Expect(hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].ClaimValidationRules).NotTo(BeEmpty()) + claimRules := hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].ClaimValidationRules + g.Expect(claimRules).Should(HaveLen(2)) + + // Verify: Rules are CEL type with expected expressions + g.Expect(claimRules[0].Type).Should(BeEquivalentTo(configv1.TokenValidationRuleTypeCEL)) + g.Expect(claimRules[0].CEL.Expression).Should(BeEquivalentTo(e2eutil.ClaimValidationExprEmailExists)) + g.Expect(claimRules[1].Type).Should(BeEquivalentTo(configv1.TokenValidationRuleTypeCEL)) + g.Expect(claimRules[1].CEL.Expression).Should(BeEquivalentTo(e2eutil.ClaimValidationExprEmailVerified)) + + // Test 1: Valid user - email exists and email_verified=true + // Should PASS validation and authenticate successfully + t.Logf("Test 1: Creating user with valid claims (email exists, email_verified=true)") + validUser, err := testResources.SetupAuthenticatedUserWithGroup(ctx, t, "claim-valid-user", "claim-valid-group", clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred(), "authentication must succeed when all claim validation rules pass") + g.Expect(validUser.Email).NotTo(BeEmpty(), "test user email must be non-empty") + t.Logf("✓ User with email='%s' and email_verified=true authenticated successfully", validUser.Email) + + // Test 2: Invalid user - email_verified=false + // Demonstrates rule 2 requirement: claims.email_verified == true + t.Logf("Test 2: Creating user with email_verified=false (violates rule 2)") + invalidUsername := "claim-invalid-user-" + e2eutil.GenerateRandomPassword(8) + invalidEmail := invalidUsername + "@test.example.com" + invalidPassword := e2eutil.GenerateRandomPassword(16) + invalidUserID, err := testResources.CreateTestUserWithEmailVerification(ctx, t, invalidUsername, invalidEmail, invalidPassword, false) + g.Expect(err).NotTo(HaveOccurred(), "creating user in Keycloak should succeed") + g.Expect(invalidEmail).NotTo(BeEmpty(), "test user has non-empty email but email_verified=false") + t.Logf("✓ Created user '%s' with email='%s' and email_verified=false (ID: %s)", invalidUsername, invalidEmail, invalidUserID) + + // Attempt to authenticate - should FAIL due to claim validation rule 2 + err = testResources.TryAuthenticateUser(ctx, t, invalidUsername, invalidPassword, clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).To(HaveOccurred(), "authentication must fail when email_verified=false") + t.Logf("✓ User with email_verified=false correctly rejected: %v", err) + + // Test 3: Invalid - empty email (violates rule 1) + // Demonstrates rule 1 requirement: has(claims.email) && claims.email != '' + t.Logf("Test 3: Creating user with empty email (violates rule 1)") + emptyEmailUsername := "claim-empty-email-" + e2eutil.GenerateRandomPassword(8) + emptyEmailPassword := e2eutil.GenerateRandomPassword(16) + emptyEmailUserID, err := testResources.CreateTestUserWithEmailVerification(ctx, t, emptyEmailUsername, "", emptyEmailPassword, true) + g.Expect(err).NotTo(HaveOccurred(), "creating user in Keycloak should succeed") + t.Logf("✓ Created user '%s' with email='' and email_verified=true (ID: %s)", emptyEmailUsername, emptyEmailUserID) + + // Attempt to authenticate - should FAIL due to claim validation rule 1 + err = testResources.TryAuthenticateUser(ctx, t, emptyEmailUsername, emptyEmailPassword, clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).To(HaveOccurred(), "authentication must fail when email is empty") + g.Expect(err.Error()).Should(ContainSubstring("Unauthorized"), + "empty email user cannot authenticate as it violates user validation rule") + t.Logf("✓ User with empty email correctly rejected: %v", err) + + t.Logf("Claim validation rules successfully validated: only users with non-empty email and email_verified=true can authenticate") + }) + + t.Run("[OCPFeatureGate:ExternalOIDCWithUpstreamParity] Test user validation rules", func(t *testing.T) { + g := NewWithT(t) + t.Logf("begin to test user validation rules") + + // Setup: Create test resources with automatic cleanup + testResources, err := e2eutil.NewTestResources(ctx, kc, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred()) + defer testResources.Cleanup(ctx, t) + + // Verify: User validation rules are configured + g.Expect(hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].UserValidationRules).NotTo(BeEmpty()) + userRules := hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].UserValidationRules + g.Expect(userRules).Should(HaveLen(2), "should have two user validation rules") + + // Verify: Rules use expected CEL expressions + expressions := []string{userRules[0].Expression, userRules[1].Expression} + g.Expect(expressions).Should(ContainElement(e2eutil.UserValidationExprNoSystemPrefix)) + g.Expect(expressions).Should(ContainElement(e2eutil.UserValidationExprNoForbiddenWord)) + t.Logf("User validation rules configured: %v", expressions) + + // Test 1: Valid user - passes all validation rules + // Should PASS validation and authenticate successfully + t.Logf("Test 1: Creating user with valid username (no system: prefix, no 'forbidden' word)") + validUser, err := testResources.SetupAuthenticatedUserWithGroup(ctx, t, "user-valid", "user-valid-group", clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred(), "authentication must succeed when all validation rules pass") + + // Username is derived from email via CEL: claims.email.split('@')[0] + expectedUsername := strings.Split(validUser.Email, "@")[0] + g.Expect(validUser.SelfSubjectReview.Status.UserInfo.Username).Should(Equal(expectedUsername)) + g.Expect(validUser.SelfSubjectReview.Status.UserInfo.Username).NotTo(HavePrefix("system:")) + g.Expect(validUser.SelfSubjectReview.Status.UserInfo.Username).NotTo(ContainSubstring("forbidden")) + t.Logf("✓ User with username='%s' authenticated successfully", validUser.SelfSubjectReview.Status.UserInfo.Username) + + // Test 2: Invalid user - username contains "forbidden" + // Demonstrates the testable user validation rule: !user.username.contains('forbidden') + t.Logf("Test 2: Creating user with 'forbidden' in username (violates user validation rule)") + forbiddenUsername := "user-forbidden-" + e2eutil.GenerateRandomPassword(8) + forbiddenEmail := forbiddenUsername + "@test.example.com" + forbiddenPassword := e2eutil.GenerateRandomPassword(16) + forbiddenUserID, err := testResources.CreateTestUser(ctx, t, forbiddenUsername, forbiddenEmail, forbiddenPassword) + g.Expect(err).NotTo(HaveOccurred(), "creating user in Keycloak should succeed") + t.Logf("Created user with email='%s', mapped username will be '%s' (ID: %s)", forbiddenEmail, forbiddenUsername, forbiddenUserID) + + // Try to authenticate - should FAIL due to user validation rule + err = testResources.TryAuthenticateUser(ctx, t, forbiddenUsername, forbiddenPassword, clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).To(HaveOccurred(), "authentication must fail when username contains 'forbidden'") + g.Expect(err.Error()).Should(ContainSubstring("Unauthorized"), + "forbidden user cannot authenticate as it violates user validation rule") + t.Logf("✓ User with 'forbidden' in username correctly rejected with error: %v", err) + + // NOTE: We cannot test the negative case for the system: prefix rule via Keycloak + // because RFC 5322 email addresses do not allow colons in the local part. + // Since username = claims.email.split('@')[0], we would need an email like + // "system:admin@test.example.com", which is invalid per email standards. + // The system: prefix rule should be tested via unit tests or envtest where claims can be mocked. + + t.Logf("User validation rules successfully validated: users with 'forbidden' in username are rejected") + }) + } }).Execute(&clusterOpts, globalOpts.Platform, globalOpts.ArtifactDir, "external-oidc", globalOpts.ServiceAccountSigningKey) + + // experiment: see if we can pass custom auth config to test here + customClusterOpts := clusterOpts + config := customClusterOpts.ExtOIDCConfig + customClusterOpts.ExtOIDCConfig.CustomAuthSpec = &configv1.AuthenticationSpec{ + Type: configv1.AuthenticationTypeOIDC, + OIDCProviders: []configv1.OIDCProvider{ + { + Name: config.OIDCProviderName, + Issuer: configv1.TokenIssuer{ + Audiences: []configv1.TokenAudience{ + configv1.TokenAudience(config.CliClientID), + configv1.TokenAudience(config.ConsoleClientID), + }, + URL: config.IssuerURL, + CertificateAuthority: configv1.ConfigMapNameReference{ + Name: config.IssuerCAConfigmapName, + }, + }, + OIDCClients: []configv1.OIDCClientConfig{ + { + ClientID: config.ConsoleClientID, + ClientSecret: configv1.SecretNameReference{ + Name: config.ConsoleClientSecretName, + }, + ComponentName: "console", + ComponentNamespace: "openshift-console", + ExtraScopes: []string{"email"}, + }, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + Expression: "claims.email.split('@')[0]", + }, + }, + UserValidationRules: []configv1.TokenUserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot use reserved system: prefix", + }, + { + Expression: "!user.username.contains('forbidden')", + Message: "username cannot contain the word 'forbidden'", + }, + }, + }, + }, + } + // currently is still under feature gate - having the tests + // run like this mean easy removal of feature gate check + // when they graduate to default feature set. + if featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUpstreamParity) { + e2eutil.NewHypershiftTest(t, ctx, func(t *testing.T, g Gomega, mgtClient crclient.Client, hostedCluster *hyperv1.HostedCluster) { + g.Expect(hostedCluster.Spec.Configuration).NotTo(BeNil()) + g.Expect(hostedCluster.Spec.Configuration.Authentication).NotTo(BeNil()) + g.Expect(hostedCluster.Spec.Configuration.Authentication.OIDCProviders).NotTo(BeEmpty()) + clientCfg := e2eutil.WaitForGuestRestConfig(t, ctx, mgtClient, hostedCluster) + authKubeConfig := e2eutil.ChangeUserForKeycloakExtOIDC(t, ctx, clientCfg, clusterOpts.ExtOIDCConfig) + authClient, err := kauthnv1typedclient.NewForConfig(authKubeConfig) + g.Expect(err).NotTo(HaveOccurred()) + selfSubjectReview, err := authClient.SelfSubjectReviews().Create(ctx, &kauthnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + t.Logf("selfSubjectReview %+v", selfSubjectReview) + + // Setup Keycloak admin client + kc, err := e2eutil.SetupKeycloakAdminClientFromCluster(ctx, t, mgtClient, clusterOpts.ExtOIDCConfig) + if err != nil { + t.Skipf("Could not setup Keycloak admin client: %v", err) + } + t.Run("[OCPFeatureGate:ExternalOIDCWithUpstreamParity] Test user validation rules 1", func(t *testing.T) { + g := NewWithT(t) + t.Logf("begin to test user validation rules") + + // Setup: Create test resources with automatic cleanup + testResources, err := e2eutil.NewTestResources(ctx, kc, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred()) + defer testResources.Cleanup(ctx, t) + + // Verify: User validation rules are configured + g.Expect(hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].UserValidationRules).NotTo(BeEmpty()) + userRules := hostedCluster.Spec.Configuration.Authentication.OIDCProviders[0].UserValidationRules + g.Expect(userRules).Should(HaveLen(2), "should have two user validation rules") + + // Verify: Rules use expected CEL expressions + expressions := []string{userRules[0].Expression, userRules[1].Expression} + g.Expect(expressions).Should(ContainElement(e2eutil.UserValidationExprNoSystemPrefix)) + g.Expect(expressions).Should(ContainElement(e2eutil.UserValidationExprNoForbiddenWord)) + t.Logf("User validation rules configured: %v", expressions) + + // Test 1: Valid user - passes all validation rules + // Should PASS validation and authenticate successfully + t.Logf("Test 1: Creating user with valid username (no system: prefix, no 'forbidden' word)") + validUser, err := testResources.SetupAuthenticatedUserWithGroup(ctx, t, "user-valid", "user-valid-group", clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).NotTo(HaveOccurred(), "authentication must succeed when all validation rules pass") + + // Username is derived from email via CEL: claims.email.split('@')[0] + expectedUsername := strings.Split(validUser.Email, "@")[0] + g.Expect(validUser.SelfSubjectReview.Status.UserInfo.Username).Should(Equal(expectedUsername)) + g.Expect(validUser.SelfSubjectReview.Status.UserInfo.Username).NotTo(HavePrefix("system:")) + g.Expect(validUser.SelfSubjectReview.Status.UserInfo.Username).NotTo(ContainSubstring("forbidden")) + t.Logf("✓ User with username='%s' authenticated successfully", validUser.SelfSubjectReview.Status.UserInfo.Username) + + // Test 2: Invalid user - username contains "forbidden" + // Demonstrates the testable user validation rule: !user.username.contains('forbidden') + t.Logf("Test 2: Creating user with 'forbidden' in username (violates user validation rule)") + forbiddenUsername := "user-forbidden-" + e2eutil.GenerateRandomPassword(8) + forbiddenEmail := forbiddenUsername + "@test.example.com" + forbiddenPassword := e2eutil.GenerateRandomPassword(16) + forbiddenUserID, err := testResources.CreateTestUser(ctx, t, forbiddenUsername, forbiddenEmail, forbiddenPassword) + g.Expect(err).NotTo(HaveOccurred(), "creating user in Keycloak should succeed") + t.Logf("Created user with email='%s', mapped username will be '%s' (ID: %s)", forbiddenEmail, forbiddenUsername, forbiddenUserID) + + // Try to authenticate - should FAIL due to user validation rule + err = testResources.TryAuthenticateUser(ctx, t, forbiddenUsername, forbiddenPassword, clientCfg, clusterOpts.ExtOIDCConfig) + g.Expect(err).To(HaveOccurred(), "authentication must fail when username contains 'forbidden'") + g.Expect(err.Error()).Should(ContainSubstring("Unauthorized"), + "forbidden user cannot authenticate as it violates user validation rule") + t.Logf("✓ User with 'forbidden' in username correctly rejected with error: %v", err) + + // NOTE: We cannot test the negative case for the system: prefix rule via Keycloak + // because RFC 5322 email addresses do not allow colons in the local part. + // Since username = claims.email.split('@')[0], we would need an email like + // "system:admin@test.example.com", which is invalid per email standards. + // The system: prefix rule should be tested via unit tests or envtest where claims can be mocked. + + t.Logf("User validation rules successfully validated: users with 'forbidden' in username are rejected") + }) + }).Execute(&customClusterOpts, globalOpts.Platform, globalOpts.ArtifactDir, "ext-oidc-user-rules-auth-cfg", globalOpts.ServiceAccountSigningKey) + } + } diff --git a/test/e2e/util/external_oidc.go b/test/e2e/util/external_oidc.go index b4dd731e8f8..a0c315cee98 100644 --- a/test/e2e/util/external_oidc.go +++ b/test/e2e/util/external_oidc.go @@ -48,6 +48,14 @@ const ( ExternalOIDCExtraKeyBarValueExpression = "extra-test-mark" ExternalOIDCExtraKeyFoo = "extratest.openshift.com/foo" ExternalOIDCExtraKeyFooValueExpression = "claims.email" // This is a variable, not a string literal + + // CEL expressions for claim validation rules + ClaimValidationExprEmailExists = "has(claims.email) && claims.email != ''" + ClaimValidationExprEmailVerified = "claims.email_verified == true" + + // CEL expressions for user validation rules + UserValidationExprNoSystemPrefix = "!user.username.startsWith('system:')" + UserValidationExprNoForbiddenWord = "!user.username.contains('forbidden')" ) type ExtOIDCConfig struct { @@ -67,6 +75,9 @@ type ExtOIDCConfig struct { // for oidcProviders.issuer.issuerCertificateAuthority IssuerCAConfigmapName string IssuerCABundleFile string + + // custom field for adding custom auth configuration + CustomAuthSpec *configv1.AuthenticationSpec } func GetExtOIDCConfig(provider, cliClientID, consoleClientID, issuerURL, consoleSecret, issuerCABundleFile, testUsers string) *ExtOIDCConfig { @@ -149,6 +160,61 @@ func (config *ExtOIDCConfig) GetAuthenticationConfig() *configv1.AuthenticationS ) } + if featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUpstreamParity) { + // Check if ExternalOIDCWithUIDAndExtraClaimMappings feature gate is enabled. + // If not, we will need to add extra mapping to access email for username + // verification later. + if !featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUIDAndExtraClaimMappings) { + authnSpec.OIDCProviders[0].ClaimMappings.Extra = append(authnSpec.OIDCProviders[0].ClaimMappings.Extra, + configv1.ExtraMapping{ + Key: ExternalOIDCExtraKeyFoo, + ValueExpression: ExternalOIDCExtraKeyFooValueExpression, + }, + ) + } + // Use CEL expression for username mapping instead of static claim + authnSpec.OIDCProviders[0].ClaimMappings.Username = configv1.UsernameClaimMapping{ + Expression: "claims.email.split('@')[0]", + } + + // Use CEL expression for groups mapping instead of static claim + authnSpec.OIDCProviders[0].ClaimMappings.Groups = configv1.PrefixedClaimMapping{ + TokenClaimMapping: configv1.TokenClaimMapping{ + Expression: "claims.?groups.orValue([])", + }, + } + + // Add claim validation rules + authnSpec.OIDCProviders[0].ClaimValidationRules = []configv1.TokenClaimValidationRule{ + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: ClaimValidationExprEmailExists, + Message: "email claim must be present and non-empty", + }, + }, + { + Type: configv1.TokenValidationRuleTypeCEL, + CEL: configv1.TokenClaimValidationCELRule{ + Expression: ClaimValidationExprEmailVerified, + Message: "email_verified claim must be true", + }, + }, + } + + // Add user validation rules + authnSpec.OIDCProviders[0].UserValidationRules = []configv1.TokenUserValidationRule{ + { + Expression: UserValidationExprNoSystemPrefix, + Message: "username cannot use reserved system: prefix", + }, + { + Expression: UserValidationExprNoForbiddenWord, + Message: "username cannot contain the word 'forbidden'", + }, + } + } + return authnSpec } @@ -173,8 +239,11 @@ func ValidateAuthenticationSpec(t *testing.T, ctx context.Context, client crclie actualAuth := hostedCluster.Spec.Configuration.Authentication g.Expect(actualAuth.OIDCProviders).NotTo(BeEmpty()) - if featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUIDAndExtraClaimMappings) { + if featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUpstreamParity) || featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUIDAndExtraClaimMappings) { g.Expect(actualAuth.OIDCProviders[0].ClaimMappings.Extra).NotTo(BeEmpty()) + } + + if featuregates.Gate().Enabled(featuregates.ExternalOIDCWithUIDAndExtraClaimMappings) { g.Expect(actualAuth.OIDCProviders[0].ClaimMappings.UID).NotTo(BeNil()) } @@ -272,7 +341,7 @@ func ChangeUserForKeycloakExtOIDC(t *testing.T, ctx context.Context, clientCfg * body, err := io.ReadAll(response.Body) g.Expect(err).NotTo(HaveOccurred()) - var respMap map[string]interface{} + var respMap map[string]any err = json.Unmarshal(body, &respMap) g.Expect(err).NotTo(HaveOccurred()) idToken, ok := respMap["id_token"].(string) @@ -326,7 +395,7 @@ func GetClientConfigForKeycloakOIDCUser(clientCfg *rest.Config, authConfig *ExtO "--issuer-url=" + authConfig.IssuerURL, "--client-id=" + authConfig.CliClientID, "--extra-scopes=email,profile", - "--callback-address=127.0.0.1:8080", + "--callback-address=127.0.0.1:0", "--certificate-authority=" + authConfig.IssuerCABundleFile, } diff --git a/test/e2e/util/hypershift_framework.go b/test/e2e/util/hypershift_framework.go index 4781067822c..24bfee0bbef 100644 --- a/test/e2e/util/hypershift_framework.go +++ b/test/e2e/util/hypershift_framework.go @@ -483,7 +483,11 @@ func (h *hypershiftTest) createHostedCluster(opts *PlatformAgnosticOptions, plat if v.Spec.Configuration == nil { v.Spec.Configuration = &hyperv1.ClusterConfiguration{} } - v.Spec.Configuration.Authentication = opts.ExtOIDCConfig.GetAuthenticationConfig() + if opts.ExtOIDCConfig.CustomAuthSpec != nil { + v.Spec.Configuration.Authentication = opts.ExtOIDCConfig.CustomAuthSpec + } else { + v.Spec.Configuration.Authentication = opts.ExtOIDCConfig.GetAuthenticationConfig() + } } } } diff --git a/test/e2e/util/keycloak.go b/test/e2e/util/keycloak.go new file mode 100644 index 00000000000..2e1fbba040e --- /dev/null +++ b/test/e2e/util/keycloak.go @@ -0,0 +1,1000 @@ +package util + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strings" + "testing" + + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + kauthnv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kauthnv1typedclient "k8s.io/client-go/kubernetes/typed/authentication/v1" + "k8s.io/client-go/rest" + crclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// KeycloakAdminClient provides methods to interact with Keycloak Admin REST API +type KeycloakAdminClient struct { + BaseURL string + AdminToken string + HTTPClient *http.Client + AdminUser string + AdminPass string +} + +// KeycloakUser represents a Keycloak user +type KeycloakUser struct { + Username string `json:"username"` + Enabled bool `json:"enabled"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` +} + +// KeycloakGroup represents a Keycloak group +type KeycloakGroup struct { + Name string `json:"name"` +} + +// KeycloakCredential represents a user password credential +type KeycloakCredential struct { + Type string `json:"type"` + Value string `json:"value"` + Temporary bool `json:"temporary"` +} + +// KeycloakClient represents a Keycloak client +type KeycloakClient struct { + ID string `json:"id"` + ClientID string `json:"clientId"` +} + +// KeycloakProtocolMapper represents a protocol mapper +type KeycloakProtocolMapper struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + ProtocolMapper string `json:"protocolMapper"` + ConsentRequired bool `json:"consentRequired"` + Config map[string]string `json:"config"` +} + +// NewKeycloakAdminClient creates a new Keycloak admin client +func NewKeycloakAdminClient(baseURL, adminUser, adminPass, caCertFile string) *KeycloakAdminClient { + return &KeycloakAdminClient{ + BaseURL: baseURL, + AdminUser: adminUser, + AdminPass: adminPass, + HTTPClient: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + } +} + +// GetAdminToken obtains an admin access token +func (kc *KeycloakAdminClient) GetAdminToken(ctx context.Context) error { + tokenURL := fmt.Sprintf("%s/realms/master/protocol/openid-connect/token", kc.BaseURL) + + formData := url.Values{ + "client_id": []string{"admin-cli"}, + "grant_type": []string{"password"}, + "username": []string{kc.AdminUser}, + "password": []string{kc.AdminPass}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, nil) + if err != nil { + return fmt.Errorf("failed to create token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Body = io.NopCloser(strings.NewReader(formData.Encode())) + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to get admin token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to get admin token, status: %d, body: %s", resp.StatusCode, string(body)) + } + + var tokenResp map[string]any + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fmt.Errorf("failed to decode token response: %w", err) + } + + accessToken, ok := tokenResp["access_token"].(string) + if !ok { + return fmt.Errorf("access_token not found in response") + } + + kc.AdminToken = accessToken + return nil +} + +// CreateGroup creates a new group in Keycloak +func (kc *KeycloakAdminClient) CreateGroup(ctx context.Context, groupName string) (string, error) { + groupURL := fmt.Sprintf("%s/admin/realms/master/groups", kc.BaseURL) + + group := KeycloakGroup{Name: groupName} + groupJSON, err := json.Marshal(group) + if err != nil { + return "", fmt.Errorf("failed to marshal group: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", groupURL, strings.NewReader(string(groupJSON))) + if err != nil { + return "", fmt.Errorf("failed to create group request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to create group: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to create group, status: %d, body: %s", resp.StatusCode, string(body)) + } + + // Extract group ID from Location header + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("location header not found in response") + } + + // Location format: https://host/admin/realms/master/groups/{groupId} + parts := strings.Split(location, "/") + if len(parts) == 0 { + return "", fmt.Errorf("failed to parse group ID from location: %s", location) + } + groupID := parts[len(parts)-1] + + return groupID, nil +} + +// CreateUser creates a new user in Keycloak +func (kc *KeycloakAdminClient) CreateUser(ctx context.Context, user KeycloakUser) (string, error) { + userURL := fmt.Sprintf("%s/admin/realms/master/users", kc.BaseURL) + + userJSON, err := json.Marshal(user) + if err != nil { + return "", fmt.Errorf("failed to marshal user: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", userURL, strings.NewReader(string(userJSON))) + if err != nil { + return "", fmt.Errorf("failed to create user request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to create user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to create user, status: %d, body: %s", resp.StatusCode, string(body)) + } + + // Extract user ID from Location header + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("location header not found in response") + } + + // Location format: https://host/admin/realms/master/users/{userId} + parts := strings.Split(location, "/") + if len(parts) == 0 { + return "", fmt.Errorf("failed to parse user ID from location: %s", location) + } + userID := parts[len(parts)-1] + + return userID, nil +} + +// SetUserPassword sets a user's password +func (kc *KeycloakAdminClient) SetUserPassword(ctx context.Context, userID, password string, temporary bool) error { + passwordURL := fmt.Sprintf("%s/admin/realms/master/users/%s/reset-password", kc.BaseURL, userID) + + credential := KeycloakCredential{ + Type: "password", + Value: password, + Temporary: temporary, + } + + credJSON, err := json.Marshal(credential) + if err != nil { + return fmt.Errorf("failed to marshal credential: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "PUT", passwordURL, strings.NewReader(string(credJSON))) + if err != nil { + return fmt.Errorf("failed to create password request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to set password: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to set password, status: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// AddUserToGroup adds a user to a group +func (kc *KeycloakAdminClient) AddUserToGroup(ctx context.Context, userID, groupID string) error { + groupURL := fmt.Sprintf("%s/admin/realms/master/users/%s/groups/%s", kc.BaseURL, userID, groupID) + + req, err := http.NewRequestWithContext(ctx, "PUT", groupURL, nil) + if err != nil { + return fmt.Errorf("failed to create add-to-group request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to add user to group: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to add user to group, status: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// GetClientByClientID retrieves a client's internal ID by its clientId +func (kc *KeycloakAdminClient) GetClientByClientID(ctx context.Context, clientID string) (string, error) { + clientsURL := fmt.Sprintf("%s/admin/realms/master/clients?clientId=%s", kc.BaseURL, url.QueryEscape(clientID)) + + req, err := http.NewRequestWithContext(ctx, "GET", clientsURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create get-client request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get client: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to get client, status: %d, body: %s", resp.StatusCode, string(body)) + } + + var clients []KeycloakClient + if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil { + return "", fmt.Errorf("failed to decode clients response: %w", err) + } + + if len(clients) == 0 { + return "", fmt.Errorf("client not found: %s", clientID) + } + + return clients[0].ID, nil +} + +// DeleteUser deletes a user from Keycloak +func (kc *KeycloakAdminClient) DeleteUser(ctx context.Context, userID string) error { + userURL := fmt.Sprintf("%s/admin/realms/master/users/%s", kc.BaseURL, userID) + + req, err := http.NewRequestWithContext(ctx, "DELETE", userURL, nil) + if err != nil { + return fmt.Errorf("failed to create delete-user request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to delete user, status: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// DeleteGroup deletes a group from Keycloak +func (kc *KeycloakAdminClient) DeleteGroup(ctx context.Context, groupID string) error { + groupURL := fmt.Sprintf("%s/admin/realms/master/groups/%s", kc.BaseURL, groupID) + + req, err := http.NewRequestWithContext(ctx, "DELETE", groupURL, nil) + if err != nil { + return fmt.Errorf("failed to create delete-group request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to delete group: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to delete group, status: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// GetUserByUsername retrieves a user ID by username +func (kc *KeycloakAdminClient) GetUserByUsername(ctx context.Context, username string) (string, error) { + usersURL := fmt.Sprintf("%s/admin/realms/master/users?username=%s&exact=true", kc.BaseURL, url.QueryEscape(username)) + + req, err := http.NewRequestWithContext(ctx, "GET", usersURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create get-user request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to get user, status: %d, body: %s", resp.StatusCode, string(body)) + } + + var users []struct { + ID string `json:"id"` + Username string `json:"username"` + } + if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { + return "", fmt.Errorf("failed to decode users response: %w", err) + } + + if len(users) == 0 { + return "", fmt.Errorf("user not found: %s", username) + } + + return users[0].ID, nil +} + +// GetGroupByName retrieves a group ID by name +func (kc *KeycloakAdminClient) GetGroupByName(ctx context.Context, groupName string) (string, error) { + groupsURL := fmt.Sprintf("%s/admin/realms/master/groups", kc.BaseURL) + + req, err := http.NewRequestWithContext(ctx, "GET", groupsURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create get-groups request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get groups: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to get groups, status: %d, body: %s", resp.StatusCode, string(body)) + } + + var groups []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil { + return "", fmt.Errorf("failed to decode groups response: %w", err) + } + + for _, group := range groups { + if group.Name == groupName { + return group.ID, nil + } + } + + return "", fmt.Errorf("group not found: %s", groupName) +} + +// CreateProtocolMapper creates a protocol mapper for a client +func (kc *KeycloakAdminClient) CreateProtocolMapper(ctx context.Context, clientID string, mapper KeycloakProtocolMapper) error { + mapperURL := fmt.Sprintf("%s/admin/realms/master/clients/%s/protocol-mappers/models", kc.BaseURL, clientID) + + mapperJSON, err := json.Marshal(mapper) + if err != nil { + return fmt.Errorf("failed to marshal mapper: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", mapperURL, strings.NewReader(string(mapperJSON))) + if err != nil { + return fmt.Errorf("failed to create mapper request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+kc.AdminToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := kc.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to create mapper: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to create mapper, status: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// SetupKeycloakTestEnvironment creates test users, groups, and protocol mappers +func SetupKeycloakTestEnvironment(t *testing.T, ctx context.Context, config *ExtOIDCConfig, adminUser, adminPass string, numUsers int) (string, error) { + g := NewWithT(t) + + // Create admin client + kc := NewKeycloakAdminClient(config.IssuerURL, adminUser, adminPass, config.IssuerCABundleFile) + + // Get admin token + err := kc.GetAdminToken(ctx) + if err != nil { + return "", fmt.Errorf("failed to get admin token: %w", err) + } + + // Create group + t.Logf("Creating Keycloak group: keycloak-testgroup-1") + groupID, err := kc.CreateGroup(ctx, "keycloak-testgroup-1") + if err != nil { + return "", fmt.Errorf("failed to create group: %w", err) + } + t.Logf("Created group with ID: %s", groupID) + + // Create users and add to group + var users []string + for i := 1; i <= numUsers; i++ { + username := fmt.Sprintf("keycloak-testuser-%d", i) + password := GenerateRandomPassword(12) + + user := KeycloakUser{ + Username: username, + Enabled: true, + FirstName: username, + LastName: "KC", + Email: fmt.Sprintf("%s@example.com", username), + EmailVerified: true, + } + + t.Logf("Creating user: %s", username) + userID, err := kc.CreateUser(ctx, user) + if err != nil { + return "", fmt.Errorf("failed to create user %s: %w", username, err) + } + + // Set password + err = kc.SetUserPassword(ctx, userID, password, false) + if err != nil { + return "", fmt.Errorf("failed to set password for user %s: %w", username, err) + } + + // Add to group + err = kc.AddUserToGroup(ctx, userID, groupID) + if err != nil { + return "", fmt.Errorf("failed to add user %s to group: %w", username, err) + } + + users = append(users, fmt.Sprintf("%s:%s", username, password)) + } + + // Create protocol mappers for clients + groupMapper := KeycloakProtocolMapper{ + Name: "groupmapper", + Protocol: "openid-connect", + ProtocolMapper: "oidc-group-membership-mapper", + ConsentRequired: false, + Config: map[string]string{ + "full.path": "false", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "false", + "claim.name": "groups", + }, + } + + // Add group mapper to CLI client + t.Logf("Adding group mapper to CLI client: %s", config.CliClientID) + cliClientID, err := kc.GetClientByClientID(ctx, config.CliClientID) + if err != nil { + return "", fmt.Errorf("failed to get CLI client ID: %w", err) + } + err = kc.CreateProtocolMapper(ctx, cliClientID, groupMapper) + g.Expect(err).NotTo(HaveOccurred(), "failed to create protocol mapper for CLI client") + + // Add group mapper to console client + t.Logf("Adding group mapper to console client: %s", config.ConsoleClientID) + consoleClientID, err := kc.GetClientByClientID(ctx, config.ConsoleClientID) + if err != nil { + return "", fmt.Errorf("failed to get console client ID: %w", err) + } + err = kc.CreateProtocolMapper(ctx, consoleClientID, groupMapper) + g.Expect(err).NotTo(HaveOccurred(), "failed to create protocol mapper for console client") + + // Return users in format "user1:pass1,user2:pass2,..." + return strings.Join(users, ","), nil +} + +// GenerateRandomPassword generates a random password +func GenerateRandomPassword(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b) +} + +// SetupKeycloakAdminClientFromCluster retrieves Keycloak admin credentials from the cluster and creates an admin client +func SetupKeycloakAdminClientFromCluster(ctx context.Context, t *testing.T, mgtClient crclient.Client, config *ExtOIDCConfig) (*KeycloakAdminClient, error) { + g := NewWithT(t) + + // Tests are ran on both AWS and Azure AKS clusters respectively. + // However, Keycloak credentials are stored differently on both. + + // On AWS, both admin username and password credentials are stored + // via a StatefulSet called 'keycloak' in the 'keycloak' namespace. + + // On AKS, the admin username is stored in a config map called + // 'keycloak-env-vars' in 'keycloak' namespace via data.KC_BOOTSTAP_ADMIN_USERNAME, + // and the admin password is stored in a secret called 'keycloak' + // in the 'keycloak' namespace via data.admin-password . + // https://github.com/bitnami/charts/tree/main/bitnami/keycloak/templates + + adminUser, adminPass := "", "" + + // Try AWS approach first: read from StatefulSet environment variables + t.Logf("Retrieving Keycloak admin credentials from StatefulSet (AWS approach)") + sts := &appsv1.StatefulSet{} + err := mgtClient.Get(ctx, crclient.ObjectKey{ + Namespace: "keycloak", + Name: "keycloak", + }, sts) + if err == nil { + // StatefulSet exists, try to read credentials from environment variables + for _, env := range sts.Spec.Template.Spec.Containers[0].Env { + if env.Name == "KC_BOOTSTRAP_ADMIN_USERNAME" { + adminUser = env.Value + } + if env.Name == "KC_BOOTSTRAP_ADMIN_PASSWORD" { + adminPass = env.Value + } + } + } + + // If credentials not found in StatefulSet, try AKS approach: ConfigMap + Secret + if adminUser == "" || adminPass == "" { + t.Logf("Credentials not found in StatefulSet, trying AKS approach (ConfigMap + Secret)") + + // Get admin username from ConfigMap + cm := &corev1.ConfigMap{} + err = mgtClient.Get(ctx, crclient.ObjectKey{ + Namespace: "keycloak", + Name: "keycloak-env-vars", + }, cm) + if err == nil && cm.Data != nil { + adminUser = cm.Data["KC_BOOTSTRAP_ADMIN_USERNAME"] + } + + // Get admin password from Secret + secret := &corev1.Secret{} + err = mgtClient.Get(ctx, crclient.ObjectKey{ + Namespace: "keycloak", + Name: "keycloak", + }, secret) + if err == nil && secret.Data != nil { + adminPass = string(secret.Data["admin-password"]) + } + } + + // Verify we found both credentials + if adminUser == "" || adminPass == "" { + return nil, fmt.Errorf("could not find Keycloak admin credentials in StatefulSet (AWS) or ConfigMap+Secret (AKS)") + } + + t.Logf("Successfully retrieved Keycloak admin credentials (username: %s)", adminUser) + + // Trim /realms/master from issuerURL + baseURL := strings.TrimSuffix(config.IssuerURL, "/realms/master") + kc := NewKeycloakAdminClient(baseURL, adminUser, adminPass, config.IssuerCABundleFile) + + // Verify access by getting admin token + err = kc.GetAdminToken(ctx) + g.Expect(err).NotTo(HaveOccurred(), "failed to get admin token") + + t.Logf("Successfully created Keycloak admin client") + return kc, nil +} + +// TestResources tracks resources created during a test for cleanup +type TestResources struct { + AdminClient *KeycloakAdminClient + UserIDs []string + GroupIDs []string + UserCreds map[string]string // username -> password +} + +func renewAdminTokenIfExpired(ctx context.Context, adminClient *KeycloakAdminClient, externalOIDCConfig *ExtOIDCConfig) error { + clientID := externalOIDCConfig.ConsoleClientID + clientSecret := externalOIDCConfig.ConsoleClientSecretValue + accessToken := adminClient.AdminToken + + requestURL := externalOIDCConfig.IssuerURL + "/protocol/openid-connect/token/introspect" + + formData := url.Values{ + "client_id": []string{clientID}, + "client_secret": []string{clientSecret}, + "token": []string{accessToken}, + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + response, err := httpClient.PostForm( + requestURL, + formData, + ) + + if err != nil { + return fmt.Errorf("failed to POST to token introspect endpoint: %w", err) + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var respMap map[string]any + + err = json.Unmarshal(body, &respMap) + if err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + + active, ok := respMap["active"].(bool) + if !ok { + return fmt.Errorf("active not found or not a bool in response") + } + + if !active { + // refresh admin token as it has expired + adminClient.GetAdminToken(ctx) + } + + return nil +} + +// NewTestResources creates a new TestResources tracker +func NewTestResources(ctx context.Context, adminClient *KeycloakAdminClient, externalOIDCConfig *ExtOIDCConfig) (*TestResources, error) { + // renew admin token if expired + err := renewAdminTokenIfExpired(ctx, adminClient, externalOIDCConfig) + + if err != nil { + return nil, fmt.Errorf("an error occurred while checking for token renewal: %w", err) + } + + return &TestResources{ + AdminClient: adminClient, + UserIDs: []string{}, + GroupIDs: []string{}, + UserCreds: make(map[string]string), + }, nil +} + +// CreateTestUser creates a user and tracks it for cleanup +func (tr *TestResources) CreateTestUser(ctx context.Context, t *testing.T, username, email, password string) (string, error) { + return tr.CreateTestUserWithEmailVerification(ctx, t, username, email, password, true) +} + +// CreateTestUserWithEmailVerification creates a user with specific email verification status and tracks it for cleanup +func (tr *TestResources) CreateTestUserWithEmailVerification(ctx context.Context, t *testing.T, username, email, password string, emailVerified bool) (string, error) { + user := KeycloakUser{ + Username: username, + Enabled: true, + FirstName: username, + LastName: "Test", + Email: email, + EmailVerified: emailVerified, + } + + userID, err := tr.AdminClient.CreateUser(ctx, user) + if err != nil { + return "", err + } + + // Set password + err = tr.AdminClient.SetUserPassword(ctx, userID, password, false) + if err != nil { + return "", err + } + + // Track for cleanup + tr.UserIDs = append(tr.UserIDs, userID) + tr.UserCreds[username] = password + + t.Logf("Created test user: %s (ID: %s, email_verified: %v)", username, userID, emailVerified) + return userID, nil +} + +// CreateTestGroup creates a group and tracks it for cleanup +func (tr *TestResources) CreateTestGroup(ctx context.Context, t *testing.T, groupName string) (string, error) { + groupID, err := tr.AdminClient.CreateGroup(ctx, groupName) + if err != nil { + return "", err + } + + // Track for cleanup + tr.GroupIDs = append(tr.GroupIDs, groupID) + + t.Logf("Created test group: %s (ID: %s)", groupName, groupID) + return groupID, nil +} + +// CreateTestUserWithRandomCredentials creates a user with generated credentials and tracks it for cleanup +func (tr *TestResources) CreateTestUserWithRandomCredentials(ctx context.Context, t *testing.T, usernamePrefix string) (userID, username, email, password string, err error) { + username = usernamePrefix + "-" + GenerateRandomPassword(8) + email = username + "@test.example.com" + password = GenerateRandomPassword(16) + + userID, err = tr.CreateTestUser(ctx, t, username, email, password) + return userID, username, email, password, err +} + +// GetTestUsersString returns users in format "user1:pass1,user2:pass2,..." +func (tr *TestResources) GetTestUsersString() string { + var users []string + for username, password := range tr.UserCreds { + users = append(users, fmt.Sprintf("%s:%s", username, password)) + } + return strings.Join(users, ",") +} + +// Cleanup deletes all tracked resources +func (tr *TestResources) Cleanup(ctx context.Context, t *testing.T) { + t.Logf("Cleaning up test resources: %d users, %d groups", len(tr.UserIDs), len(tr.GroupIDs)) + + if err := tr.AdminClient.GetAdminToken(ctx); err != nil { + t.Logf("Warning: failed to refresh admin token: %v", err) + } + + // Delete users + for _, userID := range tr.UserIDs { + if err := tr.AdminClient.DeleteUser(ctx, userID); err != nil { + t.Logf("Warning: failed to delete user %s: %v", userID, err) + } else { + t.Logf("Deleted user: %s", userID) + } + } + + // Delete groups + for _, groupID := range tr.GroupIDs { + if err := tr.AdminClient.DeleteGroup(ctx, groupID); err != nil { + t.Logf("Warning: failed to delete group %s: %v", groupID, err) + } else { + t.Logf("Deleted group: %s", groupID) + } + } + + // Clear tracking + tr.UserIDs = []string{} + tr.GroupIDs = []string{} + tr.UserCreds = make(map[string]string) +} + +// AuthenticatedTestUser contains the results of setting up an authenticated test user +type AuthenticatedTestUser struct { + Username string + Email string + Password string + UserID string + GroupID string + GroupName string + KubeConfig *rest.Config + AuthClient kauthnv1typedclient.AuthenticationV1Interface + SelfSubjectReview *kauthnv1.SelfSubjectReview +} + +// TryAuthenticateUser attempts to authenticate a user and returns error if authentication fails +// This is useful for negative testing where you expect authentication to fail +func (tr *TestResources) TryAuthenticateUser( + ctx context.Context, + t *testing.T, + username string, + password string, + clientCfg *rest.Config, + extOIDCConfig *ExtOIDCConfig, +) error { + // This function replicates ChangeUserForKeycloakExtOIDC but returns errors + // instead of using gomega assertions, allowing negative testing scenarios + + if extOIDCConfig == nil { + return fmt.Errorf("extOIDCConfig is nil") + } + if extOIDCConfig.ExternalOIDCProvider != ProviderKeycloak { + return fmt.Errorf("expected Keycloak provider, got %s", extOIDCConfig.ExternalOIDCProvider) + } + + // Step 1: Get OIDC token from Keycloak + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + requestURL := extOIDCConfig.IssuerURL + "/protocol/openid-connect/token" + oidcClientID := extOIDCConfig.CliClientID + if oidcClientID == "" { + return fmt.Errorf("oidcClientID is empty") + } + + formData := url.Values{ + "client_id": []string{oidcClientID}, + "grant_type": []string{"password"}, + "password": []string{password}, + "scope": []string{"openid email profile"}, + "username": []string{username}, + } + + response, err := httpClient.PostForm(requestURL, formData) + if err != nil { + return fmt.Errorf("failed to POST to token endpoint: %w", err) + } + defer response.Body.Close() + + // Authentication can fail at Keycloak level (e.g., email_verified=false) + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("keycloak authentication failed with status %d: %s", response.StatusCode, string(body)) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var respMap map[string]any + err = json.Unmarshal(body, &respMap) + if err != nil { + return fmt.Errorf("failed to unmarshal token response: %w", err) + } + + idToken, ok := respMap["id_token"].(string) + if !ok { + return fmt.Errorf("id_token not found or not a string in response") + } + + // Step 2: Try to authenticate with K8s using the ID token directly + // We use the token as a bearer token instead of going through the exec plugin, + // which would attempt an authorization code flow (browser-based) that requires + // user interaction. Using the token directly allows us to capture validation + // errors from the Kubernetes API server. + // Use AnonymousClientConfig to clear all auth credentials (certs, tokens) from the admin config + clientConfigForExtOIDCUser := rest.AnonymousClientConfig(rest.CopyConfig(clientCfg)) + clientConfigForExtOIDCUser.BearerToken = idToken + + authClient, err := kauthnv1typedclient.NewForConfig(clientConfigForExtOIDCUser) + if err != nil { + return fmt.Errorf("failed to create auth client: %w", err) + } + + // Authentication can fail at K8s level due to claim validation rules or user validation rules + _, err = authClient.SelfSubjectReviews().Create(ctx, &kauthnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("kubernetes authentication failed: %w", err) + } + + return nil +} + +// SetupAuthenticatedUserWithGroup creates a test user, group, adds user to group, authenticates, and gets self subject review +// This is a complete setup for testing authentication and authorization scenarios +func (tr *TestResources) SetupAuthenticatedUserWithGroup( + ctx context.Context, + t *testing.T, + usernamePrefix string, + groupNamePrefix string, + clientCfg *rest.Config, + extOIDCConfig *ExtOIDCConfig, +) (*AuthenticatedTestUser, error) { + g := NewWithT(t) + + // Create test group + groupName := groupNamePrefix + "-" + GenerateRandomPassword(8) + groupID, err := tr.CreateTestGroup(ctx, t, groupName) + if err != nil { + return nil, fmt.Errorf("failed to create test group: %w", err) + } + t.Logf("Created test group: %s (ID: %s)", groupName, groupID) + + // Create test user with specific email + username := usernamePrefix + "-" + GenerateRandomPassword(8) + email := username + "@test.example.com" + password := GenerateRandomPassword(16) + userID, err := tr.CreateTestUser(ctx, t, username, email, password) + if err != nil { + return nil, fmt.Errorf("failed to create test user: %w", err) + } + t.Logf("Created test user: %s (email: %s, ID: %s)", username, email, userID) + + // Add user to group + err = tr.AdminClient.AddUserToGroup(ctx, userID, groupID) + if err != nil { + return nil, fmt.Errorf("failed to add user to group: %w", err) + } + t.Logf("Added user %s to group %s", username, groupName) + + // Authenticate as test user + testAuthConfig := *extOIDCConfig + testAuthConfig.TestUsers = username + ":" + password + testUserKubeConfig := ChangeUserForKeycloakExtOIDC(t, ctx, clientCfg, &testAuthConfig) + testAuthClient, err := kauthnv1typedclient.NewForConfig(testUserKubeConfig) + g.Expect(err).NotTo(HaveOccurred()) + + // Get self subject review + selfSubjectReview, err := testAuthClient.SelfSubjectReviews().Create(ctx, &kauthnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get self subject review: %w", err) + } + t.Logf("Test user self subject review: %+v", selfSubjectReview.Status.UserInfo) + + return &AuthenticatedTestUser{ + Username: username, + Email: email, + Password: password, + UserID: userID, + GroupID: groupID, + GroupName: groupName, + KubeConfig: testUserKubeConfig, + AuthClient: testAuthClient, + SelfSubjectReview: selfSubjectReview, + }, nil +}