From 9dcff62f1a63b87c2c66d6cb9fcf135bb0c74fb6 Mon Sep 17 00:00:00 2001 From: Andy Goldstein Date: Mon, 7 Aug 2017 11:24:22 -0400 Subject: [PATCH] Pull in azure, gcp, oidc client-go auth provider plugins Signed-off-by: Andy Goldstein --- Godeps/Godeps.json | 15 + pkg/client/auth_providers.go | 25 ++ .../plugin/pkg/client/auth/azure/README.md | 48 +++ .../plugin/pkg/client/auth/azure/azure.go | 353 +++++++++++++++++ .../plugin/pkg/client/auth/gcp/OWNERS | 6 + .../plugin/pkg/client/auth/gcp/gcp.go | 308 +++++++++++++++ .../plugin/pkg/client/auth/oidc/OWNERS | 4 + .../plugin/pkg/client/auth/oidc/oidc.go | 370 ++++++++++++++++++ 8 files changed, 1129 insertions(+) create mode 100644 pkg/client/auth_providers.go create mode 100644 vendor/k8s.io/client-go/plugin/pkg/client/auth/azure/README.md create mode 100644 vendor/k8s.io/client-go/plugin/pkg/client/auth/azure/azure.go create mode 100644 vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/OWNERS create mode 100644 vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/gcp.go create mode 100644 vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/OWNERS create mode 100644 vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index 7565e60ca..e75fca876 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -1075,6 +1075,21 @@ "Comment": "v4.0.0-beta.0", "Rev": "df46f7f13b3da19b90b8b4f0d18b8adc6fbf28dc" }, + { + "ImportPath": "k8s.io/client-go/plugin/pkg/client/auth/azure", + "Comment": "v4.0.0-beta.0", + "Rev": "df46f7f13b3da19b90b8b4f0d18b8adc6fbf28dc" + }, + { + "ImportPath": "k8s.io/client-go/plugin/pkg/client/auth/gcp", + "Comment": "v4.0.0-beta.0", + "Rev": "df46f7f13b3da19b90b8b4f0d18b8adc6fbf28dc" + }, + { + "ImportPath": "k8s.io/client-go/plugin/pkg/client/auth/oidc", + "Comment": "v4.0.0-beta.0", + "Rev": "df46f7f13b3da19b90b8b4f0d18b8adc6fbf28dc" + }, { "ImportPath": "k8s.io/client-go/rest", "Comment": "v4.0.0-beta.0", diff --git a/pkg/client/auth_providers.go b/pkg/client/auth_providers.go new file mode 100644 index 000000000..8e5555fed --- /dev/null +++ b/pkg/client/auth_providers.go @@ -0,0 +1,25 @@ +/* +Copyright 2017 Heptio Inc. + +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 client + +// Make sure we import the client-go auth provider plugins. + +import ( + _ "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" +) diff --git a/vendor/k8s.io/client-go/plugin/pkg/client/auth/azure/README.md b/vendor/k8s.io/client-go/plugin/pkg/client/auth/azure/README.md new file mode 100644 index 000000000..0b5e62bd4 --- /dev/null +++ b/vendor/k8s.io/client-go/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/vendor/k8s.io/client-go/plugin/pkg/client/auth/azure/azure.go b/vendor/k8s.io/client-go/plugin/pkg/client/auth/azure/azure.go new file mode 100644 index 000000000..342fbb78f --- /dev/null +++ b/vendor/k8s.io/client-go/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/vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/OWNERS b/vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/OWNERS new file mode 100644 index 000000000..6cf8db8ac --- /dev/null +++ b/vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/OWNERS @@ -0,0 +1,6 @@ +approvers: +- cjcullen +- jlowdermilk +reviewers: +- cjcullen +- jlowdermilk diff --git a/vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/gcp.go b/vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/gcp.go new file mode 100644 index 000000000..59a27bea7 --- /dev/null +++ b/vendor/k8s.io/client-go/plugin/pkg/client/auth/gcp/gcp.go @@ -0,0 +1,308 @@ +/* +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" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "strings" + "sync" + "time" + + "github.com/golang/glog" + "golang.org/x/net/context" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "k8s.io/apimachinery/pkg/util/yaml" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/util/jsonpath" +) + +func init() { + if err := restclient.RegisterAuthProviderPlugin("gcp", newGCPAuthProvider); err != nil { + glog.Fatalf("Failed to register gcp auth plugin: %v", err) + } +} + +// Stubbable for testing +var execCommand = exec.Command + +// 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': { +// # 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 +} + +func newGCPAuthProvider(_ string, gcpConfig map[string]string, persister restclient.AuthProviderConfigPersister) (restclient.AuthProvider, error) { + var ts oauth2.TokenSource + var err error + if cmd, useCmd := gcpConfig["cmd-path"]; useCmd { + if len(cmd) == 0 { + return nil, fmt.Errorf("missing access token cmd") + } + 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:] + } + ts = newCmdTokenSource(cmd, args, gcpConfig["token-key"], gcpConfig["expiry-key"], gcpConfig["time-fmt"]) + } else { + ts, err = google.DefaultTokenSource(context.Background(), "https://www.googleapis.com/auth/cloud-platform") + } + 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 (g *gcpAuthProvider) WrapTransport(rt http.RoundTripper) http.RoundTripper { + return &conditionalTransport{&oauth2.Transport{Source: g.tokenSource, Base: rt}, g.persister} +} + +func (g *gcpAuthProvider) Login() error { return nil } + +type cachedTokenSource struct { + lk sync.Mutex + source oauth2.TokenSource + accessToken string + 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 { + glog.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 +} + +type commandTokenSource struct { + cmd string + args []string + tokenKey string + expiryKey string + 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...) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("error executing access token command %q: err=%v output=%s", fullCmd, err, output) + } + 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 { + glog.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 +} + +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 { + glog.V(4).Infof("The credentials that were supplied are invalid for the target cluster") + emptyCache := make(map[string]string) + t.persister.Persist(emptyCache) + } + + return res, nil +} diff --git a/vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/OWNERS b/vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/OWNERS new file mode 100644 index 000000000..d2210cb89 --- /dev/null +++ b/vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/OWNERS @@ -0,0 +1,4 @@ +approvers: +- ericchiang +reviewers: +- ericchiang diff --git a/vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go b/vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go new file mode 100644 index 000000000..ecfd13eb2 --- /dev/null +++ b/vendor/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go @@ -0,0 +1,370 @@ +/* +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 oidc + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang/glog" + "golang.org/x/oauth2" + restclient "k8s.io/client-go/rest" +) + +const ( + cfgIssuerUrl = "idp-issuer-url" + cfgClientID = "client-id" + cfgClientSecret = "client-secret" + cfgCertificateAuthority = "idp-certificate-authority" + cfgCertificateAuthorityData = "idp-certificate-authority-data" + cfgIDToken = "id-token" + cfgRefreshToken = "refresh-token" + + // Unused. Scopes aren't sent during refreshing. + cfgExtraScopes = "extra-scopes" +) + +func init() { + if err := restclient.RegisterAuthProviderPlugin("oidc", newOIDCAuthProvider); err != nil { + glog.Fatalf("Failed to register oidc auth plugin: %v", err) + } +} + +// expiryDelta determines how earlier a token should be considered +// expired than its actual expiration time. It is used to avoid late +// expirations due to client-server time mismatches. +// +// NOTE(ericchiang): this is take from golang.org/x/oauth2 +const expiryDelta = 10 * time.Second + +var cache = newClientCache() + +// Like TLS transports, keep a cache of OIDC clients indexed by issuer URL. This ensures +// current requests from different clients don't concurrently attempt to refresh the same +// set of credentials. +type clientCache struct { + mu sync.RWMutex + + cache map[cacheKey]*oidcAuthProvider +} + +func newClientCache() *clientCache { + return &clientCache{cache: make(map[cacheKey]*oidcAuthProvider)} +} + +type cacheKey struct { + // Canonical issuer URL string of the provider. + issuerURL string + clientID string +} + +func (c *clientCache) getClient(issuer, clientID string) (*oidcAuthProvider, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + client, ok := c.cache[cacheKey{issuer, clientID}] + return client, ok +} + +// setClient attempts to put the client in the cache but may return any clients +// with the same keys set before. This is so there's only ever one client for a provider. +func (c *clientCache) setClient(issuer, clientID string, client *oidcAuthProvider) *oidcAuthProvider { + c.mu.Lock() + defer c.mu.Unlock() + key := cacheKey{issuer, clientID} + + // If another client has already initialized a client for the given provider we want + // to use that client instead of the one we're trying to set. This is so all transports + // share a client and can coordinate around the same mutex when refreshing and writing + // to the kubeconfig. + if oldClient, ok := c.cache[key]; ok { + return oldClient + } + + c.cache[key] = client + return client +} + +func newOIDCAuthProvider(_ string, cfg map[string]string, persister restclient.AuthProviderConfigPersister) (restclient.AuthProvider, error) { + issuer := cfg[cfgIssuerUrl] + if issuer == "" { + return nil, fmt.Errorf("Must provide %s", cfgIssuerUrl) + } + + clientID := cfg[cfgClientID] + if clientID == "" { + return nil, fmt.Errorf("Must provide %s", cfgClientID) + } + + // Check cache for existing provider. + if provider, ok := cache.getClient(issuer, clientID); ok { + return provider, nil + } + + if len(cfg[cfgExtraScopes]) > 0 { + glog.V(2).Infof("%s auth provider field depricated, refresh request don't send scopes", + cfgExtraScopes) + } + + var certAuthData []byte + var err error + if cfg[cfgCertificateAuthorityData] != "" { + certAuthData, err = base64.StdEncoding.DecodeString(cfg[cfgCertificateAuthorityData]) + if err != nil { + return nil, err + } + } + + clientConfig := restclient.Config{ + TLSClientConfig: restclient.TLSClientConfig{ + CAFile: cfg[cfgCertificateAuthority], + CAData: certAuthData, + }, + } + + trans, err := restclient.TransportFor(&clientConfig) + if err != nil { + return nil, err + } + hc := &http.Client{Transport: trans} + + provider := &oidcAuthProvider{ + client: hc, + now: time.Now, + cfg: cfg, + persister: persister, + } + + return cache.setClient(issuer, clientID, provider), nil +} + +type oidcAuthProvider struct { + client *http.Client + + // Method for determining the current time. + now func() time.Time + + // Mutex guards persisting to the kubeconfig file and allows synchronized + // updates to the in-memory config. It also ensures concurrent calls to + // the RoundTripper only trigger a single refresh request. + mu sync.Mutex + cfg map[string]string + persister restclient.AuthProviderConfigPersister +} + +func (p *oidcAuthProvider) WrapTransport(rt http.RoundTripper) http.RoundTripper { + return &roundTripper{ + wrapped: rt, + provider: p, + } +} + +func (p *oidcAuthProvider) Login() error { + return errors.New("not yet implemented") +} + +type roundTripper struct { + provider *oidcAuthProvider + wrapped http.RoundTripper +} + +func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if len(req.Header.Get("Authorization")) != 0 { + return r.wrapped.RoundTrip(req) + } + token, err := r.provider.idToken() + if err != nil { + return nil, err + } + + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *req + // deep copy of the Header so we don't modify the original + // request's Header (as per RoundTripper contract). + r2.Header = make(http.Header) + for k, s := range req.Header { + r2.Header[k] = s + } + r2.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + return r.wrapped.RoundTrip(r2) +} + +func (p *oidcAuthProvider) idToken() (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if idToken, ok := p.cfg[cfgIDToken]; ok && len(idToken) > 0 { + valid, err := idTokenExpired(p.now, idToken) + if err != nil { + return "", err + } + if valid { + // If the cached id token is still valid use it. + return idToken, nil + } + } + + // Try to request a new token using the refresh token. + rt, ok := p.cfg[cfgRefreshToken] + if !ok || len(rt) == 0 { + return "", errors.New("No valid id-token, and cannot refresh without refresh-token") + } + + // Determine provider's OAuth2 token endpoint. + tokenURL, err := tokenEndpoint(p.client, p.cfg[cfgIssuerUrl]) + if err != nil { + return "", err + } + + config := oauth2.Config{ + ClientID: p.cfg[cfgClientID], + ClientSecret: p.cfg[cfgClientSecret], + Endpoint: oauth2.Endpoint{TokenURL: tokenURL}, + } + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, p.client) + token, err := config.TokenSource(ctx, &oauth2.Token{RefreshToken: rt}).Token() + if err != nil { + return "", fmt.Errorf("failed to refresh token: %v", err) + } + + idToken, ok := token.Extra("id_token").(string) + if !ok { + return "", fmt.Errorf("token response did not contain an id_token") + } + + // Create a new config to persist. + newCfg := make(map[string]string) + for key, val := range p.cfg { + newCfg[key] = val + } + + // Update the refresh token if the server returned another one. + if token.RefreshToken != "" && token.RefreshToken != rt { + newCfg[cfgRefreshToken] = token.RefreshToken + } + newCfg[cfgIDToken] = idToken + + // Persist new config and if successful, update the in memory config. + if err = p.persister.Persist(newCfg); err != nil { + return "", fmt.Errorf("could not perist new tokens: %v", err) + } + p.cfg = newCfg + + return idToken, nil +} + +// tokenEndpoint uses OpenID Connect discovery to determine the OAuth2 token +// endpoint for the provider, the endpoint the client will use the refresh +// token against. +func tokenEndpoint(client *http.Client, issuer string) (string, error) { + // Well known URL for getting OpenID Connect metadata. + // + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + wellKnown := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + resp, err := client.Get(wellKnown) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + // Don't produce an error that's too huge (e.g. if we get HTML back for some reason). + const n = 80 + if len(body) > n { + body = append(body[:n], []byte("...")...) + } + return "", fmt.Errorf("oidc: failed to query metadata endpoint %s: %q", resp.Status, body) + } + + // Metadata object. We only care about the token_endpoint, the thing endpoint + // we'll be refreshing against. + // + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + var metadata struct { + TokenURL string `json:"token_endpoint"` + } + if err := json.Unmarshal(body, &metadata); err != nil { + return "", fmt.Errorf("oidc: failed to decode provider discovery object: %v", err) + } + if metadata.TokenURL == "" { + return "", fmt.Errorf("oidc: discovery object doesn't contain a token_endpoint") + } + return metadata.TokenURL, nil +} + +func idTokenExpired(now func() time.Time, idToken string) (bool, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return false, fmt.Errorf("ID Token is not a valid JWT") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return false, err + } + var claims struct { + Expiry jsonTime `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return false, fmt.Errorf("parsing claims: %v", err) + } + + return now().Add(expiryDelta).Before(time.Time(claims.Expiry)), nil +} + +// jsonTime is a json.Unmarshaler that parses a unix timestamp. +// Because JSON numbers don't differentiate between ints and floats, +// we want to ensure we can parse either. +type jsonTime time.Time + +func (j *jsonTime) UnmarshalJSON(b []byte) error { + var n json.Number + if err := json.Unmarshal(b, &n); err != nil { + return err + } + var unix int64 + + if t, err := n.Int64(); err == nil { + unix = t + } else { + f, err := n.Float64() + if err != nil { + return err + } + unix = int64(f) + } + *j = jsonTime(time.Unix(unix, 0)) + return nil +} + +func (j jsonTime) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Time(j).Unix()) +}