Compare commits

..

1 Commits
1.3 ... 1.0

Author SHA1 Message Date
Hidetake Iwata
f8fbd81be1 Add windows 32bit binary 2018-03-23 20:04:07 +09:00
24 changed files with 374 additions and 1089 deletions

View File

@@ -6,12 +6,10 @@ jobs:
working_directory: /go/src/github.com/int128/kubelogin
steps:
- checkout
- run: go get -v -t -d ./...
- run: go get -v -t -d
- run: go get github.com/golang/lint/golint
- run: golint
- run: go build -v
- run: make -C integration-test/testdata
- run: go test -v ./...
release:
docker:
@@ -19,7 +17,7 @@ jobs:
working_directory: /go/src/github.com/int128/kubelogin
steps:
- checkout
- run: go get -v -t -d ./...
- 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 windows/386' -output 'dist/{{.Dir}}_{{.OS}}_{{.Arch}}'

View File

@@ -8,8 +8,3 @@ indent_size = 2
[*.go]
indent_style = tab
indent_size = 4
[Makefile]
indent_style = tab
indent_size = 4

1
.gitignore vendored
View File

@@ -1,2 +1 @@
/kubelogin
/.kubeconfig

239
README.md
View File

@@ -3,219 +3,118 @@
`kubelogin` is a command to get an OpenID Connect (OIDC) token for `kubectl` authentication.
## TL;DR
## Getting Started
1. Setup your OpenID Connect provider, e.g. Google Identity Platform or Keycloak.
1. Setup your Kubernetes cluster.
1. Setup your `kubectl`.
Download [the latest release](https://github.com/int128/kubelogin/releases) and save it as `/usr/local/bin/kubelogin`.
```
% kubelogin --help
2018/08/15 19:08:58 Usage:
kubelogin [OPTIONS]
Application Options:
--kubeconfig= Path to the kubeconfig file (default: ~/.kube/config) [$KUBECONFIG]
--insecure-skip-tls-verify If set, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure
[$KUBELOGIN_INSECURE_SKIP_TLS_VERIFY]
Help Options:
-h, --help Show this help message
```
## Getting Started with Google Account
### 1. Setup Google API
Open [Google APIs Console](https://console.developers.google.com/apis/credentials) and create an OAuth client as follows:
- Application Type: Web application
- Redirect URL: `http://localhost:8000/`
### 2. Setup Kubernetes cluster
Configure your Kubernetes API Server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
```yaml
spec:
kubeAPIServer:
oidcIssuerURL: https://accounts.google.com
oidcClientID: YOUR_CLIENT_ID.apps.googleusercontent.com
```
Here assign the `cluster-admin` role to your user.
```yaml
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: oidc-admin-group
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: User
name: https://accounts.google.com#1234567890
```
### 3. Setup kubectl and kubelogin
Setup `kubectl` to authenticate with your identity provider.
```sh
kubectl config set-credentials CLUSTER_NAME \
--auth-provider oidc \
--auth-provider-arg idp-issuer-url=https://accounts.google.com \
--auth-provider-arg client-id=YOUR_CLIENT_ID.apps.googleusercontent.com \
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET
```
Download [the latest release](https://github.com/int128/kubelogin/releases) and save it.
Run `kubelogin` and open http://localhost:8000 in your browser.
Run the command.
```
% kubelogin
2018/08/10 10:36:38 Reading .kubeconfig
2018/08/10 10:36:38 Using current context: hello.k8s.local
2018/08/10 10:36:41 Open http://localhost:8000 for authorization
2018/08/10 10:36:45 GET /
2018/08/10 10:37:07 GET /?state=...&session_state=...&code=ey...
2018/08/10 10:37:08 Updated .kubeconfig
2018/03/23 18:01:40 Reading config from /home/user/.kube/config
2018/03/23 18:01:40 Using current context: hello.k8s.local
2018/03/23 18:01:40 Using issuer: https://keycloak.example.com/auth/realms/hello
2018/03/23 18:01:40 Using client ID: kubernetes
2018/03/23 18:01:41 Starting OpenID Connect authentication:
## Automatic (recommended)
Open the following URL in the web browser:
http://localhost:8000/
## Manual
If you cannot access to localhost, instead open the following URL:
https://keycloak.example.com/auth/realms/hello/protocol/openid-connect/auth?client_id=kubernetes&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&response_type=code&scope=openid+email&state=********
Enter the code:
```
Now your `~/.kube/config` should be like:
And then open http://localhost:8000.
If you cannot access to localhost, you can choose manual interaction instead.
`kubelogin` will update your `~/.kube/config` with the ID token and refresh token, as follows:
```yaml
# ~/.kube/config (snip)
current-context: hello.k8s.local
contexts:
- context:
cluster: hello.k8s.local
user: hello.k8s.local
name: hello.k8s.local
users:
- name: hello.k8s.local
user:
auth-provider:
config:
idp-issuer-url: https://accounts.google.com
client-id: YOUR_CLIENT_ID.apps.googleusercontent.com
idp-issuer-url: https://keycloak.example.com/auth/realms/hello
client-id: kubernetes
client-secret: YOUR_SECRET
id-token: ey... # kubelogin will update ID token here
refresh-token: ey... # kubelogin will update refresh token here
id-token: ey... # kubelogin will update ID token
refresh-token: ey... # kubelogin will update refresh token
name: oidc
```
Make sure you can access to the Kubernetes cluster.
Make sure you can access to the cluster:
```
% kubectl get nodes
NAME STATUS ROLES AGE VERSION
ip-1-2-3-4.us-west-2.compute.internal Ready node 21d v1.9.6
ip-1-2-3-5.us-west-2.compute.internal Ready node 20d v1.9.6
% kubectl version
Client Version: version.Info{...}
Server Version: version.Info{...}
```
## Getting Started with Keycloak
## Prerequisite
### 1. Setup Keycloak
You have to setup your OIDC identity provider and Kubernetes cluster.
Create an OIDC client as follows:
### 1. Setup OIDC Identity Provider
- Redirect URL: `http://localhost:8000/`
- Issuer URL: `https://keycloak.example.com/auth/realms/YOUR_REALM`
This tutorial assumes you have created an OIDC client with the following:
- Issuer URL: `https://keycloak.example.com/auth/realms/hello`
- Client ID: `kubernetes`
- Groups claim: `groups`
- Client Secret: `YOUR_CLIENT_SECRET`
- Allowed redirect URLs:
- `http://localhost:8000/`
- `urn:ietf:wg:oauth:2.0:oob`
- Groups claim: `groups` (optional for group based access controll)
Then create a group `kubernetes:admin` and join to it.
### 2. Setup Kubernetes API Server
### 2. Setup Kubernetes cluster
Configure the Kubernetes API server allows your identity provider.
Configure your Kubernetes API Server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
If you are using kops, `kops edit cluster` and append the following settings:
```yaml
spec:
kubeAPIServer:
oidcIssuerURL: https://keycloak.example.com/auth/realms/YOUR_REALM
oidcClientID: kubernetes
oidcGroupsClaim: groups
oidcIssuerURL: https://keycloak.example.com/auth/realms/hello
```
Here assign the `cluster-admin` role to the `kubernetes:admin` group.
### 3. Setup kubectl
```yaml
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: keycloak-admin-group
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: Group
name: /kubernetes:admin
```
### 3. Setup kubectl and kubelogin
Setup `kubectl` to authenticate with your identity provider.
Run the following script to configure `kubectl` uses your identity provider:
```sh
kubectl config set-credentials CLUSTER_NAME \
CLUSTER_NAME=hello.k8s.local
kubectl config set-credentials $CLUSTER_NAME \
--auth-provider oidc \
--auth-provider-arg idp-issuer-url=https://keycloak.example.com/auth/realms/YOUR_REALM \
--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
# Set the context
kubectl config set-context $CLUSTER_NAME --cluster $CLUSTER_NAME --user $CLUSTER_NAME
```
Download [the latest release](https://github.com/int128/kubelogin/releases) and save it.
Run `kubelogin` and make sure you can access to the cluster.
See the previous section for details.
## Configuration
### Kubeconfig
You can set the environment variable `KUBECONFIG` to point the config file.
Default to `~/.kube/config`.
```sh
export KUBECONFIG="$PWD/.kubeconfig"
```
### OpenID Connect Provider CA Certificate
You can specify the CA certificate of your OpenID Connect provider by [`idp-certificate-authority` or `idp-certificate-authority-data` in the kubeconfig](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-kubectl).
```sh
kubectl config set-credentials CLUSTER_NAME \
--auth-provider-arg idp-certificate-authority=$PWD/ca.crt
```
### Setup script
In actual team operation, you can share the following script to your team members for easy setup.
```sh
#!/bin/sh -xe
CLUSTER_NAME="hello.k8s.local"
export KUBECONFIG="$PWD/.kubeconfig"
kubectl config set-cluster "$CLUSTER_NAME" \
--server https://api-xxx.xxx.elb.amazonaws.com \
--certificate-authority "$PWD/cluster.crt"
kubectl config set-credentials "$CLUSTER_NAME" \
--auth-provider oidc \
--auth-provider-arg idp-issuer-url=https://accounts.google.com \
--auth-provider-arg client-id=YOUR_CLIENT_ID.apps.googleusercontent.com \
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET
kubectl config set-context "$CLUSTER_NAME" --cluster "$CLUSTER_NAME" --user "$CLUSTER_NAME"
kubectl config use-context "$CLUSTER_NAME"
```
In actual team operation, you can distribute the script to your team members for easy setup.
## Contributions
@@ -223,12 +122,8 @@ kubectl config use-context "$CLUSTER_NAME"
This is an open source software licensed under Apache License 2.0.
Feel free to open issues and pull requests.
### Build
### How to build
```sh
go get github.com/int128/kubelogin
```
### Release
CircleCI publishes the build to GitHub. See [.circleci/config.yml](.circleci/config.yml).

View File

@@ -1,107 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"log"
"net/http"
"golang.org/x/oauth2"
)
// BrowserAuthCodeFlow is a flow to get a token by browser interaction.
type BrowserAuthCodeFlow struct {
oauth2.Config
Port int // HTTP server port
}
// GetToken returns a token.
func (f *BrowserAuthCodeFlow) GetToken(ctx context.Context) (*oauth2.Token, error) {
f.Config.RedirectURL = fmt.Sprintf("http://localhost:%d/", f.Port)
state, err := generateState()
if err != nil {
return nil, fmt.Errorf("Could not generate state parameter: %s", err)
}
log.Printf("Open http://localhost:%d for authorization", f.Port)
code, err := f.getCode(ctx, &f.Config, state)
if err != nil {
return nil, err
}
token, err := f.Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("Could not exchange oauth code: %s", err)
}
return token, 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
}
func (f *BrowserAuthCodeFlow) getCode(ctx context.Context, config *oauth2.Config, state string) (string, error) {
codeCh := make(chan string)
errCh := make(chan error)
server := http.Server{
Addr: fmt.Sprintf(":%d", f.Port),
Handler: &handler{
AuthCodeURL: config.AuthCodeURL(state),
Callback: func(code string, actualState string, err error) {
switch {
case err != nil:
errCh <- err
case actualState != state:
errCh <- fmt.Errorf("OAuth state did not match, should be %s but %s", state, actualState)
default:
codeCh <- code
}
},
},
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
select {
case err := <-errCh:
server.Shutdown(ctx)
return "", err
case code := <-codeCh:
server.Shutdown(ctx)
return code, nil
}
}
type handler struct {
AuthCodeURL string
Callback func(code string, state string, err error)
}
func (s *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.RequestURI)
switch r.URL.Path {
case "/":
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
errorCode := r.URL.Query().Get("error")
errorDescription := r.URL.Query().Get("error_description")
switch {
case code != "":
s.Callback(code, state, nil)
fmt.Fprintf(w, "Back to command line.")
case errorCode != "":
s.Callback("", "", fmt.Errorf("OAuth Error: %s %s", errorCode, errorDescription))
fmt.Fprintf(w, "Back to command line.")
default:
http.Redirect(w, r, s.AuthCodeURL, 302)
}
default:
http.Error(w, "Not Found", 404)
}
}

View File

@@ -1,60 +0,0 @@
package auth
import (
"context"
"fmt"
oidc "github.com/coreos/go-oidc"
"golang.org/x/oauth2"
)
// TokenSet is a set of tokens and claims.
type TokenSet struct {
IDToken string
RefreshToken string
Claims *Claims
}
// Claims represents properties in the ID token.
type Claims struct {
Email string `json:"email"`
}
// GetTokenSet retrieves a token from the OIDC provider.
func GetTokenSet(ctx context.Context, issuer string, clientID string, clientSecret string) (*TokenSet, error) {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("Could not access OIDC issuer: %s", err)
}
flow := BrowserAuthCodeFlow{
Port: 8000,
Config: oauth2.Config{
Endpoint: provider.Endpoint(),
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: []string{oidc.ScopeOpenID, "email"},
},
}
token, err := flow.GetToken(ctx)
if err != nil {
return nil, fmt.Errorf("Could not get a token: %s", 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: clientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("Could not verify the id_token: %s", err)
}
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("Could not extract claims from the token response: %s", err)
}
return &TokenSet{
IDToken: rawIDToken,
RefreshToken: token.RefreshToken,
Claims: &claims,
}, nil
}

View File

@@ -1,115 +0,0 @@
package cli
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io/ioutil"
"log"
"net/http"
"github.com/int128/kubelogin/auth"
"github.com/int128/kubelogin/kubeconfig"
flags "github.com/jessevdk/go-flags"
homedir "github.com/mitchellh/go-homedir"
"golang.org/x/oauth2"
)
// Parse parses command line arguments and returns a CLI instance.
func Parse(args []string) (*CLI, error) {
var cli CLI
parser := flags.NewParser(&cli, flags.HelpFlag)
args, err := parser.Parse()
if err != nil {
return nil, err
}
if len(args) > 0 {
return nil, fmt.Errorf("Too many argument")
}
return &cli, nil
}
// CLI represents an interface of this command.
type CLI struct {
KubeConfig string `long:"kubeconfig" default:"~/.kube/config" env:"KUBECONFIG" description:"Path to the kubeconfig file"`
SkipTLSVerify bool `long:"insecure-skip-tls-verify" env:"KUBELOGIN_INSECURE_SKIP_TLS_VERIFY" description:"If set, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure"`
// CertificateAuthority string `long:"certificate-authority" env:"KUBELOGIN_CERTIFICATE_AUTHORITY" description:"Path to a cert file for the certificate authority"`
}
// ExpandKubeConfig returns an expanded KubeConfig path.
func (c *CLI) ExpandKubeConfig() (string, error) {
d, err := homedir.Expand(c.KubeConfig)
if err != nil {
return "", fmt.Errorf("Could not expand %s", c.KubeConfig)
}
return d, nil
}
// Run performs this command.
func (c *CLI) Run(ctx context.Context) error {
path, err := c.ExpandKubeConfig()
if err != nil {
return err
}
log.Printf("Reading %s", path)
cfg, err := kubeconfig.Load(path)
if err != nil {
return fmt.Errorf("Could not load kubeconfig: %s", err)
}
log.Printf("Using current context: %s", cfg.CurrentContext)
authInfo := kubeconfig.FindCurrentAuthInfo(cfg)
if authInfo == nil {
return fmt.Errorf("Could not find current context: %s", cfg.CurrentContext)
}
authProvider, err := kubeconfig.ToOIDCAuthProviderConfig(authInfo)
if err != nil {
return fmt.Errorf("Could not find auth-provider: %s", err)
}
tlsConfig, err := c.tlsConfig(authProvider)
if err != nil {
return fmt.Errorf("Could not configure TLS: %s", err)
}
client := &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
token, err := auth.GetTokenSet(ctx, authProvider.IDPIssuerURL(), authProvider.ClientID(), authProvider.ClientSecret())
if err != nil {
return fmt.Errorf("Authentication error: %s", err)
}
authProvider.SetIDToken(token.IDToken)
authProvider.SetRefreshToken(token.RefreshToken)
kubeconfig.Write(cfg, path)
log.Printf("Updated %s", path)
return nil
}
func (c *CLI) tlsConfig(authProvider *kubeconfig.OIDCAuthProviderConfig) (*tls.Config, error) {
p := x509.NewCertPool()
if authProvider.IDPCertificateAuthority() != "" {
b, err := ioutil.ReadFile(authProvider.IDPCertificateAuthority())
if err != nil {
return nil, fmt.Errorf("Could not read idp-certificate-authority: %s", err)
}
if p.AppendCertsFromPEM(b) != true {
return nil, fmt.Errorf("Could not load CA certificate from idp-certificate-authority: %s", err)
}
log.Printf("Using CA certificate: %s", authProvider.IDPCertificateAuthority())
}
if authProvider.IDPCertificateAuthorityData() != "" {
b, err := base64.StdEncoding.DecodeString(authProvider.IDPCertificateAuthorityData())
if err != nil {
return nil, fmt.Errorf("Could not decode idp-certificate-authority-data: %s", err)
}
if p.AppendCertsFromPEM(b) != true {
return nil, fmt.Errorf("Could not load CA certificate from idp-certificate-authority-data: %s", err)
}
log.Printf("Using CA certificate of idp-certificate-authority-data")
}
cfg := &tls.Config{InsecureSkipVerify: c.SkipTLSVerify}
if len(p.Subjects()) > 0 {
cfg.RootCAs = p
}
return cfg, nil
}

