@@ -758,6 +758,288 @@ var _ = g.Describe("[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow]
758758 })
759759 })
760760
761+ g .Describe ("[OCPFeatureGate:ExternalOIDCExternalClaimsSourcing]" , g .Ordered , func () {
762+ var externalClaimsUser , externalClaimsUserPassword string
763+ var externalGroups []string
764+
765+ g .Describe ("with single external claims source" , g .Ordered , func () {
766+ g .BeforeAll (func () {
767+ testID := rand .String (8 )
768+
769+ // Create test user with NO groups initially
770+ // Groups will be assigned to the user in Keycloak, making them available via /userinfo
771+ // but they won't be in the access token's group claim yet (admin-cli mapper hasn't seen them)
772+ externalClaimsUser = fmt .Sprintf ("ext-claims-user-%s" , testID )
773+ externalClaimsUserPassword = fmt .Sprintf ("password-ext-claims-%s" , testID )
774+
775+ // Create multiple NEW groups to test external claims sourcing
776+ // These groups are created AFTER admin-cli was configured, so they won't be
777+ // in the access token - they'll only be available via external /userinfo fetch
778+ externalGroups = []string {
779+ fmt .Sprintf ("ext-group-1-%s" , testID ),
780+ fmt .Sprintf ("ext-group-2-%s" , testID ),
781+ fmt .Sprintf ("ext-group-3-%s" , testID ),
782+ }
783+
784+ for _ , grp := range externalGroups {
785+ o .Expect (keycloakCli .CreateGroup (grp )).To (o .Succeed (), "should be able to create external groups" )
786+ }
787+ o .Expect (keycloakCli .CreateUser (externalClaimsUser , externalClaimsUserPassword , externalGroups ... )).To (o .Succeed (), "should be able to create user with external groups" )
788+
789+ // Configure OIDC with external claims sourcing (CNTRLPLANE-3475)
790+ _ , _ , err := configureOIDCAuthentication (ctx , oc , keycloakNamespace , oidcClientSecret , func (provider * configv1.OIDCProvider ) {
791+ idpUrl , err := admittedURLForRoute (ctx , oc , keycloakResourceName , keycloakNamespace )
792+ o .Expect (err ).NotTo (o .HaveOccurred (), "should not encounter an error getting keycloak route URL" )
793+
794+ hostname := strings .TrimPrefix (idpUrl , "https://" )
795+
796+ // Configure external claims source to fetch groups from /userinfo endpoint
797+ provider .ExternalClaimsSources = []configv1.ExternalClaimsSource {
798+ {
799+ Authentication : configv1.ExternalSourceAuthentication {
800+ Type : configv1 .ExternalSourceAuthenticationTypeRequestProvidedToken ,
801+ },
802+ URL : configv1.SourceURL {
803+ Hostname : hostname ,
804+ PathExpression : "['realms', 'master', 'protocol', 'openid-connect', 'userinfo']" ,
805+ },
806+ TLS : configv1.ExternalSourceTLS {
807+ CertificateAuthority : configv1.ExternalSourceCertificateAuthorityConfigMapReference {
808+ Name : "keycloak-ca" ,
809+ },
810+ },
811+ Mappings : []configv1.SourcedClaimMapping {
812+ {
813+ Name : "groups" ,
814+ Expression : "response.body.groups.join(',')" ,
815+ },
816+ },
817+ },
818+ }
819+
820+ // Update groups claim mapping to parse comma-separated groups from external source
821+ provider .ClaimMappings .Groups = configv1.PrefixedClaimMapping {
822+ TokenClaimMapping : configv1.TokenClaimMapping {
823+ Expression : "claims.?groups.orValue('').split(',')" ,
824+ },
825+ }
826+ })
827+ o .Expect (err ).NotTo (o .HaveOccurred (), "should not encounter an error configuring OIDC with external claims sourcing" )
828+
829+ waitForRollout (ctx , oc )
830+ waitForHealthyOIDCClients (ctx , oc )
831+ })
832+
833+ g .It ("should source groups from external claims endpoint" , func () {
834+ // CNTRLPLANE-3475: Single source simple mapping
835+ o .Eventually (func (gomega o.Gomega ) {
836+ err := keycloakCli .Authenticate ("admin-cli" , externalClaimsUser , externalClaimsUserPassword )
837+ gomega .Expect (err ).NotTo (o .HaveOccurred (), "should not encounter an error authenticating as external claims user" )
838+
839+ copiedOC := * oc
840+ tokenOC := copiedOC .WithToken (keycloakCli .AccessToken ())
841+ ssr , err := tokenOC .KubeClient ().AuthenticationV1 ().SelfSubjectReviews ().Create (ctx , & authnv1.SelfSubjectReview {
842+ ObjectMeta : metav1.ObjectMeta {
843+ Name : fmt .Sprintf ("%s-info" , externalClaimsUser ),
844+ },
845+ }, metav1.CreateOptions {})
846+ gomega .Expect (err ).NotTo (o .HaveOccurred (), "should be able to create a SelfSubjectReview" )
847+
848+ // Verify all external groups are present
849+ for _ , expectedGroup := range externalGroups {
850+ gomega .Expect (ssr .Status .UserInfo .Groups ).To (o .ContainElement (expectedGroup ),
851+ fmt .Sprintf ("should contain external group %s" , expectedGroup ))
852+ }
853+
854+ // Verify we have at least the expected number of groups (may have system groups too)
855+ gomega .Expect (len (ssr .Status .UserInfo .Groups )).To (o .BeNumerically (">=" , len (externalGroups )),
856+ "should have at least all external groups" )
857+ }).WithTimeout (5 * time .Minute ).WithPolling (10 * time .Second ).Should (o .Succeed ())
858+ })
859+ })
860+
861+ g .Describe ("with multiple external claims sources" , g .Ordered , func () {
862+ var multiSourceUser , multiSourceUserPassword string
863+ var source1Groups , source2Groups []string
864+
865+ g .BeforeAll (func () {
866+ testID := rand .String (8 )
867+
868+ multiSourceUser = fmt .Sprintf ("multi-source-user-%s" , testID )
869+ multiSourceUserPassword = fmt .Sprintf ("password-multi-source-%s" , testID )
870+
871+ // Create groups for two different sources
872+ // These are new groups created after admin-cli configuration
873+ source1Groups = []string {fmt .Sprintf ("source1-group-%s" , testID )}
874+ source2Groups = []string {fmt .Sprintf ("source2-group-%s" , testID )}
875+
876+ for _ , grp := range append (source1Groups , source2Groups ... ) {
877+ o .Expect (keycloakCli .CreateGroup (grp )).To (o .Succeed (), "should be able to create groups" )
878+ }
879+ o .Expect (keycloakCli .CreateUser (multiSourceUser , multiSourceUserPassword , append (source1Groups , source2Groups ... )... )).To (o .Succeed ())
880+
881+ // Configure OIDC with TWO external claims sources (CNTRLPLANE-3474/3481)
882+ _ , _ , err := configureOIDCAuthentication (ctx , oc , keycloakNamespace , oidcClientSecret , func (provider * configv1.OIDCProvider ) {
883+ idpUrl , err := admittedURLForRoute (ctx , oc , keycloakResourceName , keycloakNamespace )
884+ o .Expect (err ).NotTo (o .HaveOccurred ())
885+
886+ hostname := strings .TrimPrefix (idpUrl , "https://" )
887+
888+ // Configure two external claims sources pointing to same Keycloak instance
889+ // In real scenario, these would be different IdPs
890+ provider .ExternalClaimsSources = []configv1.ExternalClaimsSource {
891+ {
892+ Authentication : configv1.ExternalSourceAuthentication {
893+ Type : configv1 .ExternalSourceAuthenticationTypeRequestProvidedToken ,
894+ },
895+ URL : configv1.SourceURL {
896+ Hostname : hostname ,
897+ PathExpression : "['realms', 'master', 'protocol', 'openid-connect', 'userinfo']" ,
898+ },
899+ TLS : configv1.ExternalSourceTLS {
900+ CertificateAuthority : configv1.ExternalSourceCertificateAuthorityConfigMapReference {
901+ Name : "keycloak-ca" ,
902+ },
903+ },
904+ Mappings : []configv1.SourcedClaimMapping {
905+ {
906+ Name : "groups_src1" ,
907+ Expression : "response.body.groups.filter(g, g.startsWith('source1-')).join(',')" ,
908+ },
909+ },
910+ },
911+ {
912+ Authentication : configv1.ExternalSourceAuthentication {
913+ Type : configv1 .ExternalSourceAuthenticationTypeRequestProvidedToken ,
914+ },
915+ URL : configv1.SourceURL {
916+ Hostname : hostname ,
917+ PathExpression : "['realms', 'master', 'protocol', 'openid-connect', 'userinfo']" ,
918+ },
919+ TLS : configv1.ExternalSourceTLS {
920+ CertificateAuthority : configv1.ExternalSourceCertificateAuthorityConfigMapReference {
921+ Name : "keycloak-ca" ,
922+ },
923+ },
924+ Mappings : []configv1.SourcedClaimMapping {
925+ {
926+ Name : "groups_src2" ,
927+ Expression : "response.body.groups.filter(g, g.startsWith('source2-')).join(',')" ,
928+ },
929+ },
930+ },
931+ }
932+
933+ provider .ClaimMappings .Groups = configv1.PrefixedClaimMapping {
934+ TokenClaimMapping : configv1.TokenClaimMapping {
935+ Expression : "(claims.?groups_src1.orValue('') + ',' + claims.?groups_src2.orValue('')).split(',').filter(g, size(g) > 0)" ,
936+ },
937+ }
938+ })
939+ o .Expect (err ).NotTo (o .HaveOccurred (), "should not encounter an error configuring multi-source external claims" )
940+
941+ waitForRollout (ctx , oc )
942+ waitForHealthyOIDCClients (ctx , oc )
943+ })
944+
945+ g .It ("should merge groups from multiple external claims sources" , func () {
946+ // CNTRLPLANE-3474/3481: Multi-source claims
947+ o .Eventually (func (gomega o.Gomega ) {
948+ err := keycloakCli .Authenticate ("admin-cli" , multiSourceUser , multiSourceUserPassword )
949+ gomega .Expect (err ).NotTo (o .HaveOccurred ())
950+
951+ copiedOC := * oc
952+ tokenOC := copiedOC .WithToken (keycloakCli .AccessToken ())
953+ ssr , err := tokenOC .KubeClient ().AuthenticationV1 ().SelfSubjectReviews ().Create (ctx , & authnv1.SelfSubjectReview {
954+ ObjectMeta : metav1.ObjectMeta {
955+ Name : fmt .Sprintf ("%s-info" , multiSourceUser ),
956+ },
957+ }, metav1.CreateOptions {})
958+ gomega .Expect (err ).NotTo (o .HaveOccurred ())
959+
960+ // Verify groups from BOTH sources are present
961+ for _ , grp := range source1Groups {
962+ gomega .Expect (ssr .Status .UserInfo .Groups ).To (o .ContainElement (grp ),
963+ "should contain groups from source 1" )
964+ }
965+ for _ , grp := range source2Groups {
966+ gomega .Expect (ssr .Status .UserInfo .Groups ).To (o .ContainElement (grp ),
967+ "should contain groups from source 2" )
968+ }
969+ }).WithTimeout (5 * time .Minute ).WithPolling (10 * time .Second ).Should (o .Succeed ())
970+ })
971+ })
972+
973+ g .Describe ("with invalid external claims source" , g .Ordered , func () {
974+ var errorHandlingUser , errorHandlingUserPassword string
975+
976+ g .BeforeAll (func () {
977+ testID := rand .String (8 )
978+
979+ errorHandlingUser = fmt .Sprintf ("error-user-%s" , testID )
980+ errorHandlingUserPassword = fmt .Sprintf ("password-error-%s" , testID )
981+
982+ o .Expect (keycloakCli .CreateUser (errorHandlingUser , errorHandlingUserPassword , group )).To (o .Succeed ())
983+
984+ // Configure OIDC with INVALID external claims source (CNTRLPLANE-3482)
985+ _ , _ , err := configureOIDCAuthentication (ctx , oc , keycloakNamespace , oidcClientSecret , func (provider * configv1.OIDCProvider ) {
986+ // Configure external claims source with unreachable host
987+ provider .ExternalClaimsSources = []configv1.ExternalClaimsSource {
988+ {
989+ Authentication : configv1.ExternalSourceAuthentication {
990+ Type : configv1 .ExternalSourceAuthenticationTypeRequestProvidedToken ,
991+ },
992+ URL : configv1.SourceURL {
993+ Hostname : "unreachable-host-that-does-not-exist.example.com" ,
994+ PathExpression : "['userinfo']" ,
995+ },
996+ Mappings : []configv1.SourcedClaimMapping {
997+ {
998+ Name : "groups" ,
999+ Expression : "response.body.groups.join(',')" ,
1000+ },
1001+ },
1002+ },
1003+ }
1004+
1005+ provider .ClaimMappings .Groups = configv1.PrefixedClaimMapping {
1006+ TokenClaimMapping : configv1.TokenClaimMapping {
1007+ Expression : "claims.?groups.orValue('').split(',')" ,
1008+ },
1009+ }
1010+ })
1011+ o .Expect (err ).NotTo (o .HaveOccurred ())
1012+
1013+ waitForRollout (ctx , oc )
1014+ waitForHealthyOIDCClients (ctx , oc )
1015+ })
1016+
1017+ g .It ("should gracefully handle unreachable external claims source" , func () {
1018+ // CNTRLPLANE-3482: Error handling - bad host
1019+ o .Eventually (func (gomega o.Gomega ) {
1020+ err := keycloakCli .Authenticate ("admin-cli" , errorHandlingUser , errorHandlingUserPassword )
1021+ gomega .Expect (err ).NotTo (o .HaveOccurred ())
1022+
1023+ copiedOC := * oc
1024+ tokenOC := copiedOC .WithToken (keycloakCli .AccessToken ())
1025+ ssr , err := tokenOC .KubeClient ().AuthenticationV1 ().SelfSubjectReviews ().Create (ctx , & authnv1.SelfSubjectReview {
1026+ ObjectMeta : metav1.ObjectMeta {
1027+ Name : fmt .Sprintf ("%s-info" , errorHandlingUser ),
1028+ },
1029+ }, metav1.CreateOptions {})
1030+
1031+ // Authentication should still succeed even if external source is unreachable
1032+ // (graceful degradation)
1033+ gomega .Expect (err ).NotTo (o .HaveOccurred (),
1034+ "should still be able to authenticate when external claims source is unreachable" )
1035+
1036+ // User should still have basic identity (from token), just not external groups
1037+ gomega .Expect (ssr .Status .UserInfo .Username ).NotTo (o .BeEmpty ())
1038+ }).WithTimeout (5 * time .Minute ).WithPolling (10 * time .Second ).Should (o .Succeed ())
1039+ })
1040+ })
1041+ })
1042+
7611043 g .AfterAll (func () {
7621044 err , modified := resetAuthentication (ctx , oc , originalAuth )
7631045 o .Expect (err ).NotTo (o .HaveOccurred (), "should not encounter an error reverting authentication to original state" )
0 commit comments