diff --git a/.travis.yml b/.travis.yml index 1406c28..6c4efad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,7 @@ language: go env: BUILD_NUMBER=$TRAVIS_BUILD_NUMBER matrix: include: - - go: 1.11.x - env: GO111MODULE=on - script: - - make vet - - make test - - go: 1.12.x + - go: 1.13.x script: - make vet - make test diff --git a/Makefile b/Makefile index c40f003..36bef6a 100644 --- a/Makefile +++ b/Makefile @@ -85,8 +85,8 @@ bin/$(ARCH)/$(BIN): -ldflags "-X $(PKG)/version.VERSION=$(VERSION) \ -X $(PKG)/version.GITHASH=$(GIT_HASH) \ -X $(PKG)/version.DOB=$(DOB) \ - -X $(PKG)/cmd.defaultClientID=$(CLIENT_ID) \ - -X $(PKG)/cmd.defaultClientSecret=$(CLIENT_SECRET)" + -X $(PKG)/cmd.buildTimeClientID=$(CLIENT_ID) \ + -X $(PKG)/cmd.buildTimeClientSecret=$(CLIENT_SECRET)" # Run go vet on repo vet: diff --git a/README.md b/README.md index a50bafd..834fbc4 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,16 @@ `dexter` is a OIDC (OpenId Connect) helper to create a hassle-free Kubernetes login experience powered by Google or Azure as Identity Provider. All you need is a properly configured Google or Azure client ID & secret. +## Supported identity providers + +| Identity Provider | State | +|--------------------|----------| +| Google | complete | +| Microsoft Azure | complete | + ## Authentication Flow -`dexter` will open a new browser window and redirect you to your configured Idp. The only interaction you have is the login at your provider and your k8s config is updated automatically. +`dexter` will open a new browser tag/window and redirect you to your configured Idp. The only interaction you have is the login at your provider and your k8s config is updated automatically. ![dexter flow](/assets/dexter_flow.png?raw=true "dexter flow") @@ -15,25 +22,31 @@ All you need is a properly configured Google or Azure client ID & secret. ![dexter in action](/assets/dexter.gif?raw=true "dexter in action") -## Configuration -### Google credentials +## OIDCProvider Configuration + +Each OpenID Connect provider requires some configuration. This basic +description may not be all you have to do but it worked at the time of +writing. + +### Google - - Open [console.developers.google.com](https://console.developers.google.com) - - Create new credentials - - OAuth Client ID - - Web Application - - Authorized redirect URIs: http://127.0.0.1:64464/callback + - Open [console.developers.google.com](https://console.developers.google.com) + - Create new credentials + - OAuth Client ID + - Web Application + - Authorized redirect URIs: http://127.0.0.1:64464/callback -### Or, configure Azure credentials +### Microsoft Azure - - Open [portal.azure.com](https://portal.azure.com) - - Go to App registrations and create a new app - - Enter reply URI http://127.0.0.1:64464/callback - - Create secret key - - Collect application ID (client ID) + - Open [portal.azure.com](https://portal.azure.com) + - Go to Appregistrations and create a new app + - Enter reply URI http://127.0.0.1:64464/callback + - Create secret key + - Collect application ID (client ID) ### Auto pilot configuration -Dexter also support auto pilot mode. If your existing kubectl context uses one of the supported OIDC-providers, Dexter will try to use the OIDC details from kubeconfig. + +`dexter` also support auto pilot mode. If your existing kubectl context uses one of the supported Identity Providers, `dexter` will try to use extract the OIDC data from kubeconfig. ## Installation @@ -87,40 +100,32 @@ Flags: Use "dexter [command] --help" for more information about a command. ``` -Running `dexter auth` will start the authentication process. +Running `dexter auth [Idp]` will start the authentication process. ``` ❯ ./build/dexter_darwin_amd64 auth --help -Use your Google login to get a JWT (JSON Web Token) and update your -local k8s config accordingly. A refresh token is added and automatically refreshed -by kubectl. Existing token configurations are overwritten. +Use a provider sub-command to authenticate against your identity provider of choice. For details go to: https://blog.gini.net/ -dexters authentication flow -=========================== - -1. Open a browser window/tab and redirect you to Google (https://accounts.google.com) -2. You login with your Google credentials -3. You will be redirected to dexters builtin webserver and can now close the browser tab -4. dexter extracts the token from the callback and patches your ~/.kube/config - -➜ Unless you have a good reason to do so please use the built-in google credentials (if they were added at build time)! - Usage: - dexter auth [flags] + dexter auth [command] + +Available Commands: + azure Authenticate with the Microsoft Azure Identity Provider + google Authenticate with the Google Identity Provider Flags: -c, --callback string Callback URL. The listen address is dreived from that. (default "http://127.0.0.1:64464/callback") -i, --client-id string Google clientID (default "REDACTED") -s, --client-secret string Google clientSecret (default "REDACTED") -d, --dry-run Toggle config overwrite - -e, --endpoint string OIDC-providers: google or azure (default "google") -h, --help help for auth - -k, --kube-config string Overwrite the default location of kube config (~/.kube/config) (default "/Users/dkerwin/.kube/config") - -t, --tenant string Your azure tenant (default "common") + -k, --kube-config string Overwrite the default location of kube config (default "/Users/dkerwin/.kube/config") Global Flags: -v, --verbose verbose output + +Use "dexter auth [command] --help" for more information about a command. ``` ## Contribution Guidelines @@ -133,9 +138,18 @@ It's awesome that you consider contributing to `dexter` and it's really simple. - update documentation if necessary - open a pull request -## Authors +## Authors & Contributors + +Initial code was written by [Daniel Kerwin](mailto:daniel@gini.net) & David González Ruiz + +Contributors (in alphabetical order): +- https://github.com/andrewsav-datacom +- https://github.com/cblims +- https://github.com/Lujeni +- https://github.com/pussinboots +- https://github.com/tillepille -Initial code was written by [Daniel Kerwin](mailto:daniel@gini.net) & [David González Ruiz](mailto:david@gini.net) +Thank you so much! ## Acknowledgements diff --git a/cmd/auth.go b/cmd/auth.go index 9614efd..b69e909 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -4,38 +4,69 @@ import ( "context" "errors" "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "os/signal" + "os/user" + "path/filepath" + "sync" + "syscall" + "time" + "github.com/coreos/go-oidc" "github.com/ghodss/yaml" "github.com/gini/dexter/utils" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/oauth2" - "golang.org/x/oauth2/google" - "golang.org/x/oauth2/microsoft" "gopkg.in/square/go-jose.v2/jwt" - "io/ioutil" k8sRuntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/clientcmd" clientCmdApi "k8s.io/client-go/tools/clientcmd/api" clientCmdLatest "k8s.io/client-go/tools/clientcmd/api/latest" - "net/http" - "net/url" - "os" - "os/signal" - "os/user" - "path/filepath" - "regexp" - "strings" - "sync" - "syscall" - "time" ) -// dexterOIDC: struct to store the required data and provide methods to -// authenticate with Googles OpenID implementation -type dexterOIDC struct { - endpoint string // azure or google - azureTenant string // azure tenant +var ( + // default injected at build time. This is optional + buildTimeClientID string + buildTimeClientSecret string + + // commandline flags + clientID string + clientSecret string + callback string + kubeConfig string + dryRun bool + + // Cobra command + AuthCmd = &cobra.Command{ + Use: "auth", + Short: "Authenticate with OIDC provider", + Long: `Use a provider sub-command to authenticate against your identity provider of choice. +For details go to: https://blog.gini.net/ +`, + } +) + +// helper type to render the k8s config +type CustomClaim struct { + Email string `json:"email"` +} + +// interface that all OIDC providers need to implement +type OIDCProvider interface { + ConfigureOAuth2Manully() error + Autopilot() error + PreflightCheck() error + GenerateAuthUrl() string + StartHTTPServer() error +} + +// DexterOIDC: struct to store the required data and provide methods to +// authenticate with OpenID providers +type DexterOIDC struct { clientID string // clientID commandline flag clientSecret string // clientSecret commandline flag callback string // callback URL commandline flag @@ -50,166 +81,67 @@ type dexterOIDC struct { signalChan chan os.Signal // react on signals from the outside world } -// initialize the struct, parse commandline flags and install a signal handler -func (d *dexterOIDC) initialize() error { - // install signal handler - signal.Notify( - oidcData.signalChan, - syscall.SIGINT, - syscall.SIGTERM, - syscall.SIGQUIT) - - // get active user (to get the homedirectory) - usr, err := user.Current() - - if err != nil { - return errors.New(fmt.Sprintf("failed to determine current user: %s", err)) +// ensure that the required parameters are defined and that the values make sense +func (d DexterOIDC) PreflightCheck() error { + if d.clientID == "" || d.clientSecret == "" { + return errors.New("clientID and clientSecret cannot be empty") } - // construct the path to the users .kube/config file as a default - kubeConfigDefaultPath := filepath.Join(usr.HomeDir, ".kube", "config") - - // setup commandline flags - AuthCmd.PersistentFlags().StringVarP(&d.endpoint, "endpoint", "e", "google", "OIDC-providers: google or azure") - AuthCmd.PersistentFlags().StringVarP(&d.azureTenant, "tenant", "t", "common", "Your azure tenant") - AuthCmd.PersistentFlags().StringVarP(&d.clientID, "client-id", "i", "REDACTED", "Google clientID") - AuthCmd.PersistentFlags().StringVarP(&d.clientSecret, "client-secret", "s", "REDACTED", "Google clientSecret") - AuthCmd.PersistentFlags().StringVarP(&d.callback, "callback", "c", "http://127.0.0.1:64464/callback", "Callback URL. The listen address is dreived from that.") - AuthCmd.PersistentFlags().StringVarP(&d.kubeConfig, "kube-config", "k", kubeConfigDefaultPath, "Overwrite the default location of kube config (~/.kube/config)") - AuthCmd.PersistentFlags().BoolVarP(&d.dryRun, "dry-run", "d", false, "Toggle config overwrite") - - // create random string as CSRF protection for the oauth2 flow - d.state = utils.RandomString() - return nil } -// setup and populate the OAuth2 config -func (d *dexterOIDC) createOauth2Config() error { +// create Oauth2 configuration +func (d *DexterOIDC) AuthInfoToOauth2(authInfo *clientCmdApi.AuthInfo) { + d.clientSecret = authInfo.AuthProvider.Config["client-secret"] + d.clientID = authInfo.AuthProvider.Config["client-id"] +} + +// attempt to set client credentials +func (d *DexterOIDC) ConfigureOAuth2Manully() error { + d.Oauth2Config.RedirectURL = d.callback + // no commandline client credentials supplied if d.clientID == "REDACTED" && d.clientSecret == "REDACTED" { // no builtin defaults - let's try auto-configuration - if defaultClientID == "" && defaultClientSecret == "" { - log.Info("Autopilot mode - no credentials set") - if err := d.autoConfigureOauth2Config(); err != nil { - return errors.New(fmt.Sprintf("failed to extract oidc configuration from the kube config: %s", err)) - } - } else if defaultClientID != "" && defaultClientSecret != "" { - // use build-time defaults if no clientId & clientSecret was provided + if buildTimeClientID == "" && buildTimeClientSecret == "" { + return errors.New("cannot set client credentials: empty commandline and builtin defaults") + } else { log.Info("Using builtin credentials - no credentials set") - d.clientID = defaultClientID - d.clientSecret = defaultClientSecret + d.clientID = buildTimeClientID + d.clientSecret = buildTimeClientSecret } } - // setup oidc client context - oidc.ClientContext(context.Background(), d.httpClient) - // populate oauth2 config - d.Oauth2Config.ClientID = oidcData.clientID - d.Oauth2Config.ClientSecret = oidcData.clientSecret - d.Oauth2Config.RedirectURL = oidcData.callback - - switch oidcData.endpoint { - case "azure": - d.Oauth2Config.Endpoint = microsoft.AzureADEndpoint(oidcData.azureTenant) - d.Oauth2Config.Scopes = []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "email"} - case "google": - d.Oauth2Config.Endpoint = google.Endpoint - d.Oauth2Config.Scopes = []string{oidc.ScopeOpenID, "profile", "email"} - default: - return errors.New(fmt.Sprintf("unsupported endpoint: %s", oidcData.endpoint)) - } + d.Oauth2Config.ClientID = d.clientID + d.Oauth2Config.ClientSecret = d.clientSecret return nil } -// populate the Oauth2Config object from the kube config. Return an error when the operation failed -func (d *dexterOIDC) autoConfigureOauth2Config() error { - // initialize the clientConfig and error variables - var clientCfg *clientCmdApi.Config - var err error - - // try to load the credentials from the kubeconfig specified on the commandline - if d.kubeConfig != "" { - clientCfg, err = clientcmd.LoadFromFile(d.kubeConfig) - - if err != nil { - return errors.New(fmt.Sprintf("failed to load kubeconfig from %s: %s", d.kubeConfig, err)) - } +func (d *DexterOIDC) Autopilot() error { + log.Info("Autopilot mode - no credentials set") + if authInfo, err := ExtractAuthInfo(d.kubeConfig); err != nil { + return errors.New(fmt.Sprintf("failed to extract oidc configuration from the kube config: %s", err)) } else { - // try to load credentials from CurrentContext - clientCfg, err = clientcmd.NewDefaultClientConfigLoadingRules().Load() + d.clientSecret = authInfo.AuthProvider.Config["client-secret"] + d.clientID = authInfo.AuthProvider.Config["client-id"] - if err != nil { - return errors.New(fmt.Sprintf("failed to load kubeconfig from the default locations: %s", err)) - } - } - - // loop through all the contexts until we find the current context - for contextName, context := range clientCfg.Contexts { - // find the context definition that matches the current active context - if contextName == clientCfg.CurrentContext { - // loop through the global authentication definitions - for authName, authInfo := range clientCfg.AuthInfos { - // find the authentication definition that is in use in the current context - if authName == context.AuthInfo { - // ensure it is oidc based - if authInfo.AuthProvider != nil && authInfo.AuthProvider.Name == "oidc" { - // verify that relevant keys exist - for _, key := range []string{"client-id", "client-secret", "idp-issuer-url"} { - if _, ok := authInfo.AuthProvider.Config[key]; !ok { - return errors.New(fmt.Sprintf("%s is missing in kubeconfig: %s", key, err)) - } - } - - // set client credentials and idp url based on the kubeconfig definition - d.clientSecret = authInfo.AuthProvider.Config["client-secret"] - d.clientID = authInfo.AuthProvider.Config["client-id"] - idp := authInfo.AuthProvider.Config["idp-issuer-url"] - - // set endpoint based on a match on the issuer URL - if strings.Contains(idp, "google") { - oidcData.endpoint = "google" - - } else if strings.Contains(idp, "microsoft") { - oidcData.endpoint = "azure" - - - re, err := regexp.Compile(`[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}`) //find uuid, this is tenant - - if err != nil { - // failed to extract the azure tenant, use default "common - oidcData.azureTenant = "common" - return nil - - } - - res := re.FindStringSubmatch(idp) - if len(res) == 1 { - oidcData.azureTenant = res[0] // found tenant - } else { - // failed to find tenant, use common - oidcData.azureTenant = "common" - } - } - - return nil - } - } - } - } + // populate oauth2 config + d.Oauth2Config.RedirectURL = d.callback + d.Oauth2Config.ClientID = d.clientID + d.Oauth2Config.ClientSecret = d.clientSecret } - return errors.New("failed to auto-configure OIDC from kubeconfig") + return nil } -func (d *dexterOIDC) authUrl() string { +func (d DexterOIDC) GenerateAuthUrl() string { return d.Oauth2Config.AuthCodeURL(d.state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) } // start HTTP server to receive callbacks. This has to be run in a go routine -func (d *dexterOIDC) startHttpServer() { +func (d DexterOIDC) StartHTTPServer() error { // set HTTP server listen address from callback URL parsedURL, err := url.Parse(d.callback) @@ -221,11 +153,55 @@ func (d *dexterOIDC) startHttpServer() { d.httpServer.Addr = parsedURL.Host http.HandleFunc("/callback", d.callbackHandler) - d.httpServer.ListenAndServe() + + go func(d DexterOIDC) { + if err := d.httpServer.ListenAndServe(); err != nil { + log.Errorf("Failed to start HTTP server: %s", err) + } + }(d) + + for { + select { + // flow was completed or error occured + case <-d.quitChan: + log.Debugf("Shutdown signal received. We're done here") + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + + d.httpServer.Shutdown(ctx) + cancel() + log.Infof("Shutdown completed") + return nil + // OS signal was received + case sig := <-d.signalChan: + close(d.quitChan) + return fmt.Errorf("signal %d (%s) received. Initiating shutdown", sig, sig) + default: + } + } +} + +// initialize the struct, parse commandline flags and install a signal handler +func (d *DexterOIDC) initialize() { + // install signal handler + signal.Notify( + d.signalChan, + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT) + + // create random string as CSRF protection for the oauth2 flow + d.state = utils.RandomString() + + d.clientID = clientID + d.clientSecret = clientSecret + d.callback = callback + d.kubeConfig = kubeConfig + d.dryRun = dryRun } // accept callbacks from your browser -func (d *dexterOIDC) callbackHandler(w http.ResponseWriter, r *http.Request) { +func (d *DexterOIDC) callbackHandler(w http.ResponseWriter, r *http.Request) { log.Info("callback received") // Get code and state from the passed form value @@ -240,7 +216,7 @@ func (d *dexterOIDC) callbackHandler(w http.ResponseWriter, r *http.Request) { } // compare callback state and initial state - if callbackState != oidcData.state { + if callbackState != d.state { log.Error("state mismatch! Someone could be tampering with your connection!") http.Error(w, "state mismatch! Someone could be tampering with your connection!", http.StatusBadRequest) return @@ -250,7 +226,7 @@ func (d *dexterOIDC) callbackHandler(w http.ResponseWriter, r *http.Request) { // create context and exchange authCode for token ctx := oidc.ClientContext(r.Context(), d.httpClient) - token, err := oidcData.Oauth2Config.Exchange(ctx, code) + token, err := d.Oauth2Config.Exchange(ctx, code) if err != nil { log.Errorf("Failed to exchange auth code: %s", err) @@ -268,7 +244,7 @@ func (d *dexterOIDC) callbackHandler(w http.ResponseWriter, r *http.Request) { } // We're done here - oidcData.quitChan <- struct{}{} + d.quitChan <- struct{}{} w.WriteHeader(http.StatusOK) w.Write([]byte("Authentication completed. It's safe to close this window now ;-)")) @@ -276,12 +252,8 @@ func (d *dexterOIDC) callbackHandler(w http.ResponseWriter, r *http.Request) { return } -type CustomClaim struct { - Email string `json:"email"` -} - // write the k8s config -func (d *dexterOIDC) writeK8sConfig(token *oauth2.Token) error { +func (d *DexterOIDC) writeK8sConfig(token *oauth2.Token) error { // acquire lock d.k8sMutex.Lock() defer d.k8sMutex.Unlock() @@ -324,7 +296,7 @@ func (d *dexterOIDC) writeK8sConfig(token *oauth2.Token) error { } // write the rendered config snipped when dry-run is enabled - if oidcData.dryRun { + if d.dryRun { // create a JSON representation json, err := k8sRuntime.Encode(clientCmdLatest.Codec, config) @@ -378,96 +350,74 @@ func (d *dexterOIDC) writeK8sConfig(token *oauth2.Token) error { return nil } -var ( - // default injected at build time. This is optional - defaultClientID string - defaultClientSecret string - - // initialize dexter OIDC config - oidcData = dexterOIDC{ - Oauth2Config: &oauth2.Config{}, - httpClient: &http.Client{Timeout: 2 * time.Second}, - quitChan: make(chan struct{}), - signalChan: make(chan os.Signal, 1), - } - - // Cobra command - AuthCmd = &cobra.Command{ - Use: "auth", - Short: "Authenticate with OIDC provider", - Long: `Use your Google login to get a JWT (JSON Web Token) and update your -local k8s config accordingly. A refresh token is added and automatically refreshed -by kubectl. Existing token configurations are overwritten. -For details go to: https://blog.gini.net/ - -dexters authentication flow -=========================== - -1. Open a browser window/tab and redirect you to Google (https://accounts.google.com) -2. You login with your Google credentials -3. You will be redirected to dexters builtin webserver and can now close the browser tab -4. dexter extracts the token from the callback and patches your ~/.kube/config +// initialize the command +func init() { + kubeConfigDefaultPath := "" -➜ Unless you have a good reason to do so please use the built-in google credentials (if they were added at build time)! -`, - RunE: authCommand, + // get active user (to get the homedirectory) + if usr, err := user.Current(); err != nil { + log.Errorf("failed to determine current user: %s", err) + } else { + // construct the path to the users .kube/config file as a default + kubeConfigDefaultPath = filepath.Join(usr.HomeDir, ".kube", "config") } -) -// initialize the command -func init() { // add the auth command rootCmd.AddCommand(AuthCmd) - // parse commandline flags - if err := oidcData.initialize(); err != nil { - log.Errorf("Failed to initialize OIDC provider: %s", err) - os.Exit(1) - } + // setup commandline flags + AuthCmd.PersistentFlags().StringVarP(&clientID, "client-id", "i", "REDACTED", "Google clientID") + AuthCmd.PersistentFlags().StringVarP(&clientSecret, "client-secret", "s", "REDACTED", "Google clientSecret") + AuthCmd.PersistentFlags().StringVarP(&callback, "callback", "c", "http://127.0.0.1:64464/callback", "Callback URL. The listen address is dreived from that.") + AuthCmd.PersistentFlags().StringVarP(&kubeConfig, "kube-config", "k", kubeConfigDefaultPath, "Overwrite the default location of kube config") + AuthCmd.PersistentFlags().BoolVarP(&dryRun, "dry-run", "d", false, "Toggle config overwrite") } -// the command to run -func authCommand(cmd *cobra.Command, args []string) error { - if oidcData.clientID == "" || oidcData.clientSecret == "" { - log.Error("clientID and clientSecret cannot be empty!") - return errors.New("clientID and clientSecret cannot be empty!") +// initiate the OIDC flow. This func should be called in each cobra command +func AuthenticateToProvider(provider OIDCProvider) error { + // attempt to set client credentials with dexter data + if err := provider.ConfigureOAuth2Manully(); err != nil { + log.Infof("Fallback to autopilot mode: %s", err) + + if err := provider.Autopilot(); err != nil { + return fmt.Errorf("failed to configure oauth2 credentials: %s", err) + } } - // setup oauth2 object - if err := oidcData.createOauth2Config(); err != nil { - log.Errorf("oauth2 configuration failed: %s", err) - return err + // ensure that the required fields and values are sane + if err := provider.PreflightCheck(); err != nil { + return fmt.Errorf("failed to complete the provider preflight check: %s", err) } log.Info("Starting auth browser session. Please check your browser instances...") - if err := utils.OpenURL(oidcData.authUrl()); err != nil { - log.Errorf("Failed to open browser session: %s", err) - return err + if err := utils.OpenURL(provider.GenerateAuthUrl()); err != nil { + return fmt.Errorf("failed to open browser session: %s", err) } - log.Infof("Spawning http server to receive callbacks (%s)", oidcData.callback) + log.Info("Spawning http server to receive callbacks") // spawn HTTP server - go oidcData.startHttpServer() + if err := provider.StartHTTPServer(); err != nil { + return fmt.Errorf("HTTP server: %s", err) + } - for { - select { - // flow was completed or error occured - case <-oidcData.quitChan: - log.Debugf("Shutdown signal received. We're done here") + return nil +} + +// extract relevant authentication data from the given kube config +func ExtractAuthInfo(kubeConfig string) (*clientCmdApi.AuthInfo, error) { + var clientCfg *clientCmdApi.Config + var authInfo *clientCmdApi.AuthInfo + var err error - ctx, _ := context.WithTimeout(context.Background(), 1*time.Second) + if clientCfg, err = utils.ParseKubernetesClientConfig(kubeConfig); err != nil { + return nil, err + } - oidcData.httpServer.Shutdown(ctx) - log.Infof("Shutdown completed") - return nil - // OS signal was received - case sig := <-oidcData.signalChan: - log.Infof("Signal %d (%s) received. Initiating shutdown", sig, sig) - close(oidcData.quitChan) - return errors.New("Signal %d (%s) received. Initiating shutdown") - default: - } + if authInfo, err = utils.ExtractOIDCAuthProvider(clientCfg); err != nil { + return nil, err } + + return authInfo, nil } diff --git a/cmd/azure.go b/cmd/azure.go new file mode 100644 index 0000000..429e86c --- /dev/null +++ b/cmd/azure.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/coreos/go-oidc" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + "golang.org/x/oauth2/microsoft" +) + +type AzureOIDC struct { + DexterOIDC // embed the base provider + tenant string // azure tenant +} + +func (a *AzureOIDC) Autopilot() error { + log.Debug("Azure OIDC autopilot mode") + + authInfo, err := ExtractAuthInfo(a.kubeConfig) + + if err != nil { + return fmt.Errorf("failed to extract oidc configuration from the kube config: %s", err) + } + + // fallback ti tenant common + a.tenant = "common" + + // call parent method to initialize client credentials + a.DexterOIDC.AuthInfoToOauth2(authInfo) + + // populate oauth2 config + a.Oauth2Config.RedirectURL = a.callback + a.Oauth2Config.ClientID = a.clientID + a.Oauth2Config.ClientSecret = a.clientSecret + + // extract the issuer url + idp := authInfo.AuthProvider.Config["idp-issuer-url"] + + // set endpoint based on a match on the issuer URL + if strings.Contains(idp, "microsoft") { + //find a uuid, this is the tenant + re, err := regexp.Compile(`[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}`) + + if err != nil { + // failed to extract the azure tenant, use default "common + return err + } + + res := re.FindStringSubmatch(idp) + if len(res) == 1 { + // found tenant: override endpoint + azureProvider.tenant = res[0] + a.Oauth2Config.Endpoint = microsoft.AzureADEndpoint(azureProvider.tenant) + + log.Debugf("Extracted tenant: %s", a.tenant) + return nil + } + } + + return fmt.Errorf("no Microsoft auth provider configuration found") +} + +var ( + // initialize dexter OIDC config + azureProvider = &AzureOIDC{ + DexterOIDC{ + Oauth2Config: &oauth2.Config{}, + httpClient: &http.Client{Timeout: 2 * time.Second}, + quitChan: make(chan struct{}), + signalChan: make(chan os.Signal, 1), + }, + "", + } + + azureCmd = &cobra.Command{ + Use: "azure", + Short: "Authenticate with the Microsoft Azure Identity Provider", + Long: `Use your Microsoft login to get a JWT (JSON Web Token) and update your +local k8s config accordingly. A refresh token is added and automatically refreshed +by kubectl. Existing token configurations are overwritten. + +For details go to: https://blog.gini.net/ + +dexters authentication flow +=========================== + +1. Open a browser window/tab and redirect you to Microsoft (https://login.microsoftonline.com/) +2. You login with your Microsoft credentials +3. You will be redirected to dexters builtin webserver and can now close the browser tab +4. dexter extracts the token from the callback and patches your ~/.kube/config + +➜ Unless you have a good reason to do so please use the built-in Microsoft credentials (if they were added at build time)! +`, + RunE: AzureCommand, + SilenceUsage: true, + } +) + +func init() { + // add the azure auth subcommand + AuthCmd.AddCommand(azureCmd) + + // setup commandline flags + azureCmd.PersistentFlags().StringVarP(&azureProvider.tenant, "tenant", "t", "common", "Your azure tenant") +} + +func AzureCommand(cmd *cobra.Command, args []string) error { + azureProvider.initialize() + + azureProvider.Oauth2Config.Endpoint = microsoft.AzureADEndpoint(azureProvider.tenant) + azureProvider.Oauth2Config.Scopes = []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "email"} + + if err := AuthenticateToProvider(azureProvider); err != nil { + return fmt.Errorf("authentication failed: %s", err) + } + + return nil +} diff --git a/cmd/google.go b/cmd/google.go new file mode 100644 index 0000000..2bdb465 --- /dev/null +++ b/cmd/google.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/coreos/go-oidc" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +type GoogleOIDC struct { + DexterOIDC +} + +var ( + googleProvider = &GoogleOIDC{ + DexterOIDC{ + Oauth2Config: &oauth2.Config{}, + httpClient: &http.Client{Timeout: 2 * time.Second}, + quitChan: make(chan struct{}), + signalChan: make(chan os.Signal, 1), + }, + } + + googleCmd = &cobra.Command{ + Use: "google", + Short: "Authenticate with the Google Identity Provider", + Long: `Use your Google login to get a JWT (JSON Web Token) and update your +local k8s config accordingly. A refresh token is added and automatically refreshed +by kubectl. Existing token configurations are overwritten. + +For details go to: https://blog.gini.net/ + +dexters authentication flow +=========================== + +1. Open a browser window/tab and redirect you to Google (https://accounts.google.com) +2. You login with your Google credentials +3. You will be redirected to dexters builtin webserver and can now close the browser tab +4. dexter extracts the token from the callback and patches your ~/.kube/config + +➜ Unless you have a good reason to do so please use the built-in google credentials (if they were added at build time)! +`, + RunE: GoogleCommand, + SilenceUsage: true, + } +) + +func init() { + // add the auth command + AuthCmd.AddCommand(googleCmd) +} + +func GoogleCommand(cmd *cobra.Command, args []string) error { + googleProvider.initialize() + + googleProvider.Oauth2Config.Endpoint = google.Endpoint + googleProvider.Oauth2Config.Scopes = []string{oidc.ScopeOpenID, "profile", "email"} + + if err := AuthenticateToProvider(googleProvider); err != nil { + return fmt.Errorf("authentication failed: %s", err) + } + + return nil +} diff --git a/go.mod b/go.mod index b797524..f17c4d6 100644 --- a/go.mod +++ b/go.mod @@ -20,3 +20,5 @@ require ( k8s.io/klog v0.4.0 // indirect sigs.k8s.io/yaml v1.1.0 // indirect ) + +go 1.13 diff --git a/go.sum b/go.sum index b16dfd1..bbb7689 100644 --- a/go.sum +++ b/go.sum @@ -204,8 +204,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/square/go-jose.v2 v2.1.9 h1:YCFbL5T2gbmC2sMG12s1x2PAlTK5TZNte3hjZEIcCAg= -gopkg.in/square/go-jose.v2 v2.1.9/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.3.0 h1:nLzhkFyl5bkblqYBoiWJUt5JkWOzmiaBtCxdJAqJd3U= gopkg.in/square/go-jose.v2 v2.3.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= diff --git a/main.go b/main.go index d5e44d9..b939acf 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,8 @@ package main import ( - log "github.com/sirupsen/logrus" "github.com/gini/dexter/cmd" + log "github.com/sirupsen/logrus" ) func main() { diff --git a/utils/utils.go b/utils/utils.go index c8beba2..bb341e2 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,9 @@ import ( "math/rand" "os/exec" "runtime" + + "k8s.io/client-go/tools/clientcmd" + clientCmdApi "k8s.io/client-go/tools/clientcmd/api" ) // helper to generate a random string @@ -47,3 +50,57 @@ func OpenURL(url string) error { return nil } + +// parse the k8s config +func ParseKubernetesClientConfig(kubeConfig string) (*clientCmdApi.Config, error) { + // initialize the clientConfig and error variables + var clientCfg *clientCmdApi.Config + var err error + + // try to load the credentials from the kubeconfig specified on the commandline + if kubeConfig != "" { + clientCfg, err = clientcmd.LoadFromFile(kubeConfig) + + if err != nil { + return nil, errors.New(fmt.Sprintf("failed to load kubeconfig from %s: %s", kubeConfig, err)) + } + } else { + // try to load credentials from CurrentContext + clientCfg, err = clientcmd.NewDefaultClientConfigLoadingRules().Load() + + if err != nil { + return nil, errors.New(fmt.Sprintf("failed to load kubeconfig from the default locations: %s", err)) + } + } + + return clientCfg, nil +} + +// find a OIDC provider in the config +func ExtractOIDCAuthProvider(config *clientCmdApi.Config) (*clientCmdApi.AuthInfo, error) { + // loop through all the contexts until we find the current context + for contextName, context := range config.Contexts { + // find the context definition that matches the current active context + if contextName == config.CurrentContext { + // loop through the global authentication definitions + for authName, authInfo := range config.AuthInfos { + // find the authentication definition that is in use in the current context + if authName == context.AuthInfo { + // ensure it is oidc based + if authInfo.AuthProvider != nil && authInfo.AuthProvider.Name == "oidc" { + // verify that relevant keys exist + for _, key := range []string{"client-id", "client-secret", "idp-issuer-url"} { + if _, ok := authInfo.AuthProvider.Config[key]; !ok { + return nil, errors.New(fmt.Sprintf("%s is missing in kubeconfig", key)) + } + } + + return authInfo, nil + } + } + } + } + } + + return nil, errors.New("no valid OIDC auth provider found") +}