46
config.go Normal file
View File

@@ -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
}

View File

@@ -1,104 +0,0 @@
package integration
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"html/template"
"log"
"math/big"
"net/http"
"testing"
"time"
jwt "github.com/dgrijalva/jwt-go"
)
// AuthHandler provides the stub handler for OIDC authentication.
type AuthHandler struct {
// Values in templates
Issuer string
AuthCode string
IDToken string
PrivateKey struct{ N, E string }
// Response templates
discoveryJSON *template.Template
tokenJSON *template.Template
jwksJSON *template.Template
}
// NewAuthHandler returns a new AuthHandler.
func NewAuthHandler(t *testing.T, issuer string) *AuthHandler {
h := &AuthHandler{
Issuer: issuer,
AuthCode: "0b70006b-f62a-4438-aba5-c0b96775d8e5",
discoveryJSON: template.Must(template.ParseFiles("testdata/oidc-discovery.json")),
tokenJSON: template.Must(template.ParseFiles("testdata/oidc-token.json")),
jwksJSON: template.Must(template.ParseFiles("testdata/oidc-jwks.json")),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.StandardClaims{
Issuer: h.Issuer,
Audience: "kubernetes",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
})
k, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("Could not generate a key pair: %s", err)
}
h.IDToken, err = token.SignedString(k)
if err != nil {
t.Fatalf("Could not generate an ID token: %s", err)
}
h.PrivateKey.E = base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes())
h.PrivateKey.N = base64.RawURLEncoding.EncodeToString(k.N.Bytes())
return h
}
func (s *AuthHandler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
m := r.Method
p := r.URL.Path
log.Printf("[auth-server] %s %s", m, r.RequestURI)
switch {
case m == "GET" && p == "/.well-known/openid-configuration":
w.Header().Add("Content-Type", "application/json")
if err := s.discoveryJSON.Execute(w, s); err != nil {
return err
}
case m == "GET" && p == "/protocol/openid-connect/auth":
// Authentication Response
// http://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
q := r.URL.Query()
to := fmt.Sprintf("%s?state=%s&code=%s", q.Get("redirect_uri"), q.Get("state"), s.AuthCode)
http.Redirect(w, r, to, 302)
case m == "POST" && p == "/protocol/openid-connect/token":
// Token Response
// http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
if err := r.ParseForm(); err != nil {
return err
}
if s.AuthCode != r.Form.Get("code") {
return fmt.Errorf("code wants %s but %s", s.AuthCode, r.Form.Get("code"))
}
w.Header().Add("Content-Type", "application/json")
if err := s.tokenJSON.Execute(w, s); err != nil {
return err
}
case m == "GET" && p == "/protocol/openid-connect/certs":
w.Header().Add("Content-Type", "application/json")
if err := s.jwksJSON.Execute(w, s); err != nil {
return err
}
default:
http.Error(w, "Not Found", 404)
}
return nil
}
func (s *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := s.serveHTTP(w, r); err != nil {
log.Printf("[auth-server] Error: %s", err)
w.WriteHeader(500)
}
}

