k8s.io/kubernetes@v1.29.3/pkg/credentialprovider/azure/azure_acr_helper.go (about) 1 //go:build !providerless 2 // +build !providerless 3 4 /* 5 Copyright 2016 The Kubernetes Authors. 6 7 Licensed under the Apache License, Version 2.0 (the "License"); 8 you may not use this file except in compliance with the License. 9 You may obtain a copy of the License at 10 11 http://www.apache.org/licenses/LICENSE-2.0 12 13 Unless required by applicable law or agreed to in writing, software 14 distributed under the License is distributed on an "AS IS" BASIS, 15 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 See the License for the specific language governing permissions and 17 limitations under the License. 18 */ 19 20 /* 21 Copyright 2017 Microsoft Corporation 22 23 MIT License 24 25 Copyright (c) Microsoft Corporation. All rights reserved. 26 27 Permission is hereby granted, free of charge, to any person obtaining a copy 28 of this software and associated documentation files (the "Software"), to deal 29 in the Software without restriction, including without limitation the rights 30 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 copies of the Software, and to permit persons to whom the Software is 32 furnished to do so, subject to the following conditions: 33 34 The above copyright notice and this permission notice shall be included in all 35 copies or substantial portions of the Software. 36 37 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 43 SOFTWARE 44 */ 45 46 // Source: https://github.com/Azure/acr-docker-credential-helper/blob/a79b541f3ee761f6cc4511863ed41fb038c19464/src/docker-credential-acr/acr_login.go 47 48 package azure 49 50 import ( 51 "bytes" 52 "encoding/json" 53 "errors" 54 "fmt" 55 "io" 56 "net/http" 57 "net/url" 58 "strconv" 59 "strings" 60 "time" 61 "unicode" 62 63 utilnet "k8s.io/apimachinery/pkg/util/net" 64 ) 65 66 type authDirective struct { 67 service string 68 realm string 69 } 70 71 type acrAuthResponse struct { 72 RefreshToken string `json:"refresh_token"` 73 } 74 75 // 5 minutes buffer time to allow timeshift between local machine and AAD 76 const userAgentHeader = "User-Agent" 77 const userAgent = "kubernetes-credentialprovider-acr" 78 79 const dockerTokenLoginUsernameGUID = "00000000-0000-0000-0000-000000000000" 80 81 var client = &http.Client{ 82 Transport: utilnet.SetTransportDefaults(&http.Transport{}), 83 Timeout: time.Second * 60, 84 } 85 86 func receiveChallengeFromLoginServer(serverAddress string) (*authDirective, error) { 87 challengeURL := url.URL{ 88 Scheme: "https", 89 Host: serverAddress, 90 Path: "v2/", 91 } 92 var err error 93 var r *http.Request 94 r, err = http.NewRequest("GET", challengeURL.String(), nil) 95 if err != nil { 96 return nil, fmt.Errorf("failed to construct request, got %v", err) 97 } 98 r.Header.Add(userAgentHeader, userAgent) 99 100 var challenge *http.Response 101 if challenge, err = client.Do(r); err != nil { 102 return nil, fmt.Errorf("error reaching registry endpoint %s, error: %s", challengeURL.String(), err) 103 } 104 defer challenge.Body.Close() 105 106 if challenge.StatusCode != 401 { 107 return nil, fmt.Errorf("registry did not issue a valid AAD challenge, status: %d", challenge.StatusCode) 108 } 109 110 var authHeader []string 111 var ok bool 112 if authHeader, ok = challenge.Header["Www-Authenticate"]; !ok { 113 return nil, fmt.Errorf("challenge response does not contain header 'Www-Authenticate'") 114 } 115 116 if len(authHeader) != 1 { 117 return nil, fmt.Errorf("registry did not issue a valid AAD challenge, authenticate header [%s]", 118 strings.Join(authHeader, ", ")) 119 } 120 121 authSections := strings.SplitN(authHeader[0], " ", 2) 122 authType := strings.ToLower(authSections[0]) 123 var authParams *map[string]string 124 if authParams, err = parseAssignments(authSections[1]); err != nil { 125 return nil, fmt.Errorf("unable to understand the contents of Www-Authenticate header %s", authSections[1]) 126 } 127 128 // verify headers 129 if !strings.EqualFold("Bearer", authType) { 130 return nil, fmt.Errorf("Www-Authenticate: expected realm: Bearer, actual: %s", authType) 131 } 132 if len((*authParams)["service"]) == 0 { 133 return nil, fmt.Errorf("Www-Authenticate: missing header \"service\"") 134 } 135 if len((*authParams)["realm"]) == 0 { 136 return nil, fmt.Errorf("Www-Authenticate: missing header \"realm\"") 137 } 138 139 return &authDirective{ 140 service: (*authParams)["service"], 141 realm: (*authParams)["realm"], 142 }, nil 143 } 144 145 func performTokenExchange( 146 serverAddress string, 147 directive *authDirective, 148 tenant string, 149 accessToken string) (string, error) { 150 var err error 151 data := url.Values{ 152 "service": []string{directive.service}, 153 "grant_type": []string{"access_token_refresh_token"}, 154 "access_token": []string{accessToken}, 155 "refresh_token": []string{accessToken}, 156 "tenant": []string{tenant}, 157 } 158 159 var realmURL *url.URL 160 if realmURL, err = url.Parse(directive.realm); err != nil { 161 return "", fmt.Errorf("Www-Authenticate: invalid realm %s", directive.realm) 162 } 163 authEndpoint := fmt.Sprintf("%s://%s/oauth2/exchange", realmURL.Scheme, realmURL.Host) 164 165 datac := data.Encode() 166 var r *http.Request 167 r, err = http.NewRequest("POST", authEndpoint, bytes.NewBufferString(datac)) 168 if err != nil { 169 return "", fmt.Errorf("failed to construct request, got %v", err) 170 } 171 r.Header.Add(userAgentHeader, userAgent) 172 r.Header.Add("Content-Type", "application/x-www-form-urlencoded") 173 r.Header.Add("Content-Length", strconv.Itoa(len(datac))) 174 175 var exchange *http.Response 176 if exchange, err = client.Do(r); err != nil { 177 return "", fmt.Errorf("Www-Authenticate: failed to reach auth url %s", authEndpoint) 178 } 179 180 defer exchange.Body.Close() 181 if exchange.StatusCode != 200 { 182 return "", fmt.Errorf("Www-Authenticate: auth url %s responded with status code %d", authEndpoint, exchange.StatusCode) 183 } 184 185 var content []byte 186 limitedReader := &io.LimitedReader{R: exchange.Body, N: maxReadLength} 187 if content, err = io.ReadAll(limitedReader); err != nil { 188 return "", fmt.Errorf("Www-Authenticate: error reading response from %s", authEndpoint) 189 } 190 191 if limitedReader.N <= 0 { 192 return "", errors.New("the read limit is reached") 193 } 194 195 var authResp acrAuthResponse 196 if err = json.Unmarshal(content, &authResp); err != nil { 197 return "", fmt.Errorf("Www-Authenticate: unable to read response %s", content) 198 } 199 200 return authResp.RefreshToken, nil 201 } 202 203 // Try and parse a string of assignments in the form of: 204 // key1 = value1, key2 = "value 2", key3 = "" 205 // Note: this method and handle quotes but does not handle escaping of quotes 206 func parseAssignments(statements string) (*map[string]string, error) { 207 var cursor int 208 result := make(map[string]string) 209 var errorMsg = fmt.Errorf("malformed header value: %s", statements) 210 for { 211 // parse key 212 equalIndex := nextOccurrence(statements, cursor, "=") 213 if equalIndex == -1 { 214 return nil, errorMsg 215 } 216 key := strings.TrimSpace(statements[cursor:equalIndex]) 217 218 // parse value 219 cursor = nextNoneSpace(statements, equalIndex+1) 220 if cursor == -1 { 221 return nil, errorMsg 222 } 223 // case: value is quoted 224 if statements[cursor] == '"' { 225 cursor = cursor + 1 226 // like I said, not handling escapes, but this will skip any comma that's 227 // within the quotes which is somewhat more likely 228 closeQuoteIndex := nextOccurrence(statements, cursor, "\"") 229 if closeQuoteIndex == -1 { 230 return nil, errorMsg 231 } 232 value := statements[cursor:closeQuoteIndex] 233 result[key] = value 234 235 commaIndex := nextNoneSpace(statements, closeQuoteIndex+1) 236 if commaIndex == -1 { 237 // no more comma, done 238 return &result, nil 239 } else if statements[commaIndex] != ',' { 240 // expect comma immediately after close quote 241 return nil, errorMsg 242 } else { 243 cursor = commaIndex + 1 244 } 245 } else { 246 commaIndex := nextOccurrence(statements, cursor, ",") 247 endStatements := commaIndex == -1 248 var untrimmed string 249 if endStatements { 250 untrimmed = statements[cursor:commaIndex] 251 } else { 252 untrimmed = statements[cursor:] 253 } 254 value := strings.TrimSpace(untrimmed) 255 256 if len(value) == 0 { 257 // disallow empty value without quote 258 return nil, errorMsg 259 } 260 261 result[key] = value 262 263 if endStatements { 264 return &result, nil 265 } 266 cursor = commaIndex + 1 267 } 268 } 269 } 270 271 func nextOccurrence(str string, start int, sep string) int { 272 if start >= len(str) { 273 return -1 274 } 275 offset := strings.Index(str[start:], sep) 276 if offset == -1 { 277 return -1 278 } 279 return offset + start 280 } 281 282 func nextNoneSpace(str string, start int) int { 283 if start >= len(str) { 284 return -1 285 } 286 offset := strings.IndexFunc(str[start:], func(c rune) bool { return !unicode.IsSpace(c) }) 287 if offset == -1 { 288 return -1 289 } 290 return offset + start 291 }