mirror of
https://github.com/kubernetes/client-go.git
synced 2025-09-09 02:50:04 +00:00
Revert "Remove gcp and azure auth plugins"
This reverts commit 916cf16cf14928702f3f90b655ddddab2c85fcec. Kubernetes-commit: 651b4f5b647a205d12fad4d0edc489d97109cccc
This commit is contained in:
committed by
Kubernetes Publisher
parent
a890e7bc14
commit
ef26118838
56
plugin/pkg/client/auth/azure/README.md
Normal file
56
plugin/pkg/client/auth/azure/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Azure Active Directory plugin for client authentication
|
||||
|
||||
This plugin provides an integration with Azure Active Directory device flow. If no tokens are present in the kubectl configuration, it will prompt a device code which can be used to login in a browser. After login it will automatically fetch the tokens and store them in the kubectl configuration. In addition it will refresh and update the tokens in the configuration when expired.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an Azure Active Directory *Web App / API* application for `apiserver` following these [instructions](https://docs.microsoft.com/en-us/azure/active-directory/active-directory-app-registration). The callback URL does not matter (just cannot be empty).
|
||||
|
||||
2. Create a second Azure Active Directory native application for `kubectl`. The callback URL does not matter (just cannot be empty).
|
||||
|
||||
3. On `kubectl` application's configuration page in Azure portal grant permissions to `apiserver` application by clicking on *Required Permissions*, click the *Add* button and search for the apiserver application created in step 1. Select "Access apiserver" under the *DELEGATED PERMISSIONS*. Once added click the *Grant Permissions* button to apply the changes.
|
||||
|
||||
4. Configure the `apiserver` to use the Azure Active Directory as an OIDC provider with following options
|
||||
|
||||
```
|
||||
--oidc-client-id="spn:APISERVER_APPLICATION_ID" \
|
||||
--oidc-issuer-url="https://sts.windows.net/TENANT_ID/"
|
||||
--oidc-username-claim="sub"
|
||||
```
|
||||
|
||||
* Replace the `APISERVER_APPLICATION_ID` with the application ID of `apiserver` application
|
||||
* Replace `TENANT_ID` with your tenant ID.
|
||||
* For a list of alternative username claims that are supported by the OIDC issuer check the JSON response at `https://sts.windows.net/TENANT_ID/.well-known/openid-configuration`.
|
||||
|
||||
5. Configure `kubectl` to use the `azure` authentication provider
|
||||
|
||||
```
|
||||
kubectl config set-credentials "USER_NAME" --auth-provider=azure \
|
||||
--auth-provider-arg=environment=AzurePublicCloud \
|
||||
--auth-provider-arg=client-id=APPLICATION_ID \
|
||||
--auth-provider-arg=tenant-id=TENANT_ID \
|
||||
--auth-provider-arg=apiserver-id=APISERVER_APPLICATION_ID
|
||||
```
|
||||
|
||||
* Supported environments: `AzurePublicCloud`, `AzureUSGovernmentCloud`, `AzureChinaCloud`, `AzureGermanCloud`
|
||||
* Replace `USER_NAME` and `TENANT_ID` with your user name and tenant ID
|
||||
* Replace `APPLICATION_ID` with the application ID of your`kubectl` application ID
|
||||
* Replace `APISERVER_APPLICATION_ID` with the application ID of your `apiserver` application ID
|
||||
* Be sure to also (create and) select a context that uses above user
|
||||
|
||||
6. (Optionally) the AAD token has `aud` claim with `spn:` prefix. To omit that, add following auth configuration:
|
||||
|
||||
```
|
||||
--auth-provider-arg=config-mode="1"
|
||||
```
|
||||
|
||||
7. The access token is acquired when first `kubectl` command is executed
|
||||
|
||||
```
|
||||
kubectl get pods
|
||||
|
||||
To sign in, use a web browser to open the page https://aka.ms/devicelogin and enter the code DEC7D48GA to authenticate.
|
||||
```
|
||||
|
||||
* After signing in a web browser, the token is stored in the configuration, and it will be reused when executing further commands.
|
||||
* The resulting username in Kubernetes depends on your [configuration of the `--oidc-username-claim` and `--oidc-username-prefix` flags on the API server](https://kubernetes.io/docs/admin/authentication/#configuring-the-api-server). If you are using any authorization method you need to give permissions to that user, e.g. by binding the user to a role in the case of RBAC.
|
477
plugin/pkg/client/auth/azure/azure.go
Normal file
477
plugin/pkg/client/auth/azure/azure.go
Normal file
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
Copyright 2017 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package azure
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/Azure/go-autorest/autorest"
|
||||
"github.com/Azure/go-autorest/autorest/adal"
|
||||
"github.com/Azure/go-autorest/autorest/azure"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/net"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
type configMode int
|
||||
|
||||
const (
|
||||
azureTokenKey = "azureTokenKey"
|
||||
tokenType = "Bearer"
|
||||
authHeader = "Authorization"
|
||||
|
||||
cfgClientID = "client-id"
|
||||
cfgTenantID = "tenant-id"
|
||||
cfgAccessToken = "access-token"
|
||||
cfgRefreshToken = "refresh-token"
|
||||
cfgExpiresIn = "expires-in"
|
||||
cfgExpiresOn = "expires-on"
|
||||
cfgEnvironment = "environment"
|
||||
cfgApiserverID = "apiserver-id"
|
||||
cfgConfigMode = "config-mode"
|
||||
|
||||
configModeDefault configMode = 0
|
||||
configModeOmitSPNPrefix configMode = 1
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := restclient.RegisterAuthProviderPlugin("azure", newAzureAuthProvider); err != nil {
|
||||
klog.Fatalf("Failed to register azure auth plugin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var cache = newAzureTokenCache()
|
||||
|
||||
type azureTokenCache struct {
|
||||
lock sync.Mutex
|
||||
cache map[string]*azureToken
|
||||
}
|
||||
|
||||
func newAzureTokenCache() *azureTokenCache {
|
||||
return &azureTokenCache{cache: make(map[string]*azureToken)}
|
||||
}
|
||||
|
||||
func (c *azureTokenCache) getToken(tokenKey string) *azureToken {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
return c.cache[tokenKey]
|
||||
}
|
||||
|
||||
func (c *azureTokenCache) setToken(tokenKey string, token *azureToken) {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
c.cache[tokenKey] = token
|
||||
}
|
||||
|
||||
var warnOnce sync.Once
|
||||
|
||||
func newAzureAuthProvider(_ string, cfg map[string]string, persister restclient.AuthProviderConfigPersister) (restclient.AuthProvider, error) {
|
||||
// deprecated in v1.22, remove in v1.25
|
||||
warnOnce.Do(func() {
|
||||
klog.Warningf(`WARNING: the azure auth plugin is deprecated in v1.22+, unavailable in v1.25+; use https://github.com/Azure/kubelogin instead.
|
||||
To learn more, consult https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins`)
|
||||
})
|
||||
|
||||
var (
|
||||
ts tokenSource
|
||||
environment azure.Environment
|
||||
err error
|
||||
mode configMode
|
||||
)
|
||||
|
||||
environment, err = azure.EnvironmentFromName(cfg[cfgEnvironment])
|
||||
if err != nil {
|
||||
environment = azure.PublicCloud
|
||||
}
|
||||
|
||||
mode = configModeDefault
|
||||
if cfg[cfgConfigMode] != "" {
|
||||
configModeInt, err := strconv.Atoi(cfg[cfgConfigMode])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s, error: %s", cfgConfigMode, err)
|
||||
}
|
||||
mode = configMode(configModeInt)
|
||||
switch mode {
|
||||
case configModeOmitSPNPrefix:
|
||||
case configModeDefault:
|
||||
default:
|
||||
return nil, fmt.Errorf("%s:%s is not a valid mode", cfgConfigMode, cfg[cfgConfigMode])
|
||||
}
|
||||
}
|
||||
ts, err = newAzureTokenSourceDeviceCode(environment, cfg[cfgClientID], cfg[cfgTenantID], cfg[cfgApiserverID], mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating a new azure token source for device code authentication: %v", err)
|
||||
}
|
||||
cacheSource := newAzureTokenSource(ts, cache, cfg, mode, persister)
|
||||
|
||||
return &azureAuthProvider{
|
||||
tokenSource: cacheSource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type azureAuthProvider struct {
|
||||
tokenSource tokenSource
|
||||
}
|
||||
|
||||
func (p *azureAuthProvider) Login() error {
|
||||
return errors.New("not yet implemented")
|
||||
}
|
||||
|
||||
func (p *azureAuthProvider) WrapTransport(rt http.RoundTripper) http.RoundTripper {
|
||||
return &azureRoundTripper{
|
||||
tokenSource: p.tokenSource,
|
||||
roundTripper: rt,
|
||||
}
|
||||
}
|
||||
|
||||
type azureRoundTripper struct {
|
||||
tokenSource tokenSource
|
||||
roundTripper http.RoundTripper
|
||||
}
|
||||
|
||||
var _ net.RoundTripperWrapper = &azureRoundTripper{}
|
||||
|
||||
func (r *azureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if len(req.Header.Get(authHeader)) != 0 {
|
||||
return r.roundTripper.RoundTrip(req)
|
||||
}
|
||||
|
||||
token, err := r.tokenSource.Token()
|
||||
if err != nil {
|
||||
klog.Errorf("Failed to acquire a token: %v", err)
|
||||
return nil, fmt.Errorf("acquiring a token for authorization header: %v", err)
|
||||
}
|
||||
|
||||
// clone the request in order to avoid modifying the headers of the original request
|
||||
req2 := new(http.Request)
|
||||
*req2 = *req
|
||||
req2.Header = make(http.Header, len(req.Header))
|
||||
for k, s := range req.Header {
|
||||
req2.Header[k] = append([]string(nil), s...)
|
||||
}
|
||||
|
||||
req2.Header.Set(authHeader, fmt.Sprintf("%s %s", tokenType, token.token.AccessToken))
|
||||
|
||||
return r.roundTripper.RoundTrip(req2)
|
||||
}
|
||||
|
||||
func (r *azureRoundTripper) WrappedRoundTripper() http.RoundTripper { return r.roundTripper }
|
||||
|
||||
type azureToken struct {
|
||||
token adal.Token
|
||||
environment string
|
||||
clientID string
|
||||
tenantID string
|
||||
apiserverID string
|
||||
}
|
||||
|
||||
type tokenSource interface {
|
||||
Token() (*azureToken, error)
|
||||
Refresh(*azureToken) (*azureToken, error)
|
||||
}
|
||||
|
||||
type azureTokenSource struct {
|
||||
source tokenSource
|
||||
cache *azureTokenCache
|
||||
lock sync.Mutex
|
||||
configMode configMode
|
||||
cfg map[string]string
|
||||
persister restclient.AuthProviderConfigPersister
|
||||
}
|
||||
|
||||
func newAzureTokenSource(source tokenSource, cache *azureTokenCache, cfg map[string]string, configMode configMode, persister restclient.AuthProviderConfigPersister) tokenSource {
|
||||
return &azureTokenSource{
|
||||
source: source,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
persister: persister,
|
||||
configMode: configMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Token fetches a token from the cache of configuration if present otherwise
|
||||
// acquires a new token from the configured source. Automatically refreshes
|
||||
// the token if expired.
|
||||
func (ts *azureTokenSource) Token() (*azureToken, error) {
|
||||
ts.lock.Lock()
|
||||
defer ts.lock.Unlock()
|
||||
|
||||
var err error
|
||||
token := ts.cache.getToken(azureTokenKey)
|
||||
|
||||
if token != nil && !token.token.IsExpired() {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// retrieve from config if no cache
|
||||
if token == nil {
|
||||
tokenFromCfg, err := ts.retrieveTokenFromCfg()
|
||||
|
||||
if err == nil {
|
||||
token = tokenFromCfg
|
||||
}
|
||||
}
|
||||
|
||||
if token != nil {
|
||||
// cache and return if the token is as good
|
||||
// avoids frequent persistor calls
|
||||
if !token.token.IsExpired() {
|
||||
ts.cache.setToken(azureTokenKey, token)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
klog.V(4).Info("Refreshing token.")
|
||||
tokenFromRefresh, err := ts.Refresh(token)
|
||||
switch {
|
||||
case err == nil:
|
||||
token = tokenFromRefresh
|
||||
case autorest.IsTokenRefreshError(err):
|
||||
klog.V(4).Infof("Failed to refresh expired token, proceed to auth: %v", err)
|
||||
// reset token to nil so that the token source will be used to acquire new
|
||||
token = nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected error when refreshing token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
tokenFromSource, err := ts.source.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed acquiring new token: %v", err)
|
||||
}
|
||||
token = tokenFromSource
|
||||
}
|
||||
|
||||
// sanity check
|
||||
if token == nil {
|
||||
return nil, fmt.Errorf("unable to acquire token")
|
||||
}
|
||||
|
||||
// corner condition, newly got token is valid but expired
|
||||
if token.token.IsExpired() {
|
||||
return nil, fmt.Errorf("newly acquired token is expired")
|
||||
}
|
||||
|
||||
err = ts.storeTokenInCfg(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storing the refreshed token in configuration: %v", err)
|
||||
}
|
||||
ts.cache.setToken(azureTokenKey, token)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (ts *azureTokenSource) retrieveTokenFromCfg() (*azureToken, error) {
|
||||
accessToken := ts.cfg[cfgAccessToken]
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("no access token in cfg: %s", cfgAccessToken)
|
||||
}
|
||||
refreshToken := ts.cfg[cfgRefreshToken]
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("no refresh token in cfg: %s", cfgRefreshToken)
|
||||
}
|
||||
environment := ts.cfg[cfgEnvironment]
|
||||
if environment == "" {
|
||||
return nil, fmt.Errorf("no environment in cfg: %s", cfgEnvironment)
|
||||
}
|
||||
clientID := ts.cfg[cfgClientID]
|
||||
if clientID == "" {
|
||||
return nil, fmt.Errorf("no client ID in cfg: %s", cfgClientID)
|
||||
}
|
||||
tenantID := ts.cfg[cfgTenantID]
|
||||
if tenantID == "" {
|
||||
return nil, fmt.Errorf("no tenant ID in cfg: %s", cfgTenantID)
|
||||
}
|
||||
resourceID := ts.cfg[cfgApiserverID]
|
||||
if resourceID == "" {
|
||||
return nil, fmt.Errorf("no apiserver ID in cfg: %s", cfgApiserverID)
|
||||
}
|
||||
expiresIn := ts.cfg[cfgExpiresIn]
|
||||
if expiresIn == "" {
|
||||
return nil, fmt.Errorf("no expiresIn in cfg: %s", cfgExpiresIn)
|
||||
}
|
||||
expiresOn := ts.cfg[cfgExpiresOn]
|
||||
if expiresOn == "" {
|
||||
return nil, fmt.Errorf("no expiresOn in cfg: %s", cfgExpiresOn)
|
||||
}
|
||||
tokenAudience := resourceID
|
||||
if ts.configMode == configModeDefault {
|
||||
tokenAudience = fmt.Sprintf("spn:%s", resourceID)
|
||||
}
|
||||
|
||||
return &azureToken{
|
||||
token: adal.Token{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: json.Number(expiresIn),
|
||||
ExpiresOn: json.Number(expiresOn),
|
||||
NotBefore: json.Number(expiresOn),
|
||||
Resource: tokenAudience,
|
||||
Type: tokenType,
|
||||
},
|
||||
environment: environment,
|
||||
clientID: clientID,
|
||||
tenantID: tenantID,
|
||||
apiserverID: resourceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ts *azureTokenSource) storeTokenInCfg(token *azureToken) error {
|
||||
newCfg := make(map[string]string)
|
||||
newCfg[cfgAccessToken] = token.token.AccessToken
|
||||
newCfg[cfgRefreshToken] = token.token.RefreshToken
|
||||
newCfg[cfgEnvironment] = token.environment
|
||||
newCfg[cfgClientID] = token.clientID
|
||||
newCfg[cfgTenantID] = token.tenantID
|
||||
newCfg[cfgApiserverID] = token.apiserverID
|
||||
newCfg[cfgExpiresIn] = string(token.token.ExpiresIn)
|
||||
newCfg[cfgExpiresOn] = string(token.token.ExpiresOn)
|
||||
newCfg[cfgConfigMode] = strconv.Itoa(int(ts.configMode))
|
||||
|
||||
err := ts.persister.Persist(newCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persisting the configuration: %v", err)
|
||||
}
|
||||
ts.cfg = newCfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *azureTokenSource) Refresh(token *azureToken) (*azureToken, error) {
|
||||
return ts.source.Refresh(token)
|
||||
}
|
||||
|
||||
// refresh outdated token with adal.
|
||||
func (ts *azureTokenSourceDeviceCode) Refresh(token *azureToken) (*azureToken, error) {
|
||||
env, err := azure.EnvironmentFromName(token.environment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var oauthConfig *adal.OAuthConfig
|
||||
if ts.configMode == configModeOmitSPNPrefix {
|
||||
oauthConfig, err = adal.NewOAuthConfigWithAPIVersion(env.ActiveDirectoryEndpoint, token.tenantID, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building the OAuth configuration without api-version for token refresh: %v", err)
|
||||
}
|
||||
} else {
|
||||
oauthConfig, err = adal.NewOAuthConfig(env.ActiveDirectoryEndpoint, token.tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building the OAuth configuration for token refresh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
callback := func(t adal.Token) error {
|
||||
return nil
|
||||
}
|
||||
spt, err := adal.NewServicePrincipalTokenFromManualToken(
|
||||
*oauthConfig,
|
||||
token.clientID,
|
||||
token.apiserverID,
|
||||
token.token,
|
||||
callback)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating new service principal for token refresh: %v", err)
|
||||
}
|
||||
|
||||
if err := spt.Refresh(); err != nil {
|
||||
// Caller expects IsTokenRefreshError(err) to trigger prompt.
|
||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
|
||||
return &azureToken{
|
||||
token: spt.Token(),
|
||||
environment: token.environment,
|
||||
clientID: token.clientID,
|
||||
tenantID: token.tenantID,
|
||||
apiserverID: token.apiserverID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type azureTokenSourceDeviceCode struct {
|
||||
environment azure.Environment
|
||||
clientID string
|
||||
tenantID string
|
||||
apiserverID string
|
||||
configMode configMode
|
||||
}
|
||||
|
||||
func newAzureTokenSourceDeviceCode(environment azure.Environment, clientID string, tenantID string, apiserverID string, configMode configMode) (tokenSource, error) {
|
||||
if clientID == "" {
|
||||
return nil, errors.New("client-id is empty")
|
||||
}
|
||||
if tenantID == "" {
|
||||
return nil, errors.New("tenant-id is empty")
|
||||
}
|
||||
if apiserverID == "" {
|
||||
return nil, errors.New("apiserver-id is empty")
|
||||
}
|
||||
return &azureTokenSourceDeviceCode{
|
||||
environment: environment,
|
||||
clientID: clientID,
|
||||
tenantID: tenantID,
|
||||
apiserverID: apiserverID,
|
||||
configMode: configMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ts *azureTokenSourceDeviceCode) Token() (*azureToken, error) {
|
||||
var (
|
||||
oauthConfig *adal.OAuthConfig
|
||||
err error
|
||||
)
|
||||
if ts.configMode == configModeOmitSPNPrefix {
|
||||
oauthConfig, err = adal.NewOAuthConfigWithAPIVersion(ts.environment.ActiveDirectoryEndpoint, ts.tenantID, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building the OAuth configuration without api-version for device code authentication: %v", err)
|
||||
}
|
||||
} else {
|
||||
oauthConfig, err = adal.NewOAuthConfig(ts.environment.ActiveDirectoryEndpoint, ts.tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building the OAuth configuration for device code authentication: %v", err)
|
||||
}
|
||||
}
|
||||
client := &autorest.Client{}
|
||||
deviceCode, err := adal.InitiateDeviceAuth(client, *oauthConfig, ts.clientID, ts.apiserverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialing the device code authentication: %v", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(os.Stderr, *deviceCode.Message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prompting the device code message: %v", err)
|
||||
}
|
||||
|
||||
token, err := adal.WaitForUserCompletion(client, deviceCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("waiting for device code authentication to complete: %v", err)
|
||||
}
|
||||
|
||||
return &azureToken{
|
||||
token: *token,
|
||||
environment: ts.environment.Name,
|
||||
clientID: ts.clientID,
|
||||
tenantID: ts.tenantID,
|
||||
apiserverID: ts.apiserverID,
|
||||
}, nil
|
||||
}
|
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package azure
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := rest.RegisterAuthProviderPlugin("azure", newAzureAuthProvider); err != nil {
|
||||
klog.Fatalf("Failed to register azure auth plugin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newAzureAuthProvider(_ string, _ map[string]string, _ rest.AuthProviderConfigPersister) (rest.AuthProvider, error) {
|
||||
return nil, errors.New(`The azure auth plugin has been removed.
|
||||
Please use the https://github.com/Azure/kubelogin kubectl/client-go credential plugin instead.
|
||||
See https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins for further details`)
|
||||
}
|
534
plugin/pkg/client/auth/azure/azure_test.go
Normal file
534
plugin/pkg/client/auth/azure/azure_test.go
Normal file
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
Copyright 2017 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package azure
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/go-autorest/autorest/adal"
|
||||
"github.com/Azure/go-autorest/autorest/azure"
|
||||
)
|
||||
|
||||
func TestAzureAuthProvider(t *testing.T) {
|
||||
t.Run("validate against invalid configurations", func(t *testing.T) {
|
||||
vectors := []struct {
|
||||
cfg map[string]string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
cfg: map[string]string{
|
||||
cfgClientID: "foo",
|
||||
cfgApiserverID: "foo",
|
||||
cfgTenantID: "foo",
|
||||
cfgConfigMode: "-1",
|
||||
},
|
||||
expectedError: "config-mode:-1 is not a valid mode",
|
||||
},
|
||||
{
|
||||
cfg: map[string]string{
|
||||
cfgClientID: "foo",
|
||||
cfgApiserverID: "foo",
|
||||
cfgTenantID: "foo",
|
||||
cfgConfigMode: "2",
|
||||
},
|
||||
expectedError: "config-mode:2 is not a valid mode",
|
||||
},
|
||||
{
|
||||
cfg: map[string]string{
|
||||
cfgClientID: "foo",
|
||||
cfgApiserverID: "foo",
|
||||
cfgTenantID: "foo",
|
||||
cfgConfigMode: "foo",
|
||||
},
|
||||
expectedError: "failed to parse config-mode, error: strconv.Atoi: parsing \"foo\": invalid syntax",
|
||||
},
|
||||
}
|
||||
|
||||
for _, v := range vectors {
|
||||
persister := &fakePersister{}
|
||||
_, err := newAzureAuthProvider("", v.cfg, persister)
|
||||
if !strings.Contains(err.Error(), v.expectedError) {
|
||||
t.Errorf("cfg %v should fail with message containing '%s'. actual: '%s'", v.cfg, v.expectedError, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("it should return non-nil provider in happy cases", func(t *testing.T) {
|
||||
vectors := []struct {
|
||||
cfg map[string]string
|
||||
expectedConfigMode configMode
|
||||
}{
|
||||
{
|
||||
cfg: map[string]string{
|
||||
cfgClientID: "foo",
|
||||
cfgApiserverID: "foo",
|
||||
cfgTenantID: "foo",
|
||||
},
|
||||
expectedConfigMode: configModeDefault,
|
||||
},
|
||||
{
|
||||
cfg: map[string]string{
|
||||
cfgClientID: "foo",
|
||||
cfgApiserverID: "foo",
|
||||
cfgTenantID: "foo",
|
||||
cfgConfigMode: "0",
|
||||
},
|
||||
expectedConfigMode: configModeDefault,
|
||||
},
|
||||
{
|
||||
cfg: map[string]string{
|
||||
cfgClientID: "foo",
|
||||
cfgApiserverID: "foo",
|
||||
cfgTenantID: "foo",
|
||||
cfgConfigMode: "1",
|
||||
},
|
||||
expectedConfigMode: configModeOmitSPNPrefix,
|
||||
},
|
||||
}
|
||||
|
||||
for _, v := range vectors {
|
||||
persister := &fakePersister{}
|
||||
provider, err := newAzureAuthProvider("", v.cfg, persister)
|
||||
if err != nil {
|
||||
t.Errorf("newAzureAuthProvider should not fail with '%s'", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatalf("newAzureAuthProvider should return non-nil provider")
|
||||
}
|
||||
azureProvider := provider.(*azureAuthProvider)
|
||||
if azureProvider == nil {
|
||||
t.Fatalf("newAzureAuthProvider should return an instance of type azureAuthProvider")
|
||||
}
|
||||
ts := azureProvider.tokenSource.(*azureTokenSource)
|
||||
if ts == nil {
|
||||
t.Fatalf("azureAuthProvider should be an instance of azureTokenSource")
|
||||
}
|
||||
if ts.configMode != v.expectedConfigMode {
|
||||
t.Errorf("expected configMode: %d, actual: %d", v.expectedConfigMode, ts.configMode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenSourceDeviceCode(t *testing.T) {
|
||||
var (
|
||||
clientID = "clientID"
|
||||
tenantID = "tenantID"
|
||||
apiserverID = "apiserverID"
|
||||
configMode = configModeDefault
|
||||
azureEnv = azure.Environment{}
|
||||
)
|
||||
t.Run("validate to create azureTokenSourceDeviceCode", func(t *testing.T) {
|
||||
if _, err := newAzureTokenSourceDeviceCode(azureEnv, clientID, tenantID, apiserverID, configModeDefault); err != nil {
|
||||
t.Errorf("newAzureTokenSourceDeviceCode should not have failed. err: %s", err)
|
||||
}
|
||||
|
||||
if _, err := newAzureTokenSourceDeviceCode(azureEnv, clientID, tenantID, apiserverID, configModeOmitSPNPrefix); err != nil {
|
||||
t.Errorf("newAzureTokenSourceDeviceCode should not have failed. err: %s", err)
|
||||
}
|
||||
|
||||
_, err := newAzureTokenSourceDeviceCode(azureEnv, "", tenantID, apiserverID, configMode)
|
||||
actual := "client-id is empty"
|
||||
if err.Error() != actual {
|
||||
t.Errorf("newAzureTokenSourceDeviceCode should have failed. expected: %s, actual: %s", actual, err)
|
||||
}
|
||||
|
||||
_, err = newAzureTokenSourceDeviceCode(azureEnv, clientID, "", apiserverID, configMode)
|
||||
actual = "tenant-id is empty"
|
||||
if err.Error() != actual {
|
||||
t.Errorf("newAzureTokenSourceDeviceCode should have failed. expected: %s, actual: %s", actual, err)
|
||||
}
|
||||
|
||||
_, err = newAzureTokenSourceDeviceCode(azureEnv, clientID, tenantID, "", configMode)
|
||||
actual = "apiserver-id is empty"
|
||||
if err.Error() != actual {
|
||||
t.Errorf("newAzureTokenSourceDeviceCode should have failed. expected: %s, actual: %s", actual, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
func TestAzureTokenSource(t *testing.T) {
|
||||
configModes := []configMode{configModeOmitSPNPrefix, configModeDefault}
|
||||
expectedConfigModes := []string{"1", "0"}
|
||||
|
||||
for i, configMode := range configModes {
|
||||
t.Run(fmt.Sprintf("validate token from cfg with configMode %v", configMode), func(t *testing.T) {
|
||||
const (
|
||||
serverID = "fakeServerID"
|
||||
clientID = "fakeClientID"
|
||||
tenantID = "fakeTenantID"
|
||||
accessToken = "fakeToken"
|
||||
environment = "fakeEnvironment"
|
||||
refreshToken = "fakeToken"
|
||||
expiresIn = "foo"
|
||||
expiresOn = "foo"
|
||||
)
|
||||
cfg := map[string]string{
|
||||
cfgConfigMode: strconv.Itoa(int(configMode)),
|
||||
cfgApiserverID: serverID,
|
||||
cfgClientID: clientID,
|
||||
cfgTenantID: tenantID,
|
||||
cfgEnvironment: environment,
|
||||
cfgAccessToken: accessToken,
|
||||
cfgRefreshToken: refreshToken,
|
||||
cfgExpiresIn: expiresIn,
|
||||
cfgExpiresOn: expiresOn,
|
||||
}
|
||||
fakeSource := fakeTokenSource{token: newFakeAzureToken("fakeToken", time.Now().Add(3600*time.Second))}
|
||||
persiter := &fakePersister{cache: make(map[string]string)}
|
||||
tokenCache := newAzureTokenCache()
|
||||
tokenSource := newAzureTokenSource(&fakeSource, tokenCache, cfg, configMode, persiter)
|
||||
azTokenSource := tokenSource.(*azureTokenSource)
|
||||
token, err := azTokenSource.retrieveTokenFromCfg()
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve the token form cfg: %s", err)
|
||||
}
|
||||
if token.apiserverID != serverID {
|
||||
t.Errorf("expecting token.apiserverID: %s, actual: %s", serverID, token.apiserverID)
|
||||
}
|
||||
if token.clientID != clientID {
|
||||
t.Errorf("expecting token.clientID: %s, actual: %s", clientID, token.clientID)
|
||||
}
|
||||
if token.tenantID != tenantID {
|
||||
t.Errorf("expecting token.tenantID: %s, actual: %s", tenantID, token.tenantID)
|
||||
}
|
||||
expectedAudience := serverID
|
||||
if configMode == configModeDefault {
|
||||
expectedAudience = fmt.Sprintf("spn:%s", serverID)
|
||||
}
|
||||
if token.token.Resource != expectedAudience {
|
||||
t.Errorf("expecting adal token.Resource: %s, actual: %s", expectedAudience, token.token.Resource)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate token against cache", func(t *testing.T) {
|
||||
fakeAccessToken := "fake token 1"
|
||||
fakeSource := fakeTokenSource{token: newFakeAzureToken(fakeAccessToken, time.Now().Add(3600*time.Second))}
|
||||
cfg := make(map[string]string)
|
||||
persiter := &fakePersister{cache: make(map[string]string)}
|
||||
tokenCache := newAzureTokenCache()
|
||||
tokenSource := newAzureTokenSource(&fakeSource, tokenCache, cfg, configMode, persiter)
|
||||
token, err := tokenSource.Token()
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve the token form cache: %v", err)
|
||||
}
|
||||
|
||||
wantCacheLen := 1
|
||||
if len(tokenCache.cache) != wantCacheLen {
|
||||
t.Errorf("Token() cache length error: got %v, want %v", len(tokenCache.cache), wantCacheLen)
|
||||
}
|
||||
|
||||
if token != tokenCache.cache[azureTokenKey] {
|
||||
t.Error("Token() returned token != cached token")
|
||||
}
|
||||
|
||||
wantCfg := token2Cfg(token)
|
||||
wantCfg[cfgConfigMode] = expectedConfigModes[i]
|
||||
persistedCfg := persiter.Cache()
|
||||
|
||||
wantCfgLen := len(wantCfg)
|
||||
persistedCfgLen := len(persistedCfg)
|
||||
if wantCfgLen != persistedCfgLen {
|
||||
t.Errorf("wantCfgLen and persistedCfgLen do not match, wantCfgLen=%v, persistedCfgLen=%v", wantCfgLen, persistedCfgLen)
|
||||
}
|
||||
|
||||
for k, v := range persistedCfg {
|
||||
if strings.Compare(v, wantCfg[k]) != 0 {
|
||||
t.Errorf("Token() persisted cfg %s: got %v, want %v", k, v, wantCfg[k])
|
||||
}
|
||||
}
|
||||
|
||||
fakeSource.token = newFakeAzureToken("fake token 2", time.Now().Add(3600*time.Second))
|
||||
token, err = tokenSource.Token()
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve the cached token: %v", err)
|
||||
}
|
||||
|
||||
if token.token.AccessToken != fakeAccessToken {
|
||||
t.Errorf("Token() didn't return the cached token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureTokenSourceScenarios(t *testing.T) {
|
||||
expiredToken := newFakeAzureToken("expired token", time.Now().Add(-time.Second))
|
||||
extendedToken := newFakeAzureToken("extend token", time.Now().Add(1000*time.Second))
|
||||
fakeToken := newFakeAzureToken("fake token", time.Now().Add(1000*time.Second))
|
||||
wrongToken := newFakeAzureToken("wrong token", time.Now().Add(1000*time.Second))
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceToken *azureToken
|
||||
refreshToken *azureToken
|
||||
cachedToken *azureToken
|
||||
configToken *azureToken
|
||||
expectToken *azureToken
|
||||
tokenErr error
|
||||
refreshErr error
|
||||
expectErr string
|
||||
tokenCalls uint
|
||||
refreshCalls uint
|
||||
persistCalls uint
|
||||
}{
|
||||
{
|
||||
name: "new config",
|
||||
sourceToken: fakeToken,
|
||||
expectToken: fakeToken,
|
||||
tokenCalls: 1,
|
||||
persistCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "load token from cache",
|
||||
sourceToken: wrongToken,
|
||||
cachedToken: fakeToken,
|
||||
configToken: wrongToken,
|
||||
expectToken: fakeToken,
|
||||
},
|
||||
{
|
||||
name: "load token from config",
|
||||
sourceToken: wrongToken,
|
||||
configToken: fakeToken,
|
||||
expectToken: fakeToken,
|
||||
},
|
||||
{
|
||||
name: "cached token timeout, extend success, config token should never load",
|
||||
cachedToken: expiredToken,
|
||||
refreshToken: extendedToken,
|
||||
configToken: wrongToken,
|
||||
expectToken: extendedToken,
|
||||
refreshCalls: 1,
|
||||
persistCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "config token timeout, extend failure, acquire new token",
|
||||
configToken: expiredToken,
|
||||
refreshErr: fakeTokenRefreshError{message: "FakeError happened when refreshing"},
|
||||
sourceToken: fakeToken,
|
||||
expectToken: fakeToken,
|
||||
refreshCalls: 1,
|
||||
tokenCalls: 1,
|
||||
persistCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "extend failure with fmt.Errorf nested tokenRefreshError",
|
||||
configToken: expiredToken,
|
||||
refreshErr: fmt.Errorf("refreshing token: %w", fakeTokenRefreshError{message: "nested FakeError happened when refreshing"}),
|
||||
sourceToken: fakeToken,
|
||||
expectToken: fakeToken,
|
||||
refreshCalls: 1,
|
||||
tokenCalls: 1,
|
||||
persistCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "unexpected error when extend",
|
||||
configToken: expiredToken,
|
||||
refreshErr: errors.New("unexpected refresh error"),
|
||||
sourceToken: fakeToken,
|
||||
expectErr: "unexpected refresh error",
|
||||
refreshCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "token error",
|
||||
tokenErr: errors.New("tokenerr"),
|
||||
expectErr: "tokenerr",
|
||||
tokenCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "Token() got expired token",
|
||||
sourceToken: expiredToken,
|
||||
expectErr: "newly acquired token is expired",
|
||||
tokenCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "Token() got nil but no error",
|
||||
sourceToken: nil,
|
||||
expectErr: "unable to acquire token",
|
||||
tokenCalls: 1,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
configModes := []configMode{configModeOmitSPNPrefix, configModeDefault}
|
||||
|
||||
for _, configMode := range configModes {
|
||||
t.Run(fmt.Sprintf("%s with configMode: %v", tc.name, configMode), func(t *testing.T) {
|
||||
persister := newFakePersister()
|
||||
|
||||
cfg := map[string]string{
|
||||
cfgConfigMode: strconv.Itoa(int(configMode)),
|
||||
}
|
||||
if tc.configToken != nil {
|
||||
cfg = token2Cfg(tc.configToken)
|
||||
}
|
||||
|
||||
tokenCache := newAzureTokenCache()
|
||||
if tc.cachedToken != nil {
|
||||
tokenCache.setToken(azureTokenKey, tc.cachedToken)
|
||||
}
|
||||
|
||||
fakeSource := fakeTokenSource{
|
||||
token: tc.sourceToken,
|
||||
tokenErr: tc.tokenErr,
|
||||
refreshToken: tc.refreshToken,
|
||||
refreshErr: tc.refreshErr,
|
||||
}
|
||||
|
||||
tokenSource := newAzureTokenSource(&fakeSource, tokenCache, cfg, configMode, &persister)
|
||||
token, err := tokenSource.Token()
|
||||
|
||||
if token != nil && fakeSource.token != nil && token.apiserverID != fakeSource.token.apiserverID {
|
||||
t.Errorf("expecting apiservierID: %s, got: %s", fakeSource.token.apiserverID, token.apiserverID)
|
||||
}
|
||||
if fakeSource.tokenCalls != tc.tokenCalls {
|
||||
t.Errorf("expecting tokenCalls: %v, got: %v", tc.tokenCalls, fakeSource.tokenCalls)
|
||||
}
|
||||
|
||||
if fakeSource.refreshCalls != tc.refreshCalls {
|
||||
t.Errorf("expecting refreshCalls: %v, got: %v", tc.refreshCalls, fakeSource.refreshCalls)
|
||||
}
|
||||
|
||||
if persister.calls != tc.persistCalls {
|
||||
t.Errorf("expecting persister calls: %v, got: %v", tc.persistCalls, persister.calls)
|
||||
}
|
||||
|
||||
if tc.expectErr != "" {
|
||||
if !strings.Contains(err.Error(), tc.expectErr) {
|
||||
t.Errorf("expecting error %v, got %v", tc.expectErr, err)
|
||||
}
|
||||
if token != nil {
|
||||
t.Errorf("token should be nil in err situation, got %v", token)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("error should be nil, got %v", err)
|
||||
}
|
||||
if token.token.AccessToken != tc.expectToken.token.AccessToken {
|
||||
t.Errorf("token should have accessToken %v, got %v", token.token.AccessToken, tc.expectToken.token.AccessToken)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakePersister struct {
|
||||
lock sync.Mutex
|
||||
cache map[string]string
|
||||
calls uint
|
||||
}
|
||||
|
||||
func newFakePersister() fakePersister {
|
||||
return fakePersister{cache: make(map[string]string), calls: 0}
|
||||
}
|
||||
|
||||
func (p *fakePersister) Persist(cache map[string]string) error {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
p.calls++
|
||||
p.cache = map[string]string{}
|
||||
for k, v := range cache {
|
||||
p.cache[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePersister) Cache() map[string]string {
|
||||
ret := map[string]string{}
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
for k, v := range p.cache {
|
||||
ret[k] = v
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// a simple token source simply always returns the token property
|
||||
type fakeTokenSource struct {
|
||||
token *azureToken
|
||||
tokenCalls uint
|
||||
tokenErr error
|
||||
refreshToken *azureToken
|
||||
refreshCalls uint
|
||||
refreshErr error
|
||||
}
|
||||
|
||||
func (ts *fakeTokenSource) Token() (*azureToken, error) {
|
||||
ts.tokenCalls++
|
||||
return ts.token, ts.tokenErr
|
||||
}
|
||||
|
||||
func (ts *fakeTokenSource) Refresh(*azureToken) (*azureToken, error) {
|
||||
ts.refreshCalls++
|
||||
return ts.refreshToken, ts.refreshErr
|
||||
}
|
||||
|
||||
func token2Cfg(token *azureToken) map[string]string {
|
||||
cfg := make(map[string]string)
|
||||
cfg[cfgAccessToken] = token.token.AccessToken
|
||||
cfg[cfgRefreshToken] = token.token.RefreshToken
|
||||
cfg[cfgEnvironment] = token.environment
|
||||
cfg[cfgClientID] = token.clientID
|
||||
cfg[cfgTenantID] = token.tenantID
|
||||
cfg[cfgApiserverID] = token.apiserverID
|
||||
cfg[cfgExpiresIn] = string(token.token.ExpiresIn)
|
||||
cfg[cfgExpiresOn] = string(token.token.ExpiresOn)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func newFakeAzureToken(accessToken string, expiresOnTime time.Time) *azureToken {
|
||||
return &azureToken{
|
||||
token: newFakeADALToken(accessToken, strconv.FormatInt(expiresOnTime.Unix(), 10)),
|
||||
environment: "testenv",
|
||||
clientID: "fake",
|
||||
tenantID: "fake",
|
||||
apiserverID: "fake",
|
||||
}
|
||||
}
|
||||
|
||||
func newFakeADALToken(accessToken string, expiresOn string) adal.Token {
|
||||
return adal.Token{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "fake",
|
||||
ExpiresIn: "3600",
|
||||
ExpiresOn: json.Number(expiresOn),
|
||||
NotBefore: json.Number(expiresOn),
|
||||
Resource: "fake",
|
||||
Type: "fake",
|
||||
}
|
||||
}
|
||||
|
||||
// copied from go-autorest/adal
|
||||
type fakeTokenRefreshError struct {
|
||||
message string
|
||||
resp *http.Response
|
||||
}
|
||||
|
||||
// Error implements the error interface which is part of the TokenRefreshError interface.
|
||||
func (tre fakeTokenRefreshError) Error() string {
|
||||
return tre.message
|
||||
}
|
||||
|
||||
// Response implements the TokenRefreshError interface, it returns the raw HTTP response from the refresh operation.
|
||||
func (tre fakeTokenRefreshError) Response() *http.Response {
|
||||
return tre.resp
|
||||
}
|
8
plugin/pkg/client/auth/gcp/OWNERS
Normal file
8
plugin/pkg/client/auth/gcp/OWNERS
Normal file
@@ -0,0 +1,8 @@
|
||||
# See the OWNERS docs at https://go.k8s.io/owners
|
||||
|
||||
approvers:
|
||||
- cjcullen
|
||||
reviewers:
|
||||
- cjcullen
|
||||
emeritus_approvers:
|
||||
- jlowdermilk
|
390
plugin/pkg/client/auth/gcp/gcp.go
Normal file
390
plugin/pkg/client/auth/gcp/gcp.go
Normal file
@@ -0,0 +1,390 @@
|
||||
/*
|
||||
Copyright 2016 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package gcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"k8s.io/apimachinery/pkg/util/net"
|
||||
"k8s.io/apimachinery/pkg/util/yaml"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/util/jsonpath"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := restclient.RegisterAuthProviderPlugin("gcp", newGCPAuthProvider); err != nil {
|
||||
klog.Fatalf("Failed to register gcp auth plugin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Stubbable for testing
|
||||
execCommand = exec.Command
|
||||
|
||||
// defaultScopes:
|
||||
// - cloud-platform is the base scope to authenticate to GCP.
|
||||
// - userinfo.email is used to authenticate to GKE APIs with gserviceaccount
|
||||
// email instead of numeric uniqueID.
|
||||
defaultScopes = []string{
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email"}
|
||||
)
|
||||
|
||||
// gcpAuthProvider is an auth provider plugin that uses GCP credentials to provide
|
||||
// tokens for kubectl to authenticate itself to the apiserver. A sample json config
|
||||
// is provided below with all recognized options described.
|
||||
//
|
||||
// {
|
||||
// 'auth-provider': {
|
||||
// # Required
|
||||
// "name": "gcp",
|
||||
//
|
||||
// 'config': {
|
||||
// # Authentication options
|
||||
// # These options are used while getting a token.
|
||||
//
|
||||
// # comma-separated list of GCP API scopes. default value of this field
|
||||
// # is "https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email".
|
||||
// # to override the API scopes, specify this field explicitly.
|
||||
// "scopes": "https://www.googleapis.com/auth/cloud-platform"
|
||||
//
|
||||
// # Caching options
|
||||
//
|
||||
// # Raw string data representing cached access token.
|
||||
// "access-token": "ya29.CjWdA4GiBPTt",
|
||||
// # RFC3339Nano expiration timestamp for cached access token.
|
||||
// "expiry": "2016-10-31 22:31:9.123",
|
||||
//
|
||||
// # Command execution options
|
||||
// # These options direct the plugin to execute a specified command and parse
|
||||
// # token and expiry time from the output of the command.
|
||||
//
|
||||
// # Command to execute for access token. Command output will be parsed as JSON.
|
||||
// # If "cmd-args" is not present, this value will be split on whitespace, with
|
||||
// # the first element interpreted as the command, remaining elements as args.
|
||||
// "cmd-path": "/usr/bin/gcloud",
|
||||
//
|
||||
// # Arguments to pass to command to execute for access token.
|
||||
// "cmd-args": "config config-helper --output=json"
|
||||
//
|
||||
// # JSONPath to the string field that represents the access token in
|
||||
// # command output. If omitted, defaults to "{.access_token}".
|
||||
// "token-key": "{.credential.access_token}",
|
||||
//
|
||||
// # JSONPath to the string field that represents expiration timestamp
|
||||
// # of the access token in the command output. If omitted, defaults to
|
||||
// # "{.token_expiry}"
|
||||
// "expiry-key": ""{.credential.token_expiry}",
|
||||
//
|
||||
// # golang reference time in the format that the expiration timestamp uses.
|
||||
// # If omitted, defaults to time.RFC3339Nano
|
||||
// "time-fmt": "2006-01-02 15:04:05.999999999"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
type gcpAuthProvider struct {
|
||||
tokenSource oauth2.TokenSource
|
||||
persister restclient.AuthProviderConfigPersister
|
||||
}
|
||||
|
||||
var warnOnce sync.Once
|
||||
|
||||
func newGCPAuthProvider(_ string, gcpConfig map[string]string, persister restclient.AuthProviderConfigPersister) (restclient.AuthProvider, error) {
|
||||
warnOnce.Do(func() {
|
||||
klog.Warningf(`WARNING: the gcp auth plugin is deprecated in v1.22+, unavailable in v1.25+; use gcloud instead.
|
||||
To learn more, consult https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke`)
|
||||
})
|
||||
|
||||
ts, err := tokenSource(isCmdTokenSource(gcpConfig), gcpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cts, err := newCachedTokenSource(gcpConfig["access-token"], gcpConfig["expiry"], persister, ts, gcpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gcpAuthProvider{cts, persister}, nil
|
||||
}
|
||||
|
||||
func isCmdTokenSource(gcpConfig map[string]string) bool {
|
||||
_, ok := gcpConfig["cmd-path"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func tokenSource(isCmd bool, gcpConfig map[string]string) (oauth2.TokenSource, error) {
|
||||
// Command-based token source
|
||||
if isCmd {
|
||||
cmd := gcpConfig["cmd-path"]
|
||||
if len(cmd) == 0 {
|
||||
return nil, fmt.Errorf("missing access token cmd")
|
||||
}
|
||||
if gcpConfig["scopes"] != "" {
|
||||
return nil, fmt.Errorf("scopes can only be used when kubectl is using a gcp service account key")
|
||||
}
|
||||
var args []string
|
||||
if cmdArgs, ok := gcpConfig["cmd-args"]; ok {
|
||||
args = strings.Fields(cmdArgs)
|
||||
} else {
|
||||
fields := strings.Fields(cmd)
|
||||
cmd = fields[0]
|
||||
args = fields[1:]
|
||||
}
|
||||
return newCmdTokenSource(cmd, args, gcpConfig["token-key"], gcpConfig["expiry-key"], gcpConfig["time-fmt"]), nil
|
||||
}
|
||||
|
||||
// Google Application Credentials-based token source
|
||||
scopes := parseScopes(gcpConfig)
|
||||
ts, err := google.DefaultTokenSource(context.Background(), scopes...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot construct google default token source: %v", err)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// parseScopes constructs a list of scopes that should be included in token source
|
||||
// from the config map.
|
||||
func parseScopes(gcpConfig map[string]string) []string {
|
||||
scopes, ok := gcpConfig["scopes"]
|
||||
if !ok {
|
||||
return defaultScopes
|
||||
}
|
||||
if scopes == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(gcpConfig["scopes"], ",")
|
||||
}
|
||||
|
||||
func (g *gcpAuthProvider) WrapTransport(rt http.RoundTripper) http.RoundTripper {
|
||||
var resetCache map[string]string
|
||||
if cts, ok := g.tokenSource.(*cachedTokenSource); ok {
|
||||
resetCache = cts.baseCache()
|
||||
} else {
|
||||
resetCache = make(map[string]string)
|
||||
}
|
||||
return &conditionalTransport{&oauth2.Transport{Source: g.tokenSource, Base: rt}, g.persister, resetCache}
|
||||
}
|
||||
|
||||
func (g *gcpAuthProvider) Login() error { return nil }
|
||||
|
||||
type cachedTokenSource struct {
|
||||
lk sync.Mutex
|
||||
source oauth2.TokenSource
|
||||
accessToken string `datapolicy:"token"`
|
||||
expiry time.Time
|
||||
persister restclient.AuthProviderConfigPersister
|
||||
cache map[string]string
|
||||
}
|
||||
|
||||
func newCachedTokenSource(accessToken, expiry string, persister restclient.AuthProviderConfigPersister, ts oauth2.TokenSource, cache map[string]string) (*cachedTokenSource, error) {
|
||||
var expiryTime time.Time
|
||||
if parsedTime, err := time.Parse(time.RFC3339Nano, expiry); err == nil {
|
||||
expiryTime = parsedTime
|
||||
}
|
||||
if cache == nil {
|
||||
cache = make(map[string]string)
|
||||
}
|
||||
return &cachedTokenSource{
|
||||
source: ts,
|
||||
accessToken: accessToken,
|
||||
expiry: expiryTime,
|
||||
persister: persister,
|
||||
cache: cache,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *cachedTokenSource) Token() (*oauth2.Token, error) {
|
||||
tok := t.cachedToken()
|
||||
if tok.Valid() && !tok.Expiry.IsZero() {
|
||||
return tok, nil
|
||||
}
|
||||
tok, err := t.source.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cache := t.update(tok)
|
||||
if t.persister != nil {
|
||||
if err := t.persister.Persist(cache); err != nil {
|
||||
klog.V(4).Infof("Failed to persist token: %v", err)
|
||||
}
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (t *cachedTokenSource) cachedToken() *oauth2.Token {
|
||||
t.lk.Lock()
|
||||
defer t.lk.Unlock()
|
||||
return &oauth2.Token{
|
||||
AccessToken: t.accessToken,
|
||||
TokenType: "Bearer",
|
||||
Expiry: t.expiry,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *cachedTokenSource) update(tok *oauth2.Token) map[string]string {
|
||||
t.lk.Lock()
|
||||
defer t.lk.Unlock()
|
||||
t.accessToken = tok.AccessToken
|
||||
t.expiry = tok.Expiry
|
||||
ret := map[string]string{}
|
||||
for k, v := range t.cache {
|
||||
ret[k] = v
|
||||
}
|
||||
ret["access-token"] = t.accessToken
|
||||
ret["expiry"] = t.expiry.Format(time.RFC3339Nano)
|
||||
return ret
|
||||
}
|
||||
|
||||
// baseCache is the base configuration value for this TokenSource, without any cached ephemeral tokens.
|
||||
func (t *cachedTokenSource) baseCache() map[string]string {
|
||||
t.lk.Lock()
|
||||
defer t.lk.Unlock()
|
||||
ret := map[string]string{}
|
||||
for k, v := range t.cache {
|
||||
ret[k] = v
|
||||
}
|
||||
delete(ret, "access-token")
|
||||
delete(ret, "expiry")
|
||||
return ret
|
||||
}
|
||||
|
||||
type commandTokenSource struct {
|
||||
cmd string
|
||||
args []string
|
||||
tokenKey string `datapolicy:"token"`
|
||||
expiryKey string `datapolicy:"secret-key"`
|
||||
timeFmt string
|
||||
}
|
||||
|
||||
func newCmdTokenSource(cmd string, args []string, tokenKey, expiryKey, timeFmt string) *commandTokenSource {
|
||||
if len(timeFmt) == 0 {
|
||||
timeFmt = time.RFC3339Nano
|
||||
}
|
||||
if len(tokenKey) == 0 {
|
||||
tokenKey = "{.access_token}"
|
||||
}
|
||||
if len(expiryKey) == 0 {
|
||||
expiryKey = "{.token_expiry}"
|
||||
}
|
||||
return &commandTokenSource{
|
||||
cmd: cmd,
|
||||
args: args,
|
||||
tokenKey: tokenKey,
|
||||
expiryKey: expiryKey,
|
||||
timeFmt: timeFmt,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commandTokenSource) Token() (*oauth2.Token, error) {
|
||||
fullCmd := strings.Join(append([]string{c.cmd}, c.args...), " ")
|
||||
cmd := execCommand(c.cmd, c.args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error executing access token command %q: err=%v output=%s stderr=%s", fullCmd, err, output, string(stderr.Bytes()))
|
||||
}
|
||||
token, err := c.parseTokenCmdOutput(output)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing output for access token command %q: %v", fullCmd, err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c *commandTokenSource) parseTokenCmdOutput(output []byte) (*oauth2.Token, error) {
|
||||
output, err := yaml.ToJSON(output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken, err := parseJSONPath(data, "token-key", c.tokenKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing token-key %q from %q: %v", c.tokenKey, string(output), err)
|
||||
}
|
||||
expiryStr, err := parseJSONPath(data, "expiry-key", c.expiryKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing expiry-key %q from %q: %v", c.expiryKey, string(output), err)
|
||||
}
|
||||
var expiry time.Time
|
||||
if t, err := time.Parse(c.timeFmt, expiryStr); err != nil {
|
||||
klog.V(4).Infof("Failed to parse token expiry from %s (fmt=%s): %v", expiryStr, c.timeFmt, err)
|
||||
} else {
|
||||
expiry = t
|
||||
}
|
||||
|
||||
return &oauth2.Token{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
Expiry: expiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseJSONPath(input interface{}, name, template string) (string, error) {
|
||||
j := jsonpath.New(name)
|
||||
buf := new(bytes.Buffer)
|
||||
if err := j.Parse(template); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := j.Execute(buf, input); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
type conditionalTransport struct {
|
||||
oauthTransport *oauth2.Transport
|
||||
persister restclient.AuthProviderConfigPersister
|
||||
resetCache map[string]string
|
||||
}
|
||||
|
||||
var _ net.RoundTripperWrapper = &conditionalTransport{}
|
||||
|
||||
func (t *conditionalTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if len(req.Header.Get("Authorization")) != 0 {
|
||||
return t.oauthTransport.Base.RoundTrip(req)
|
||||
}
|
||||
|
||||
res, err := t.oauthTransport.RoundTrip(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode == 401 {
|
||||
klog.V(4).Infof("The credentials that were supplied are invalid for the target cluster")
|
||||
t.persister.Persist(t.resetCache)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (t *conditionalTransport) WrappedRoundTripper() http.RoundTripper { return t.oauthTransport.Base }
|
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package gcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := rest.RegisterAuthProviderPlugin("gcp", newGCPAuthProvider); err != nil {
|
||||
klog.Fatalf("Failed to register gcp auth plugin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newGCPAuthProvider(_ string, _ map[string]string, _ rest.AuthProviderConfigPersister) (rest.AuthProvider, error) {
|
||||
return nil, errors.New(`The gcp auth plugin has been removed.
|
||||
Please use the "gke-gcloud-auth-plugin" kubectl/client-go credential plugin instead.
|
||||
See https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke for further details`)
|
||||
}
|
527
plugin/pkg/client/auth/gcp/gcp_test.go
Normal file
527
plugin/pkg/client/auth/gcp/gcp_test.go
Normal file
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
Copyright 2016 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package gcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type fakeOutput struct {
|
||||
args []string
|
||||
output string
|
||||
}
|
||||
|
||||
var (
|
||||
wantCmd []string
|
||||
// Output for fakeExec, keyed by command
|
||||
execOutputs = map[string]fakeOutput{
|
||||
"/default/no/args": {
|
||||
args: []string{},
|
||||
output: `{
|
||||
"access_token": "faketoken",
|
||||
"token_expiry": "2016-10-31T22:31:09.123000000Z"
|
||||
}`},
|
||||
"/default/legacy/args": {
|
||||
args: []string{"arg1", "arg2", "arg3"},
|
||||
output: `{
|
||||
"access_token": "faketoken",
|
||||
"token_expiry": "2016-10-31T22:31:09.123000000Z"
|
||||
}`},
|
||||
"/space in path/customkeys": {
|
||||
args: []string{"can", "haz", "auth"},
|
||||
output: `{
|
||||
"token": "faketoken",
|
||||
"token_expiry": {
|
||||
"datetime": "2016-10-31 22:31:09.123"
|
||||
}
|
||||
}`},
|
||||
"missing/tokenkey/noargs": {
|
||||
args: []string{},
|
||||
output: `{
|
||||
"broken": "faketoken",
|
||||
"token_expiry": {
|
||||
"datetime": "2016-10-31 22:31:09.123000000Z"
|
||||
}
|
||||
}`},
|
||||
"missing/expirykey/legacyargs": {
|
||||
args: []string{"split", "on", "whitespace"},
|
||||
output: `{
|
||||
"access_token": "faketoken",
|
||||
"expires": "2016-10-31T22:31:09.123000000Z"
|
||||
}`},
|
||||
"invalid expiry/timestamp": {
|
||||
args: []string{"foo", "--bar", "--baz=abc,def"},
|
||||
output: `{
|
||||
"access_token": "faketoken",
|
||||
"token_expiry": "sometime soon, idk"
|
||||
}`},
|
||||
"badjson": {
|
||||
args: []string{},
|
||||
output: `{
|
||||
"access_token": "faketoken",
|
||||
"token_expiry": "sometime soon, idk"
|
||||
------
|
||||
`},
|
||||
}
|
||||
)
|
||||
|
||||
func fakeExec(command string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestHelperProcess", "--", command}
|
||||
cs = append(cs, args...)
|
||||
cmd := exec.Command(os.Args[0], cs...)
|
||||
cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
// Strip out the leading args used to exec into this function.
|
||||
gotCmd := os.Args[3]
|
||||
gotArgs := os.Args[4:]
|
||||
output, ok := execOutputs[gotCmd]
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stdout, "unexpected call cmd=%q args=%v\n", gotCmd, gotArgs)
|
||||
os.Exit(1)
|
||||
} else if !reflect.DeepEqual(output.args, gotArgs) {
|
||||
fmt.Fprintf(os.Stdout, "call cmd=%q got args %v, want: %v\n", gotCmd, gotArgs, output.args)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, output.output)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func Test_isCmdTokenSource(t *testing.T) {
|
||||
c1 := map[string]string{"cmd-path": "foo"}
|
||||
if v := isCmdTokenSource(c1); !v {
|
||||
t.Fatalf("cmd-path present in config (%+v), but got %v", c1, v)
|
||||
}
|
||||
|
||||
c2 := map[string]string{"cmd-args": "foo bar"}
|
||||
if v := isCmdTokenSource(c2); v {
|
||||
t.Fatalf("cmd-path not present in config (%+v), but got %v", c2, v)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tokenSource_cmd(t *testing.T) {
|
||||
if _, err := tokenSource(true, map[string]string{}); err == nil {
|
||||
t.Fatalf("expected error, cmd-args not present in config")
|
||||
}
|
||||
|
||||
c := map[string]string{
|
||||
"cmd-path": "foo",
|
||||
"cmd-args": "bar"}
|
||||
ts, err := tokenSource(true, c)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to return cmd token source: %+v", err)
|
||||
}
|
||||
if ts == nil {
|
||||
t.Fatal("returned nil token source")
|
||||
}
|
||||
if _, ok := ts.(*commandTokenSource); !ok {
|
||||
t.Fatalf("returned token source type:(%T) expected:(*commandTokenSource)", ts)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tokenSource_cmdCannotBeUsedWithScopes(t *testing.T) {
|
||||
c := map[string]string{
|
||||
"cmd-path": "foo",
|
||||
"scopes": "A,B"}
|
||||
if _, err := tokenSource(true, c); err == nil {
|
||||
t.Fatal("expected error when scopes is used with cmd-path")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tokenSource_applicationDefaultCredentials_fails(t *testing.T) {
|
||||
// try to use empty ADC file
|
||||
fakeTokenFile, err := ioutil.TempFile("", "adctoken")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fake token file: +%v", err)
|
||||
}
|
||||
fakeTokenFile.Close()
|
||||
defer os.Remove(fakeTokenFile.Name())
|
||||
|
||||
os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", fakeTokenFile.Name())
|
||||
defer os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
if _, err := tokenSource(false, map[string]string{}); err == nil {
|
||||
t.Fatalf("expected error because specified ADC token file is not a JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tokenSource_applicationDefaultCredentials(t *testing.T) {
|
||||
fakeTokenFile, err := ioutil.TempFile("", "adctoken")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fake token file: +%v", err)
|
||||
}
|
||||
fakeTokenFile.Close()
|
||||
defer os.Remove(fakeTokenFile.Name())
|
||||
if err := ioutil.WriteFile(fakeTokenFile.Name(), []byte(`{"type":"service_account"}`), 0600); err != nil {
|
||||
t.Fatalf("failed to write to fake token file: %+v", err)
|
||||
}
|
||||
|
||||
os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", fakeTokenFile.Name())
|
||||
defer os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
ts, err := tokenSource(false, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get a token source: %+v", err)
|
||||
}
|
||||
if ts == nil {
|
||||
t.Fatal("returned nil token source")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseScopes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in map[string]string
|
||||
out []string
|
||||
}{
|
||||
{
|
||||
map[string]string{},
|
||||
[]string{
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email"},
|
||||
},
|
||||
{
|
||||
map[string]string{"scopes": ""},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
map[string]string{"scopes": "A,B,C"},
|
||||
[]string{"A", "B", "C"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := parseScopes(c.in)
|
||||
if !reflect.DeepEqual(got, c.out) {
|
||||
t.Errorf("expected=%v, got=%v", c.out, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errEquiv(got, want error) bool {
|
||||
if got == want {
|
||||
return true
|
||||
}
|
||||
if got != nil && want != nil {
|
||||
return strings.Contains(got.Error(), want.Error())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCmdTokenSource(t *testing.T) {
|
||||
execCommand = fakeExec
|
||||
fakeExpiry := time.Date(2016, 10, 31, 22, 31, 9, 123000000, time.UTC)
|
||||
customFmt := "2006-01-02 15:04:05.999999999"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
gcpConfig map[string]string
|
||||
tok *oauth2.Token
|
||||
newErr, tokenErr error
|
||||
}{
|
||||
{
|
||||
"default",
|
||||
map[string]string{
|
||||
"cmd-path": "/default/no/args",
|
||||
},
|
||||
&oauth2.Token{
|
||||
AccessToken: "faketoken",
|
||||
TokenType: "Bearer",
|
||||
Expiry: fakeExpiry,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"default legacy args",
|
||||
map[string]string{
|
||||
"cmd-path": "/default/legacy/args arg1 arg2 arg3",
|
||||
},
|
||||
&oauth2.Token{
|
||||
AccessToken: "faketoken",
|
||||
TokenType: "Bearer",
|
||||
Expiry: fakeExpiry,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
|
||||
{
|
||||
"custom keys",
|
||||
map[string]string{
|
||||
"cmd-path": "/space in path/customkeys",
|
||||
"cmd-args": "can haz auth",
|
||||
"token-key": "{.token}",
|
||||
"expiry-key": "{.token_expiry.datetime}",
|
||||
"time-fmt": customFmt,
|
||||
},
|
||||
&oauth2.Token{
|
||||
AccessToken: "faketoken",
|
||||
TokenType: "Bearer",
|
||||
Expiry: fakeExpiry,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing cmd",
|
||||
map[string]string{
|
||||
"cmd-path": "",
|
||||
},
|
||||
nil,
|
||||
fmt.Errorf("missing access token cmd"),
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing token-key",
|
||||
map[string]string{
|
||||
"cmd-path": "missing/tokenkey/noargs",
|
||||
"token-key": "{.token}",
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
fmt.Errorf("error parsing token-key %q", "{.token}"),
|
||||
},
|
||||
|
||||
{
|
||||
"missing expiry-key",
|
||||
map[string]string{
|
||||
"cmd-path": "missing/expirykey/legacyargs split on whitespace",
|
||||
"expiry-key": "{.expiry}",
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
fmt.Errorf("error parsing expiry-key %q", "{.expiry}"),
|
||||
},
|
||||
{
|
||||
"invalid expiry timestamp",
|
||||
map[string]string{
|
||||
"cmd-path": "invalid expiry/timestamp",
|
||||
"cmd-args": "foo --bar --baz=abc,def",
|
||||
},
|
||||
&oauth2.Token{
|
||||
AccessToken: "faketoken",
|
||||
TokenType: "Bearer",
|
||||
Expiry: time.Time{},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"bad JSON",
|
||||
map[string]string{
|
||||
"cmd-path": "badjson",
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
fmt.Errorf("invalid character '-' after object key:value pair"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
provider, err := newGCPAuthProvider("", tc.gcpConfig, nil /* persister */)
|
||||
if !errEquiv(err, tc.newErr) {
|
||||
t.Errorf("%q newGCPAuthProvider error: got %v, want %v", tc.name, err, tc.newErr)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ts := provider.(*gcpAuthProvider).tokenSource.(*cachedTokenSource).source.(*commandTokenSource)
|
||||
wantCmd = append([]string{ts.cmd}, ts.args...)
|
||||
tok, err := ts.Token()
|
||||
if !errEquiv(err, tc.tokenErr) {
|
||||
t.Errorf("%q Token() error: got %v, want %v", tc.name, err, tc.tokenErr)
|
||||
}
|
||||
if !reflect.DeepEqual(tok, tc.tok) {
|
||||
t.Errorf("%q Token() got %v, want %v", tc.name, tok, tc.tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakePersister struct {
|
||||
lk sync.Mutex
|
||||
cache map[string]string
|
||||
}
|
||||
|
||||
func (f *fakePersister) Persist(cache map[string]string) error {
|
||||
f.lk.Lock()
|
||||
defer f.lk.Unlock()
|
||||
f.cache = map[string]string{}
|
||||
for k, v := range cache {
|
||||
f.cache[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePersister) read() map[string]string {
|
||||
ret := map[string]string{}
|
||||
f.lk.Lock()
|
||||
defer f.lk.Unlock()
|
||||
for k, v := range f.cache {
|
||||
ret[k] = v
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type fakeTokenSource struct {
|
||||
token *oauth2.Token
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTokenSource) Token() (*oauth2.Token, error) {
|
||||
return f.token, f.err
|
||||
}
|
||||
|
||||
func TestCachedTokenSource(t *testing.T) {
|
||||
tok := &oauth2.Token{AccessToken: "fakeaccesstoken"}
|
||||
persister := &fakePersister{}
|
||||
source := &fakeTokenSource{
|
||||
token: tok,
|
||||
err: nil,
|
||||
}
|
||||
cache := map[string]string{
|
||||
"foo": "bar",
|
||||
"baz": "bazinga",
|
||||
}
|
||||
ts, err := newCachedTokenSource("fakeaccesstoken", "", persister, source, cache)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
_, err := ts.Token()
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
cache["access-token"] = "fakeaccesstoken"
|
||||
cache["expiry"] = tok.Expiry.Format(time.RFC3339Nano)
|
||||
if got := persister.read(); !reflect.DeepEqual(got, cache) {
|
||||
t.Errorf("got cache %v, want %v", got, cache)
|
||||
}
|
||||
}
|
||||
|
||||
type MockTransport struct {
|
||||
res *http.Response
|
||||
}
|
||||
|
||||
func (t *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.res, nil
|
||||
}
|
||||
|
||||
func Test_cmdTokenSource_roundTrip(t *testing.T) {
|
||||
|
||||
accessToken := "fakeToken"
|
||||
fakeExpiry := time.Now().Add(time.Hour)
|
||||
fakeExpiryStr := fakeExpiry.Format(time.RFC3339Nano)
|
||||
fs := &fakeTokenSource{
|
||||
token: &oauth2.Token{
|
||||
AccessToken: accessToken,
|
||||
Expiry: fakeExpiry,
|
||||
},
|
||||
}
|
||||
|
||||
cmdCache := map[string]string{
|
||||
"cmd-path": "/path/to/tokensource/cmd",
|
||||
"cmd-args": "--output=json",
|
||||
}
|
||||
cmdCacheUpdated := map[string]string{
|
||||
"cmd-path": "/path/to/tokensource/cmd",
|
||||
"cmd-args": "--output=json",
|
||||
"access-token": accessToken,
|
||||
"expiry": fakeExpiryStr,
|
||||
}
|
||||
simpleCacheUpdated := map[string]string{
|
||||
"access-token": accessToken,
|
||||
"expiry": fakeExpiryStr,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
res http.Response
|
||||
baseCache, expectedCache map[string]string
|
||||
}{
|
||||
{
|
||||
"Unauthorized",
|
||||
http.Response{StatusCode: http.StatusUnauthorized},
|
||||
make(map[string]string),
|
||||
make(map[string]string),
|
||||
},
|
||||
{
|
||||
"Unauthorized, nonempty defaultCache",
|
||||
http.Response{StatusCode: http.StatusUnauthorized},
|
||||
cmdCache,
|
||||
cmdCache,
|
||||
},
|
||||
{
|
||||
"Authorized",
|
||||
http.Response{StatusCode: http.StatusOK},
|
||||
make(map[string]string),
|
||||
simpleCacheUpdated,
|
||||
},
|
||||
{
|
||||
"Authorized, nonempty defaultCache",
|
||||
http.Response{StatusCode: http.StatusOK},
|
||||
cmdCache,
|
||||
cmdCacheUpdated,
|
||||
},
|
||||
}
|
||||
|
||||
persister := &fakePersister{}
|
||||
req := http.Request{Header: http.Header{}}
|
||||
|
||||
for _, tc := range tests {
|
||||
cts, err := newCachedTokenSource(accessToken, fakeExpiry.String(), persister, fs, tc.baseCache)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from newCachedTokenSource: %v", err)
|
||||
}
|
||||
authProvider := gcpAuthProvider{cts, persister}
|
||||
|
||||
fakeTransport := MockTransport{&tc.res}
|
||||
transport := (authProvider.WrapTransport(&fakeTransport))
|
||||
// call Token to persist/update cache
|
||||
if _, err := cts.Token(); err != nil {
|
||||
t.Fatalf("unexpected error from cachedTokenSource.Token(): %v", err)
|
||||
}
|
||||
|
||||
transport.RoundTrip(&req)
|
||||
|
||||
if got := persister.read(); !reflect.DeepEqual(got, tc.expectedCache) {
|
||||
t.Errorf("got cache %v, want %v", got, tc.expectedCache)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user