Skip to content

Commit 5ef42f5

Browse files
Merge pull request #29917 from everettraven/feature/external-oidc-tests
CNTRLPLANE-945: Add tests for ExternalOIDC and ExternalOIDCWithUIDAndExtraClaimMappings features
2 parents debace2 + bca8317 commit 5ef42f5

File tree

9 files changed

+1469
-0
lines changed

9 files changed

+1469
-0
lines changed

pkg/testsuites/standard_suites.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,16 @@ var staticSuites = []ginkgo.TestSuite{
430430
},
431431
TestTimeout: 60 * time.Minute,
432432
},
433+
{
434+
Name: "openshift/auth/external-oidc",
435+
Description: templates.LongDesc(`
436+
This test suite runs tests to validate cluster behavior when cluster authentication is configured to use an external OIDC provider.
437+
`),
438+
Qualifiers: []string{
439+
`name.contains("[Suite:openshift/auth/external-oidc") && !name.contains("[Skipped]")`,
440+
},
441+
TestTimeout: 120 * time.Minute,
442+
},
433443
}
434444

435445
func withExcludedTestsFilter(baseExpr string) string {
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
package authentication
2+
3+
import (
4+
"bytes"
5+
"crypto/tls"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
12+
"k8s.io/apimachinery/pkg/runtime"
13+
)
14+
15+
type keycloakClient struct {
16+
realm string
17+
client *http.Client
18+
adminURL *url.URL
19+
20+
accessToken string
21+
idToken string
22+
}
23+
24+
func keycloakClientFor(keycloakURL string) (*keycloakClient, error) {
25+
baseURL, err := url.Parse(keycloakURL)
26+
if err != nil {
27+
return nil, fmt.Errorf("parsing url: %w", err)
28+
}
29+
30+
transport := &http.Transport{
31+
TLSClientConfig: &tls.Config{
32+
InsecureSkipVerify: true,
33+
},
34+
}
35+
36+
return &keycloakClient{
37+
realm: "master",
38+
client: &http.Client{
39+
Transport: transport,
40+
},
41+
adminURL: baseURL.JoinPath("admin", "realms", "master"),
42+
}, nil
43+
}
44+
45+
type group struct {
46+
Name string `json:"name"`
47+
}
48+
49+
func (kc *keycloakClient) CreateGroup(name string) error {
50+
groupURL := kc.adminURL.JoinPath("groups")
51+
52+
group := group{
53+
Name: name,
54+
}
55+
56+
groupBytes, err := json.Marshal(group)
57+
if err != nil {
58+
return fmt.Errorf("marshalling group configuration %v", group)
59+
}
60+
61+
resp, err := kc.DoRequest(http.MethodPost, groupURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(groupBytes))
62+
if err != nil {
63+
return fmt.Errorf("sending POST request to %q to create group %s", groupURL.String(), name)
64+
}
65+
defer resp.Body.Close()
66+
67+
if resp.StatusCode != http.StatusCreated {
68+
respBytes, _ := io.ReadAll(resp.Body)
69+
return fmt.Errorf("failed creating group %q: %s - %s", name, resp.Status, respBytes)
70+
}
71+
72+
return nil
73+
}
74+
75+
type user struct {
76+
Username string `json:"username"`
77+
Email string `json:"email"`
78+
Enabled bool `json:"enabled"`
79+
EmailVerified bool `json:"emailVerified"`
80+
Groups []string `json:"groups"`
81+
Credentials []credential `json:"credentials"`
82+
}
83+
84+
type credential struct {
85+
Temporary bool `json:"temporary"`
86+
Type credentialType `json:"type"`
87+
Value string `json:"value"`
88+
}
89+
90+
type credentialType string
91+
92+
const (
93+
credentialTypePassword credentialType = "password"
94+
)
95+
96+
func (kc *keycloakClient) CreateUser(username, password string, groups ...string) error {
97+
userURL := kc.adminURL.JoinPath("users")
98+
99+
user := user{
100+
Username: username,
101+
Email: fmt.Sprintf("%[email protected]", username),
102+
Enabled: true,
103+
EmailVerified: true,
104+
Groups: groups,
105+
Credentials: []credential{
106+
{
107+
Temporary: true,
108+
Type: credentialTypePassword,
109+
Value: password,
110+
},
111+
},
112+
}
113+
114+
userBytes, err := json.Marshal(user)
115+
if err != nil {
116+
return fmt.Errorf("marshalling user configuration %v", user)
117+
}
118+
119+
resp, err := kc.DoRequest(http.MethodPost, userURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(userBytes))
120+
if err != nil {
121+
return fmt.Errorf("sending POST request to %q to create user %v", userURL.String(), user)
122+
}
123+
defer resp.Body.Close()
124+
125+
if resp.StatusCode != http.StatusCreated {
126+
respBytes, _ := io.ReadAll(resp.Body)
127+
return fmt.Errorf("failed creating user %v: %s - %s", user, resp.Status, respBytes)
128+
}
129+
130+
return nil
131+
}
132+
133+
type authenticationResponse struct {
134+
AccessToken string `json:"access_token"`
135+
IDToken string `json:"id_token"`
136+
}
137+
138+
func (kc *keycloakClient) Authenticate(clientID, username, password string) error {
139+
data := url.Values{}
140+
data.Set("username", username)
141+
data.Set("password", password)
142+
data.Set("grant_type", "password")
143+
data.Set("client_id", clientID)
144+
data.Set("scope", "openid")
145+
146+
tokenURL := *kc.adminURL
147+
tokenURL.Path = fmt.Sprintf("/realms/%s/protocol/openid-connect/token", kc.realm)
148+
149+
resp, err := kc.DoRequest(http.MethodPost, tokenURL.String(), "application/x-www-form-urlencoded", false, bytes.NewBuffer([]byte(data.Encode())))
150+
if err != nil {
151+
return fmt.Errorf("authenticating as user %q: %w", username, err)
152+
}
153+
defer resp.Body.Close()
154+
155+
respBody := &authenticationResponse{}
156+
157+
err = json.NewDecoder(resp.Body).Decode(respBody)
158+
if err != nil {
159+
return fmt.Errorf("unmarshalling response data: %w", err)
160+
}
161+
162+
kc.accessToken = respBody.AccessToken
163+
kc.idToken = respBody.IDToken
164+
165+
return nil
166+
}
167+
168+
func (kc *keycloakClient) DoRequest(method, url, contentType string, authenticated bool, body io.Reader) (*http.Response, error) {
169+
if len(kc.accessToken) == 0 && authenticated {
170+
panic("must authenticate before calling keycloakClient.DoRequest")
171+
}
172+
173+
req, err := http.NewRequest(method, url, body)
174+
if err != nil {
175+
return nil, fmt.Errorf("building request: %w", err)
176+
}
177+
178+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", kc.accessToken))
179+
req.Header.Set("Content-Type", contentType)
180+
req.Header.Set("Accept", runtime.ContentTypeJSON)
181+
182+
return kc.client.Do(req)
183+
}
184+
185+
func (kc *keycloakClient) AccessToken() string {
186+
return kc.accessToken
187+
}
188+
189+
func (kc *keycloakClient) IdToken() string {
190+
return kc.idToken
191+
}
192+
193+
func (kc *keycloakClient) ConfigureClient(clientId string) error {
194+
client, err := kc.GetClientByClientID(clientId)
195+
if err != nil {
196+
return fmt.Errorf("getting client %q: %w", clientId, err)
197+
}
198+
199+
if err := kc.CreateClientGroupMapper(client.ID, "test-groups-mapper", "groups"); err != nil {
200+
return fmt.Errorf("creating group mapper for client %q: %w", clientId, err)
201+
}
202+
203+
if err := kc.CreateClientAudienceMapper(client.ID, "test-aud-mapper"); err != nil {
204+
return fmt.Errorf("creating audience mapper for client %q: %w", clientId, err)
205+
}
206+
207+
return nil
208+
}
209+
210+
type groupMapper struct {
211+
Name string `json:"name"`
212+
Protocol protocol `json:"protocol"`
213+
ProtocolMapper protocolMapper `json:"protocolMapper"`
214+
Config groupMapperConfig `json:"config"`
215+
}
216+
217+
type protocol string
218+
219+
const (
220+
protocolOpenIDConnect protocol = "openid-connect"
221+
)
222+
223+
type protocolMapper string
224+
225+
const (
226+
protocolMapperOpenIDConnectGroupMembership protocolMapper = "oidc-group-membership-mapper"
227+
protocolMapperOpenIDConnectAudience protocolMapper = "oidc-audience-mapper"
228+
)
229+
230+
type groupMapperConfig struct {
231+
FullPath booleanString `json:"full.path"`
232+
IDTokenClaim booleanString `json:"id.token.claim"`
233+
AccessTokenClaim booleanString `json:"access.token.claim"`
234+
UserInfoTokenClaim booleanString `json:"userinfo.token.claim"`
235+
ClaimName string `json:"claim.name"`
236+
}
237+
238+
type booleanString string
239+
240+
const (
241+
booleanStringTrue booleanString = "true"
242+
booleanStringFalse booleanString = "false"
243+
)
244+
245+
func (kc *keycloakClient) CreateClientGroupMapper(clientId, name, claim string) error {
246+
mappersURL := *kc.adminURL
247+
mappersURL.Path += fmt.Sprintf("/clients/%s/protocol-mappers/models", clientId)
248+
249+
mapper := &groupMapper{
250+
Name: name,
251+
Protocol: protocolOpenIDConnect,
252+
ProtocolMapper: protocolMapperOpenIDConnectGroupMembership,
253+
Config: groupMapperConfig{
254+
FullPath: booleanStringFalse,
255+
IDTokenClaim: booleanStringTrue,
256+
AccessTokenClaim: booleanStringTrue,
257+
UserInfoTokenClaim: booleanStringTrue,
258+
ClaimName: claim,
259+
},
260+
}
261+
262+
mapperBytes, err := json.Marshal(mapper)
263+
if err != nil {
264+
return err
265+
}
266+
267+
// Keycloak does not return the object on successful create so there's no need to attempt to retrieve it from the response
268+
resp, err := kc.DoRequest(http.MethodPost, mappersURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(mapperBytes))
269+
if err != nil {
270+
return err
271+
}
272+
defer resp.Body.Close()
273+
274+
if resp.StatusCode != http.StatusCreated {
275+
respBytes, _ := io.ReadAll(resp.Body)
276+
return fmt.Errorf("failed creating mapper %q: %s %s", name, resp.Status, respBytes)
277+
}
278+
279+
return nil
280+
}
281+
282+
type audienceMapper struct {
283+
Name string `json:"name"`
284+
Protocol protocol `json:"protocol"`
285+
ProtocolMapper protocolMapper `json:"protocolMapper"`
286+
Config audienceMapperConfig `json:"config"`
287+
}
288+
289+
type audienceMapperConfig struct {
290+
IDTokenClaim booleanString `json:"id.token.claim"`
291+
AccessTokenClaim booleanString `json:"access.token.claim"`
292+
IntrospectionTokenClaim booleanString `json:"introspection.token.claim"`
293+
IncludedClientAudience string `json:"included.client.audience"`
294+
IncludedCustomAudience string `json:"included.custom.audience"`
295+
LightweightClaim booleanString `json:"lightweight.claim"`
296+
}
297+
298+
func (kc *keycloakClient) CreateClientAudienceMapper(clientId, name string) error {
299+
mappersURL := *kc.adminURL
300+
mappersURL.Path += fmt.Sprintf("/clients/%s/protocol-mappers/models", clientId)
301+
302+
mapper := &audienceMapper{
303+
Name: name,
304+
Protocol: protocolOpenIDConnect,
305+
ProtocolMapper: protocolMapperOpenIDConnectAudience,
306+
Config: audienceMapperConfig{
307+
IDTokenClaim: booleanStringFalse,
308+
AccessTokenClaim: booleanStringTrue,
309+
IntrospectionTokenClaim: booleanStringTrue,
310+
IncludedClientAudience: "admin-cli",
311+
LightweightClaim: booleanStringFalse,
312+
},
313+
}
314+
315+
mapperBytes, err := json.Marshal(mapper)
316+
if err != nil {
317+
return err
318+
}
319+
320+
// Keycloak does not return the object on successful create so there's no need to attempt to retrieve it from the response
321+
resp, err := kc.DoRequest(http.MethodPost, mappersURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(mapperBytes))
322+
if err != nil {
323+
return err
324+
}
325+
defer resp.Body.Close()
326+
327+
if resp.StatusCode != http.StatusCreated {
328+
respBytes, _ := io.ReadAll(resp.Body)
329+
return fmt.Errorf("failed creating mapper %q: %s %s", name, resp.Status, respBytes)
330+
}
331+
332+
return nil
333+
}
334+
335+
type client struct {
336+
ClientID string `json:"clientID"`
337+
ID string `json:"id"`
338+
}
339+
340+
// ListClients retrieves all clients
341+
func (kc *keycloakClient) ListClients() ([]client, error) {
342+
clientsURL := *kc.adminURL
343+
clientsURL.Path += "/clients"
344+
345+
resp, err := kc.DoRequest(http.MethodGet, clientsURL.String(), runtime.ContentTypeJSON, true, nil)
346+
if err != nil {
347+
return nil, err
348+
}
349+
defer resp.Body.Close()
350+
351+
if resp.StatusCode != http.StatusOK {
352+
return nil, fmt.Errorf("listing clients failed: %s", resp.Status)
353+
}
354+
355+
clients := []client{}
356+
err = json.NewDecoder(resp.Body).Decode(&clients)
357+
if err != nil {
358+
return nil, fmt.Errorf("unmarshalling response data: %w", err)
359+
}
360+
361+
return clients, err
362+
}
363+
364+
func (kc *keycloakClient) GetClientByClientID(clientID string) (*client, error) {
365+
clients, err := kc.ListClients()
366+
if err != nil {
367+
return nil, err
368+
}
369+
370+
for _, c := range clients {
371+
if c.ClientID == clientID {
372+
return &c, nil
373+
}
374+
}
375+
376+
return nil, fmt.Errorf("client with clientID %q not found", clientID)
377+
}

0 commit comments

Comments
 (0)