1
0
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:
Oliver Feldmann
2022-03-16 11:22:48 +01:00
committed by GitHub
parent 7ec512cb9f
commit 4208bc050c
2 changed files with 497 additions and 0 deletions

136
pkg/xsuaa/xsuaa.go Normal file
View 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
View 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")
})
}