View File

@@ -1,160 +0,0 @@
package integration
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"io/ioutil"
"net/http"
"os"
"testing"
"time"
"github.com/int128/kubelogin/cli"
)
const caCert = "testdata/authserver-ca.crt"
const tlsCert = "testdata/authserver.crt"
const tlsKey = "testdata/authserver.key"
func Test(t *testing.T) {
ctx := context.Background()
authServer := &http.Server{
Addr: "localhost:9000",
Handler: NewAuthHandler(t, "http://localhost:9000"),
}
defer authServer.Shutdown(ctx)
kubeconfig := createKubeconfig(t, &kubeconfigValues{
Issuer: "http://localhost:9000",
})
defer os.Remove(kubeconfig)
go listenAndServe(t, authServer)
go authenticate(t, &tls.Config{})
c := cli.CLI{
KubeConfig: kubeconfig,
}
if err := c.Run(ctx); err != nil {
t.Fatal(err)
}
verifyKubeconfig(t, kubeconfig)
}
func TestWithSkipTLSVerify(t *testing.T) {
ctx := context.Background()
authServer := &http.Server{
Addr: "localhost:9000",
Handler: NewAuthHandler(t, "https://localhost:9000"),
}
defer authServer.Shutdown(ctx)
kubeconfig := createKubeconfig(t, &kubeconfigValues{
Issuer: "https://localhost:9000",
})
defer os.Remove(kubeconfig)
go listenAndServeTLS(t, authServer)
go authenticate(t, &tls.Config{InsecureSkipVerify: true})
c := cli.CLI{
KubeConfig: kubeconfig,
SkipTLSVerify: true,
}
if err := c.Run(ctx); err != nil {
t.Fatal(err)
}
verifyKubeconfig(t, kubeconfig)
}
func TestWithCACert(t *testing.T) {
ctx := context.Background()
authServer := &http.Server{
Addr: "localhost:9000",
Handler: NewAuthHandler(t, "https://localhost:9000"),
}
defer authServer.Shutdown(ctx)
kubeconfig := createKubeconfig(t, &kubeconfigValues{
Issuer: "https://localhost:9000",
IDPCertificateAuthority: caCert,
})
defer os.Remove(kubeconfig)
go listenAndServeTLS(t, authServer)
go authenticate(t, &tls.Config{RootCAs: loadCACert(t)})
c := cli.CLI{
KubeConfig: kubeconfig,
}
if err := c.Run(ctx); err != nil {
t.Fatal(err)
}
verifyKubeconfig(t, kubeconfig)
}
func TestWithCACertData(t *testing.T) {
ctx := context.Background()
authServer := &http.Server{
Addr: "localhost:9000",
Handler: NewAuthHandler(t, "https://localhost:9000"),
}
defer authServer.Shutdown(ctx)
b, err := ioutil.ReadFile(caCert)
if err != nil {
t.Fatal(err)
}
kubeconfig := createKubeconfig(t, &kubeconfigValues{
Issuer: "https://localhost:9000",
IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString(b),
})
defer os.Remove(kubeconfig)
go listenAndServeTLS(t, authServer)
go authenticate(t, &tls.Config{RootCAs: loadCACert(t)})
c := cli.CLI{
KubeConfig: kubeconfig,
}
if err := c.Run(ctx); err != nil {
t.Fatal(err)
}
verifyKubeconfig(t, kubeconfig)
}
func authenticate(t *testing.T, tlsConfig *tls.Config) {
t.Helper()
time.Sleep(100 * time.Millisecond)
client := http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
res, err := client.Get("http://localhost:8000/")
if err != nil {
t.Error(err)
return
}
if res.StatusCode != 200 {
t.Errorf("StatusCode wants 200 but %d: res=%+v", res.StatusCode, res)
}
}
func loadCACert(t *testing.T) *x509.CertPool {
p := x509.NewCertPool()
b, err := ioutil.ReadFile(caCert)
if err != nil {
t.Fatal(err)
}
if !p.AppendCertsFromPEM(b) {
t.Fatalf("Could not AppendCertsFromPEM")
}
return p
}
func listenAndServe(t *testing.T, s *http.Server) {
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
t.Fatal(err)
}
}
func listenAndServeTLS(t *testing.T, s *http.Server) {
if err := s.ListenAndServeTLS(tlsCert, tlsKey); err != nil && err != http.ErrServerClosed {
t.Fatal(err)
}
}

