Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support login to UAA using API token #819

Merged
merged 2 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions pkg/auth/common/login_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ func WithCertInfo(tlsSkipVerify bool, caCertData string) LoginOption {
}
}

// WithClientID specifies a OAuth Client ID to use
func WithClientID(clientID string) LoginOption {
return func(h *TanzuLoginHandler) error {
h.clientID = clientID
if h.oauthConfig != nil {
h.oauthConfig.ClientID = clientID
}
return nil
}
}

// WithListenerPort specifies a TCP listener port on localhost, which will be used for the redirect_uri and to handle the
// authorization code callback. By default, a random high port will be chosen which requires the authorization server
// to support wildcard port numbers as described by https://tools.ietf.org/html/rfc8252#section-7.3:
Expand Down
6 changes: 4 additions & 2 deletions pkg/auth/common/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const (
extraIDToken = "id_token"
)

var currentTime = time.Now
marckhouzam marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind adding a comment to explain that currentTime is used to allow tests to override it?

Copy link
Contributor

@anujc25 anujc25 Oct 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be removed and addressed as part of #820 (comment) by using separate package.


const (
APITokenType = "api-token"
IDTokenType = "id-token"
Expand Down Expand Up @@ -97,7 +99,7 @@ func GetToken(g *types.GlobalServerAuth, tokenGetter func(refreshOrAPIToken, acc
g.RefreshToken = token.RefreshToken
g.AccessToken = token.AccessToken
g.IDToken = token.IDToken
expiration := time.Now().Local().Add(time.Duration(token.ExpiresIn))
expiration := currentTime().Local().Add(time.Duration(token.ExpiresIn) * time.Second)
g.Expiration = expiration
g.Permissions = claims.Permissions

Expand Down Expand Up @@ -171,7 +173,7 @@ func ParseToken(tkn *oauth2.Token, idpType config.IdpType) (*Claims, error) {
func IsExpired(tokenExpiry time.Time) bool {
// refresh at half token life
two := 2
now := time.Now().Unix()
now := currentTime().Unix()
marckhouzam marked this conversation as resolved.
Show resolved Hide resolved
halfDur := -time.Duration((tokenExpiry.Unix()-now)/int64(two)) * time.Second
return tokenExpiry.Add(halfDur).Unix() < now
}
14 changes: 12 additions & 2 deletions pkg/auth/common/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,19 @@ func TestGetToken_Valid_NotExpired(t *testing.T) {
}

func TestGetToken_Expired(t *testing.T) {
var theOneNow = time.Now()
// override currentTime to always returns same value
currentTime = func() time.Time {
return theOneNow
}

assert := assert.New(t)

accessToken := generateJWTToken(
`{"sub":"1234567890","username":"joe","context_name":"1516239022"}`,
)
expireTime := time.Now().Add(-time.Minute * 30)

expireTime := currentTime().Add(-time.Minute * 30)

serverAuth := configtypes.GlobalServerAuth{
Issuer: "https://oidc.example.com",
Expand All @@ -206,7 +213,8 @@ func TestGetToken_Expired(t *testing.T) {
}

newRefreshToken := "LetMeInAgain"
newExpiry := int64(time.Until(time.Now().Add(time.Minute * 30)).Seconds())
newExpiryTime := currentTime().Local().Add(time.Minute * 30)
newExpiry := int64(30 * 60)

tokenGetter := createMockTokenGetter(newRefreshToken, newExpiry)

Expand All @@ -215,4 +223,6 @@ func TestGetToken_Expired(t *testing.T) {
assert.NotNil(tok)
assert.Equal(tok.AccessToken, accessToken)
assert.Equal(tok.RefreshToken, newRefreshToken)
assert.Equal(tok.Expiry, newExpiryTime)
assert.Equal(serverAuth.Expiration, newExpiryTime)
}
26 changes: 22 additions & 4 deletions pkg/auth/uaa/tanzu.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,28 @@
package uaa

import (
"os"
"strconv"

"golang.org/x/term"

"github.com/vmware-tanzu/tanzu-plugin-runtime/config"

"github.com/vmware-tanzu/tanzu-cli/pkg/auth/common"
"github.com/vmware-tanzu/tanzu-cli/pkg/constants"
)

const (
// Tanzu CLI client ID for UAA that has http://127.0.0.1/callback as the
// only allowed Redirect URI and does not have an associated client secret.
tanzuCLIClientID = "tp_cli_app"
tanzuCLIClientSecret = ""
defaultListenAddress = "127.0.0.1:0"
defaultCallbackPath = "/callback"
tanzuCLIClientID = "tp_cli_app"
// Alternate client ID for UAA associated with a longer refresh token
// validity. Use this for CLI use cases where it is impractical to
// interactively reauthenticate once the refresh token expires.
tanzuCLIClientIDExtended = "tp_cli_app_ext"
marckhouzam marked this conversation as resolved.
Show resolved Hide resolved
tanzuCLIClientSecret = ""
defaultListenAddress = "127.0.0.1:0"
defaultCallbackPath = "/callback"
)

func getIssuerEndpoints(issuerURL string) common.IssuerEndPoints {
Expand All @@ -27,6 +35,16 @@ func getIssuerEndpoints(issuerURL string) common.IssuerEndPoints {
}
}

func GetAlternateClientID() string {
// Default to use the same client id, even for non-interactive login use cases.
clientID := tanzuCLIClientID
if useAlternateClientID, _ := strconv.ParseBool(os.Getenv(constants.UAAUseAlternateClient)); useAlternateClientID {
// Unless the env var is set
clientID = tanzuCLIClientIDExtended
}
return clientID
}

func TanzuLogin(issuerURL string, opts ...common.LoginOption) (*common.Token, error) {
issuerEndpoints := getIssuerEndpoints(issuerURL)

Expand Down
75 changes: 64 additions & 11 deletions pkg/auth/uaa/uaa.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,78 @@
package uaa

import (
"fmt"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"

"github.com/pkg/errors"

"github.com/vmware-tanzu/tanzu-cli/pkg/auth/common"
"github.com/vmware-tanzu/tanzu-cli/pkg/constants"
"github.com/vmware-tanzu/tanzu-cli/pkg/interfaces"
)

var (
httpRestClient interfaces.HTTPClient
)

// GetAccessTokenFromAPIToken fetches access token using the API-token.
func GetAccessTokenFromAPIToken(apiToken, uaaEndpoint, endpointCACertPath string, skipTLSVerify bool) (*common.Token, error) {
tokenURL := getIssuerEndpoints(uaaEndpoint).TokenURL
data := url.Values{}
data.Set("refresh_token", apiToken)
data.Set("client_id", GetAlternateClientID())
data.Set("grant_type", "refresh_token")

req, _ := http.NewRequestWithContext(context.Background(), "POST", tokenURL, bytes.NewBufferString(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

if httpRestClient == nil {
tlsConfig := common.GetTLSConfig(uaaEndpoint, endpointCACertPath, skipTLSVerify)
if tlsConfig == nil {
return nil, errors.New("unable to set up tls config")
}

tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = tlsConfig
httpRestClient = &http.Client{Transport: tr}
}

resp, err := httpRestClient.Do(req)
if err != nil {
return nil, errors.WithMessage(err, "Failed to obtain access token. Please provide valid API token")
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, errors.Errorf("Failed to obtain access token. Please provide valid API token -- %s", string(body))
}

defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
token := common.Token{}

if err = json.Unmarshal(body, &token); err != nil {
return nil, errors.Wrap(err, "could not unmarshal auth token")
}

return &token, nil
}

// GetTokens fetches the UAA access token
func GetTokens(refreshOrAPIToken, _, issuer, tokenType string) (*common.Token, error) {
var token *common.Token
var err error

clientID := tanzuCLIClientID
if tokenType == common.APITokenType {
return nil, fmt.Errorf("api token unsupported")
} else if tokenType == common.IDTokenType {
loginOptions := []common.LoginOption{common.WithRefreshToken(refreshOrAPIToken), common.WithListenerPortFromEnv(constants.TanzuCLIOAuthLocalListenerPort)}
token, err = TanzuLogin(issuer, loginOptions...)
if err != nil {
return nil, err
}
clientID = GetAlternateClientID()
}
loginOptions := []common.LoginOption{common.WithRefreshToken(refreshOrAPIToken), common.WithListenerPortFromEnv(constants.TanzuCLIOAuthLocalListenerPort), common.WithClientID(clientID)}

token, err := TanzuLogin(issuer, loginOptions...)
if err != nil {
return nil, err
}

return token, err
}
92 changes: 92 additions & 0 deletions pkg/auth/uaa/uaa_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2023 VMware, Inc. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package uaa

import (
"bytes"
"fmt"
"io"
"net/http"
"testing"

"github.com/stretchr/testify/assert"

"github.com/vmware-tanzu/tanzu-cli/pkg/fakes"
)

const (
fakeIssuerURL = "https://auth0.com/"
fakeAPIToken = "fake_api_token"
fakeCACrtPath = "/fake/ca.crt"
fakeSkipVerify = false
)

func TestGetAccessTokenFromAPIToken(t *testing.T) {
assert := assert.New(t)
fakeHTTPClient := &fakes.FakeHTTPClient{}
responseBody := io.NopCloser(bytes.NewReader([]byte(`{
"id_token": "abc",
"token_type": "Test",
"expires_in": 86400,
"scope": "Test",
"access_token": "LetMeIn",
"refresh_token": "LetMeInAgain"}`)))
fakeHTTPClient.DoReturns(&http.Response{
StatusCode: 200,
Body: responseBody,
}, nil)
httpRestClient = fakeHTTPClient
token, err := GetAccessTokenFromAPIToken(fakeAPIToken, fakeIssuerURL, fakeCACrtPath, fakeSkipVerify)
if err != nil {
fmt.Println(err)
fmt.Println("Error...................................")
}
assert.Nil(err)
assert.Equal("LetMeIn", token.AccessToken)

req := fakeHTTPClient.DoArgsForCall(0)
bodyBytes, _ := io.ReadAll(req.Body)
body := string(bodyBytes)

assert.Contains(body, "refresh_token="+fakeAPIToken)
assert.Contains(body, "client_id="+GetAlternateClientID())
assert.Contains(body, "grant_type=refresh_token")
}

func TestGetAccessTokenFromAPIToken_FailStatus(t *testing.T) {
assert := assert.New(t)
fakeHTTPClient := &fakes.FakeHTTPClient{}
responseBody := io.NopCloser(bytes.NewReader([]byte(``)))
fakeHTTPClient.DoReturns(&http.Response{
StatusCode: 403,
Body: responseBody,
}, nil)
httpRestClient = fakeHTTPClient
token, err := GetAccessTokenFromAPIToken(fakeAPIToken, fakeIssuerURL, fakeCACrtPath, fakeSkipVerify)
assert.NotNil(err)
assert.Contains(err.Error(), "Failed to obtain access token. Please provide valid API token")
assert.Nil(token)
}

func TestGetAccessTokenFromAPIToken_InvalidResponse(t *testing.T) {
assert := assert.New(t)
fakeHTTPClient := &fakes.FakeHTTPClient{}
responseBody := io.NopCloser(bytes.NewReader([]byte(`[{
"id_token": "abc",
"token_type": "Test",
"expires_in": 86400,
"scope": "Test",
"access_token": "LetMeIn",
"refresh_token": "LetMeInAgain"}]`)))
fakeHTTPClient.DoReturns(&http.Response{
StatusCode: 200,
Body: responseBody,
}, nil)
httpRestClient = fakeHTTPClient

token, err := GetAccessTokenFromAPIToken(fakeAPIToken, fakeIssuerURL, fakeCACrtPath, fakeSkipVerify)
assert.NotNil(err)
assert.Contains(err.Error(), "could not unmarshal")
assert.Nil(token)
}
48 changes: 45 additions & 3 deletions pkg/command/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -732,11 +732,53 @@ func getSelfManagedOrg(c *configtypes.Context) (string, string) {
return orgID, orgName
}

func globalTanzuLoginUAA(c *configtypes.Context, generateContextNameFunc func(orgName, endpoint string, isStaging bool) string) error {
func doUAAAPITokenAuthAndUpdateContext(c *configtypes.Context, uaaEndpoint, apiTokenValue string) (claims *commonauth.Claims, err error) {
token, err := uaa.GetAccessTokenFromAPIToken(apiTokenValue, uaaEndpoint, endpointCACertPath, skipTLSVerify)
if err != nil {
return nil, errors.Wrap(err, "failed to get token from UAA")
}
claims, err = commonauth.ParseToken(&oauth2.Token{AccessToken: token.AccessToken}, config.UAAIdpType)
if err != nil {
return nil, err
}

a := configtypes.GlobalServerAuth{}
a.Issuer = uaaEndpoint
a.UserName = claims.Username
a.Permissions = claims.Permissions
a.AccessToken = token.AccessToken
a.IDToken = token.IDToken
a.RefreshToken = apiTokenValue
a.Type = commonauth.APITokenType
expiresAt := time.Now().Local().Add(time.Second * time.Duration(token.ExpiresIn))
a.Expiration = expiresAt
c.GlobalOpts.Auth = a
if c.AdditionalMetadata == nil {
c.AdditionalMetadata = make(map[string]interface{})
}

return claims, nil
}

func doUAAAuthentication(c *configtypes.Context) (*commonauth.Claims, error) {
if c.AdditionalMetadata[config.TanzuAuthEndpointKey] == nil {
return nil, errors.New("auth endpoint not set")
}
uaaEndpoint := c.AdditionalMetadata[config.TanzuAuthEndpointKey].(string)
log.V(7).Infof("Login to UAA endpoint: %s", uaaEndpoint)

claims, err := doInteractiveLoginAndUpdateContext(c, uaaEndpoint)
apiTokenValue, ok := os.LookupEnv(config.EnvAPITokenKey)
// Use API Token login flow if TANZU_API_TOKEN environment variable is set, else fall back to interactive login flow
if ok {
log.Info("TANZU_API_TOKEN is set")
return doUAAAPITokenAuthAndUpdateContext(c, uaaEndpoint, apiTokenValue)
}

return doInteractiveLoginAndUpdateContext(c, uaaEndpoint)
}

func globalTanzuLoginUAA(c *configtypes.Context, generateContextNameFunc func(orgName, endpoint string, isStaging bool) string) error {
claims, err := doUAAAuthentication(c)
if err != nil {
return err
}
Expand Down Expand Up @@ -885,7 +927,7 @@ func doCSPAuthentication(c *configtypes.Context) (*commonauth.Claims, error) {
apiTokenValue, apiTokenExists := os.LookupEnv(config.EnvAPITokenKey)
// Use API Token login flow if TANZU_API_TOKEN environment variable is set, else fall back to default interactive login flow
if apiTokenExists {
log.Info("API token env var is set")
log.Info("TANZU_API_TOKEN is set")
return doCSPAPITokenAuthAndUpdateContext(c, apiTokenValue)
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/constants/env_variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const (
// This may not be necessary or could be discoverable in the future, at which point this will be ignored.
UAALoginOrgName = "TANZU_CLI_SM_ORGANIZATION_NAME"

// UAAUseAlternateClient allows use of an alternate UAA client for non-interactive logins
UAAUseAlternateClient = "TANZU_CLI_USE_ALTERNATE_UAA_CLIENT"

// TanzuCLIOAuthLocalListenerPort is the port to be used by local listener for OAuth authorization flow
TanzuCLIOAuthLocalListenerPort = "TANZU_CLI_OAUTH_LOCAL_LISTENER_PORT"

Expand Down
Loading