github.com/vmware/go-vcloud-director/v2@v2.24.0/govcd/org_saml.go (about) 1 /* 2 * Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License. 3 */ 4 5 package govcd 6 7 import ( 8 "bytes" 9 "encoding/json" 10 "encoding/xml" 11 "fmt" 12 "github.com/vmware/go-vcloud-director/v2/types/v56" 13 "github.com/vmware/go-vcloud-director/v2/util" 14 "io" 15 "net/http" 16 "net/url" 17 "regexp" 18 "strings" 19 ) 20 21 // GetFederationSettings retrieves the current federation (SAML) settings for a given organization 22 func (adminOrg *AdminOrg) GetFederationSettings() (*types.OrgFederationSettings, error) { 23 var settings types.OrgFederationSettings 24 25 if adminOrg.AdminOrg.OrgSettings == nil || adminOrg.AdminOrg.OrgSettings.Link == nil { 26 return nil, fmt.Errorf("no Org settings links found in Org %s", adminOrg.AdminOrg.Name) 27 } 28 fsUrl := getUrlFromLink(adminOrg.AdminOrg.OrgSettings.Link, "down", types.MimeFederationSettingsXml) 29 if fsUrl == "" { 30 return nil, fmt.Errorf("no link found for federation settings (SAML: %s) in Org %s", types.MimeFederationSettingsXml, adminOrg.AdminOrg.Name) 31 } 32 33 resp, err := adminOrg.client.ExecuteRequest(fsUrl, http.MethodGet, types.MimeFederationSettingsXml, 34 "error fetching federation settings: %s", nil, &settings) 35 36 if err != nil { 37 return nil, err 38 } 39 40 _, err = checkResp(resp, err) 41 if err != nil { 42 return nil, err 43 } 44 45 return &settings, nil 46 } 47 48 // SetFederationSettings creates or replaces federation (SAML) settings for a given organization 49 func (adminOrg *AdminOrg) SetFederationSettings(settings *types.OrgFederationSettings) (*types.OrgFederationSettings, error) { 50 51 if adminOrg.AdminOrg.OrgSettings == nil || adminOrg.AdminOrg.OrgSettings.Link == nil { 52 return nil, fmt.Errorf("no Org settings links found in Org %s", adminOrg.AdminOrg.Name) 53 } 54 fsUrl := getUrlFromLink(adminOrg.AdminOrg.OrgSettings.Link, "down", types.MimeFederationSettingsJson) 55 if fsUrl == "" { 56 return nil, fmt.Errorf("no URL found for federation settings (SAML) in Org %s", adminOrg.AdminOrg.Name) 57 } 58 59 setUrl, err := url.Parse(fsUrl) 60 if err != nil { 61 return nil, err 62 } 63 64 text := bytes.Buffer{} 65 encoder := json.NewEncoder(&text) 66 encoder.SetEscapeHTML(false) 67 err = encoder.Encode(settings) 68 if err != nil { 69 return nil, err 70 } 71 body := strings.NewReader(text.String()) 72 apiVersion := adminOrg.client.APIVersion 73 headAccept := http.Header{} 74 // NOTE: given that the UI uses JSON based API to run SAML settings, it seemed the safest way to 75 // imitate it and use JSON payload and results for this operation 76 headAccept.Set("Accept", types.JSONMime) 77 headAccept.Set("Content-Type", types.MimeFederationSettingsJson) 78 request := adminOrg.client.newRequest(nil, nil, http.MethodPut, *setUrl, body, apiVersion, headAccept) 79 request.Header.Set("Accept", fmt.Sprintf("application/*+json;version=%s", apiVersion)) 80 request.Header.Set("Content-Type", types.MimeFederationSettingsJson) 81 82 resp, err := adminOrg.client.Http.Do(request) 83 if err != nil { 84 return nil, err 85 } 86 87 if !isSuccessStatus(resp.StatusCode) { 88 body, _ := io.ReadAll(resp.Body) 89 var jsonError types.OpenApiError 90 err = json.Unmarshal(body, &jsonError) 91 // By default, we return the whole response body as error message. This may also contain the stack trace 92 message := string(body) 93 // if the body contains a valid JSON representation of the error, we return a more agile message, using the 94 // exposed fields, and hiding the stack trace from view 95 if err == nil { 96 message = fmt.Sprintf("%s - %s", jsonError.MinorErrorCode, jsonError.Message) 97 } 98 return nil, fmt.Errorf("error setting SAML for org %s: %s (%d) - %s", adminOrg.AdminOrg.Name, resp.Status, resp.StatusCode, message) 99 } 100 101 _, err = checkResp(resp, err) 102 if err != nil { 103 return nil, err 104 } 105 106 return adminOrg.GetFederationSettings() 107 } 108 109 // UnsetFederationSettings removes federation (SAML) settings for a given organization 110 func (adminOrg *AdminOrg) UnsetFederationSettings() error { 111 settings, err := adminOrg.GetFederationSettings() 112 if err != nil { 113 return fmt.Errorf("[UnsetFederationSettings] error getting SAML settings for Org %s: %s", adminOrg.AdminOrg.Name, err) 114 } 115 116 settings.SAMLMetadata = "" 117 settings.Enabled = false 118 _, err = adminOrg.SetFederationSettings(settings) 119 return err 120 } 121 122 // GetServiceProviderSamlMetadata retrieves the service provider SAML metadata of the given Org 123 func (adminOrg *AdminOrg) GetServiceProviderSamlMetadata() (*types.VcdSamlMetadata, error) { 124 125 metadataText, err := adminOrg.RetrieveServiceProviderSamlMetadata() 126 if err != nil { 127 return nil, err 128 } 129 var metadata types.VcdSamlMetadata 130 131 err = xml.Unmarshal([]byte(metadataText), &metadata) 132 if err != nil { 133 return nil, fmt.Errorf("[GetSamlMetadata] error decoding metadata retrieved from %s: %s", adminOrg.AdminOrg.Name, err) 134 } 135 136 return &metadata, nil 137 } 138 139 // RetrieveServiceProviderSamlMetadata retrieves the SAML metadata of the given Org 140 func (adminOrg *AdminOrg) RetrieveServiceProviderSamlMetadata() (string, error) { 141 142 settings, err := adminOrg.GetFederationSettings() 143 if err != nil { 144 return "", err 145 } 146 metadataUrl := getUrlFromLink(settings.Link, "down", types.MimeSamlMetadata) 147 if metadataUrl == "" { 148 return "", fmt.Errorf("[RetrieveRemoteDocument] no URL found for metadata retrieval (%s) in org %s", types.MimeSamlMetadata, adminOrg.AdminOrg.Name) 149 } 150 151 metadataText, err := adminOrg.client.RetrieveRemoteDocument(metadataUrl) 152 if err != nil { 153 return "", fmt.Errorf("[RetrieveRemoteDocument] error retrieving SAML metadata from %s: %s", metadataUrl, err) 154 } 155 return string(metadataText), nil 156 } 157 158 func getUrlFromLink(linkList types.LinkList, wantRel, wantType string) string { 159 for _, link := range linkList { 160 if link.Rel == wantRel && link.Type == wantType { 161 return link.HREF 162 } 163 } 164 return "" 165 } 166 167 var ( 168 // samlMetadataItems contains name space identifiers and corresponding tags 169 // that should be found in VCD SAML service provider metadata 170 samlMetadataItems = map[string][]string{ 171 "ds": { 172 "KeyInfo", 173 "X509Certificate", 174 "X509Data", 175 }, 176 "md": { 177 "AssertionConsumerService", 178 "EntityDescriptor", 179 "KeyDescriptor", 180 "NameIDFormat", 181 "SPSSODescriptor", 182 "SingleLogoutService", 183 }, 184 "hoksso": { 185 "ProtocolBinding", 186 }, 187 } 188 ) 189 190 // RetrieveRemoteDocument gets the contents of a given URL 191 func (client *Client) RetrieveRemoteDocument(metadataUrl string) ([]byte, error) { 192 193 retrieveUrl, err := url.Parse(metadataUrl) 194 if err != nil { 195 return nil, err 196 } 197 request := client.newRequest(nil, nil, http.MethodGet, *retrieveUrl, nil, client.APIVersion, nil) 198 199 resp, err := client.Http.Do(request) 200 201 if err != nil { 202 return nil, fmt.Errorf("[RetrieveRemoteDocument] error retrieving metadata from %s: %s", metadataUrl, err) 203 } 204 205 body, err := io.ReadAll(resp.Body) 206 if err != nil { 207 return nil, fmt.Errorf("[RetrieveRemoteDocument] error reading response body from metadata retrieved from %s: %s", metadataUrl, err) 208 } 209 210 util.ProcessResponseOutput("[RetrieveRemoteDocument]", resp, string(body)) 211 return body, nil 212 } 213 214 // normalizeServiceProviderSamlMetadata takes a string containing the XML code with Metadata definition 215 // and makes sure it has all the expected elements 216 func normalizeServiceProviderSamlMetadata(in string) (string, error) { 217 var metadata types.VcdSamlMetadata 218 219 // Phase 1: Decode the XML, to find possible encoding errors 220 err := xml.Unmarshal([]byte(in), &metadata) 221 if err != nil { 222 return "", fmt.Errorf("[normalizeSamlMetadata] error decoding SAML metadata definition from XML: %s", err) 223 } 224 225 // Phase 2: Add the namespace definition elements, required to recognize the structure as a valid SAML definition 226 metadata.Md = types.SamlNamespaceMd 227 metadata.SPSSODescriptor.Ds = types.SamlNamespaceDs 228 for i := 0; i < len(metadata.SPSSODescriptor.AssertionConsumerService); i++ { 229 metadata.SPSSODescriptor.AssertionConsumerService[i].Hoksso = types.SamlNamespaceHoksso 230 } 231 232 // Phase 3: Convert the data structure to text again. The text now includes the needed namespace definition elements 233 out, err := xml.Marshal(metadata) 234 if err != nil { 235 return "", fmt.Errorf("[normalizeSamlMetadata] error encoding SAML metadata text: %s", err) 236 } 237 238 // Phase 4: Add the namespace elements to the XML text 239 metadataText := string(out) 240 for ns, fields := range samlMetadataItems { 241 if !strings.Contains(metadataText, ns) { 242 return metadataText, fmt.Errorf("[normalizeSamlMetadata] namespace '%s' not found in SAML metadata", ns) 243 } 244 for _, fieldName := range fields { 245 fullName := fmt.Sprintf("%s:%s", ns, fieldName) 246 // If we find just "FieldName", but not "namespace:FieldName", then we replace the bare FieldName with the full identifier 247 if strings.Contains(metadataText, fieldName) && !strings.Contains(metadataText, fullName) { 248 metadataText = strings.Replace(metadataText, fieldName, fullName, -1) 249 } 250 } 251 } 252 253 return metadataText, nil 254 } 255 256 // validateNamespaceDefinition checks that a metadata XML text contains the expected namespace definition 257 func validateNamespaceDefinition(metadataText string, namespace string) bool { 258 reEmptyDefinition := regexp.MustCompile(`xmlns:` + namespace + `\s*=\s*""`) 259 reFilledDefinition := regexp.MustCompile(`xmlns:` + namespace + `\s*=\s*"\S+"`) 260 // Check that the namespace is mentioned at all in the metadata text 261 if !strings.Contains(metadataText, namespace) { 262 return false 263 } 264 // Check that an empty namespace definition is NOT found in the metadata text 265 // (for example: xmlns:md="") 266 if reEmptyDefinition.FindString(metadataText) != "" { 267 return false 268 } 269 // Check that a filled namespace definition is found in the metadata text 270 // (for example: xmlns:md="something") 271 found := reFilledDefinition.FindString(metadataText) 272 return found != "" 273 } 274 275 // ValidateSamlServiceProviderMetadata tells whether a given string contains valid XML that defines SAML service provider metadata 276 // Returns nil on valid data, and an array of errors for invalid data 277 func ValidateSamlServiceProviderMetadata(metadataText string) []error { 278 var metadata types.VcdSamlMetadata 279 var errors []error 280 281 // Check n. 1: encode the string into XML, thus establishing that it is valid syntax 282 err := xml.Unmarshal([]byte(metadataText), &metadata) 283 if err != nil { 284 errors = append(errors, fmt.Errorf("[ValidateSamlMetadata] error decoding XML into SAML metadata structure: %s", err)) 285 } 286 287 reNameSpace, err := regexp.Compile(`<(\w+):(\w+)`) 288 289 if err != nil { 290 errors = append(errors, fmt.Errorf("error compiling regular expression: %s", err)) 291 return errors 292 } 293 294 nsInfoList := reNameSpace.FindAllStringSubmatch(metadataText, -1) 295 processed := map[string]bool{} 296 297 // Check n. 2: make sure that each namespace used in the metadata text has a corresponding definition 298 for _, nsInfo := range nsInfoList { 299 seen, ok := processed[nsInfo[0]] 300 if ok && seen { 301 continue 302 } 303 ns := nsInfo[1] 304 if !validateNamespaceDefinition(metadataText, ns) { 305 errors = append(errors, fmt.Errorf("[ValidateSamlMetadata] namespace '%s' undefined in SAML metadata", ns)) 306 } 307 processed[nsInfo[0]] = true 308 } 309 310 if len(errors) == 0 { 311 return nil 312 } 313 return errors 314 } 315 316 // GetErrorMessageFromErrorSlice returns a single error message from a list of error 317 func GetErrorMessageFromErrorSlice(errors []error) string { 318 result := "" 319 for i, err := range errors { 320 result = fmt.Sprintf("%s\n%2d %s", result, i, err) 321 } 322 return result 323 }