View File

@@ -1,44 +0,0 @@
package integration
import (
"html/template"
"io/ioutil"
"strings"
"testing"
)
type kubeconfigValues struct {
Issuer string
IDPCertificateAuthority string
IDPCertificateAuthorityData string
}
func createKubeconfig(t *testing.T, v *kubeconfigValues) string {
t.Helper()
f, err := ioutil.TempFile("", "kubeconfig")
if err != nil {
t.Fatal(err)
}
defer f.Close()
tpl, err := template.ParseFiles("testdata/kubeconfig.yaml")
if err != nil {
t.Fatal(err)
}
if err := tpl.Execute(f, v); err != nil {
t.Fatal(err)
}
return f.Name()
}
func verifyKubeconfig(t *testing.T, kubeconfig string) {
b, err := ioutil.ReadFile(kubeconfig)
if err != nil {
t.Fatal(err)
}
if strings.Index(string(b), "id-token: ey") == -1 {
t.Errorf("kubeconfig wants id-token but %s", string(b))
}
if strings.Index(string(b), "refresh-token: 44df4c82-5ce7-4260-b54d-1da0d396ef2a") == -1 {
t.Errorf("kubeconfig wants refresh-token but %s", string(b))
}
}

View File

@@ -1,4 +0,0 @@
/CA
*.key
*.csr
*.crt

