Skip to content

Commit

Permalink
Support login to UAA using API token
Browse files Browse the repository at this point in the history
- enable the API token login flow for UAA.
- sets token type to api-token
- supports use of alternate client ID
- update token refresh logic of UAA to use the alternate ID

Signed-off-by: Vui Lam <[email protected]>
  • Loading branch information
vuil committed Oct 7, 2024
1 parent 9896322 commit f9ad2cf
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 18 deletions.
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
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"
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

0 comments on commit f9ad2cf

Please sign in to comment.