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  }