View File

@@ -1,50 +0,0 @@
.PHONY: clean
all: authserver.crt authserver-ca.crt
clean:
rm -v authserver*
authserver-ca.key:
openssl genrsa -out $@ 1024
authserver-ca.csr: openssl.cnf authserver-ca.key
openssl req -config openssl.cnf \
-new \
-key authserver-ca.key \
-subj "/CN=Hello CA" \
-out $@
openssl req -noout -text -in $@
authserver-ca.crt: authserver-ca.csr authserver-ca.key
openssl x509 -req \
-signkey authserver-ca.key \
-in authserver-ca.csr \
-out $@
openssl x509 -text -in $@
authserver.key:
openssl genrsa -out $@ 1024
authserver.csr: openssl.cnf authserver.key
openssl req -config openssl.cnf \
-new \
-key authserver.key \
-subj "/CN=localhost" \
-out $@
openssl req -noout -text -in $@
authserver.crt: openssl.cnf authserver.csr authserver-ca.key authserver-ca.crt
rm -fr ./CA
mkdir -p ./CA
touch CA/index.txt
touch CA/index.txt.attr
echo 00 > CA/serial
openssl ca -config openssl.cnf \
-extensions v3_req \
-batch \
-cert authserver-ca.crt \
-keyfile authserver-ca.key \
-in authserver.csr \
-out $@
openssl x509 -text -in $@

