You've already forked sap-jenkins-library
							
							
				mirror of
				https://github.com/SAP/jenkins-library.git
				synced 2025-10-30 23:57:50 +02:00 
			
		
		
		
	Add bearer token retrieval function (#3595)
* Add bearer token retrieval function Retrieving a bearer token from the xsuaa service on BTP is always the same. With these functions one can retrieve a bearer token and set it to the given header as 'Authorization'. * CodeClimate fixes * Refactor test * Add basic auth to token retrieve request Co-authored-by: Thorsten Duda <thorsten.duda@sap.com>
This commit is contained in:
		
							
								
								
									
										136
									
								
								pkg/xsuaa/xsuaa.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								pkg/xsuaa/xsuaa.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| package xsuaa | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"github.com/pkg/errors" | ||||
| 	"io/ioutil" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| const authHeaderKey = "Authorization" | ||||
| const oneHourInSeconds = 3600.0 | ||||
|  | ||||
| // XSUAA contains the fields to authenticate to a xsuaa service instance on BTP to retrieve a access token | ||||
| // It also caches the latest retrieved access token | ||||
| type XSUAA struct { | ||||
| 	OAuthURL        string | ||||
| 	ClientID        string | ||||
| 	ClientSecret    string | ||||
| 	CachedAuthToken AuthToken | ||||
| } | ||||
|  | ||||
| // AuthToken provides a structure for the XSUAA auth token to be marshalled into | ||||
| type AuthToken struct { | ||||
| 	TokenType   string        `json:"token_type"` | ||||
| 	AccessToken string        `json:"access_token"` | ||||
| 	ExpiresIn   time.Duration `json:"expires_in"` | ||||
| 	ExpiresAt   time.Time | ||||
| } | ||||
|  | ||||
| // SetAuthHeaderIfNotPresent retrieves a XSUAA bearer token and sets the 'Authorization' header on a given http.Header. | ||||
| // If another 'Authorization' header is already present, no change is done to the given header. | ||||
| func (x *XSUAA) SetAuthHeaderIfNotPresent(header *http.Header) error { | ||||
| 	if len(header.Get(authHeaderKey)) > 0 { | ||||
| 		return nil | ||||
| 	} | ||||
| 	if len(x.OAuthURL) == 0 || | ||||
| 		len(x.ClientID) == 0 || | ||||
| 		len(x.ClientSecret) == 0 { | ||||
| 		return errors.Errorf("OAuthURL, ClientID and ClientSecret have to be set on the xsuaa instance") | ||||
| 	} | ||||
|  | ||||
| 	secondsOfValidityLeft := x.CachedAuthToken.ExpiresAt.Sub(time.Now()).Seconds() | ||||
| 	if len(x.CachedAuthToken.AccessToken) == 0 || | ||||
| 		(secondsOfValidityLeft > 0 && secondsOfValidityLeft < oneHourInSeconds) { | ||||
| 		token, err := x.GetBearerToken() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		x.CachedAuthToken = token | ||||
| 	} | ||||
| 	header.Add(authHeaderKey, fmt.Sprintf("%s %s", x.CachedAuthToken.TokenType, x.CachedAuthToken.AccessToken)) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // GetBearerToken authenticates to and retrieves the auth information from the provided XSUAA oAuth base url. The following path | ||||
| // and query is always used: /oauth/token?grant_type=client_credentials&response_type=token. The gotten JSON string is marshalled | ||||
| // into an AuthToken struct and returned. If no 'access_token' field was present in the JSON response, an error is returned. | ||||
| func (x *XSUAA) GetBearerToken() (authToken AuthToken, err error) { | ||||
| 	const method = http.MethodGet | ||||
| 	const urlPathAndQuery = "oauth/token?grant_type=client_credentials&response_type=token" | ||||
|  | ||||
| 	oauthBaseURL, err := url.Parse(x.OAuthURL) | ||||
| 	if err != nil { | ||||
| 		return | ||||
| 	} | ||||
| 	entireURL := fmt.Sprintf("%s://%s/%s", oauthBaseURL.Scheme, oauthBaseURL.Host, urlPathAndQuery) | ||||
|  | ||||
| 	httpClient := http.Client{} | ||||
|  | ||||
| 	request, err := http.NewRequest(method, entireURL, nil) | ||||
| 	if err != nil { | ||||
| 		return | ||||
| 	} | ||||
| 	request.Header.Add("Accept", "application/json") | ||||
| 	request.SetBasicAuth(x.ClientID, x.ClientSecret) | ||||
|  | ||||
| 	response, httpErr := httpClient.Do(request) | ||||
| 	if httpErr != nil { | ||||
| 		err = errors.Wrapf(httpErr, "fetching an access token failed: HTTP %s request to %s failed", | ||||
| 			method, entireURL) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	bodyText, err := readResponseBody(response) | ||||
| 	if err != nil { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if response.StatusCode != http.StatusOK { | ||||
| 		err = errors.Errorf("fetching an access token failed: HTTP %s request to %s failed: "+ | ||||
| 			"expected response code 200, got '%d', response body: '%s'", | ||||
| 			method, entireURL, response.StatusCode, bodyText) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	parsingErr := json.Unmarshal(bodyText, &authToken) | ||||
| 	if err != nil { | ||||
| 		err = errors.Wrapf(parsingErr, "HTTP response body could not be parsed as JSON: %s", bodyText) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if authToken.AccessToken == "" { | ||||
| 		err = errors.Errorf("expected authToken field 'access_token' in json response: got response body: '%s'", | ||||
| 			bodyText) | ||||
| 		return | ||||
| 	} | ||||
| 	if authToken.TokenType == "" { | ||||
| 		authToken.TokenType = "bearer" | ||||
| 	} | ||||
| 	if authToken.ExpiresIn > 0 { | ||||
| 		authToken.ExpiresAt = setExpireTime(time.Now(), authToken.ExpiresIn) | ||||
| 	} | ||||
|  | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func setExpireTime(now time.Time, secondsValid time.Duration) time.Time { | ||||
| 	return now.Add(time.Second * secondsValid) | ||||
| } | ||||
|  | ||||
| func readResponseBody(response *http.Response) ([]byte, error) { | ||||
| 	if response == nil { | ||||
| 		return nil, errors.Errorf("did not retrieve an HTTP response") | ||||
| 	} | ||||
| 	if response.Body != nil { | ||||
| 		defer response.Body.Close() | ||||
| 	} | ||||
| 	bodyText, readErr := ioutil.ReadAll(response.Body) | ||||
| 	if readErr != nil { | ||||
| 		return nil, errors.Wrap(readErr, "HTTP response body could not be read") | ||||
| 	} | ||||
| 	return bodyText, nil | ||||
| } | ||||
							
								
								
									
										361
									
								
								pkg/xsuaa/xsuaa_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										361
									
								
								pkg/xsuaa/xsuaa_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,361 @@ | ||||
| package xsuaa | ||||
|  | ||||
| import ( | ||||
| 	"encoding/base64" | ||||
| 	"github.com/jarcoal/httpmock" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func TestXSUAA_GetBearerToken(t *testing.T) { | ||||
| 	type ( | ||||
| 		fields struct { | ||||
| 			ClientID     string | ||||
| 			ClientSecret string | ||||
| 		} | ||||
| 		want struct { | ||||
| 			authToken AuthToken | ||||
| 			errRegex  string | ||||
| 		} | ||||
| 		response struct { | ||||
| 			statusCode int | ||||
| 			bodyText   string | ||||
| 		} | ||||
| 	) | ||||
| 	tests := []struct { | ||||
| 		name         string | ||||
| 		fields       fields | ||||
| 		oauthUrlPath string | ||||
| 		want         want | ||||
| 		response     response | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "Straight forward", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			want: want{ | ||||
| 				authToken: AuthToken{ | ||||
| 					TokenType:   "bearer", | ||||
| 					AccessToken: "1234", | ||||
| 					ExpiresIn:   9876, | ||||
| 				}}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "expires_in": 9876, "token_type": "bearer"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "No expiring duration", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			want: want{ | ||||
| 				authToken: AuthToken{ | ||||
| 					TokenType:   "bearer", | ||||
| 					AccessToken: "1234", | ||||
| 				}}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "token_type": "bearer"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "OAuth Url with path", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			oauthUrlPath: "/oauth/token?grant_type=client_credentials", | ||||
| 			want: want{ | ||||
| 				authToken: AuthToken{ | ||||
| 					TokenType:   "bearer", | ||||
| 					AccessToken: "1234", | ||||
| 					ExpiresIn:   9876, | ||||
| 				}}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "expires_in": 9876, "token_type": "bearer"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "No token type", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			want: want{ | ||||
| 				authToken: AuthToken{ | ||||
| 					TokenType:   "bearer", | ||||
| 					AccessToken: "1234", | ||||
| 					ExpiresIn:   9876, | ||||
| 				}}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "expires_in": 9876}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "HTTP error", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			want: want{errRegex: `fetching an access token failed: HTTP GET request to .*/oauth/token\?grant_type=client_credentials&response_type=token ` + | ||||
| 				`failed: expected response code 200, got '401', response body: '{"error": "unauthorized"}'`}, | ||||
| 			response: response{ | ||||
| 				statusCode: 401, | ||||
| 				bodyText:   `{"error": "unauthorized"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Wrong response code", | ||||
| 			want: want{errRegex: `expected response code 200, got '201', response body: '{"success": "created"}'`}, | ||||
| 			response: response{ | ||||
| 				statusCode: 201, | ||||
| 				bodyText:   `{"success": "created"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "No 'access_token' field in json response", | ||||
| 			want: want{errRegex: `expected authToken field 'access_token' in json response: got response body: '{"authToken": "1234"}'`}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"authToken": "1234"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			var requestedUrlPath string | ||||
| 			var requestedAuthHeader string | ||||
| 			// Start a local HTTP server | ||||
| 			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				requestedUrlPath = req.URL.String() | ||||
| 				if tt.response.statusCode != 0 { | ||||
| 					rw.WriteHeader(tt.response.statusCode) | ||||
| 				} | ||||
| 				requestedAuthHeader = req.Header.Get(authHeaderKey) | ||||
| 				rw.Write([]byte(tt.response.bodyText)) | ||||
| 			})) | ||||
| 			// Close the server when test finishes | ||||
| 			defer server.Close() | ||||
|  | ||||
| 			oauthUrl := server.URL + tt.oauthUrlPath | ||||
| 			x := &XSUAA{ | ||||
| 				OAuthURL:     oauthUrl, | ||||
| 				ClientID:     tt.fields.ClientID, | ||||
| 				ClientSecret: tt.fields.ClientSecret, | ||||
| 			} | ||||
| 			gotToken, err := x.GetBearerToken() | ||||
| 			if tt.want.errRegex != "" { | ||||
| 				require.Error(t, err, "Error expected") | ||||
| 				assert.Regexp(t, tt.want.errRegex, err.Error()) | ||||
| 				return | ||||
| 			} | ||||
| 			require.NoError(t, err, "No error expected") | ||||
| 			assert.Equal(t, tt.want.authToken.TokenType, gotToken.TokenType, "Did not receive expected token type.") | ||||
| 			assert.Equal(t, tt.want.authToken.AccessToken, gotToken.AccessToken, "Did not receive expected access token.") | ||||
| 			if tt.want.authToken.ExpiresIn == 0 { | ||||
| 				assert.Equal(t, time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), | ||||
| 					gotToken.ExpiresAt, "ExpiresAt should be date zero") | ||||
| 			} else { | ||||
| 				assert.NotEqual(t, time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), | ||||
| 					gotToken.ExpiresAt, "ExpiresAt should be proper date") | ||||
| 			} | ||||
| 			wantUrlPath := "/oauth/token?grant_type=client_credentials&response_type=token" | ||||
| 			assert.Equal(t, wantUrlPath, requestedUrlPath) | ||||
| 			wantAuth := tt.fields.ClientID + ":" + tt.fields.ClientSecret | ||||
| 			assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte(wantAuth)), requestedAuthHeader) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func Test_readResponseBody(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name        string | ||||
| 		response    *http.Response | ||||
| 		want        []byte | ||||
| 		wantErrText string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:     "Straight forward", | ||||
| 			response: httpmock.NewStringResponse(200, "test string"), | ||||
| 			want:     []byte("test string"), | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "No response error", | ||||
| 			wantErrText: "did not retrieve an HTTP response", | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			got, err := readResponseBody(tt.response) | ||||
| 			if tt.wantErrText != "" { | ||||
| 				require.Error(t, err, "Error expected") | ||||
| 				assert.EqualError(t, err, tt.wantErrText, "Error is not equal") | ||||
| 				return | ||||
| 			} | ||||
| 			require.NoError(t, err, "No error expected") | ||||
| 			assert.Equal(t, tt.want, got, "Did not receive expected body") | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestXSUAA_SetAuthHeaderIfNotPresent(t *testing.T) { | ||||
| 	type ( | ||||
| 		fields struct { | ||||
| 			ClientID        string | ||||
| 			ClientSecret    string | ||||
| 			CachedAuthToken AuthToken | ||||
| 		} | ||||
| 		args struct { | ||||
| 			authHeader string | ||||
| 		} | ||||
| 		want struct { | ||||
| 			token    string | ||||
| 			errRegex string | ||||
| 		} | ||||
| 		response struct { | ||||
| 			statusCode int | ||||
| 			bodyText   string | ||||
| 		} | ||||
| 	) | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		fields   fields | ||||
| 		args     args | ||||
| 		want     want | ||||
| 		response response | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "Straight forward", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			want: want{token: "bearer 1234"}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "expires_in": 9876, "token_type": "bearer"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Error case", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			want: want{errRegex: `fetching an access token failed: HTTP GET request to .*/oauth/token\?grant_type=client_credentials&response_type=token ` + | ||||
| 				`failed: expected response code 200, got '401', response body: '{"error": "unauthorized"}'`}, | ||||
| 			response: response{ | ||||
| 				statusCode: 401, | ||||
| 				bodyText:   `{"error": "unauthorized"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Missing field parameter", | ||||
| 			fields: fields{ | ||||
| 				ClientID: "myClientID", | ||||
| 			}, | ||||
| 			want: want{errRegex: `OAuthURL, ClientID and ClientSecret have to be set on the xsuaa instance`}, | ||||
| 			response: response{ | ||||
| 				statusCode: 401, | ||||
| 				bodyText:   `{"error": "unauthorized"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Different token type", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			want: want{token: "jwt 1234"}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "expires_in": 9876, "token_type": "jwt"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Auth authHeader already set", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 			}, | ||||
| 			args: args{authHeader: "basic eW91aGF2ZXRvb211Y2g6dGltZQ=="}, | ||||
| 			want: want{token: "basic eW91aGF2ZXRvb211Y2g6dGltZQ=="}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "expires_in": 9876, "token_type": "jwt"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Valid token skips getting a new one", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 				CachedAuthToken: AuthToken{ | ||||
| 					TokenType:   "bearer", | ||||
| 					AccessToken: "4321", | ||||
| 					ExpiresAt:   time.Now().Add(43200 * time.Second), | ||||
| 				}, | ||||
| 			}, | ||||
| 			want: want{token: "bearer 4321"}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Token about to expire", | ||||
| 			fields: fields{ | ||||
| 				ClientID:     "myClientID", | ||||
| 				ClientSecret: "secret", | ||||
| 				CachedAuthToken: AuthToken{ | ||||
| 					TokenType:   "junk", | ||||
| 					AccessToken: "4321", | ||||
| 					ExpiresAt:   time.Now().Add(100 * time.Second), | ||||
| 				}, | ||||
| 			}, | ||||
| 			want: want{token: "bearer 1234"}, | ||||
| 			response: response{ | ||||
| 				bodyText: `{"access_token": "1234", "expires_in": 9876, "token_type": "bearer"}`, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			// Start a local HTTP server | ||||
| 			server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { | ||||
| 				if tt.response.statusCode != 0 { | ||||
| 					rw.WriteHeader(tt.response.statusCode) | ||||
| 				} | ||||
| 				rw.Write([]byte(tt.response.bodyText)) | ||||
| 			})) | ||||
| 			// Close the server when test finishes | ||||
| 			defer server.Close() | ||||
|  | ||||
| 			x := &XSUAA{ | ||||
| 				OAuthURL:        server.URL, | ||||
| 				ClientID:        tt.fields.ClientID, | ||||
| 				ClientSecret:    tt.fields.ClientSecret, | ||||
| 				CachedAuthToken: tt.fields.CachedAuthToken, | ||||
| 			} | ||||
| 			header := make(http.Header) | ||||
| 			if len(tt.args.authHeader) > 0 { | ||||
| 				header.Add(authHeaderKey, tt.args.authHeader) | ||||
| 			} | ||||
| 			err := x.SetAuthHeaderIfNotPresent(&header) | ||||
| 			if tt.want.errRegex != "" { | ||||
| 				require.Error(t, err, "Error expected") | ||||
| 				assert.Regexp(t, tt.want.errRegex, err.Error(), "") | ||||
| 				return | ||||
| 			} | ||||
| 			require.NoError(t, err, "No error expected") | ||||
| 			assert.Equal(t, tt.want.token, header.Get("Authorization")) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func Test_setExpireTime(t *testing.T) { | ||||
| 	t.Run("Straight forward", func(t *testing.T) { | ||||
| 		dummyTime := time.Date(2022, 1, 1, 12, 0, 0, 0, time.UTC) | ||||
| 		got := setExpireTime(dummyTime, time.Duration(43200)) | ||||
| 		want := time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC) | ||||
| 		assert.Equal(t, got, want, "Time should have increased by 12 hours") | ||||
| 	}) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user