commit 014f102748256678ff4e79a96ef9ce8da21f47b3 Author: Hidetake Iwata Date: Wed Mar 21 10:14:07 2018 +0900 Initial commit diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..4b22aa4 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,36 @@ +version: 2 +jobs: + build: + docker: + - image: circleci/golang:1.10 + working_directory: /go/src/github.com/int128/kubelogin + steps: + - checkout + - run: go get -v -t -d + - run: go get github.com/golang/lint/golint + - run: golint + - run: go build -v + + release: + docker: + - image: circleci/golang:1.10 + working_directory: /go/src/github.com/int128/kubelogin + steps: + - checkout + - run: go get -v -t -d + - run: go get github.com/mitchellh/gox + - run: go get github.com/tcnksm/ghr + - run: gox --osarch 'darwin/amd64 linux/amd64 windows/amd64' -output 'dist/{{.Dir}}_{{.OS}}_{{.Arch}}' + - run: ghr -u "$CIRCLE_PROJECT_USERNAME" -r "$CIRCLE_PROJECT_REPONAME" "$CIRCLE_TAG" dist + +workflows: + version: 2 + build: + jobs: + - build + - release: + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..df88633 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef2837 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/kubelogin diff --git a/README.md b/README.md new file mode 100644 index 0000000..4112b6b --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# kubelogin [![CircleCI](https://circleci.com/gh/int128/kubelogin.svg?style=shield)](https://circleci.com/gh/int128/kubelogin) + +`kubelogin` is a command tool to setup authentication for `kubectl`. + +Currently OpenID Connect (OIDC) is supported. + +## Getting Started + +### 1. Setup OIDC Identity Provider + +This article assumes you have created an OIDC client with the following: + +- Issuer URL: `https://keycloak.example.com/auth/realms/hello` +- Redirect URL: `https://kubernetes-dashboard.example.com/*` +- Client ID: `kubernetes` +- Client Secret: `YOUR_CLIENT_SECRET` +- Groups claim: `groups` (optional for group based access controll) + +### 2. Setup Kubernetes API Server + +Setup the Kubernetes API server allows your identity provider. + +If you are using kops, `kops edit cluster` and append the following settings: + +```yaml +spec: + kubeAPIServer: + oidcClientID: kubernetes + oidcGroupsClaim: groups + oidcIssuerURL: https://keycloak.example.com/auth/realms/hello +``` + +### 3. Setup kubectl + +```sh +CLUSTER_NAME=hello.k8s.local + +kubectl config set-cluster $CLUSTER_NAME \ + --server https://api.example.com \ + --certificate-authority ~/.kube/$CLUSTER_NAME.crt + +kubectl config set-credentials $CLUSTER_NAME \ + --auth-provider oidc \ + --auth-provider-arg idp-issuer-url=https://keycloak.example.com/auth/realms/hello \ + --auth-provider-arg client-id=kubernetes \ + --auth-provider-arg client-secret=YOUR_CLIENT_SECRET + +kubectl config set-context $CLUSTER_NAME --cluster $CLUSTER_NAME --user $CLUSTER_NAME +``` + +### 4. Use kubelogin and kubectl + +Refresh the ID token: + +``` +% kubelogin +2018/03/21 17:13:20 Reading config from ~/.kube/config +---- Authentication ---- +1. Open the following URL: + +https://keycloak.example.com/auth/realms/hello/protocol/openid-connect/auth?client_id=... + +2. Enter the code: ey... + +2018/03/21 17:13:32 Updated ~/.kube/config +``` + +Make sure you can access to the cluster: + +``` +% kubectl version +Client Version: version.Info{...} +Server Version: version.Info{...} +``` diff --git a/config.go b/config.go new file mode 100644 index 0000000..b1ddb5b --- /dev/null +++ b/config.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + + "github.com/mitchellh/go-homedir" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +// GetCurrentAuthInfo returns the current authInfo +func GetCurrentAuthInfo(config api.Config) *api.AuthInfo { + context := config.Contexts[config.CurrentContext] + if context == nil { + return nil + } + authInfo := config.AuthInfos[context.AuthInfo] + return authInfo +} + +// ReadKubeConfig returns the current config +func ReadKubeConfig(path string) (*api.Config, error) { + config, err := clientcmd.LoadFromFile(path) + if err != nil { + return nil, err + } + return config, nil +} + +// WriteKubeConfig writes the config +func WriteKubeConfig(config api.Config, path string) error { + return clientcmd.WriteToFile(config, path) +} + +// FindKubeConfig returns env:KUBECONFIG or ~/.kube/config +func FindKubeConfig() (string, error) { + env := os.Getenv("KUBECONFIG") + if env != "" { + return env, nil + } + path, err := homedir.Expand("~/.kube/config") + if err != nil { + return "", err + } + return path, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6c0d5dc --- /dev/null +++ b/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "log" + + "k8s.io/client-go/tools/clientcmd/api" +) + +func main() { + kubeConfigPath, err := FindKubeConfig() + if err != nil { + log.Fatal(err) + } + + log.Printf("Reading config from %s", kubeConfigPath) + kubeConfig, err := ReadKubeConfig(kubeConfigPath) + if err != nil { + log.Fatal(err) + } + + authInfo := GetCurrentAuthInfo(*kubeConfig) + if authInfo == nil { + log.Fatal("Could not find the current user") + } + authProvider := authInfo.AuthProvider + if authInfo == nil { + log.Fatal("auth-provider is not set in the config") + } + + switch authProvider.Name { + case "oidc": + if err := mutateConfigWithOIDC(authProvider); err != nil { + log.Fatal(err) + } + log.Printf("Updating %s", kubeConfigPath) + WriteKubeConfig(*kubeConfig, kubeConfigPath) + + default: + log.Fatalf("Currently auth-provider `%s` is not supported", authProvider.Name) + } +} + +func mutateConfigWithOIDC(authProvider *api.AuthProviderConfig) error { + issuer := authProvider.Config["idp-issuer-url"] + clientID := authProvider.Config["client-id"] + clientSecret := authProvider.Config["client-secret"] + oidcToken, err := GetOIDCTokenByAuthCode(issuer, clientID, clientSecret) + if err != nil { + return err + } + authProvider.Config["id-token"] = oidcToken.IDToken + authProvider.Config["refresh-token"] = oidcToken.RefreshToken + return nil +} diff --git a/oidc.go b/oidc.go new file mode 100644 index 0000000..2c160a1 --- /dev/null +++ b/oidc.go @@ -0,0 +1,91 @@ +package main + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + + "github.com/coreos/go-oidc" + "golang.org/x/oauth2" +) + +// OIDCToken is a token set +type OIDCToken struct { + IDToken string + RefreshToken string + IDTokenClaim *IDTokenClaim +} + +// IDTokenClaim represents an ID token decoded +type IDTokenClaim struct { + Email string `json:"email"` +} + +// GetOIDCTokenByAuthCode returns a token retrieved by auth code grant +func GetOIDCTokenByAuthCode(issuer string, clientID string, clientSecret string) (*OIDCToken, error) { + provider, err := oidc.NewProvider(oauth2.NoContext, issuer) + if err != nil { + return nil, err + } + + config := oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: "urn:ietf:wg:oauth:2.0:oob", + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "email"}, + } + + state, err := generateState() + if err != nil { + return nil, err + } + + authCodeURL := config.AuthCodeURL(state) + fmt.Printf(`---- Authentication ---- +1. Open the following URL: + +%s + +2. Enter the code: `, authCodeURL) + var code string + if _, err := fmt.Scanln(&code); err != nil { + return nil, err + } + fmt.Println() + + token, err := config.Exchange(oauth2.NoContext, code) + if err != nil { + return nil, err + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("id_token is missing in the token response: %s", token) + } + + verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID}) + idToken, err := verifier.Verify(oauth2.NoContext, rawIDToken) + if err != nil { + return nil, err + } + + idTokenClaim := IDTokenClaim{} + if err := idToken.Claims(&idTokenClaim); err != nil { + return nil, err + } + + return &OIDCToken{ + IDToken: rawIDToken, + IDTokenClaim: &idTokenClaim, + RefreshToken: token.RefreshToken, + }, nil +} + +func generateState() (string, error) { + var n uint64 + if err := binary.Read(rand.Reader, binary.LittleEndian, &n); err != nil { + return "", err + } + return fmt.Sprintf("%x", n), nil +}