View File

@@ -1,28 +0,0 @@
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://api.hello.k8s.example.com
name: hello.k8s.local
contexts:
- context:
cluster: hello.k8s.local
user: hello.k8s.local
name: hello.k8s.local
current-context: hello.k8s.local
preferences: {}
users:
- name: hello.k8s.local
user:
auth-provider:
config:
client-id: kubernetes
client-secret: a3c508c3-73c9-42e2-ab14-487a1bf67c33
idp-issuer-url: {{ .Issuer }}
#{{ if .IDPCertificateAuthority }}
idp-certificate-authority: {{ .IDPCertificateAuthority }}
#{{ end }}
#{{ if .IDPCertificateAuthorityData }}
idp-certificate-authority-data: {{ .IDPCertificateAuthorityData }}
#{{ end }}
name: oidc

View File

@@ -1,85 +0,0 @@
{
"issuer": "{{ .Issuer }}",
"authorization_endpoint": "{{ .Issuer }}/protocol/openid-connect/auth",
"token_endpoint": "{{ .Issuer }}/protocol/openid-connect/token",
"token_introspection_endpoint": "{{ .Issuer }}/protocol/openid-connect/token/introspect",
"userinfo_endpoint": "{{ .Issuer }}/protocol/openid-connect/userinfo",
"end_session_endpoint": "{{ .Issuer }}/protocol/openid-connect/logout",
"jwks_uri": "{{ .Issuer }}/protocol/openid-connect/certs",
"check_session_iframe": "{{ .Issuer }}/protocol/openid-connect/login-status-iframe.html",
"grant_types_supported": [
"authorization_code",
"implicit",
"refresh_token",
"password",
"client_credentials"
],
"response_types_supported": [
"code",
"none",
"id_token",
"token",
"id_token token",
"code id_token",
"code token",
"code id_token token"
],
"subject_types_supported": [
"public",
"pairwise"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"userinfo_signing_alg_values_supported": [
"RS256"
],
"request_object_signing_alg_values_supported": [
"none",
"RS256"
],
"response_modes_supported": [
"query",
"fragment",
"form_post"
],
"registration_endpoint": "{{ .Issuer }}/clients-registrations/openid-connect",
"token_endpoint_auth_methods_supported": [
"private_key_jwt",
"client_secret_basic",
"client_secret_post",
"client_secret_jwt"
],
"token_endpoint_auth_signing_alg_values_supported": [
"RS256"
],
"claims_supported": [
"sub",
"iss",
"auth_time",
"name",
"given_name",
"family_name",
"preferred_username",
"email"
],
"claim_types_supported": [
"normal"
],
"claims_parameter_supported": false,
"scopes_supported": [
"openid",
"offline_access",
"phone",
"address",
"email",
"profile"
],
"request_parameter_supported": true,
"request_uri_parameter_supported": true,
"code_challenge_methods_supported": [
"plain",
"S256"
],
"tls_client_certificate_bound_access_tokens": true
}

View File

@@ -1,12 +0,0 @@
{
"keys": [
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": "xxx",
"n": "{{ .PrivateKey.N }}",
"e": "{{ .PrivateKey.E }}"
}
]
}

