Skip to content

Commit 6509ded

Browse files
committed
Add tests for external claims source featuregate
1 parent 63cd4df commit 6509ded

2 files changed

Lines changed: 315 additions & 4 deletions

File tree

test/extended/authentication/keycloak_client.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,25 @@ func (kc *keycloakClient) ConfigureClient(clientId string) error {
219219
return nil
220220
}
221221

222+
// ConfigureClientForExternalClaims configures a client for external claims testing
223+
// where groups are ONLY available via /userinfo endpoint, not in access/id tokens
224+
func (kc *keycloakClient) ConfigureClientForExternalClaims(clientId string) error {
225+
client, err := kc.GetClientByClientID(clientId)
226+
if err != nil {
227+
return fmt.Errorf("getting client %q: %w", clientId, err)
228+
}
229+
230+
if err := kc.CreateClientGroupMapperUserInfoOnly(client.ID, "external-claims-groups-mapper", "groups"); err != nil {
231+
return fmt.Errorf("creating external claims group mapper for client %q: %w", clientId, err)
232+
}
233+
234+
if err := kc.CreateClientAudienceMapper(client.ID, "external-claims-aud-mapper"); err != nil {
235+
return fmt.Errorf("creating audience mapper for client %q: %w", clientId, err)
236+
}
237+
238+
return nil
239+
}
240+
222241
type groupMapper struct {
223242
Name string `json:"name"`
224243
Protocol protocol `json:"protocol"`
@@ -255,6 +274,16 @@ const (
255274
)
256275

257276
func (kc *keycloakClient) CreateClientGroupMapper(clientId, name, claim string) error {
277+
return kc.createClientGroupMapperWithConfig(clientId, name, claim, true, true, true)
278+
}
279+
280+
// CreateClientGroupMapperUserInfoOnly creates a group mapper that only includes groups in userinfo, not in access/id tokens
281+
// This is useful for testing external claims sourcing where groups should only come from the external source
282+
func (kc *keycloakClient) CreateClientGroupMapperUserInfoOnly(clientId, name, claim string) error {
283+
return kc.createClientGroupMapperWithConfig(clientId, name, claim, false, false, true)
284+
}
285+
286+
func (kc *keycloakClient) createClientGroupMapperWithConfig(clientId, name, claim string, idToken, accessToken, userInfoToken bool) error {
258287
mappersURL := *kc.adminURL
259288
mappersURL.Path += fmt.Sprintf("/clients/%s/protocol-mappers/models", clientId)
260289

@@ -264,9 +293,9 @@ func (kc *keycloakClient) CreateClientGroupMapper(clientId, name, claim string)
264293
ProtocolMapper: protocolMapperOpenIDConnectGroupMembership,
265294
Config: groupMapperConfig{
266295
FullPath: booleanStringFalse,
267-
IDTokenClaim: booleanStringTrue,
268-
AccessTokenClaim: booleanStringTrue,
269-
UserInfoTokenClaim: booleanStringTrue,
296+
IDTokenClaim: booleanString(fmt.Sprintf("%t", idToken)),
297+
AccessTokenClaim: booleanString(fmt.Sprintf("%t", accessToken)),
298+
UserInfoTokenClaim: booleanString(fmt.Sprintf("%t", userInfoToken)),
270299
ClaimName: claim,
271300
},
272301
}
@@ -283,7 +312,7 @@ func (kc *keycloakClient) CreateClientGroupMapper(clientId, name, claim string)
283312
}
284313
defer resp.Body.Close()
285314

286-
if resp.StatusCode != http.StatusCreated {
315+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
287316
respBytes, _ := io.ReadAll(resp.Body)
288317
return fmt.Errorf("failed creating mapper %q: %s %s", name, resp.Status, respBytes)
289318
}

test/extended/authentication/oidc.go

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)