From e12b58626cb4cf5f820b924eba476b51df0571dc Mon Sep 17 00:00:00 2001 From: Cosmin Cojocar Date: Mon, 3 Apr 2017 09:56:27 +0200 Subject: [PATCH] Add client auth plugin for Azure Active Directory This plugin acquires a fresh access token for apiserver from Azure Active Directory using the device code flow. The access token is saved in the configuration in order to be reused for upcomming accesses to appiserver. In additon the access token is automatically refreshed when expired. Kubernetes-commit: 682d5ec01f37c65117b2496865cc9bf0cd9e0902 --- Godeps/Godeps.json | 16 + plugin/pkg/client/auth/BUILD | 1 + plugin/pkg/client/auth/azure/BUILD | 29 ++ plugin/pkg/client/auth/azure/README.md | 48 +++ plugin/pkg/client/auth/azure/azure.go | 353 +++++++++++++++++++++ plugin/pkg/client/auth/azure/azure_test.go | 133 ++++++++ plugin/pkg/client/auth/plugins.go | 1 + 7 files changed, 581 insertions(+) create mode 100644 plugin/pkg/client/auth/azure/BUILD create mode 100644 plugin/pkg/client/auth/azure/README.md create mode 100644 plugin/pkg/client/auth/azure/azure.go create mode 100644 plugin/pkg/client/auth/azure/azure_test.go diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index a2bbefe1..b854e513 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -14,6 +14,18 @@ "ImportPath": "cloud.google.com/go/internal", "Rev": "3b1ae45394a234c385be014e9a488f2bb6eef821" }, + { + "ImportPath": "github.com/Azure/go-autorest/autorest", + "Rev": "d7c034a8af24eda120dd6460bfcd6d9ed14e43ca" + }, + { + "ImportPath": "github.com/Azure/go-autorest/autorest/azure", + "Rev": "d7c034a8af24eda120dd6460bfcd6d9ed14e43ca" + }, + { + "ImportPath": "github.com/Azure/go-autorest/autorest/date", + "Rev": "d7c034a8af24eda120dd6460bfcd6d9ed14e43ca" + }, { "ImportPath": "github.com/PuerkitoBio/purell", "Rev": "8a290539e2e8629dbc4e6bad948158f790ec31f4" @@ -58,6 +70,10 @@ "ImportPath": "github.com/davecgh/go-spew/spew", "Rev": "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d" }, + { + "ImportPath": "github.com/dgrijalva/jwt-go", + "Rev": "01aeca54ebda6e0fbfafd0a524d234159c05ec20" + }, { "ImportPath": "github.com/docker/distribution/digest", "Rev": "cd27f179f2c10c5d300e6d09025b538c475b0d51" diff --git a/plugin/pkg/client/auth/BUILD b/plugin/pkg/client/auth/BUILD index b74fe879..d500b8d8 100644 --- a/plugin/pkg/client/auth/BUILD +++ b/plugin/pkg/client/auth/BUILD @@ -12,6 +12,7 @@ go_library( srcs = ["plugins.go"], tags = ["automanaged"], deps = [ + "//vendor/k8s.io/client-go/plugin/pkg/client/auth/azure:go_default_library", "//vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp:go_default_library", "//vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc:go_default_library", ], diff --git a/plugin/pkg/client/auth/azure/BUILD b/plugin/pkg/client/auth/azure/BUILD new file mode 100644 index 00000000..0c8f3095 --- /dev/null +++ b/plugin/pkg/client/auth/azure/BUILD @@ -0,0 +1,29 @@ +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +load( + "@io_bazel_rules_go//go:def.bzl", + "go_library", + "go_test", +) + +go_test( + name = "go_default_test", + srcs = ["azure_test.go"], + library = ":go_default_library", + tags = ["automanaged"], + deps = ["//vendor/github.com/Azure/go-autorest/autorest/azure:go_default_library"], +) + +go_library( + name = "go_default_library", + srcs = ["azure.go"], + tags = ["automanaged"], + deps = [ + "//vendor/github.com/Azure/go-autorest/autorest:go_default_library", + "//vendor/github.com/Azure/go-autorest/autorest/azure:go_default_library", + "//vendor/github.com/golang/glog:go_default_library", + "//vendor/k8s.io/client-go/rest:go_default_library", + ], +) diff --git a/plugin/pkg/client/auth/azure/README.md b/plugin/pkg/client/auth/azure/README.md new file mode 100644 index 00000000..0b5e62bd --- /dev/null +++ b/plugin/pkg/client/auth/azure/README.md @@ -0,0 +1,48 @@ +# 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 stored them in the kubectl configuration. In addition it will refresh and update the tokens in 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) + +2. Create a second Azure Active Directory native application for `kubectl` + +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. + +5. Configure the `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 + + 6. 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 next commands. diff --git a/plugin/pkg/client/auth/azure/azure.go b/plugin/pkg/client/auth/azure/azure.go new file mode 100644 index 00000000..342fbb78 --- /dev/null +++ b/plugin/pkg/client/auth/azure/azure.go @@ -0,0 +1,353 @@ +/* +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 ( + "errors" + "fmt" + "net/http" + "os" + "sync" + + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/azure" + "github.com/golang/glog" + + restclient "k8s.io/client-go/rest" +) + +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" +) + +func init() { + if err := restclient.RegisterAuthProviderPlugin("azure", newAzureAuthProvider); err != nil { + glog.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 +} + +func newAzureAuthProvider(_ string, cfg map[string]string, persister restclient.AuthProviderConfigPersister) (restclient.AuthProvider, error) { + var ts tokenSource + + environment, err := azure.EnvironmentFromName(cfg[cfgEnvironment]) + if err != nil { + environment = azure.PublicCloud + } + ts, err = newAzureTokenSourceDeviceCode(environment, cfg[cfgClientID], cfg[cfgTenantID], cfg[cfgApiserverID]) + if err != nil { + return nil, fmt.Errorf("creating a new azure token source for device code authentication: %v", err) + } + cacheSource := newAzureTokenSource(ts, cache, cfg, 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 +} + +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 { + glog.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) +} + +type azureToken struct { + token azure.Token + clientID string + tenantID string + apiserverID string +} + +type tokenSource interface { + Token() (*azureToken, error) +} + +type azureTokenSource struct { + source tokenSource + cache *azureTokenCache + lock sync.Mutex + cfg map[string]string + persister restclient.AuthProviderConfigPersister +} + +func newAzureTokenSource(source tokenSource, cache *azureTokenCache, cfg map[string]string, persister restclient.AuthProviderConfigPersister) tokenSource { + return &azureTokenSource{ + source: source, + cache: cache, + cfg: cfg, + persister: persister, + } +} + +// 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, err = ts.retrieveTokenFromCfg() + if err != nil { + token, err = ts.source.Token() + if err != nil { + return nil, fmt.Errorf("acquiring a new fresh token: %v", err) + } + } + if !token.token.IsExpired() { + ts.cache.setToken(azureTokenKey, token) + err = ts.storeTokenInCfg(token) + if err != nil { + return nil, fmt.Errorf("storing the token in configuration: %v", err) + } + } + } + if token.token.IsExpired() { + token, err = ts.refreshToken(token) + if err != nil { + return nil, fmt.Errorf("refreshing the expired token: %v", err) + } + ts.cache.setToken(azureTokenKey, token) + err = ts.storeTokenInCfg(token) + if err != nil { + return nil, fmt.Errorf("storing the refreshed token in configuration: %v", err) + } + } + 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) + } + 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) + } + apiserverID := ts.cfg[cfgApiserverID] + if apiserverID == "" { + return nil, fmt.Errorf("no apiserver ID in cfg: %s", apiserverID) + } + 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) + } + + return &azureToken{ + token: azure.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: expiresIn, + ExpiresOn: expiresOn, + NotBefore: expiresOn, + Resource: fmt.Sprintf("spn:%s", apiserverID), + Type: tokenType, + }, + clientID: clientID, + tenantID: tenantID, + apiserverID: apiserverID, + }, nil +} + +func (ts *azureTokenSource) storeTokenInCfg(token *azureToken) error { + newCfg := make(map[string]string) + newCfg[cfgAccessToken] = token.token.AccessToken + newCfg[cfgRefreshToken] = token.token.RefreshToken + newCfg[cfgClientID] = token.clientID + newCfg[cfgTenantID] = token.tenantID + newCfg[cfgApiserverID] = token.apiserverID + newCfg[cfgExpiresIn] = token.token.ExpiresIn + newCfg[cfgExpiresOn] = token.token.ExpiresOn + + err := ts.persister.Persist(newCfg) + if err != nil { + return fmt.Errorf("persisting the configuration: %v", err) + } + ts.cfg = newCfg + return nil +} + +func (ts *azureTokenSource) refreshToken(token *azureToken) (*azureToken, error) { + oauthConfig, err := azure.PublicCloud.OAuthConfigForTenant(token.tenantID) + if err != nil { + return nil, fmt.Errorf("building the OAuth configuration for token refresh: %v", err) + } + + callback := func(t azure.Token) error { + return nil + } + spt, err := azure.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 { + return nil, fmt.Errorf("refreshing token: %v", err) + } + + return &azureToken{ + token: spt.Token, + clientID: token.clientID, + tenantID: token.tenantID, + apiserverID: token.apiserverID, + }, nil +} + +type azureTokenSourceDeviceCode struct { + environment azure.Environment + clientID string + tenantID string + apiserverID string +} + +func newAzureTokenSourceDeviceCode(environment azure.Environment, clientID string, tenantID string, apiserverID string) (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, + }, nil +} + +func (ts *azureTokenSourceDeviceCode) Token() (*azureToken, error) { + oauthConfig, err := ts.environment.OAuthConfigForTenant(ts.tenantID) + if err != nil { + return nil, fmt.Errorf("building the OAuth configuration for device code authentication: %v", err) + } + client := &autorest.Client{} + deviceCode, err := azure.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 := azure.WaitForUserCompletion(client, deviceCode) + if err != nil { + return nil, fmt.Errorf("waiting for device code authentication to complete: %v", err) + } + + return &azureToken{ + token: *token, + clientID: ts.clientID, + tenantID: ts.tenantID, + apiserverID: ts.apiserverID, + }, nil +} diff --git a/plugin/pkg/client/auth/azure/azure_test.go b/plugin/pkg/client/auth/azure/azure_test.go new file mode 100644 index 00000000..78d28b6e --- /dev/null +++ b/plugin/pkg/client/auth/azure/azure_test.go @@ -0,0 +1,133 @@ +/* +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 ( + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Azure/go-autorest/autorest/azure" +) + +func TestAzureTokenSource(t *testing.T) { + fakeAccessToken := "fake token 1" + fakeSource := fakeTokenSource{ + accessToken: fakeAccessToken, + expiresOn: strconv.FormatInt(time.Now().Add(3600*time.Second).Unix(), 10), + } + cfg := make(map[string]string) + persiter := &fakePersister{cache: make(map[string]string)} + tokenCache := newAzureTokenCache() + tokenSource := newAzureTokenSource(&fakeSource, tokenCache, cfg, 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) + persistedCfg := persiter.Cache() + 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.accessToken = "fake token 2" + 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") + } +} + +type fakePersister struct { + lock sync.Mutex + cache map[string]string +} + +func (p *fakePersister) Persist(cache map[string]string) error { + p.lock.Lock() + defer p.lock.Unlock() + 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 +} + +type fakeTokenSource struct { + expiresOn string + accessToken string +} + +func (ts *fakeTokenSource) Token() (*azureToken, error) { + return &azureToken{ + token: newFackeAzureToken(ts.accessToken, ts.expiresOn), + clientID: "fake", + tenantID: "fake", + apiserverID: "fake", + }, nil +} + +func token2Cfg(token *azureToken) map[string]string { + cfg := make(map[string]string) + cfg[cfgAccessToken] = token.token.AccessToken + cfg[cfgRefreshToken] = token.token.RefreshToken + cfg[cfgClientID] = token.clientID + cfg[cfgTenantID] = token.tenantID + cfg[cfgApiserverID] = token.apiserverID + cfg[cfgExpiresIn] = token.token.ExpiresIn + cfg[cfgExpiresOn] = token.token.ExpiresOn + return cfg +} + +func newFackeAzureToken(accessToken string, expiresOn string) azure.Token { + return azure.Token{ + AccessToken: accessToken, + RefreshToken: "fake", + ExpiresIn: "3600", + ExpiresOn: expiresOn, + NotBefore: expiresOn, + Resource: "fake", + Type: "fake", + } +} diff --git a/plugin/pkg/client/auth/plugins.go b/plugin/pkg/client/auth/plugins.go index 5cb2375f..0328fbd8 100644 --- a/plugin/pkg/client/auth/plugins.go +++ b/plugin/pkg/client/auth/plugins.go @@ -18,6 +18,7 @@ package auth import ( // Initialize all known client auth plugins. + _ "k8s.io/client-go/plugin/pkg/client/auth/azure" _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" )