View File

@@ -1,7 +0,0 @@
{
"access_token": "7eaae8ab-8f69-45d9-ab7c-73560cd9444d",
"token_type": "Bearer",
"refresh_token": "44df4c82-5ce7-4260-b54d-1da0d396ef2a",
"expires_in": 3600,
"id_token": "{{ .IDToken }}"
}

View File

@@ -1,37 +0,0 @@
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = ./CA
certs = $dir
crl_dir = $dir
database = $dir/index.txt
new_certs_dir = $dir
default_md = sha256
policy = policy_match
serial = $dir/serial
default_days = 365
[ policy_match ]
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
distinguished_name = req_distinguished_name
req_extensions = v3_req
x509_extensions = v3_ca
[ req_distinguished_name ]
commonName = Common Name (e.g. server FQDN or YOUR name)
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = DNS:localhost
[ v3_ca ]
basicConstraints = CA:true

View File

@@ -1,66 +0,0 @@
package kubeconfig
import (
"fmt"
"k8s.io/client-go/tools/clientcmd/api"
)
// FindCurrentAuthInfo returns the authInfo of current context.
// If the current context does not exist, this returns nil.
func FindCurrentAuthInfo(config *api.Config) *api.AuthInfo {
context := config.Contexts[config.CurrentContext]
if context == nil {
return nil
}
return config.AuthInfos[context.AuthInfo]
}
// ToOIDCAuthProviderConfig converts from api.AuthInfo to OIDCAuthProviderConfig.
func ToOIDCAuthProviderConfig(authInfo *api.AuthInfo) (*OIDCAuthProviderConfig, error) {
if authInfo.AuthProvider == nil {
return nil, fmt.Errorf("auth-provider is not set, did you setup kubectl as listed here: https://github.com/int128/kubelogin#3-setup-kubectl")
}
if authInfo.AuthProvider.Name != "oidc" {
return nil, fmt.Errorf("auth-provider `%s` is not supported", authInfo.AuthProvider.Name)
}
return (*OIDCAuthProviderConfig)(authInfo.AuthProvider), nil
}
// OIDCAuthProviderConfig represents OIDC configuration in the kubeconfig.
type OIDCAuthProviderConfig api.AuthProviderConfig
// IDPIssuerURL returns the idp-issuer-url.
func (c *OIDCAuthProviderConfig) IDPIssuerURL() string {
return c.Config["idp-issuer-url"]
}
// ClientID returns the client-id.
func (c *OIDCAuthProviderConfig) ClientID() string {
return c.Config["client-id"]
}
// ClientSecret returns the client-secret.
func (c *OIDCAuthProviderConfig) ClientSecret() string {
return c.Config["client-secret"]
}
// IDPCertificateAuthority returns the idp-certificate-authority.
func (c *OIDCAuthProviderConfig) IDPCertificateAuthority() string {
return c.Config["idp-certificate-authority"]
}
// IDPCertificateAuthorityData returns the idp-certificate-authority-data.
func (c *OIDCAuthProviderConfig) IDPCertificateAuthorityData() string {
return c.Config["idp-certificate-authority-data"]
}
// SetIDToken replaces the id-token.
func (c *OIDCAuthProviderConfig) SetIDToken(idToken string) {
c.Config["id-token"] = idToken
}
// SetRefreshToken replaces the refresh-token.
func (c *OIDCAuthProviderConfig) SetRefreshToken(refreshToken string) {
c.Config["refresh-token"] = refreshToken
}

View File

