github.com/verrazzano/verrazzano@v1.7.1/tests/e2e/pkg/keycloak.go (about) 1 // Copyright (c) 2021, 2023, Oracle and/or its affiliates. 2 // Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. 3 4 package pkg 5 6 import ( 7 "context" 8 "encoding/json" 9 "fmt" 10 "io" 11 "net/http" 12 "net/url" 13 "strings" 14 "time" 15 16 "go.uber.org/zap" 17 18 "github.com/hashicorp/go-retryablehttp" 19 "github.com/onsi/gomega" 20 "github.com/verrazzano/verrazzano/pkg/k8sutil" 21 k8smeta "k8s.io/apimachinery/pkg/apis/meta/v1" 22 ) 23 24 type KeycloakRESTClient struct { 25 kubeConfigPath string 26 keycloakIngressHost string 27 adminAccessToken string 28 httpClient *retryablehttp.Client 29 } 30 31 const ( 32 keycloakNamespace = "keycloak" 33 keycloadIngressName = "keycloak" 34 keycloakAdminUserPasswordSecret = "keycloak-http" //nolint:gosec //#gosec G101 35 keycloakAdminUserRealm = "master" 36 keycloakAdminUserName = "keycloakadmin" 37 38 verrazzanoPgClientID = "verrazzano-pg" 39 keycloakAdminClientID = "admin-cli" 40 41 TestKeycloakMasterUserIDKey = "TEST_KEYCLOAK_MASTER_USERID" 42 TestKeycloakVerrazzanoUserIDKey = "TEST_KEYCLOAK_VZ_USERID" 43 44 TestKeycloakNamespace = "keycloak-test-ns" 45 TestKeycloakConfigMap = "keycloak-test-cm" 46 ) 47 48 // NewKeycloakRESTClient creates a new Keycloak REST client. 49 func NewKeycloakAdminRESTClient() (*KeycloakRESTClient, error) { 50 kubeconfigPath, err := k8sutil.GetKubeConfigLocation() 51 if err != nil { 52 return nil, err 53 } 54 55 clientset, err := GetKubernetesClientsetForCluster(kubeconfigPath) 56 if err != nil { 57 return nil, err 58 } 59 ingress, err := clientset.NetworkingV1().Ingresses(keycloakNamespace).Get(context.TODO(), keycloadIngressName, k8smeta.GetOptions{}) 60 if err != nil { 61 return nil, err 62 } 63 httpClient, err := GetVerrazzanoHTTPClient(kubeconfigPath) 64 if err != nil { 65 return nil, err 66 } 67 68 secret, err := GetSecret(keycloakNamespace, keycloakAdminUserPasswordSecret) 69 if err != nil { 70 return nil, err 71 } 72 keycloakAdminPassword := strings.TrimSpace(string(secret.Data["password"])) 73 74 ingressHost := ingress.Spec.Rules[0].Host 75 keycloakLoginURL := fmt.Sprintf("https://%s/auth/realms/%s/protocol/openid-connect/token", ingressHost, keycloakAdminUserRealm) 76 body := fmt.Sprintf("username=%s&password=%s&grant_type=password&client_id=%s", keycloakAdminUserName, keycloakAdminPassword, keycloakAdminClientID) 77 resp, err := PostWithHostHeader(keycloakLoginURL, "application/x-www-form-urlencoded", ingressHost, strings.NewReader(body)) 78 if err != nil { 79 return nil, err 80 } 81 if resp.StatusCode != http.StatusOK { 82 return nil, fmt.Errorf("failed to login as admin user") 83 } 84 token := JTq(string(resp.Body), "access_token").(string) 85 if token == "" { 86 return nil, fmt.Errorf("failed to obtain valid access token") 87 } 88 89 client := KeycloakRESTClient{ 90 kubeConfigPath: kubeconfigPath, 91 keycloakIngressHost: ingress.Spec.Rules[0].Host, 92 adminAccessToken: token, 93 httpClient: httpClient} 94 return &client, nil 95 } 96 97 // GetRealm gets realm data from Keycloak. 98 func (c *KeycloakRESTClient) GetRealm(realm string) (map[string]interface{}, error) { 99 requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s", c.keycloakIngressHost, realm) 100 request, err := retryablehttp.NewRequest("GET", requestURL, nil) 101 request.Host = c.keycloakIngressHost 102 request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken)) 103 request.Header.Add("Accept", "application/json") 104 if err != nil { 105 return nil, err 106 } 107 response, err := c.httpClient.Do(request) 108 if err != nil { 109 return nil, err 110 } 111 if response == nil { 112 return nil, fmt.Errorf("invalid response") 113 } 114 defer response.Body.Close() 115 if response.StatusCode != 200 { 116 return nil, fmt.Errorf("invalid response status: %d", response.StatusCode) 117 } 118 responseBody, err := io.ReadAll(response.Body) 119 if err != nil { 120 return nil, err 121 } 122 jsonMap := make(map[string]interface{}) 123 err = json.Unmarshal(responseBody, &jsonMap) 124 if err != nil { 125 return nil, err 126 } 127 return jsonMap, nil 128 } 129 130 // GetRealm gets a bearer token from a realm. 131 func (c *KeycloakRESTClient) GetToken(realm string, username string, password string, clientid string, log *zap.SugaredLogger) (string, error) { 132 form := url.Values{} 133 form.Add("username", username) 134 form.Add("password", password) 135 form.Add("grant_type", "password") 136 form.Add("client_id", clientid) 137 138 requestURL := fmt.Sprintf("https://%s/auth/realms/%s/protocol/openid-connect/token", c.keycloakIngressHost, realm) 139 response, err := PostWithHostHeader(requestURL, "application/x-www-form-urlencoded", c.keycloakIngressHost, strings.NewReader(form.Encode())) 140 log.Debugf("response: %s", response.Body) 141 if response.StatusCode != 200 { 142 return "", fmt.Errorf("invalid response status: %d", response.StatusCode) 143 } 144 if err != nil { 145 return "", err 146 } 147 if response.StatusCode != http.StatusOK { 148 return "", fmt.Errorf("failed to access token endpoint") 149 } 150 token := JTq(string(response.Body), "access_token").(string) 151 if token == "" { 152 return "", fmt.Errorf("failed to obtain valid access token") 153 } 154 155 return token, nil 156 } 157 158 // CreateUser creates a user in Keycloak 159 // curl -v http://localhost:8080/auth/admin/realms/apiv2/users -H "Content-Type: application/json" -H "Authorization: bearer $TOKEN" --data '{"username":"someuser", "firstName":"xyz", "lastName":"xyz", "email":"demo2@gmail.com", "enabled":"true"}' 160 func (c *KeycloakRESTClient) CreateUser(userRealm string, userName string, firstName string, lastName string, password string) (string, error) { 161 requestData := map[string]interface{}{ 162 "username": userName, 163 "firstName": firstName, 164 "lastName": lastName, 165 "credentials": [...]map[string]interface{}{{ 166 "type": "password", 167 "value": password, 168 "temporary": false}, 169 }, 170 } 171 requestBody, err := json.Marshal(requestData) 172 if err != nil { 173 fmt.Printf("marshal request failed: %v\n", err) 174 return "", err 175 } 176 177 requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users", c.keycloakIngressHost, userRealm) 178 request, err := retryablehttp.NewRequest("POST", requestURL, requestBody) 179 if err != nil { 180 return "", err 181 } 182 request.Host = c.keycloakIngressHost 183 request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken)) 184 request.Header.Add("Content-Type", "application/json") 185 response, err := c.httpClient.Do(request) 186 if err != nil { 187 return "", err 188 } 189 if response == nil { 190 return "", fmt.Errorf("invalid response") 191 } 192 defer response.Body.Close() 193 location := response.Header.Get("Location") 194 if response.StatusCode != 201 { 195 return location, fmt.Errorf("invalid response status code: %d", response.StatusCode) 196 } 197 if location == "" { 198 return location, fmt.Errorf("invalid response location") 199 } 200 return location, nil 201 } 202 203 // DeleteUser deletes a user from Keycloak 204 // DELETE /auth/admin/realms/<realm>/users/<userID> 205 func (c *KeycloakRESTClient) DeleteUser(userRealm string, userID string) error { 206 requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users/%s", c.keycloakIngressHost, userRealm, userID) 207 request, err := retryablehttp.NewRequest("DELETE", requestURL, nil) 208 if err != nil { 209 return err 210 } 211 request.Host = c.keycloakIngressHost 212 request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken)) 213 response, err := c.httpClient.Do(request) 214 if err != nil { 215 return err 216 } 217 if response == nil { 218 return fmt.Errorf("invalid response") 219 } 220 defer response.Body.Close() 221 // all responses in the 200 range are acceptable 222 // in practice, this call returns 204 (No Content) for success 223 if response.StatusCode < 200 || response.StatusCode >= 300 { 224 return fmt.Errorf("invalid response status: %d", response.StatusCode) 225 } 226 return nil 227 } 228 229 // VerifyUserExists verifies the user exists in Keycloak 230 // GET /auth/admin/realms/<realm>/users/<userID> 231 func (c *KeycloakRESTClient) VerifyUserExists(userRealm string, userID string) (bool, error) { 232 requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users/%s", c.keycloakIngressHost, userRealm, userID) 233 request, err := retryablehttp.NewRequest("GET", requestURL, nil) 234 if err != nil { 235 return false, err 236 } 237 request.Host = c.keycloakIngressHost 238 request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken)) 239 response, err := c.httpClient.Do(request) 240 if err != nil { 241 return false, err 242 } 243 if response == nil { 244 return false, fmt.Errorf("invalid response") 245 } 246 defer response.Body.Close() 247 if response.StatusCode != 200 { 248 return false, fmt.Errorf("invalid response status: %d", response.StatusCode) 249 } 250 return true, nil 251 } 252 253 // SetPassword sets a user's password in Keycloak 254 // PUT /auth/admin/realms/{realm}/users/{id}/reset-password 255 // { "type": "password", "temporary": false, "value": "..." } 256 func (c *KeycloakRESTClient) SetPassword(userRealm string, userID string, password string) error { 257 requestData := map[string]interface{}{ 258 "type": "password", 259 "value": password, 260 "temporary": false} 261 requestBody, err := json.Marshal(requestData) 262 if err != nil { 263 return err 264 } 265 requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users/%s/reset-password", c.keycloakIngressHost, userRealm, userID) 266 request, err := retryablehttp.NewRequest("PUT", requestURL, requestBody) 267 if err != nil { 268 fmt.Printf("create reset-password request failed=%v\n", err) 269 return err 270 } 271 request.Host = c.keycloakIngressHost 272 request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken)) 273 request.Header.Add("Content-Type", "application/json") 274 response, err := c.httpClient.Do(request) 275 if err != nil { 276 return err 277 } 278 if response == nil { 279 return fmt.Errorf("invalid response") 280 } 281 defer response.Body.Close() 282 if response.StatusCode != 204 { 283 return fmt.Errorf("invalid response status: %d", response.StatusCode) 284 } 285 return nil 286 } 287 288 // VerifyKeycloakAccess verifies access to Keycloak 289 func VerifyKeycloakAccess(log *zap.SugaredLogger) error { 290 waitTimeout := 5 * time.Minute 291 pollingInterval := 5 * time.Second 292 if !IsManagedClusterProfile() { 293 var keycloakURL string 294 kubeconfigPath, err := k8sutil.GetKubeConfigLocation() 295 if err != nil { 296 log.Error(fmt.Sprintf("Error getting kubeconfig: %v", err)) 297 return err 298 } 299 300 gomega.Eventually(func() error { 301 api := EventuallyGetAPIEndpoint(kubeconfigPath) 302 ingress, err := api.GetIngress("keycloak", "keycloak") 303 if err != nil { 304 return err 305 } 306 keycloakURL = fmt.Sprintf("https://%s", ingress.Spec.Rules[0].Host) 307 log.Infof("Found ingress URL: %s", keycloakURL) 308 return nil 309 }, waitTimeout, pollingInterval).ShouldNot(gomega.HaveOccurred()) 310 311 gomega.Expect(keycloakURL).NotTo(gomega.BeEmpty()) 312 var httpResponse *HTTPResponse 313 314 gomega.Eventually(func() (*HTTPResponse, error) { 315 var err error 316 httpResponse, err = GetWebPage(keycloakURL, "") 317 return httpResponse, err 318 }, waitTimeout, pollingInterval).Should(HasStatus(http.StatusOK)) 319 gomega.Expect(CheckNoServerHeader(httpResponse)).To(gomega.BeTrue(), "Found unexpected server header in response") 320 } 321 return nil 322 }