@@ -1,22 +0,0 @@
package kubeconfig
import (
"fmt"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
// Load loads the file and returns the Config.
func Load(path string) (*api.Config, error) {
config, err := clientcmd.LoadFromFile(path)
if err != nil {
return nil, fmt.Errorf("Could not load kubeconfig from %s: %s", path, err)
}
return config, nil
}
// Write writes the config to the file.
func Write(config *api.Config, path string) error {
return clientcmd.WriteToFile(*config, path)
}

49
main.go
View File

@@ -1,20 +1,57 @@
package main
import (
"context"
"log"
"os"
"github.com/int128/kubelogin/cli"
"k8s.io/client-go/tools/clientcmd/api"
)
func main() {
c, err := cli.Parse(os.Args)
kubeConfigPath, err := FindKubeConfig()
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
if err := c.Run(ctx); err != nil {
log.Printf("Reading config from %s", kubeConfigPath)
kubeConfig, err := ReadKubeConfig(kubeConfigPath)
if err != nil {
log.Fatal(err)
}
log.Printf("Using current context: %s", kubeConfig.CurrentContext)
authInfo := GetCurrentAuthInfo(*kubeConfig)
if authInfo == nil {
log.Fatal("Could not find the current user")
}
authProvider := authInfo.AuthProvider
if authProvider == 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)
}
WriteKubeConfig(*kubeConfig, kubeConfigPath)
log.Printf("Updated %s", 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"]
log.Printf("Using issuer: %s", issuer)
log.Printf("Using client ID: %s", clientID)
oidcToken, err := GetOIDCToken(issuer, clientID, clientSecret)
if err != nil {
return err
}
authProvider.Config["id-token"] = oidcToken.IDToken
authProvider.Config["refresh-token"] = oidcToken.RefreshToken
return nil
}

130
oidc.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"log"
"github.com/coreos/go-oidc"
"golang.org/x/oauth2"
)
// OIDCToken is a token set
type OIDCToken struct {
IDToken string
RefreshToken string
}
// GetOIDCToken returns a token retrieved by auth code grant
func GetOIDCToken(issuer string, clientID string, clientSecret string) (*OIDCToken, error) {
port := 8000
provider, err := oidc.NewProvider(oauth2.NoContext, issuer)
if err != nil {
return nil, err
}
state, err := generateState()
if err != nil {
return nil, err
}
webBrowserConfig := oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: fmt.Sprintf("http://localhost:%d/", port),
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email"},
}
cliConfig := oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: "urn:ietf:wg:oauth:2.0:oob",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email"},
}
showInstructionToGetToken(webBrowserConfig.RedirectURL, cliConfig.AuthCodeURL(state))
token, err := getTokenByWebBrowserOrCLI(webBrowserConfig, cliConfig, state)
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)
}
log.Printf("Verifying ID token...")
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
idToken, err := verifier.Verify(oauth2.NoContext, rawIDToken)
if err != nil {
return nil, err
}
idTokenClaim := struct {
Email string `json:"email"`
}{}
if err := idToken.Claims(&idTokenClaim); err != nil {
return nil, err
}
log.Printf("You are logged in as %s (%s)", idTokenClaim.Email, idToken.Subject)
return &OIDCToken{
IDToken: rawIDToken,
RefreshToken: token.RefreshToken,
}, nil
}
func getTokenByWebBrowserOrCLI(webBrowserConfig oauth2.Config, cliConfig oauth2.Config, state string) (*oauth2.Token, error) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
webBrowserAuthCodeCh := make(chan string)
cliAuthCodeCh := make(chan string)
errCh := make(chan error)
go ReceiveAuthCodeFromWebBrowser(ctx, webBrowserConfig.AuthCodeURL(state), state, webBrowserAuthCodeCh, errCh)
go ReceiveAuthCodeFromCLI(ctx, cliAuthCodeCh, errCh)
select {
case err := <-errCh:
return nil, err
case authCode := <-webBrowserAuthCodeCh:
log.Printf("Exchanging code and token...")
return webBrowserConfig.Exchange(ctx, authCode)
case authCode := <-cliAuthCodeCh:
log.Printf("Exchanging code and token...")
return cliConfig.Exchange(ctx, authCode)
}
}
func showInstructionToGetToken(localhostURL string, cliAuthCodeURL string) {
log.Printf("Starting OpenID Connect authentication:")
fmt.Printf(`
## Automatic (recommended)
Open the following URL in the web browser:
%s
## Manual
If you cannot access to localhost, instead open the following URL:
%s
Enter the code: `, localhostURL, cliAuthCodeURL)
}
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
}

16
oidc_cli.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"context"
"fmt"
)
// ReceiveAuthCodeFromCLI receives an auth code from CLI
func ReceiveAuthCodeFromCLI(ctx context.Context, authCodeCh chan<- string, errCh chan<- error) {
var authCode string
if _, err := fmt.Scanln(&authCode); err != nil {
errCh <- err
} else {
authCodeCh <- authCode
}
}

70
oidc_http.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
)
// ReceiveAuthCodeFromWebBrowser starts a server and receives an auth code
func ReceiveAuthCodeFromWebBrowser(ctx context.Context, authCodeURL string, state string, authCodeCh chan<- string, errCh chan<- error) {
server := http.Server{
Addr: ":8000",
Handler: &AuthCodeGrantHandler{
AuthCodeURL: authCodeURL,
State: state,
Resolve: func(authCode string) { authCodeCh <- authCode },
Reject: func(err error) { errCh <- err },
},
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
select {
case <-ctx.Done():
server.Shutdown(context.Background())
}
}
// AuthCodeGrantHandler handles requests for OIDC auth code grant
type AuthCodeGrantHandler struct {
AuthCodeURL string
State string
Resolve func(string)
Reject func(error)
}
func (s *AuthCodeGrantHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.RequestURI)
switch r.URL.Path {
case "/":
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
errorCode := r.URL.Query().Get("error")
errorDescription := r.URL.Query().Get("error_description")
switch {
case code != "" && state == s.State:
s.Resolve(code)
fmt.Fprintf(w, "Please back to the command line.")
case code != "" && state != s.State:
s.Reject(fmt.Errorf("OIDC state did not match. expected=%s, actual=%s", s.State, state))
fmt.Fprintf(w, "Please back to the command line.")
case errorCode != "":
s.Reject(fmt.Errorf("OIDC error: %s %s", errorCode, errorDescription))
fmt.Fprintf(w, "Please back to the command line.")
default:
http.Redirect(w, r, s.AuthCodeURL, 302)
}
default:
http.Error(w, "Not Found", 404)
}
}