Compare commits

...

45 Commits

Author SHA1 Message Date
Hidetake Iwata
7011f03094 Release v1.11.0 2019-05-16 21:59:50 +09:00
Hidetake Iwata
6aef98cef7 Bump to int128/oauth2cli:v1.4.0 (#82) 2019-05-16 21:55:56 +09:00
Hidetake Iwata
93bb1d39b9 Add fallback ports for local server (#79) 2019-05-16 20:38:45 +09:00
Hidetake Iwata
c8116e2eae Update keycloak.md 2019-05-14 15:42:05 +09:00
Hidetake Iwata
f2de8dd987 Refactor log messages and etc. (#77)
* Refactor log messages

* Refactor token verification
2019-05-14 13:49:43 +09:00
Hidetake Iwata
915fb35bc8 Raise error on invalid certificate (as kubectl) (#73) 2019-05-14 11:32:11 +09:00
Hidetake Iwata
51ccd70af3 go mod tidy 2019-05-14 11:26:06 +09:00
dependabot[bot]
c6df597fb0 Bump github.com/golang/mock from 1.3.0 to 1.3.1 (#76)
Bumps [github.com/golang/mock](https://github.com/golang/mock) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/golang/mock/releases)
- [Commits](https://github.com/golang/mock/compare/v1.3.0...1.3.1)

Signed-off-by: dependabot[bot] <support@dependabot.com>
2019-05-14 11:24:03 +09:00
Hidetake Iwata
ee78f6f735 Make integration tests in parallel (#75) 2019-05-13 21:25:20 +09:00
Hidetake Iwata
6e484a2b89 Add golangci-lint (#74)
* Add golangci-lint

* Fix lint errors
2019-05-13 10:19:25 +09:00
Hidetake Iwata
8050db7e05 go mod tidy 2019-05-13 09:26:28 +09:00
dependabot[bot]
cd54ca0df0 Bump github.com/golang/mock from 1.2.0 to 1.3.0 (#71)
Bumps [github.com/golang/mock](https://github.com/golang/mock) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/golang/mock/releases)
- [Commits](https://github.com/golang/mock/compare/v1.2.0...v1.3.0)

Signed-off-by: dependabot[bot] <support@dependabot.com>
2019-05-04 17:58:42 +09:00
Hidetake Iwata
45f83b0b0e Add multiple kubeconfig support (#70) 2019-04-29 14:34:56 +09:00
Hidetake Iwata
51b7ca1600 Fix templates for the latest goxzst 2019-04-22 09:42:01 +09:00
Hidetake Iwata
83f85a9b53 Dump all claims of ID token to debug log (#68)
* Dump all claims of ID token to debug log

* Add dump when a user already has a token
2019-04-19 10:29:56 +09:00
Hidetake Iwata
d82c8a2dd1 Fix error of go get and remove golint (#69)
* Remove golint

* Fix error of go get github.com/int128/ghcp
2019-04-19 10:26:03 +09:00
Hidetake Iwata
072bee6992 Add --certificate-authority option (#67) 2019-04-18 10:50:38 +09:00
Hidetake Iwata
5c07850a68 Add --user option (#66) 2019-04-18 10:38:23 +09:00
Hidetake Iwata
5c8c80f055 Add --context option (#65) 2019-04-18 10:12:48 +09:00
Hidetake Iwata
bc7bfabfb2 Move to pflags (#62) 2019-04-17 16:58:17 +09:00
Hidetake Iwata
73112546de Update README.md 2019-04-17 16:56:44 +09:00
Hidetake Iwata
356f0d519d Add debug log feature (#58) 2019-04-16 09:27:38 +09:00
Hidetake Iwata
e84d29bc6b Fix flaky test (#61) 2019-04-16 09:13:39 +09:00
Hidetake Iwata
ae80ebf148 Refactor: use dedicated Logger on authentication callback (#59) 2019-04-14 14:49:12 +09:00
Nathaniel Irons
5942c82b5f Change expected binary name to kubelogin (#57) 2019-04-12 10:16:18 +09:00
Hidetake Iwata
a5f9c698ea Bump Go to 1.12.3 (#56) 2019-04-11 12:52:41 +09:00
Hidetake Iwata
40ef2c25b8 Update README.md 2019-04-11 10:48:19 +09:00
Hidetake Iwata
2422c46271 Refactor integration tests (#54)
* Refactor integration tests

* Refactor: move keys

* Refactor authserver

* Refactor: do not generate key on test runtime

* Refactor: package keys in integration tests

* Refactor: use context on sending browser request

* Refactor: use testing.T logger on integration tests
2019-04-11 10:43:05 +09:00
Hidetake Iwata
6ca0ee8013 Skip login if kubeconfig has valid ID token (#52)
* Skip login if kubeconfig has valid ID token

* Add integration test for skip login feature
2019-04-10 10:20:13 +09:00
Hidetake Iwata
9ac252667a Update README.md 2019-04-09 13:34:47 +09:00
Hidetake Iwata
000711f52e Add support of HTTP_PROXY, HTTPS_PROXY and NO_PROXY (#51) 2019-04-09 13:17:12 +09:00
Hidetake Iwata
e465c4852b Introduce dig container (#50) 2019-04-09 12:37:44 +09:00
Hidetake Iwata
003badb0bc Fix nil pointer error (#49) 2019-04-09 10:19:25 +09:00
Hidetake Iwata
0873a193a5 Refactor: extract adaptors.Logger 2019-04-08 16:07:26 +09:00
Hidetake Iwata
5e80b1858e Refactor: logs 2019-04-08 16:07:26 +09:00
Hidetake Iwata
c816281657 Refactor: add test of usecases.Login 2019-04-08 13:35:01 +09:00
Hidetake Iwata
0db49860f9 Refactor: extract adaptors.HTTPClientConfig 2019-04-08 13:35:01 +09:00
Hidetake Iwata
d70c9db036 Refactor: extract adaptors.HTTP 2019-04-08 13:35:01 +09:00
Hidetake Iwata
675b5e5fff Refactor: extract adaptors.OIDC 2019-04-08 13:35:01 +09:00
Hidetake Iwata
e3bfc321a2 Refactor: extract adaptors.KubeConfig 2019-04-08 13:35:01 +09:00
Hidetake Iwata
b34d0fb32f Refactor: add di package 2019-04-08 13:35:01 +09:00
Hidetake Iwata
4c61a71ed4 Add test of adaptors.Cmd (#45) 2019-04-07 19:33:53 +09:00
Hidetake Iwata
8a02ed0fb0 Split cli package into adaptors and use-cases (#44) 2019-04-05 14:52:15 +09:00
Hidetake Iwata
3485c5408e Replace with errors.Errorf() and errors.Wrapf() (#43) 2019-04-05 12:12:00 +09:00
Hidetake Iwata
fb99977e98 Update README.md 2019-04-05 10:53:32 +09:00
54 changed files with 2711 additions and 719 deletions

View File

@@ -2,7 +2,7 @@ version: 2
jobs:
build:
docker:
- image: circleci/golang:1.11.1
- image: circleci/golang:1.12.3
steps:
- run: |
mkdir -p ~/bin
@@ -11,11 +11,12 @@ jobs:
curl -L -o ~/bin/kubectl https://storage.googleapis.com/kubernetes-release/release/v1.14.0/bin/linux/amd64/kubectl
chmod +x ~/bin/kubectl
- run: |
go get -v \
golang.org/x/lint/golint \
github.com/int128/goxzst \
github.com/tcnksm/ghr \
github.com/int128/ghcp
curl -L -o ~/bin/ghcp https://github.com/int128/ghcp/releases/download/v1.3.0/ghcp_linux_amd64
chmod +x ~/bin/ghcp
- run: |
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b ~/bin v1.16.0
- run: go get github.com/int128/goxzst
- run: go get github.com/tcnksm/ghr
- checkout
# workaround for https://github.com/golang/go/issues/27925
- run: sed -e '/^k8s.io\/client-go /d' -i go.sum

2
.gitignore vendored
View File

@@ -2,4 +2,4 @@
/dist
/kubelogin
/kubectl-oidc_login
/.kubeconfig
/.kubeconfig*

View File

@@ -8,10 +8,9 @@ LDFLAGS := -X main.version=$(CIRCLE_TAG)
all: $(TARGET)
check:
golint
go vet
$(MAKE) -C cli_test/authserver/testdata
go test -v ./...
golangci-lint run
$(MAKE) -C adaptors_test/keys/testdata
go test -v -race ./...
$(TARGET): $(wildcard *.go)
go build -o $@ -ldflags "$(LDFLAGS)"

View File

@@ -1,10 +1,10 @@
# kubelogin [![CircleCI](https://circleci.com/gh/int128/kubelogin.svg?style=shield)](https://circleci.com/gh/int128/kubelogin)
This is a kubectl plugin for [Kubernetes OpenID Connect (OIDC) authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
It gets a token from the OIDC provider and writes it to the kubeconfig.
It updates the kubeconfig file with an ID token and refresh token got from the OIDC provider.
## TL;DR
## Getting Started
You need to setup the following components:
@@ -13,25 +13,32 @@ You need to setup the following components:
- Role for your group or user
- kubectl authentication
You can install this by brew tap or from the [releases](https://github.com/int128/kubelogin/releases).
You can install the latest release from [Homebrew](https://brew.sh/), [Krew](https://github.com/kubernetes-sigs/krew) or [GitHub Releases](https://github.com/int128/kubelogin/releases) as follows:
```sh
# Homebrew
brew tap int128/kubelogin
brew install kubelogin
# Krew
kubectl krew install oidc-login
# GitHub Releases
curl -LO https://github.com/int128/kubelogin/releases/download/v1.11.0/kubelogin_linux_amd64.zip
unzip kubelogin_linux_amd64.zip
ln -s kubelogin kubectl-oidc_login
```
After initial setup or when the token has been expired, just run:
```
% kubelogin
2018/08/27 15:03:06 Reading /home/user/.kube/config
2018/08/27 15:03:06 Using current context: hello.k8s.local
2018/08/27 15:03:07 Open http://localhost:8000 for authorization
2018/08/27 15:03:09 Got token for subject=cf228a73-47fe-4986-a2a8-b2ced80a884b
2018/08/27 15:03:09 Updated /home/user/.kube/config
Open http://localhost:8000 for authentication
You got a valid token until 2019-05-16 22:03:13 +0900 JST
Updated ~/.kubeconfig
```
or run as a plugin:
or run as a kubectl plugin:
```
% kubectl oidc-login
@@ -46,27 +53,30 @@ For more, see the following documents:
- [Getting Started with Google Identity Platform](docs/google.md)
- [Team Operation](docs/team_ops.md)
If you are using other platforms, please contribute documents via pull requests.
## Configuration
This supports the following options.
This document is for the development version.
If you are looking for a specific version, see [the release tags](https://github.com/int128/kubelogin/tags).
Kubelogin supports the following options.
```
kubelogin [OPTIONS]
Application Options:
--kubeconfig= Path to the kubeconfig file (default: ~/.kube/config) [$KUBECONFIG]
--listen-port= Port used by kubelogin to bind its webserver (default: 8000) [$KUBELOGIN_LISTEN_PORT]
--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]
--skip-open-browser If set, it does not open the browser on authentication. [$KUBELOGIN_SKIP_OPEN_BROWSER]
Help Options:
-h, --help Show this help message
Options:
--kubeconfig string Path to the kubeconfig file
--context string The name of the kubeconfig context to use
--user string The name of the kubeconfig user to use. Prior to --context
--listen-port ints Port to bind to the local server. If multiple ports are given, it will try the ports in order (default [8000,18000])
--skip-open-browser If true, it does not open the browser on authentication
--certificate-authority string Path to a cert file for the certificate authority
--insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure
-v, --v int If set to 1 or greater, it shows debug log
```
This also supports the following keys of `auth-provider` in kubeconfig.
See [kubectl authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-kubectl).
It supports the following keys of `auth-provider` in a kubeconfig.
See [kubectl authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-kubectl) for more.
Key | Direction | Value
----|-----------|------
@@ -80,15 +90,21 @@ Key | Direction | Value
`refresh-token` | Write | Refresh token got from the provider.
### Kubeconfig path
### Kubeconfig
You can set the environment variable `KUBECONFIG` to point the config file.
Default to `~/.kube/config`.
You can set path to the kubeconfig file by the option or the environment variable just like kubectl.
It defaults to `~/.kube/config`.
```sh
export KUBECONFIG="$PWD/.kubeconfig"
# by the option
kubelogin --kubeconfig /path/to/kubeconfig
# by the environment variable
KUBECONFIG="/path/to/kubeconfig1:/path/to/kubeconfig2" kubelogin
```
If you set multiple files, kubelogin will find the file which has the current authentication (i.e. `user` and `auth-provider`) and write a token to it.
### Extra scopes
@@ -106,16 +122,35 @@ sed -i '' -e s/SCOPES/email,profile/ $KUBECONFIG
```
### Redirect URIs
By default kubelogin starts the local server at port 8000 or 18000.
You need to register the following redirect URIs to the OIDC provider:
- `http://localhost:8000`
- `http://localhost:18000` (used if port 8000 is already in use)
You can change the ports by the option:
```sh
kubelogin --listen-port 12345 --listen-port 23456
```
### CA Certificates
You can set your self-signed certificates for the OIDC provider (not Kubernetes API server) by `idp-certificate-authority` and `idp-certificate-authority-data` in the kubeconfig.
You can set your self-signed certificates for the OIDC provider (not Kubernetes API server) by kubeconfig or option.
```sh
kubectl config set-credentials keycloak \
--auth-provider-arg idp-certificate-authority=$HOME/.kube/keycloak-ca.pem
```
If kubelogin could not parse the certificate, it shows a warning and skips it.
### HTTP Proxy
You can set the following environment variables if you are behind a proxy: `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY`.
See also [net/http#ProxyFromEnvironment](https://golang.org/pkg/net/http/#ProxyFromEnvironment).
## Contributions

100
adaptors/cmd.go Normal file
View File

@@ -0,0 +1,100 @@
package adaptors
import (
"context"
"strings"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/kubeconfig"
"github.com/int128/kubelogin/usecases/interfaces"
"github.com/spf13/pflag"
"go.uber.org/dig"
)
const usage = `Login to the OpenID Connect provider and update the kubeconfig.
kubelogin %[2]s
Examples:
# Login to the current provider and update ~/.kube/config
%[1]s
Options:
%[3]s
Usage:
%[1]s [options]`
var defaultListenPort = []int{8000, 18000}
func NewCmd(i Cmd) adaptors.Cmd {
return &i
}
type Cmd struct {
dig.In
Login usecases.Login
Logger adaptors.Logger
}
func (cmd *Cmd) Run(ctx context.Context, args []string, version string) int {
executable := executableName(args[0])
f := pflag.NewFlagSet(executable, pflag.ContinueOnError)
f.SortFlags = false
f.Usage = func() {
cmd.Logger.Printf(usage, executable, version, f.FlagUsages())
}
var o cmdOptions
f.StringVar(&o.KubeConfig, "kubeconfig", "", "Path to the kubeconfig file")
f.StringVar(&o.KubeContext, "context", "", "The name of the kubeconfig context to use")
f.StringVar(&o.KubeUser, "user", "", "The name of the kubeconfig user to use. Prior to --context")
f.IntSliceVar(&o.ListenPort, "listen-port", defaultListenPort, "Port to bind to the local server. If multiple ports are given, it will try the ports in order")
f.BoolVar(&o.SkipOpenBrowser, "skip-open-browser", false, "If true, it does not open the browser on authentication")
f.StringVar(&o.CertificateAuthority, "certificate-authority", "", "Path to a cert file for the certificate authority")
f.BoolVar(&o.SkipTLSVerify, "insecure-skip-tls-verify", false, "If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure")
f.IntVarP(&o.Verbose, "v", "v", 0, "If set to 1 or greater, it shows debug log")
if err := f.Parse(args[1:]); err != nil {
if err == pflag.ErrHelp {
return 1
}
cmd.Logger.Printf("Error: invalid arguments: %s", err)
return 1
}
if len(f.Args()) > 0 {
cmd.Logger.Printf("Error: too many arguments")
return 1
}
cmd.Logger.SetLevel(adaptors.LogLevel(o.Verbose))
in := usecases.LoginIn{
KubeConfigFilename: o.KubeConfig,
KubeContextName: kubeconfig.ContextName(o.KubeContext),
KubeUserName: kubeconfig.UserName(o.KubeUser),
CertificateAuthorityFilename: o.CertificateAuthority,
SkipTLSVerify: o.SkipTLSVerify,
ListenPort: o.ListenPort,
SkipOpenBrowser: o.SkipOpenBrowser,
}
if err := cmd.Login.Do(ctx, in); err != nil {
cmd.Logger.Printf("Error: %s", err)
return 1
}
return 0
}
type cmdOptions struct {
KubeConfig string
KubeContext string
KubeUser string
CertificateAuthority string
SkipTLSVerify bool
ListenPort []int
SkipOpenBrowser bool
Verbose int
}
func executableName(arg0 string) string {
if strings.HasPrefix(arg0, "kubectl-") {
return strings.ReplaceAll(strings.ReplaceAll(arg0, "-", " "), "_", "-")
}
return arg0
}

111
adaptors/cmd_test.go Normal file
View File

@@ -0,0 +1,111 @@
package adaptors
import (
"context"
"testing"
"github.com/golang/mock/gomock"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/adaptors/mock_adaptors"
"github.com/int128/kubelogin/usecases/interfaces"
"github.com/int128/kubelogin/usecases/mock_usecases"
)
func TestCmd_Run(t *testing.T) {
const executable = "kubelogin"
const version = "HEAD"
t.Run("Defaults", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
login := mock_usecases.NewMockLogin(ctrl)
login.EXPECT().
Do(ctx, usecases.LoginIn{
ListenPort: defaultListenPort,
})
logger := mock_adaptors.NewLogger(t, ctrl)
logger.EXPECT().
SetLevel(adaptors.LogLevel(0))
cmd := Cmd{
Login: login,
Logger: logger,
}
exitCode := cmd.Run(ctx, []string{executable}, version)
if exitCode != 0 {
t.Errorf("exitCode wants 0 but %d", exitCode)
}
})
t.Run("FullOptions", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
login := mock_usecases.NewMockLogin(ctrl)
login.EXPECT().
Do(ctx, usecases.LoginIn{
KubeConfigFilename: "/path/to/kubeconfig",
KubeContextName: "hello.k8s.local",
KubeUserName: "google",
CertificateAuthorityFilename: "/path/to/cacert",
SkipTLSVerify: true,
ListenPort: []int{10080, 20080},
SkipOpenBrowser: true,
})
logger := mock_adaptors.NewLogger(t, ctrl)
logger.EXPECT().
SetLevel(adaptors.LogLevel(1))
cmd := Cmd{
Login: login,
Logger: logger,
}
exitCode := cmd.Run(ctx, []string{executable,
"--kubeconfig", "/path/to/kubeconfig",
"--context", "hello.k8s.local",
"--user", "google",
"--listen-port", "10080",
"--listen-port", "20080",
"--skip-open-browser",
"--certificate-authority", "/path/to/cacert",
"--insecure-skip-tls-verify",
"-v1",
}, version)
if exitCode != 0 {
t.Errorf("exitCode wants 0 but %d", exitCode)
}
})
t.Run("TooManyArgs", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cmd := Cmd{
Login: mock_usecases.NewMockLogin(ctrl),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
exitCode := cmd.Run(context.TODO(), []string{executable, "some"}, version)
if exitCode != 1 {
t.Errorf("exitCode wants 1 but %d", exitCode)
}
})
}
func TestCmd_executableName(t *testing.T) {
t.Run("kubelogin", func(t *testing.T) {
e := executableName("kubelogin")
if e != "kubelogin" {
t.Errorf("executableName wants kubelogin but %s", e)
}
})
t.Run("kubectl-oidc_login", func(t *testing.T) {
e := executableName("kubectl-oidc_login")
if e != "kubectl oidc-login" {
t.Errorf("executableName wants kubectl oidc-login but %s", e)
}
})
}

85
adaptors/http.go Normal file
View File

@@ -0,0 +1,85 @@
package adaptors
import (
"crypto/tls"
"crypto/x509"
"encoding/base64"
"io/ioutil"
"net/http"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/infrastructure"
"github.com/pkg/errors"
"go.uber.org/dig"
)
func NewHTTP(i HTTP) adaptors.HTTP {
return &i
}
type HTTP struct {
dig.In
Logger adaptors.Logger
}
func (h *HTTP) NewClient(config adaptors.HTTPClientConfig) (*http.Client, error) {
pool := x509.NewCertPool()
if filename := config.OIDCConfig.IDPCertificateAuthority(); filename != "" {
h.Logger.Debugf(1, "Loading the certificate %s", filename)
err := appendCertificateFromFile(pool, filename)
if err != nil {
return nil, errors.Wrapf(err, "could not load the certificate of idp-certificate-authority")
}
}
if data := config.OIDCConfig.IDPCertificateAuthorityData(); data != "" {
h.Logger.Debugf(1, "Loading the certificate of idp-certificate-authority-data")
err := appendEncodedCertificate(pool, data)
if err != nil {
return nil, errors.Wrapf(err, "could not load the certificate of idp-certificate-authority-data")
}
}
if config.CertificateAuthorityFilename != "" {
h.Logger.Debugf(1, "Loading the certificate %s", config.CertificateAuthorityFilename)
err := appendCertificateFromFile(pool, config.CertificateAuthorityFilename)
if err != nil {
return nil, errors.Wrapf(err, "could not load the certificate")
}
}
var tlsConfig tls.Config
if len(pool.Subjects()) > 0 {
tlsConfig.RootCAs = pool
}
tlsConfig.InsecureSkipVerify = config.SkipTLSVerify
return &http.Client{
Transport: &infrastructure.LoggingTransport{
Base: &http.Transport{
TLSClientConfig: &tlsConfig,
Proxy: http.ProxyFromEnvironment,
},
Logger: h.Logger,
},
}, nil
}
func appendCertificateFromFile(pool *x509.CertPool, filename string) error {
b, err := ioutil.ReadFile(filename)
if err != nil {
return errors.Wrapf(err, "could not read %s", filename)
}
if !pool.AppendCertsFromPEM(b) {
return errors.Errorf("could not append certificate from %s", filename)
}
return nil
}
func appendEncodedCertificate(pool *x509.CertPool, base64String string) error {
b, err := base64.StdEncoding.DecodeString(base64String)
if err != nil {
return errors.Wrapf(err, "could not decode base64")
}
if !pool.AppendCertsFromPEM(b) {
return errors.Errorf("could not append certificate")
}
return nil
}

View File

@@ -0,0 +1,74 @@
package adaptors
import (
"context"
"net/http"
"github.com/coreos/go-oidc"
"github.com/int128/kubelogin/kubeconfig"
)
//go:generate mockgen -package mock_adaptors -destination ../mock_adaptors/mock_adaptors.go github.com/int128/kubelogin/adaptors/interfaces KubeConfig,HTTP,OIDC,Logger
type Cmd interface {
Run(ctx context.Context, args []string, version string) int
}
type KubeConfig interface {
LoadByDefaultRules(filename string) (*kubeconfig.Config, error)
LoadFromFile(filename string) (*kubeconfig.Config, error)
WriteToFile(config *kubeconfig.Config, filename string) error
}
type HTTP interface {
NewClient(config HTTPClientConfig) (*http.Client, error)
}
type HTTPClientConfig struct {
OIDCConfig kubeconfig.OIDCConfig
CertificateAuthorityFilename string
SkipTLSVerify bool
}
type OIDC interface {
Authenticate(ctx context.Context, in OIDCAuthenticateIn, cb OIDCAuthenticateCallback) (*OIDCAuthenticateOut, error)
Verify(ctx context.Context, in OIDCVerifyIn) (*oidc.IDToken, error)
}
type OIDCAuthenticateIn struct {
Config kubeconfig.OIDCConfig
Client *http.Client // HTTP client for oidc and oauth2
LocalServerPort []int // HTTP server port candidates
SkipOpenBrowser bool // skip opening browser if true
}
type OIDCAuthenticateCallback struct {
ShowLocalServerURL func(url string)
}
type OIDCAuthenticateOut struct {
VerifiedIDToken *oidc.IDToken
IDToken string
RefreshToken string
}
type OIDCVerifyIn struct {
Config kubeconfig.OIDCConfig
Client *http.Client
}
type Logger interface {
Printf(format string, v ...interface{})
Debugf(level LogLevel, format string, v ...interface{})
SetLevel(level LogLevel)
IsEnabled(level LogLevel) bool
}
// LogLevel represents a log level for debug.
//
// 0 = None
// 1 = Including in/out
// 2 = Including transport headers
// 3 = Including transport body
//
type LogLevel int

44
adaptors/kubeconfig.go Normal file
View File

@@ -0,0 +1,44 @@
package adaptors
import (
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/kubeconfig"
"github.com/pkg/errors"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
func NewKubeConfig() adaptors.KubeConfig {
return &KubeConfig{}
}
type KubeConfig struct{}
// LoadByDefaultRules loads the config by the default rules, that is same as kubectl.
func (*KubeConfig) LoadByDefaultRules(filename string) (*kubeconfig.Config, error) {
rules := clientcmd.NewDefaultClientConfigLoadingRules()
rules.ExplicitPath = filename
config, err := rules.Load()
if err != nil {
return nil, errors.Wrapf(err, "could not read the kubeconfig")
}
return (*kubeconfig.Config)(config), err
}
// LoadFromFile loads the config from the single file.
func (*KubeConfig) LoadFromFile(filename string) (*kubeconfig.Config, error) {
config, err := clientcmd.LoadFromFile(filename)
if err != nil {
return nil, errors.Wrapf(err, "could not read the kubeconfig from %s", filename)
}
return (*kubeconfig.Config)(config), err
}
// WriteToFile writes the config to the single file.
func (*KubeConfig) WriteToFile(config *kubeconfig.Config, filename string) error {
err := clientcmd.WriteToFile(*(*api.Config)(config), filename)
if err != nil {
return errors.Wrapf(err, "could not write the kubeconfig to %s", filename)
}
return err
}

View File

@@ -0,0 +1,74 @@
package adaptors
import (
"os"
"testing"
)
func TestKubeConfig_LoadByDefaultRules(t *testing.T) {
var adaptor KubeConfig
t.Run("google.yaml>keycloak.yaml", func(t *testing.T) {
setenv(t, "KUBECONFIG", "testdata/kubeconfig.google.yaml"+string(os.PathListSeparator)+"testdata/kubeconfig.keycloak.yaml")
defer unsetenv(t, "KUBECONFIG")
config, err := adaptor.LoadByDefaultRules("")
if err != nil {
t.Fatalf("Could not load the configs: %s", err)
}
if w := "google@hello.k8s.local"; w != config.CurrentContext {
t.Errorf("CurrentContext wants %s but %s", w, config.CurrentContext)
}
if _, ok := config.Contexts["google@hello.k8s.local"]; !ok {
t.Errorf("Contexts[google@hello.k8s.local] is missing")
}
if _, ok := config.Contexts["keycloak@hello.k8s.local"]; !ok {
t.Errorf("Contexts[keycloak@hello.k8s.local] is missing")
}
if _, ok := config.AuthInfos["google"]; !ok {
t.Errorf("AuthInfos[google] is missing")
}
if _, ok := config.AuthInfos["keycloak"]; !ok {
t.Errorf("AuthInfos[keycloak] is missing")
}
})
t.Run("keycloak.yaml>google.yaml", func(t *testing.T) {
setenv(t, "KUBECONFIG", "testdata/kubeconfig.keycloak.yaml"+string(os.PathListSeparator)+"testdata/kubeconfig.google.yaml")
defer unsetenv(t, "KUBECONFIG")
config, err := adaptor.LoadByDefaultRules("")
if err != nil {
t.Fatalf("Could not load the configs: %s", err)
}
if w := "keycloak@hello.k8s.local"; w != config.CurrentContext {
t.Errorf("CurrentContext wants %s but %s", w, config.CurrentContext)
}
if _, ok := config.Contexts["google@hello.k8s.local"]; !ok {
t.Errorf("Contexts[google@hello.k8s.local] is missing")
}
if _, ok := config.Contexts["keycloak@hello.k8s.local"]; !ok {
t.Errorf("Contexts[keycloak@hello.k8s.local] is missing")
}
if _, ok := config.AuthInfos["google"]; !ok {
t.Errorf("AuthInfos[google] is missing")
}
if _, ok := config.AuthInfos["keycloak"]; !ok {
t.Errorf("AuthInfos[keycloak] is missing")
}
})
}
func setenv(t *testing.T, key, value string) {
t.Helper()
if err := os.Setenv(key, value); err != nil {
t.Fatalf("Could not set the env var %s=%s: %s", key, value, err)
}
}
func unsetenv(t *testing.T, key string) {
t.Helper()
if err := os.Unsetenv(key); err != nil {
t.Fatalf("Could not unset the env var %s: %s", key, err)
}
}

49
adaptors/logger.go Normal file
View File

@@ -0,0 +1,49 @@
package adaptors
import (
"log"
"os"
"github.com/int128/kubelogin/adaptors/interfaces"
)
// NewLogger returns a Logger with the standard log.Logger for messages and debug.
func NewLogger() adaptors.Logger {
return &Logger{
stdLogger: log.New(os.Stderr, "", 0),
debugLogger: log.New(os.Stderr, "", log.Ltime|log.Lmicroseconds),
}
}
// NewLoggerWith returns a Logger with the given standard log.Logger.
func NewLoggerWith(l stdLogger) *Logger {
return &Logger{
stdLogger: l,
debugLogger: l,
}
}
type stdLogger interface {
Printf(format string, v ...interface{})
}
// Logger wraps the standard log.Logger and just provides debug level.
type Logger struct {
stdLogger
debugLogger stdLogger
level adaptors.LogLevel
}
func (l *Logger) Debugf(level adaptors.LogLevel, format string, v ...interface{}) {
if l.IsEnabled(level) {
l.debugLogger.Printf(format, v...)
}
}
func (l *Logger) SetLevel(level adaptors.LogLevel) {
l.level = level
}
func (l *Logger) IsEnabled(level adaptors.LogLevel) bool {
return level <= l.level
}

53
adaptors/logger_test.go Normal file
View File

@@ -0,0 +1,53 @@
package adaptors
import (
"fmt"
"testing"
"github.com/int128/kubelogin/adaptors/interfaces"
)
type mockStdLogger struct {
count int
}
func (l *mockStdLogger) Printf(format string, v ...interface{}) {
l.count++
}
func TestLogger_Debugf(t *testing.T) {
for _, c := range []struct {
loggerLevel adaptors.LogLevel
debugfLevel adaptors.LogLevel
count int
}{
{0, 0, 1},
{0, 1, 0},
{1, 0, 1},
{1, 1, 1},
{1, 2, 0},
{2, 1, 1},
{2, 2, 1},
{2, 3, 0},
} {
t.Run(fmt.Sprintf("%+v", c), func(t *testing.T) {
m := &mockStdLogger{}
l := &Logger{debugLogger: m, level: c.loggerLevel}
l.Debugf(c.debugfLevel, "hello")
if m.count != c.count {
t.Errorf("count wants %d but %d", c.count, m.count)
}
})
}
}
func TestLogger_Printf(t *testing.T) {
m := &mockStdLogger{}
l := &Logger{stdLogger: m}
l.Printf("hello")
if m.count != 1 {
t.Errorf("count wants %d but %d", 1, m.count)
}
}

View File

@@ -0,0 +1,31 @@
package mock_adaptors
import (
"github.com/golang/mock/gomock"
"github.com/int128/kubelogin/adaptors/interfaces"
)
func NewLogger(t testingLogger, ctrl *gomock.Controller) *Logger {
return &Logger{
MockLogger: NewMockLogger(ctrl),
testingLogger: t,
}
}
type testingLogger interface {
Logf(format string, v ...interface{})
}
// Logger provides mock feature but overrides output methods with actual logging.
type Logger struct {
*MockLogger
testingLogger testingLogger
}
func (l *Logger) Printf(format string, v ...interface{}) {
l.testingLogger.Logf(format, v...)
}
func (l *Logger) Debugf(level adaptors.LogLevel, format string, v ...interface{}) {
l.testingLogger.Logf(format, v...)
}

View File

@@ -0,0 +1,236 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/int128/kubelogin/adaptors/interfaces (interfaces: KubeConfig,HTTP,OIDC,Logger)
// Package mock_adaptors is a generated GoMock package.
package mock_adaptors
import (
context "context"
go_oidc "github.com/coreos/go-oidc"
gomock "github.com/golang/mock/gomock"
interfaces "github.com/int128/kubelogin/adaptors/interfaces"
kubeconfig "github.com/int128/kubelogin/kubeconfig"
http "net/http"
reflect "reflect"
)
// MockKubeConfig is a mock of KubeConfig interface
type MockKubeConfig struct {
ctrl *gomock.Controller
recorder *MockKubeConfigMockRecorder
}
// MockKubeConfigMockRecorder is the mock recorder for MockKubeConfig
type MockKubeConfigMockRecorder struct {
mock *MockKubeConfig
}
// NewMockKubeConfig creates a new mock instance
func NewMockKubeConfig(ctrl *gomock.Controller) *MockKubeConfig {
mock := &MockKubeConfig{ctrl: ctrl}
mock.recorder = &MockKubeConfigMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockKubeConfig) EXPECT() *MockKubeConfigMockRecorder {
return m.recorder
}
// LoadByDefaultRules mocks base method
func (m *MockKubeConfig) LoadByDefaultRules(arg0 string) (*kubeconfig.Config, error) {
ret := m.ctrl.Call(m, "LoadByDefaultRules", arg0)
ret0, _ := ret[0].(*kubeconfig.Config)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LoadByDefaultRules indicates an expected call of LoadByDefaultRules
func (mr *MockKubeConfigMockRecorder) LoadByDefaultRules(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadByDefaultRules", reflect.TypeOf((*MockKubeConfig)(nil).LoadByDefaultRules), arg0)
}
// LoadFromFile mocks base method
func (m *MockKubeConfig) LoadFromFile(arg0 string) (*kubeconfig.Config, error) {
ret := m.ctrl.Call(m, "LoadFromFile", arg0)
ret0, _ := ret[0].(*kubeconfig.Config)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LoadFromFile indicates an expected call of LoadFromFile
func (mr *MockKubeConfigMockRecorder) LoadFromFile(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadFromFile", reflect.TypeOf((*MockKubeConfig)(nil).LoadFromFile), arg0)
}
// WriteToFile mocks base method
func (m *MockKubeConfig) WriteToFile(arg0 *kubeconfig.Config, arg1 string) error {
ret := m.ctrl.Call(m, "WriteToFile", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// WriteToFile indicates an expected call of WriteToFile
func (mr *MockKubeConfigMockRecorder) WriteToFile(arg0, arg1 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteToFile", reflect.TypeOf((*MockKubeConfig)(nil).WriteToFile), arg0, arg1)
}
// MockHTTP is a mock of HTTP interface
type MockHTTP struct {
ctrl *gomock.Controller
recorder *MockHTTPMockRecorder
}
// MockHTTPMockRecorder is the mock recorder for MockHTTP
type MockHTTPMockRecorder struct {
mock *MockHTTP
}
// NewMockHTTP creates a new mock instance
func NewMockHTTP(ctrl *gomock.Controller) *MockHTTP {
mock := &MockHTTP{ctrl: ctrl}
mock.recorder = &MockHTTPMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockHTTP) EXPECT() *MockHTTPMockRecorder {
return m.recorder
}
// NewClient mocks base method
func (m *MockHTTP) NewClient(arg0 interfaces.HTTPClientConfig) (*http.Client, error) {
ret := m.ctrl.Call(m, "NewClient", arg0)
ret0, _ := ret[0].(*http.Client)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// NewClient indicates an expected call of NewClient
func (mr *MockHTTPMockRecorder) NewClient(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewClient", reflect.TypeOf((*MockHTTP)(nil).NewClient), arg0)
}
// MockOIDC is a mock of OIDC interface
type MockOIDC struct {
ctrl *gomock.Controller
recorder *MockOIDCMockRecorder
}
// MockOIDCMockRecorder is the mock recorder for MockOIDC
type MockOIDCMockRecorder struct {
mock *MockOIDC
}
// NewMockOIDC creates a new mock instance
func NewMockOIDC(ctrl *gomock.Controller) *MockOIDC {
mock := &MockOIDC{ctrl: ctrl}
mock.recorder = &MockOIDCMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockOIDC) EXPECT() *MockOIDCMockRecorder {
return m.recorder
}
// Authenticate mocks base method
func (m *MockOIDC) Authenticate(arg0 context.Context, arg1 interfaces.OIDCAuthenticateIn, arg2 interfaces.OIDCAuthenticateCallback) (*interfaces.OIDCAuthenticateOut, error) {
ret := m.ctrl.Call(m, "Authenticate", arg0, arg1, arg2)
ret0, _ := ret[0].(*interfaces.OIDCAuthenticateOut)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Authenticate indicates an expected call of Authenticate
func (mr *MockOIDCMockRecorder) Authenticate(arg0, arg1, arg2 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authenticate", reflect.TypeOf((*MockOIDC)(nil).Authenticate), arg0, arg1, arg2)
}
// Verify mocks base method
func (m *MockOIDC) Verify(arg0 context.Context, arg1 interfaces.OIDCVerifyIn) (*go_oidc.IDToken, error) {
ret := m.ctrl.Call(m, "Verify", arg0, arg1)
ret0, _ := ret[0].(*go_oidc.IDToken)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Verify indicates an expected call of Verify
func (mr *MockOIDCMockRecorder) Verify(arg0, arg1 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockOIDC)(nil).Verify), arg0, arg1)
}
// MockLogger is a mock of Logger interface
type MockLogger struct {
ctrl *gomock.Controller
recorder *MockLoggerMockRecorder
}
// MockLoggerMockRecorder is the mock recorder for MockLogger
type MockLoggerMockRecorder struct {
mock *MockLogger
}
// NewMockLogger creates a new mock instance
func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
mock := &MockLogger{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
return m.recorder
}
// Debugf mocks base method
func (m *MockLogger) Debugf(arg0 interfaces.LogLevel, arg1 string, arg2 ...interface{}) {
varargs := []interface{}{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Debugf", varargs...)
}
// Debugf indicates an expected call of Debugf
func (mr *MockLoggerMockRecorder) Debugf(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...)
}
// IsEnabled mocks base method
func (m *MockLogger) IsEnabled(arg0 interfaces.LogLevel) bool {
ret := m.ctrl.Call(m, "IsEnabled", arg0)
ret0, _ := ret[0].(bool)
return ret0
}
// IsEnabled indicates an expected call of IsEnabled
func (mr *MockLoggerMockRecorder) IsEnabled(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsEnabled", reflect.TypeOf((*MockLogger)(nil).IsEnabled), arg0)
}
// Printf mocks base method
func (m *MockLogger) Printf(arg0 string, arg1 ...interface{}) {
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Printf", varargs...)
}
// Printf indicates an expected call of Printf
func (mr *MockLoggerMockRecorder) Printf(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Printf", reflect.TypeOf((*MockLogger)(nil).Printf), varargs...)
}
// SetLevel mocks base method
func (m *MockLogger) SetLevel(arg0 interfaces.LogLevel) {
m.ctrl.Call(m, "SetLevel", arg0)
}
// SetLevel indicates an expected call of SetLevel
func (mr *MockLoggerMockRecorder) SetLevel(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLevel", reflect.TypeOf((*MockLogger)(nil).SetLevel), arg0)
}

73
adaptors/oidc.go Normal file
View File

@@ -0,0 +1,73 @@
package adaptors
import (
"context"
"github.com/coreos/go-oidc"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/oauth2cli"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)
func NewOIDC() adaptors.OIDC {
return &OIDC{}
}
type OIDC struct{}
func (*OIDC) Authenticate(ctx context.Context, in adaptors.OIDCAuthenticateIn, cb adaptors.OIDCAuthenticateCallback) (*adaptors.OIDCAuthenticateOut, error) {
if in.Client != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, in.Client)
}
provider, err := oidc.NewProvider(ctx, in.Config.IDPIssuerURL())
if err != nil {
return nil, errors.Wrapf(err, "could not discovery the OIDC issuer")
}
config := oauth2cli.Config{
OAuth2Config: oauth2.Config{
Endpoint: provider.Endpoint(),
ClientID: in.Config.ClientID(),
ClientSecret: in.Config.ClientSecret(),
Scopes: append(in.Config.ExtraScopes(), oidc.ScopeOpenID),
},
LocalServerPort: in.LocalServerPort,
SkipOpenBrowser: in.SkipOpenBrowser,
AuthCodeOptions: []oauth2.AuthCodeOption{oauth2.AccessTypeOffline},
ShowLocalServerURL: cb.ShowLocalServerURL,
}
token, err := oauth2cli.GetToken(ctx, config)
if err != nil {
return nil, errors.Wrapf(err, "could not get a token")
}
idToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.Errorf("id_token is missing in the token response: %s", token)
}
verifier := provider.Verifier(&oidc.Config{ClientID: in.Config.ClientID()})
verifiedIDToken, err := verifier.Verify(ctx, idToken)
if err != nil {
return nil, errors.Wrapf(err, "could not verify the id_token")
}
return &adaptors.OIDCAuthenticateOut{
VerifiedIDToken: verifiedIDToken,
IDToken: idToken,
RefreshToken: token.RefreshToken,
}, nil
}
func (*OIDC) Verify(ctx context.Context, in adaptors.OIDCVerifyIn) (*oidc.IDToken, error) {
if in.Client != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, in.Client)
}
provider, err := oidc.NewProvider(ctx, in.Config.IDPIssuerURL())
if err != nil {
return nil, errors.Wrapf(err, "could not discovery the OIDC issuer")
}
verifier := provider.Verifier(&oidc.Config{ClientID: in.Config.ClientID()})
verifiedIDToken, err := verifier.Verify(ctx, in.Config.IDToken())
if err != nil {
return nil, errors.Wrapf(err, "could not verify the id_token")
}
return verifiedIDToken, nil
}

View File

@@ -0,0 +1,17 @@
apiVersion: v1
clusters: []
contexts:
- context:
cluster: hello.k8s.local
user: google
name: google@hello.k8s.local
current-context: google@hello.k8s.local
kind: Config
preferences: {}
users:
- name: google
user:
auth-provider:
config:
client-id: CLIENT_ID.apps.googleusercontent.com
name: oidc

View File

@@ -0,0 +1,16 @@
apiVersion: v1
contexts:
- context:
cluster: hello.k8s.local
user: keycloak
name: keycloak@hello.k8s.local
current-context: keycloak@hello.k8s.local
kind: Config
preferences: {}
users:
- name: keycloak
user:
auth-provider:
config:
client-id: kubernetes
name: oidc

View File

@@ -0,0 +1,39 @@
package authserver
import (
"crypto/rsa"
"net/http"
"testing"
)
// Config represents server configuration.
type Config struct {
Addr string
Issuer string
Scope string
TLSServerCert string
TLSServerKey string
IDToken string
RefreshToken string
IDTokenKeyPair *rsa.PrivateKey
}
// Start starts a HTTP server.
func Start(t *testing.T, c Config) *http.Server {
s := &http.Server{
Addr: c.Addr,
Handler: newHandler(t, c),
}
go func() {
var err error
if c.TLSServerCert != "" && c.TLSServerKey != "" {
err = s.ListenAndServeTLS(c.TLSServerCert, c.TLSServerKey)
} else {
err = s.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
t.Error(err)
}
}()
return s
}

View File

@@ -1,75 +1,65 @@
package authserver
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"log"
"math/big"
"net/http"
"testing"
"text/template"
"time"
jwt "github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
)
type handler struct {
t *testing.T
discovery *template.Template
token *template.Template
jwks *template.Template
authCode string
Issuer string
Scope string // Default to openid
IDToken string
PrivateKey struct{ N, E string }
// Template values
Issuer string
Scope string // Default to openid
IDToken string
RefreshToken string
PrivateKey struct{ N, E string }
}
func newHandler(t *testing.T, c *Config) *handler {
func newHandler(t *testing.T, c Config) *handler {
tpl, err := template.ParseFiles(
"authserver/testdata/oidc-discovery.json",
"authserver/testdata/oidc-token.json",
"authserver/testdata/oidc-jwks.json",
)
if err != nil {
t.Fatalf("could not read the templates: %s", err)
}
h := handler{
discovery: readTemplate(t, "oidc-discovery.json"),
token: readTemplate(t, "oidc-token.json"),
jwks: readTemplate(t, "oidc-jwks.json"),
authCode: "3d24a8bd-35e6-457d-999e-e04bb1dfcec7",
Issuer: c.Issuer,
Scope: c.Scope,
t: t,
discovery: tpl.Lookup("oidc-discovery.json"),
token: tpl.Lookup("oidc-token.json"),
jwks: tpl.Lookup("oidc-jwks.json"),
authCode: "3d24a8bd-35e6-457d-999e-e04bb1dfcec7",
Issuer: c.Issuer,
Scope: c.Scope,
IDToken: c.IDToken,
RefreshToken: c.RefreshToken,
}
if h.Scope == "" {
h.Scope = "openid"
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.StandardClaims{
Issuer: c.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)
if c.IDTokenKeyPair != nil {
h.PrivateKey.E = base64.RawURLEncoding.EncodeToString(big.NewInt(int64(c.IDTokenKeyPair.E)).Bytes())
h.PrivateKey.N = base64.RawURLEncoding.EncodeToString(c.IDTokenKeyPair.N.Bytes())
}
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 readTemplate(t *testing.T, name string) *template.Template {
t.Helper()
tpl, err := template.ParseFiles("authserver/testdata/" + name)
if err != nil {
t.Fatalf("Could not read template %s: %s", name, err)
}
return tpl
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h.serveHTTP(w, r); err != nil {
log.Printf("[auth-server] Error: %s", err)
h.t.Logf("[auth-server] Error: %s", err)
w.WriteHeader(500)
}
}
@@ -77,19 +67,19 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (h *handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
m := r.Method
p := r.URL.Path
log.Printf("[auth-server] %s %s", m, r.RequestURI)
h.t.Logf("[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 := h.discovery.Execute(w, h); err != nil {
return err
return errors.Wrapf(err, "could not execute the template")
}
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()
if h.Scope != q.Get("scope") {
return fmt.Errorf("scope wants %s but %s", h.Scope, q.Get("scope"))
return errors.Errorf("scope wants %s but %s", h.Scope, q.Get("scope"))
}
to := fmt.Sprintf("%s?state=%s&code=%s", q.Get("redirect_uri"), q.Get("state"), h.authCode)
http.Redirect(w, r, to, 302)
@@ -97,19 +87,19 @@ func (h *handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
// Token Response
// http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
if err := r.ParseForm(); err != nil {
return err
return errors.Wrapf(err, "could not parse the form")
}
if h.authCode != r.Form.Get("code") {
return fmt.Errorf("code wants %s but %s", h.authCode, r.Form.Get("code"))
return errors.Errorf("code wants %s but %s", h.authCode, r.Form.Get("code"))
}
w.Header().Add("Content-Type", "application/json")
if err := h.token.Execute(w, h); err != nil {
return err
return errors.Wrapf(err, "could not execute the template")
}
case m == "GET" && p == "/protocol/openid-connect/certs":
w.Header().Add("Content-Type", "application/json")
if err := h.jwks.Execute(w, h); err != nil {
return err
return errors.Wrapf(err, "could not execute the template")
}
default:
http.Error(w, "Not Found", 404)

View File

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

306
adaptors_test/cmd_test.go Normal file
View File

@@ -0,0 +1,306 @@
package adaptors_test
import (
"context"
"crypto/tls"
"net/http"
"os"
"sync"
"testing"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/adaptors_test/authserver"
"github.com/int128/kubelogin/adaptors_test/keys"
"github.com/int128/kubelogin/adaptors_test/kubeconfig"
"github.com/int128/kubelogin/adaptors_test/logger"
"github.com/int128/kubelogin/di"
)
// Run the integration tests.
// This assumes that port 800x and 900x are available.
//
// 1. Start the auth server at port 900x.
// 2. Run the Cmd.
// 3. Open a request for port 800x.
// 4. Wait for the Cmd.
// 5. Shutdown the auth server.
//
func TestCmd_Run(t *testing.T) {
timeout := 1 * time.Second
t.Run("NoTLS", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "http://localhost:9001")
serverConfig := authserver.Config{
Addr: "localhost:9001",
Issuer: "http://localhost:9001",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
}
server := authserver.Start(t, serverConfig)
defer shutdown(t, ctx, server)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: serverConfig.Issuer,
})
defer os.Remove(kubeConfigFilename)
var wg sync.WaitGroup
startBrowserRequest(t, ctx, &wg, "http://localhost:8001", nil)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser", "--listen-port", "8001")
wg.Wait()
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("env:KUBECONFIG", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "http://localhost:9002")
serverConfig := authserver.Config{
Addr: "localhost:9002",
Issuer: "http://localhost:9002",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
}
server := authserver.Start(t, serverConfig)
defer shutdown(t, ctx, server)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: serverConfig.Issuer,
})
defer os.Remove(kubeConfigFilename)
setenv(t, "KUBECONFIG", kubeConfigFilename+string(os.PathListSeparator)+"kubeconfig/testdata/dummy.yaml")
defer unsetenv(t, "KUBECONFIG")
var wg sync.WaitGroup
startBrowserRequest(t, ctx, &wg, "http://localhost:8002", nil)
runCmd(t, ctx, "--skip-open-browser", "--listen-port", "8002")
wg.Wait()
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("ExtraScopes", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "http://localhost:9003")
serverConfig := authserver.Config{
Addr: "localhost:9003",
Issuer: "http://localhost:9003",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
Scope: "profile groups openid",
}
server := authserver.Start(t, serverConfig)
defer shutdown(t, ctx, server)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: serverConfig.Issuer,
ExtraScopes: "profile,groups",
})
defer os.Remove(kubeConfigFilename)
var wg sync.WaitGroup
startBrowserRequest(t, ctx, &wg, "http://localhost:8003", nil)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser", "--listen-port", "8003")
wg.Wait()
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("CACert", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "https://localhost:9004")
serverConfig := authserver.Config{
Addr: "localhost:9004",
Issuer: "https://localhost:9004",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
TLSServerCert: keys.TLSServerCert,
TLSServerKey: keys.TLSServerKey,
}
server := authserver.Start(t, serverConfig)
defer shutdown(t, ctx, server)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: serverConfig.Issuer,
IDPCertificateAuthority: keys.TLSCACert,
})
defer os.Remove(kubeConfigFilename)
var wg sync.WaitGroup
startBrowserRequest(t, ctx, &wg, "http://localhost:8004", keys.TLSCACertAsConfig)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser", "--listen-port", "8004")
wg.Wait()
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("CACertData", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "https://localhost:9005")
serverConfig := authserver.Config{
Addr: "localhost:9005",
Issuer: "https://localhost:9005",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
TLSServerCert: keys.TLSServerCert,
TLSServerKey: keys.TLSServerKey,
}
server := authserver.Start(t, serverConfig)
defer shutdown(t, ctx, server)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: serverConfig.Issuer,
IDPCertificateAuthorityData: keys.TLSCACertAsBase64,
})
defer os.Remove(kubeConfigFilename)
var wg sync.WaitGroup
startBrowserRequest(t, ctx, &wg, "http://localhost:8005", keys.TLSCACertAsConfig)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser", "--listen-port", "8005")
wg.Wait()
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("AlreadyHaveValidToken", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
serverConfig := authserver.Config{
Addr: "localhost:9006",
Issuer: "http://localhost:9006",
IDTokenKeyPair: keys.JWSKeyPair,
}
server := authserver.Start(t, serverConfig)
defer shutdown(t, ctx, server)
idToken := newIDToken(t, serverConfig.Issuer)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: serverConfig.Issuer,
IDToken: idToken,
})
defer os.Remove(kubeConfigFilename)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser")
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
})
})
}
func newIDToken(t *testing.T, issuer string) string {
t.Helper()
var claims struct {
jwt.StandardClaims
Groups []string `json:"groups"`
}
claims.StandardClaims = jwt.StandardClaims{
Issuer: issuer,
Audience: "kubernetes",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
Subject: "SUBJECT",
IssuedAt: time.Now().Unix(),
}
claims.Groups = []string{"admin", "users"}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
s, err := token.SignedString(keys.JWSKeyPair)
if err != nil {
t.Fatalf("Could not sign the claims: %s", err)
}
return s
}
func runCmd(t *testing.T, ctx context.Context, args ...string) {
t.Helper()
newLogger := func() adaptors.Logger {
return logger.New(t)
}
if err := di.InvokeWithExtra(func(cmd adaptors.Cmd) {
exitCode := cmd.Run(ctx, append([]string{"kubelogin", "--v=1"}, args...), "HEAD")
if exitCode != 0 {
t.Errorf("exit status wants 0 but %d", exitCode)
}
}, newLogger); err != nil {
t.Errorf("Invoke returned error: %+v", err)
}
}
func startBrowserRequest(t *testing.T, ctx context.Context, wg *sync.WaitGroup, url string, tlsConfig *tls.Config) {
t.Helper()
client := http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Errorf("could not create a request: %s", err)
return
}
req = req.WithContext(ctx)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond)
resp, err := client.Do(req)
if err != nil {
t.Errorf("could not send a request: %s", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("StatusCode wants 200 but %d", resp.StatusCode)
}
}()
wg.Add(1)
}
func shutdown(t *testing.T, ctx context.Context, s *http.Server) {
if err := s.Shutdown(ctx); err != nil {
t.Errorf("Could not shutdown the auth server: %s", err)
}
}
func setenv(t *testing.T, key, value string) {
t.Helper()
if err := os.Setenv(key, value); err != nil {
t.Fatalf("Could not set the env var %s=%s: %s", key, value, err)
}
}
func unsetenv(t *testing.T, key string) {
t.Helper()
if err := os.Unsetenv(key); err != nil {
t.Fatalf("Could not unset the env var %s: %s", key, err)
}
}

View File

@@ -0,0 +1,71 @@
package keys
import (
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"io/ioutil"
"github.com/pkg/errors"
)
// TLSCACert is path to the CA certificate.
// This should be generated by Makefile before test.
const TLSCACert = "keys/testdata/ca.crt"
// TLSCACertAsBase64 is a base64 encoded string of TLSCACert.
var TLSCACertAsBase64 string
// TLSCACertAsConfig is a TLS config including TLSCACert.
var TLSCACertAsConfig = &tls.Config{RootCAs: x509.NewCertPool()}
// TLSServerCert is path to the server certificate.
// This should be generated by Makefile before test.
const TLSServerCert = "keys/testdata/server.crt"
// TLSServerKey is path to the server key.
// This should be generated by Makefile before test.
const TLSServerKey = "keys/testdata/server.key"
// JWSKey is path to the key for signing ID tokens.
const JWSKey = "keys/testdata/jws.key"
// JWSKeyPair is the key pair loaded from JWSKey.
var JWSKeyPair *rsa.PrivateKey
func init() {
var err error
JWSKeyPair, err = readPrivateKey(JWSKey)
if err != nil {
panic(err)
}
b, err := ioutil.ReadFile(TLSCACert)
if err != nil {
panic(err)
}
TLSCACertAsBase64 = base64.StdEncoding.EncodeToString(b)
if !TLSCACertAsConfig.RootCAs.AppendCertsFromPEM(b) {
panic("could not append the CA cert")
}
}
func readPrivateKey(name string) (*rsa.PrivateKey, error) {
b, err := ioutil.ReadFile(name)
if err != nil {
return nil, errors.Wrapf(err, "could not read JWSKey")
}
block, rest := pem.Decode(b)
if block == nil {
return nil, errors.New("could not decode PEM")
}
if len(rest) > 0 {
return nil, errors.New("PEM should contain single key but multiple keys")
}
k, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, errors.Wrapf(err, "could not parse the key")
}
return k, nil
}

View File

@@ -1,6 +1,6 @@
.PHONY: clean
all: server.crt ca.crt
all: server.crt ca.crt jws.key
clean:
rm -v ca.* server.*
@@ -48,3 +48,6 @@ server.crt: openssl.cnf server.csr ca.key ca.crt
-in server.csr \
-out $@
openssl x509 -text -in $@
jws.key:
openssl genrsa -out $@ 1024

View File

@@ -0,0 +1,79 @@
package kubeconfig
import (
"html/template"
"io/ioutil"
"os"
"testing"
"gopkg.in/yaml.v2"
)
// Values represents values in .kubeconfig template.
type Values struct {
Issuer string
ExtraScopes string
IDPCertificateAuthority string
IDPCertificateAuthorityData string
IDToken string
}
// Create creates a kubeconfig file and returns path to it.
func Create(t *testing.T, v *Values) string {
t.Helper()
f, err := ioutil.TempFile("", "kubeconfig")
if err != nil {
t.Fatal(err)
}
defer f.Close()
tpl, err := template.ParseFiles("kubeconfig/testdata/kubeconfig.yaml")
if err != nil {
t.Fatal(err)
}
if err := tpl.Execute(f, v); err != nil {
t.Fatal(err)
}
return f.Name()
}
type AuthProviderConfig struct {
IDToken string `yaml:"id-token"`
RefreshToken string `yaml:"refresh-token"`
}
// Verify returns true if the kubeconfig has valid values.
func Verify(t *testing.T, kubeconfig string, want AuthProviderConfig) {
t.Helper()
f, err := os.Open(kubeconfig)
if err != nil {
t.Errorf("could not open kubeconfig: %s", err)
return
}
defer f.Close()
var y struct {
Users []struct {
User struct {
AuthProvider struct {
Config AuthProviderConfig `yaml:"config"`
} `yaml:"auth-provider"`
} `yaml:"user"`
} `yaml:"users"`
}
d := yaml.NewDecoder(f)
if err := d.Decode(&y); err != nil {
t.Errorf("could not decode YAML: %s", err)
return
}
if len(y.Users) != 1 {
t.Errorf("len(users) wants 1 but %d", len(y.Users))
return
}
currentConfig := y.Users[0].User.AuthProvider.Config
if currentConfig.IDToken != want.IDToken {
t.Errorf("id-token wants %s but %s", want.IDToken, currentConfig.IDToken)
}
if currentConfig.RefreshToken != want.RefreshToken {
t.Errorf("refresh-token wants %s but %s", want.RefreshToken, currentConfig.RefreshToken)
}
}

View File

@@ -0,0 +1,4 @@
apiVersion: v1
current-context: dummy
kind: Config
preferences: {}

View File

@@ -27,5 +27,8 @@ users:
#{{ end }}
#{{ if .IDPCertificateAuthorityData }}
idp-certificate-authority-data: {{ .IDPCertificateAuthorityData }}
#{{ end }}
#{{ if .IDToken }}
id-token: {{ .IDToken }}
#{{ end }}
name: oidc

View File

@@ -0,0 +1,21 @@
package logger
import (
"github.com/int128/kubelogin/adaptors"
)
func New(t testingLogger) *adaptors.Logger {
return adaptors.NewLoggerWith(&bridge{t})
}
type testingLogger interface {
Logf(format string, v ...interface{})
}
type bridge struct {
t testingLogger
}
func (b *bridge) Printf(format string, v ...interface{}) {
b.t.Logf(format, v...)
}

View File

@@ -1,87 +0,0 @@
package auth
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"os"
oidc "github.com/coreos/go-oidc"
"github.com/int128/oauth2cli"
"golang.org/x/oauth2"
)
// TokenSet is a set of tokens and claims.
type TokenSet struct {
IDToken string
RefreshToken string
}
// Config represents OIDC configuration.
type Config struct {
Issuer string
ClientID string
ClientSecret string
ExtraScopes []string // Additional scopes
Client *http.Client // HTTP client for oidc and oauth2
LocalServerPort int // HTTP server port
SkipOpenBrowser bool // skip opening browser if true
}
// GetTokenSet retrives a token from the OIDC provider and returns a TokenSet.
func (c *Config) GetTokenSet(ctx context.Context) (*TokenSet, error) {
if c.Client != nil {
// https://github.com/int128/kubelogin/issues/31
val, ok := os.LookupEnv("HTTPS_PROXY")
if ok {
proxyURL, err := url.Parse(val)
if err != nil {
log.Printf("HTTPS_PROXY %s cannot be parsed into a URL\n", val)
} else {
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
c.Client = &http.Client{
Transport: transport,
}
}
}
//
ctx = context.WithValue(ctx, oauth2.HTTPClient, c.Client)
}
provider, err := oidc.NewProvider(ctx, c.Issuer)
if err != nil {
return nil, fmt.Errorf("Could not discovery the OIDC issuer: %s", err)
}
flow := oauth2cli.AuthCodeFlow{
Config: oauth2.Config{
Endpoint: provider.Endpoint(),
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: append(c.ExtraScopes, oidc.ScopeOpenID),
},
LocalServerPort: c.LocalServerPort,
SkipOpenBrowser: c.SkipOpenBrowser,
AuthCodeOptions: []oauth2.AuthCodeOption{oauth2.AccessTypeOffline},
}
token, err := flow.GetToken(ctx)
if err != nil {
return nil, fmt.Errorf("Could not get a token: %s", err)
}
idToken, 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: c.ClientID})
verifiedIDToken, err := verifier.Verify(ctx, idToken)
if err != nil {
return nil, fmt.Errorf("Could not verify the id_token: %s", err)
}
log.Printf("Got token for subject=%s", verifiedIDToken.Subject)
return &TokenSet{
IDToken: idToken,
RefreshToken: token.RefreshToken,
}, nil
}

View File

@@ -1,92 +0,0 @@
package cli
import (
"context"
"fmt"
"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"
)
// Parse parses command line arguments and returns a CLI instance.
func Parse(osArgs []string, version string) (*CLI, error) {
var cli CLI
parser := flags.NewParser(&cli, flags.HelpFlag)
parser.LongDescription = fmt.Sprintf(`Version %s
This updates the kubeconfig for Kubernetes OpenID Connect (OIDC) authentication.`,
version)
args, err := parser.ParseArgs(osArgs[1:])
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"`
ListenPort int `long:"listen-port" default:"8000" env:"KUBELOGIN_LISTEN_PORT" description:"Port used by kubelogin to bind its webserver"`
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"`
SkipOpenBrowser bool `long:"skip-open-browser" env:"KUBELOGIN_SKIP_OPEN_BROWSER" description:"If set, it does not open the browser on authentication."`
}
// 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: %s", c.KubeConfig, err)
}
return d, nil
}
// Run performs this command.
func (c *CLI) Run(ctx context.Context) error {
log.Printf("Reading %s", c.KubeConfig)
path, err := c.ExpandKubeConfig()
if err != nil {
return err
}
cfg, err := kubeconfig.Read(path)
if err != nil {
return fmt.Errorf("Could not read kubeconfig: %s", err)
}
log.Printf("Using current-context: %s", cfg.CurrentContext)
authProvider, err := kubeconfig.FindOIDCAuthProvider(cfg)
if err != nil {
return fmt.Errorf(`Could not find OIDC configuration in kubeconfig: %s
Did you setup kubectl for OIDC authentication?
kubectl config set-credentials %s \
--auth-provider oidc \
--auth-provider-arg idp-issuer-url=https://issuer.example.com \
--auth-provider-arg client-id=YOUR_CLIENT_ID \
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET`,
err, cfg.CurrentContext)
}
tlsConfig := c.tlsConfig(authProvider)
authConfig := &auth.Config{
Issuer: authProvider.IDPIssuerURL(),
ClientID: authProvider.ClientID(),
ClientSecret: authProvider.ClientSecret(),
ExtraScopes: authProvider.ExtraScopes(),
Client: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}},
LocalServerPort: c.ListenPort,
SkipOpenBrowser: c.SkipOpenBrowser,
}
token, err := authConfig.GetTokenSet(ctx)
if err != nil {
return fmt.Errorf("Could not get token from OIDC provider: %s", err)
}
authProvider.SetIDToken(token.IDToken)
authProvider.SetRefreshToken(token.RefreshToken)
kubeconfig.Write(cfg, path)
log.Printf("Updated %s", c.KubeConfig)
return nil
}

View File

@@ -1,35 +0,0 @@
package cli
import (
"testing"
)
func TestParse(t *testing.T) {
c, err := Parse([]string{"kubelogin"}, "version")
if err != nil {
t.Errorf("Parse returned error: %s", err)
}
if c == nil {
t.Errorf("Parse should return CLI but nil")
}
}
func TestParse_TooManyArgs(t *testing.T) {
c, err := Parse([]string{"kubelogin", "some"}, "version")
if err == nil {
t.Errorf("Parse should return error but nil")
}
if c != nil {
t.Errorf("Parse should return nil but %+v", c)
}
}
func TestParse_Help(t *testing.T) {
c, err := Parse([]string{"kubelogin", "--help"}, "version")
if err == nil {
t.Errorf("Parse should return error but nil")
}
if c != nil {
t.Errorf("Parse should return nil but %+v", c)
}
}

View File

@@ -1,57 +0,0 @@
package cli
import (
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io/ioutil"
"log"
"github.com/int128/kubelogin/kubeconfig"
)
func (c *CLI) tlsConfig(authProvider *kubeconfig.OIDCAuthProvider) *tls.Config {
p := x509.NewCertPool()
if ca := authProvider.IDPCertificateAuthority(); ca != "" {
if err := appendCertFile(p, ca); err != nil {
log.Printf("Skip CA certificate of idp-certificate-authority: %s", err)
} else {
log.Printf("Using CA certificate: %s", ca)
}
}
if ca := authProvider.IDPCertificateAuthorityData(); ca != "" {
if err := appendCertData(p, ca); err != nil {
log.Printf("Skip CA certificate of idp-certificate-authority-data: %s", err)
} else {
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
}
func appendCertFile(p *x509.CertPool, name string) error {
b, err := ioutil.ReadFile(name)
if err != nil {
return fmt.Errorf("Could not read %s: %s", name, err)
}
if p.AppendCertsFromPEM(b) != true {
return fmt.Errorf("Could not append certificate from %s", name)
}
return nil
}
func appendCertData(p *x509.CertPool, data string) error {
b, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return fmt.Errorf("Could not decode base64: %s", err)
}
if p.AppendCertsFromPEM(b) != true {
return fmt.Errorf("Could not append certificate")
}
return nil
}

View File

@@ -1,49 +0,0 @@
package authserver
import (
"net/http"
"testing"
)
// Addr is address to listen.
const Addr = "localhost:9000"
// CACert is path to the CA certificate.
// This should be generated by Makefile before test.
const CACert = "authserver/testdata/ca.crt"
// ServerCert is path to the server certificate.
// This should be generated by Makefile before test.
const ServerCert = "authserver/testdata/server.crt"
// ServerKey is path to the server key.
// This should be generated by Makefile before test.
const ServerKey = "authserver/testdata/server.key"
// Config represents server configuration.
type Config struct {
Issuer string
Scope string
Cert string
Key string
}
// Start starts a HTTP server.
func (c *Config) Start(t *testing.T) *http.Server {
s := &http.Server{
Addr: Addr,
Handler: newHandler(t, c),
}
go func() {
var err error
if c.Cert != "" && c.Key != "" {
err = s.ListenAndServeTLS(c.Cert, c.Key)
} else {
err = s.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
t.Error(err)
}
}()
return s
}

View File

@@ -1,167 +0,0 @@
package cli_test
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io/ioutil"
"net/http"
"os"
"testing"
"time"
"github.com/int128/kubelogin/cli"
"github.com/int128/kubelogin/cli_test/authserver"
"github.com/int128/kubelogin/cli_test/kubeconfig"
"golang.org/x/sync/errgroup"
)
// End-to-end test.
//
// 1. Start the auth server at port 9000.
// 2. Run the CLI.
// 3. Open a request for port 8000.
// 4. Wait for the CLI.
// 5. Shutdown the auth server.
func TestE2E(t *testing.T) {
data := map[string]struct {
kubeconfigValues kubeconfig.Values
cli cli.CLI
serverConfig authserver.Config
clientTLS *tls.Config
}{
"NoTLS": {
kubeconfig.Values{Issuer: "http://localhost:9000"},
cli.CLI{},
authserver.Config{Issuer: "http://localhost:9000"},
&tls.Config{},
},
"ExtraScope": {
kubeconfig.Values{
Issuer: "http://localhost:9000",
ExtraScopes: "profile groups",
},
cli.CLI{},
authserver.Config{
Issuer: "http://localhost:9000",
Scope: "profile groups openid",
},
&tls.Config{},
},
"SkipTLSVerify": {
kubeconfig.Values{Issuer: "https://localhost:9000"},
cli.CLI{SkipTLSVerify: true},
authserver.Config{
Issuer: "https://localhost:9000",
Cert: authserver.ServerCert,
Key: authserver.ServerKey,
},
&tls.Config{InsecureSkipVerify: true},
},
"CACert": {
kubeconfig.Values{
Issuer: "https://localhost:9000",
IDPCertificateAuthority: authserver.CACert,
},
cli.CLI{},
authserver.Config{
Issuer: "https://localhost:9000",
Cert: authserver.ServerCert,
Key: authserver.ServerKey,
},
&tls.Config{RootCAs: readCert(t, authserver.CACert)},
},
"CACertData": {
kubeconfig.Values{
Issuer: "https://localhost:9000",
IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString(read(t, authserver.CACert)),
},
cli.CLI{},
authserver.Config{
Issuer: "https://localhost:9000",
Cert: authserver.ServerCert,
Key: authserver.ServerKey,
},
&tls.Config{RootCAs: readCert(t, authserver.CACert)},
},
"InvalidCACertShouldBeSkipped": {
kubeconfig.Values{
Issuer: "http://localhost:9000",
IDPCertificateAuthority: "e2e_test.go",
},
cli.CLI{},
authserver.Config{Issuer: "http://localhost:9000"},
&tls.Config{},
},
"InvalidCACertDataShouldBeSkipped": {
kubeconfig.Values{
Issuer: "http://localhost:9000",
IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte("foo")),
},
cli.CLI{},
authserver.Config{Issuer: "http://localhost:9000"},
&tls.Config{},
},
}
for name, c := range data {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server := c.serverConfig.Start(t)
defer server.Shutdown(ctx)
kcfg := kubeconfig.Create(t, &c.kubeconfigValues)
defer os.Remove(kcfg)
c.cli.KubeConfig = kcfg
c.cli.SkipOpenBrowser = true
c.cli.ListenPort = 8000
var eg errgroup.Group
eg.Go(func() error {
return c.cli.Run(ctx)
})
if err := openBrowserRequest(c.clientTLS); err != nil {
cancel()
t.Error(err)
}
if err := eg.Wait(); err != nil {
t.Fatalf("CLI returned error: %s", err)
}
kubeconfig.Verify(t, kcfg)
})
}
}
func openBrowserRequest(tlsConfig *tls.Config) error {
time.Sleep(50 * time.Millisecond)
client := http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
res, err := client.Get("http://localhost:8000/")
if err != nil {
return fmt.Errorf("Could not send a request: %s", err)
}
if res.StatusCode != 200 {
return fmt.Errorf("StatusCode wants 200 but %d", res.StatusCode)
}
return nil
}
func read(t *testing.T, name string) []byte {
t.Helper()
b, err := ioutil.ReadFile(name)
if err != nil {
t.Fatalf("Could not read %s: %s", name, err)
}
return b
}
func readCert(t *testing.T, name string) *x509.CertPool {
t.Helper()
p := x509.NewCertPool()
b := read(t, name)
if !p.AppendCertsFromPEM(b) {
t.Fatalf("Could not append cert from %s", name)
}
return p
}

View File

@@ -1,48 +0,0 @@
package kubeconfig
import (
"html/template"
"io/ioutil"
"strings"
"testing"
)
// Values represents values in .kubeconfig template.
type Values struct {
Issuer string
ExtraScopes string
IDPCertificateAuthority string
IDPCertificateAuthorityData string
}
// Create creates a kubeconfig file and returns path to it.
func Create(t *testing.T, v *Values) string {
t.Helper()
f, err := ioutil.TempFile("", "kubeconfig")
if err != nil {
t.Fatal(err)
}
defer f.Close()
tpl, err := template.ParseFiles("kubeconfig/testdata/kubeconfig.yaml")
if err != nil {
t.Fatal(err)
}
if err := tpl.Execute(f, v); err != nil {
t.Fatal(err)
}
return f.Name()
}
// Verify returns true if the kubeconfig has valid values.
func Verify(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))
}
}

42
di/di.go Normal file
View File

@@ -0,0 +1,42 @@
// Package di provides dependency injection.
package di
import (
"github.com/int128/kubelogin/adaptors"
adaptorsInterfaces "github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/usecases"
"github.com/pkg/errors"
"go.uber.org/dig"
)
var constructors = []interface{}{
usecases.NewLogin,
adaptors.NewCmd,
adaptors.NewKubeConfig,
adaptors.NewOIDC,
adaptors.NewHTTP,
}
var extraConstructors = []interface{}{
adaptors.NewLogger,
}
// Invoke runs the function with the default constructors.
func Invoke(f func(cmd adaptorsInterfaces.Cmd)) error {
return InvokeWithExtra(f, extraConstructors...)
}
// InvokeWithExtra runs the function with the given constructors.
func InvokeWithExtra(f func(cmd adaptorsInterfaces.Cmd), extra ...interface{}) error {
c := dig.New()
for _, constructor := range append(constructors, extra...) {
if err := c.Provide(constructor); err != nil {
return errors.Wrapf(err, "could not provide the constructor")
}
}
if err := c.Invoke(f); err != nil {
return errors.Wrapf(err, "could not invoke")
}
return nil
}

18
di/di_test.go Normal file
View File

@@ -0,0 +1,18 @@
package di_test
import (
"testing"
adaptors "github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/di"
)
func TestInvoke(t *testing.T) {
if err := di.Invoke(func(cmd adaptors.Cmd) {
if cmd == nil {
t.Errorf("cmd wants non-nil but nil")
}
}); err != nil {
t.Fatalf("Invoke returned error: %+v", err)
}
}

View File

@@ -2,30 +2,37 @@
## Prerequisite
- You have administrator access to the Keycloak.
- You have the Cluster Admin role of the Kubernetes cluster.
- You have an administrator role of the Keycloak realm.
- You have an administrator role of the Kubernetes cluster.
- You can configure the Kubernetes API server.
- `kubectl` and `kubelogin` are installed to your computer.
- `kubectl` and `kubelogin` are installed.
## 1. Setup Keycloak
Open the Keycloak and create an OIDC client as follows:
- Redirect URL: `http://localhost:8000/`
- Issuer URL: `https://keycloak.example.com/auth/realms/YOUR_REALM`
- Client ID: `kubernetes`
- Groups claim: `groups`
- Valid Redirect URLs:
- `http://localhost:8000`
- `http://localhost:18000` (used if the port 8000 is already in use)
- Issuer URL: `https://keycloak.example.com/auth/realms/YOUR_REALM`
Create a group `kubernetes:admin` and join to it.
This is used for group based access control.
You can associate client roles by adding the following mapper:
- Name: `groups`
- Mapper Type: `User Client Role`
- Client ID: `kubernetes`
- Client Role prefix: `kubernetes:`
- Token Claim Name: `groups`
- Add to ID token: on
For example, if you have the `admin` role of the client, you will get a JWT with the claim `{"groups": ["kubernetes:admin"]}`.
## 2. Setup Kubernetes API server
Configure your Kubernetes API server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
### kops
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and add the following spec:
```yaml
spec:
@@ -50,7 +57,7 @@ roleRef:
name: cluster-admin
subjects:
- kind: Group
name: /kubernetes:admin
name: kubernetes:admin
```
You can create a custom role and assign it as well.

14
go.mod
View File

@@ -5,25 +5,23 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/gogo/protobuf v1.2.1 // indirect
github.com/golang/mock v1.3.1
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf // indirect
github.com/imdario/mergo v0.3.7 // indirect
github.com/int128/oauth2cli v1.1.0
github.com/jessevdk/go-flags v1.4.0
github.com/int128/oauth2cli v1.4.0
github.com/json-iterator/go v1.1.6 // indirect
github.com/mitchellh/go-homedir v1.1.0
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/pkg/errors v0.8.1
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
github.com/spf13/pflag v1.0.3 // indirect
github.com/spf13/pflag v1.0.3
github.com/stretchr/testify v1.3.0 // indirect
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c // indirect
golang.org/x/net v0.0.0-20190328230028-74de082e2cca // indirect
go.uber.org/dig v1.7.0
golang.org/x/oauth2 v0.0.0-20190319182350-c85d3e98c914
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/square/go-jose.v2 v2.3.0 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
gopkg.in/yaml.v2 v2.2.2
k8s.io/api v0.0.0-20190222213804-5cb15d344471 // indirect
k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628 // indirect
k8s.io/client-go v10.0.0+incompatible

24
go.sum
View File

@@ -8,22 +8,20 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck=
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI=
github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/int128/oauth2cli v1.1.0 h1:qAT6C8GyaLaSf0aseQUTcJvZ+j2MueETzGkpoFow0kc=
github.com/int128/oauth2cli v1.1.0/go.mod h1:R1iBtRu+y4+DF4efDU0UePUYWjWfggwFI1KY1dw5E1M=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/int128/oauth2cli v1.4.0 h1:Xt4uk2lIb9Mf9Xyd5o43Hf9iV5izb2jYK3zRX/cPgh0=
github.com/int128/oauth2cli v1.4.0/go.mod h1:81pWOyFVt1TRyZ7lZDtZuAGOE/S/jEpb1mpocRopI6U=
github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
@@ -41,18 +39,19 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
go.uber.org/dig v1.7.0 h1:E5/L92iQTNJTjfgJF2KgU+/JpMaiuvK2DHLBj0+kSZk=
go.uber.org/dig v1.7.0/go.mod h1:z+dSd2TP9Usi48jL8M3v63iSBVkiwtVyMKxMZYYauPg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c h1:Vj5n4GlwjmQteupaxJ9+0FNOmBrHfq7vN4btdGoDZgI=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190328230028-74de082e2cca h1:hyA6yiAgbUwuWqtscNvWAI7U1CtlaD1KilQ6iudt1aI=
golang.org/x/net v0.0.0-20190328230028-74de082e2cca/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/oauth2 v0.0.0-20190319182350-c85d3e98c914 h1:jIOcLT9BZzyJ9ce+IwwZ+aF9yeCqzrR+NrD68a/SHKw=
golang.org/x/oauth2 v0.0.0-20190319182350-c85d3e98c914/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
@@ -60,6 +59,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

50
infrastructure/http.go Normal file
View File

@@ -0,0 +1,50 @@
package infrastructure
import (
"net/http"
"net/http/httputil"
"github.com/int128/kubelogin/adaptors/interfaces"
)
const (
logLevelDumpHeaders = 2
logLevelDumpBody = 3
)
type LoggingTransport struct {
Base http.RoundTripper
Logger adaptors.Logger
}
func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !t.IsDumpEnabled() {
return t.Base.RoundTrip(req)
}
reqDump, err := httputil.DumpRequestOut(req, t.IsDumpBodyEnabled())
if err != nil {
t.Logger.Debugf(logLevelDumpHeaders, "Error: could not dump the request: %s", err)
return t.Base.RoundTrip(req)
}
t.Logger.Debugf(logLevelDumpHeaders, "%s", string(reqDump))
resp, err := t.Base.RoundTrip(req)
if err != nil {
return resp, err
}
respDump, err := httputil.DumpResponse(resp, t.IsDumpBodyEnabled())
if err != nil {
t.Logger.Debugf(logLevelDumpHeaders, "Error: could not dump the response: %s", err)
return resp, err
}
t.Logger.Debugf(logLevelDumpHeaders, "%s", string(respDump))
return resp, err
}
func (t *LoggingTransport) IsDumpEnabled() bool {
return t.Logger.IsEnabled(logLevelDumpHeaders)
}
func (t *LoggingTransport) IsDumpBodyEnabled() bool {
return t.Logger.IsEnabled(logLevelDumpBody)
}

View File

@@ -0,0 +1,90 @@
package infrastructure
import (
"bufio"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/golang/mock/gomock"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/adaptors/mock_adaptors"
)
type mockTransport struct {
req *http.Request
resp *http.Response
}
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.req = req
return t.resp, nil
}
func TestLoggingTransport_RoundTrip(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := mock_adaptors.NewLogger(t, ctrl)
logger.EXPECT().
IsEnabled(gomock.Any()).
Return(true).
AnyTimes()
req := httptest.NewRequest("GET", "http://example.com/hello", nil)
resp, err := http.ReadResponse(bufio.NewReader(strings.NewReader(`HTTP/1.1 200 OK
Host: example.com
dummy`)), req)
if err != nil {
t.Errorf("could not create a response: %s", err)
}
defer resp.Body.Close()
transport := &LoggingTransport{
Base: &mockTransport{resp: resp},
Logger: logger,
}
gotResp, err := transport.RoundTrip(req)
if err != nil {
t.Errorf("RoundTrip error: %s", err)
}
if gotResp != resp {
t.Errorf("resp wants %v but %v", resp, gotResp)
}
}
func TestLoggingTransport_IsDumpEnabled(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := mock_adaptors.NewLogger(t, ctrl)
logger.EXPECT().
IsEnabled(adaptors.LogLevel(logLevelDumpHeaders)).
Return(true)
transport := &LoggingTransport{
Logger: logger,
}
if transport.IsDumpEnabled() != true {
t.Errorf("IsDumpEnabled wants true")
}
}
func TestLoggingTransport_IsDumpBodyEnabled(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
logger := mock_adaptors.NewLogger(t, ctrl)
logger.EXPECT().
IsEnabled(adaptors.LogLevel(logLevelDumpBody)).
Return(true)
transport := &LoggingTransport{
Logger: logger,
}
if transport.IsDumpBodyEnabled() != true {
t.Errorf("IsDumpBodyEnabled wants true")
}
}

View File

@@ -1,75 +1,56 @@
package kubeconfig
import (
"fmt"
"strings"
"k8s.io/client-go/tools/clientcmd/api"
)
// FindOIDCAuthProvider returns the current OIDC authProvider.
// If the context, auth-info or auth-provider does not exist, this returns an error.
// If auth-provider is not "oidc", this returns an error.
func FindOIDCAuthProvider(config *api.Config) (*OIDCAuthProvider, error) {
context := config.Contexts[config.CurrentContext]
if context == nil {
return nil, fmt.Errorf("context %s does not exist", config.CurrentContext)
}
authInfo := config.AuthInfos[context.AuthInfo]
if authInfo == nil {
return nil, fmt.Errorf("auth-info %s does not exist", context.AuthInfo)
}
if authInfo.AuthProvider == nil {
return nil, fmt.Errorf("auth-provider is not set")
}
if authInfo.AuthProvider.Name != "oidc" {
return nil, fmt.Errorf("auth-provider name is %s but must be oidc", authInfo.AuthProvider.Name)
}
return (*OIDCAuthProvider)(authInfo.AuthProvider), nil
}
// OIDCAuthProvider represents OIDC configuration in the kubeconfig.
type OIDCAuthProvider api.AuthProviderConfig
// OIDCConfig represents config of an oidc auth-provider.
type OIDCConfig map[string]string
// IDPIssuerURL returns the idp-issuer-url.
func (c *OIDCAuthProvider) IDPIssuerURL() string {
return c.Config["idp-issuer-url"]
func (c OIDCConfig) IDPIssuerURL() string {
return c["idp-issuer-url"]
}
// ClientID returns the client-id.
func (c *OIDCAuthProvider) ClientID() string {
return c.Config["client-id"]
func (c OIDCConfig) ClientID() string {
return c["client-id"]
}
// ClientSecret returns the client-secret.
func (c *OIDCAuthProvider) ClientSecret() string {
return c.Config["client-secret"]
func (c OIDCConfig) ClientSecret() string {
return c["client-secret"]
}
// IDPCertificateAuthority returns the idp-certificate-authority.
func (c *OIDCAuthProvider) IDPCertificateAuthority() string {
return c.Config["idp-certificate-authority"]
func (c OIDCConfig) IDPCertificateAuthority() string {
return c["idp-certificate-authority"]
}
// IDPCertificateAuthorityData returns the idp-certificate-authority-data.
func (c *OIDCAuthProvider) IDPCertificateAuthorityData() string {
return c.Config["idp-certificate-authority-data"]
func (c OIDCConfig) IDPCertificateAuthorityData() string {
return c["idp-certificate-authority-data"]
}
// ExtraScopes returns the extra-scopes.
func (c *OIDCAuthProvider) ExtraScopes() []string {
if c.Config["extra-scopes"] == "" {
func (c OIDCConfig) ExtraScopes() []string {
if c["extra-scopes"] == "" {
return []string{}
}
return strings.Split(c.Config["extra-scopes"], ",")
return strings.Split(c["extra-scopes"], ",")
}
// IDToken returns the id-token.
func (c OIDCConfig) IDToken() string {
return c["id-token"]
}
// SetIDToken replaces the id-token.
func (c *OIDCAuthProvider) SetIDToken(idToken string) {
c.Config["id-token"] = idToken
func (c OIDCConfig) SetIDToken(idToken string) {
c["id-token"] = idToken
}
// SetRefreshToken replaces the refresh-token.
func (c *OIDCAuthProvider) SetRefreshToken(refreshToken string) {
c.Config["refresh-token"] = refreshToken
func (c OIDCConfig) SetRefreshToken(refreshToken string) {
c["refresh-token"] = refreshToken
}

View File

@@ -1,16 +1,66 @@
// Package kubeconfig provides the models of kubeconfig file.
package kubeconfig
import (
"k8s.io/client-go/tools/clientcmd"
"github.com/pkg/errors"
"k8s.io/client-go/tools/clientcmd/api"
)
// Read parses the file and returns the Config.
func Read(path string) (*api.Config, error) {
return clientcmd.LoadFromFile(path)
type ContextName string
type UserName string
// Config represents a config.
type Config api.Config
// Context represents a context.
type Context api.Context
// User represents a user.
type User api.AuthInfo
// CurrentAuth represents the current authentication, that is,
// context, user and auth-provider.
type CurrentAuth struct {
ContextName ContextName // empty if UserName is given
Context *Context // nil if UserName is given
UserName UserName
User *User
OIDCConfig OIDCConfig
}
// Write writes the config to the file.
func Write(config *api.Config, path string) error {
return clientcmd.WriteToFile(*config, path)
// FindCurrentAuth resolves the current context and user.
// If contextName is given, this returns the user of the context.
// If userName is given, this ignores the context and returns the user.
// If any context or user is not found, this returns an error.
func FindCurrentAuth(config *Config, contextName ContextName, userName UserName) (*CurrentAuth, error) {
var kubeContext *Context
if userName == "" {
if contextName == "" {
contextName = ContextName(config.CurrentContext)
}
contextNode := config.Contexts[string(contextName)]
if contextNode == nil {
return nil, errors.Errorf("context %s does not exist", contextName)
}
kubeContext = (*Context)(contextNode)
userName = UserName(kubeContext.AuthInfo)
}
userNode := config.AuthInfos[string(userName)]
if userNode == nil {
return nil, errors.Errorf("user %s does not exist", userName)
}
user := (*User)(userNode)
if user.AuthProvider == nil {
return nil, errors.Errorf("auth-provider is missing")
}
if user.AuthProvider.Name != "oidc" {
return nil, errors.Errorf("auth-provider must be oidc but is %s", user.AuthProvider.Name)
}
return &CurrentAuth{
ContextName: contextName,
Context: kubeContext,
UserName: userName,
User: user,
OIDCConfig: user.AuthProvider.Config,
}, nil
}

View File

@@ -3,9 +3,9 @@ class Kubelogin < Formula
homepage "https://github.com/int128/kubelogin"
url "https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_darwin_amd64.zip"
version "{{ env "VERSION" }}"
sha256 "{{ .darwin_amd64_zip_sha256 }}"
sha256 "{{ sha256 .darwin_amd64_archive }}"
def install
bin.install "kubelogin_darwin_amd64" => "kubelogin"
bin.install "kubelogin" => "kubelogin"
ln_s bin/"kubelogin", bin/"kubectl-oidc_login"
end
test do

12
main.go
View File

@@ -5,18 +5,16 @@ import (
"log"
"os"
"github.com/int128/kubelogin/cli"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/di"
)
var version = "HEAD"
func main() {
c, err := cli.Parse(os.Args, version)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
if err := c.Run(ctx); err != nil {
if err := di.Invoke(func(cmd adaptors.Cmd) {
os.Exit(cmd.Run(context.Background(), os.Args, version))
}); err != nil {
log.Fatalf("Error: %s", err)
}
}

View File

@@ -26,7 +26,7 @@ spec:
version: {{ env "VERSION" }}
platforms:
- uri: https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_linux_amd64.zip
sha256: "{{ .linux_amd64_zip_sha256 }}"
sha256: "{{ sha256 .linux_amd64_archive }}"
bin: kubelogin
files:
- from: "kubelogin"
@@ -36,7 +36,7 @@ spec:
os: linux
arch: amd64
- uri: https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_darwin_amd64.zip
sha256: "{{ .darwin_amd64_zip_sha256 }}"
sha256: "{{ sha256 .darwin_amd64_archive }}"
bin: kubelogin
files:
- from: "kubelogin"
@@ -46,7 +46,7 @@ spec:
os: darwin
arch: amd64
- uri: https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_windows_amd64.zip
sha256: "{{ .windows_amd64_zip_sha256 }}"
sha256: "{{ sha256 .windows_amd64_archive }}"
bin: kubelogin.exe
files:
- from: "kubelogin.exe"

View File

@@ -0,0 +1,23 @@
package usecases
import (
"context"
"github.com/int128/kubelogin/kubeconfig"
)
//go:generate mockgen -package mock_usecases -destination ../mock_usecases/mock_usecases.go github.com/int128/kubelogin/usecases/interfaces Login
type Login interface {
Do(ctx context.Context, in LoginIn) error
}
type LoginIn struct {
KubeConfigFilename string // Default to the environment variable or global config as kubectl
KubeContextName kubeconfig.ContextName // Default to the current context but ignored if KubeUserName is set
KubeUserName kubeconfig.UserName // Default to the user of the context
CertificateAuthorityFilename string // Optional
SkipTLSVerify bool
SkipOpenBrowser bool
ListenPort []int
}

123
usecases/login.go Normal file
View File

@@ -0,0 +1,123 @@
package usecases
import (
"context"
"github.com/coreos/go-oidc"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/kubeconfig"
"github.com/int128/kubelogin/usecases/interfaces"
"github.com/pkg/errors"
"go.uber.org/dig"
)
const oidcConfigErrorMessage = `No OIDC configuration found. Did you setup kubectl for OIDC authentication?
kubectl config set-credentials CONTEXT_NAME \
--auth-provider oidc \
--auth-provider-arg idp-issuer-url=https://issuer.example.com \
--auth-provider-arg client-id=YOUR_CLIENT_ID \
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET`
func NewLogin(i Login) usecases.Login {
return &i
}
type Login struct {
dig.In
KubeConfig adaptors.KubeConfig
HTTP adaptors.HTTP
OIDC adaptors.OIDC
Logger adaptors.Logger
}
func (u *Login) Do(ctx context.Context, in usecases.LoginIn) error {
u.Logger.Debugf(1, "WARNING: log may contain your secrets such as token or password")
mergedKubeConfig, err := u.KubeConfig.LoadByDefaultRules(in.KubeConfigFilename)
if err != nil {
return errors.Wrapf(err, "could not load the kubeconfig")
}
auth, err := kubeconfig.FindCurrentAuth(mergedKubeConfig, in.KubeContextName, in.KubeUserName)
if err != nil {
u.Logger.Printf(oidcConfigErrorMessage)
return errors.Wrapf(err, "could not find the current authentication provider")
}
u.Logger.Debugf(1, "Using the authentication provider of the user %s", auth.UserName)
destinationKubeConfigFilename := auth.User.LocationOfOrigin
if destinationKubeConfigFilename == "" {
return errors.Errorf("could not determine the kubeconfig to write")
}
u.Logger.Debugf(1, "A token will be written to %s", destinationKubeConfigFilename)
hc, err := u.HTTP.NewClient(adaptors.HTTPClientConfig{
OIDCConfig: auth.OIDCConfig,
CertificateAuthorityFilename: in.CertificateAuthorityFilename,
SkipTLSVerify: in.SkipTLSVerify,
})
if err != nil {
return errors.Wrapf(err, "could not set up a HTTP client")
}
if auth.OIDCConfig.IDToken() != "" {
u.Logger.Debugf(1, "Found the ID token in the kubeconfig")
token, err := u.OIDC.Verify(ctx, adaptors.OIDCVerifyIn{Config: auth.OIDCConfig, Client: hc})
if err == nil {
u.Logger.Printf("You already have a valid token until %s", token.Expiry)
u.dumpIDToken(token)
return nil
}
u.Logger.Debugf(1, "The ID token was invalid: %s", err)
}
out, err := u.OIDC.Authenticate(ctx,
adaptors.OIDCAuthenticateIn{
Config: auth.OIDCConfig,
Client: hc,
LocalServerPort: in.ListenPort,
SkipOpenBrowser: in.SkipOpenBrowser,
},
adaptors.OIDCAuthenticateCallback{
ShowLocalServerURL: func(url string) {
u.Logger.Printf("Open %s for authentication", url)
},
})
if err != nil {
return errors.Wrapf(err, "could not get a token from the OIDC provider")
}
u.Logger.Printf("You got a valid token until %s", out.VerifiedIDToken.Expiry)
u.dumpIDToken(out.VerifiedIDToken)
if err := u.writeToken(destinationKubeConfigFilename, auth.UserName, out); err != nil {
return errors.Wrapf(err, "could not write the token to the kubeconfig")
}
return nil
}
func (u *Login) dumpIDToken(token *oidc.IDToken) {
var claims map[string]interface{}
if err := token.Claims(&claims); err != nil {
u.Logger.Debugf(1, "Error while inspection of the ID token: %s", err)
}
for k, v := range claims {
u.Logger.Debugf(1, "The ID token has the claim: %s=%v", k, v)
}
}
func (u *Login) writeToken(filename string, userName kubeconfig.UserName, out *adaptors.OIDCAuthenticateOut) error {
config, err := u.KubeConfig.LoadFromFile(filename)
if err != nil {
return errors.Wrapf(err, "could not load %s", filename)
}
auth, err := kubeconfig.FindCurrentAuth(config, "", userName)
if err != nil {
return errors.Wrapf(err, "could not find the user %s in %s", userName, filename)
}
auth.OIDCConfig.SetIDToken(out.IDToken)
auth.OIDCConfig.SetRefreshToken(out.RefreshToken)
u.Logger.Debugf(1, "Writing the ID token and refresh token to %s", filename)
if err := u.KubeConfig.WriteToFile(config, filename); err != nil {
return errors.Wrapf(err, "could not update %s", filename)
}
u.Logger.Printf("Updated %s", filename)
return nil
}

586
usecases/login_test.go Normal file
View File

@@ -0,0 +1,586 @@
package usecases
import (
"context"
"net/http"
"testing"
"github.com/coreos/go-oidc"
"github.com/golang/mock/gomock"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/adaptors/mock_adaptors"
"github.com/int128/kubelogin/kubeconfig"
"github.com/int128/kubelogin/usecases/interfaces"
"github.com/pkg/errors"
"k8s.io/client-go/tools/clientcmd/api"
)
type loginTestFixture struct {
googleOIDCConfig kubeconfig.OIDCConfig
googleOIDCConfigWithToken kubeconfig.OIDCConfig
googleKubeConfig *kubeconfig.Config
googleKubeConfigWithToken *kubeconfig.Config
keycloakOIDCConfig kubeconfig.OIDCConfig
keycloakOIDCConfigWithToken kubeconfig.OIDCConfig
keycloakKubeConfig *kubeconfig.Config
keycloakKubeConfigWithToken *kubeconfig.Config
mergedKubeConfig *kubeconfig.Config
}
func newLoginTestFixture() loginTestFixture {
var f loginTestFixture
f.googleOIDCConfig = kubeconfig.OIDCConfig{
"client-id": "GOOGLE_CLIENT_ID",
"client-secret": "GOOGLE_CLIENT_SECRET",
"idp-issuer-url": "https://accounts.google.com",
}
f.googleKubeConfig = &kubeconfig.Config{
APIVersion: "v1",
CurrentContext: "googleContext",
Contexts: map[string]*api.Context{
"googleContext": {
LocationOfOrigin: "/path/to/google",
AuthInfo: "google",
Cluster: "example.k8s.local",
},
},
AuthInfos: map[string]*api.AuthInfo{
"google": {
LocationOfOrigin: "/path/to/google",
AuthProvider: &api.AuthProviderConfig{
Name: "oidc",
Config: f.googleOIDCConfig,
},
},
},
}
f.googleOIDCConfigWithToken = kubeconfig.OIDCConfig{
"client-id": "GOOGLE_CLIENT_ID",
"client-secret": "GOOGLE_CLIENT_SECRET",
"idp-issuer-url": "https://accounts.google.com",
"id-token": "YOUR_ID_TOKEN",
"refresh-token": "YOUR_REFRESH_TOKEN",
}
f.googleKubeConfigWithToken = &kubeconfig.Config{
APIVersion: "v1",
CurrentContext: "googleContext",
Contexts: map[string]*api.Context{
"googleContext": {
LocationOfOrigin: "/path/to/google",
AuthInfo: "google",
Cluster: "example.k8s.local",
},
},
AuthInfos: map[string]*api.AuthInfo{
"google": {
LocationOfOrigin: "/path/to/google",
AuthProvider: &api.AuthProviderConfig{
Name: "oidc",
Config: f.googleOIDCConfigWithToken,
},
},
},
}
f.keycloakOIDCConfig = kubeconfig.OIDCConfig{
"client-id": "KEYCLOAK_CLIENT_ID",
"client-secret": "KEYCLOAK_CLIENT_SECRET",
"idp-issuer-url": "https://keycloak.example.com",
}
f.keycloakKubeConfig = &kubeconfig.Config{
APIVersion: "v1",
CurrentContext: "googleContext",
Contexts: map[string]*api.Context{
"keycloakContext": {
LocationOfOrigin: "/path/to/keycloak",
AuthInfo: "keycloak",
Cluster: "example.k8s.local",
},
},
AuthInfos: map[string]*api.AuthInfo{
"keycloak": {
LocationOfOrigin: "/path/to/keycloak",
AuthProvider: &api.AuthProviderConfig{
Name: "oidc",
Config: f.keycloakOIDCConfig,
},
},
},
}
f.keycloakOIDCConfigWithToken = kubeconfig.OIDCConfig{
"client-id": "KEYCLOAK_CLIENT_ID",
"client-secret": "KEYCLOAK_CLIENT_SECRET",
"idp-issuer-url": "https://keycloak.example.com",
"id-token": "YOUR_ID_TOKEN",
"refresh-token": "YOUR_REFRESH_TOKEN",
}
f.keycloakKubeConfigWithToken = &kubeconfig.Config{
APIVersion: "v1",
CurrentContext: "googleContext",
Contexts: map[string]*api.Context{
"keycloakContext": {
LocationOfOrigin: "/path/to/keycloak",
AuthInfo: "keycloak",
Cluster: "example.k8s.local",
},
},
AuthInfos: map[string]*api.AuthInfo{
"keycloak": {
LocationOfOrigin: "/path/to/keycloak",
AuthProvider: &api.AuthProviderConfig{
Name: "oidc",
Config: f.keycloakOIDCConfigWithToken,
},
},
},
}
f.mergedKubeConfig = &kubeconfig.Config{
APIVersion: "v1",
CurrentContext: "googleContext",
Contexts: map[string]*api.Context{
"googleContext": {
LocationOfOrigin: "/path/to/google",
AuthInfo: "google",
Cluster: "example.k8s.local",
},
"keycloakContext": {
LocationOfOrigin: "/path/to/keycloak",
AuthInfo: "keycloak",
Cluster: "example.k8s.local",
},
},
AuthInfos: map[string]*api.AuthInfo{
"google": {
LocationOfOrigin: "/path/to/google",
AuthProvider: &api.AuthProviderConfig{
Name: "oidc",
Config: f.googleOIDCConfig,
},
},
"keycloak": {
LocationOfOrigin: "/path/to/keycloak",
AuthProvider: &api.AuthProviderConfig{
Name: "oidc",
Config: f.keycloakOIDCConfig,
},
},
},
}
return f
}
func TestLogin_Do(t *testing.T) {
httpClient := &http.Client{}
newMockOIDC := func(ctrl *gomock.Controller, ctx context.Context, in adaptors.OIDCAuthenticateIn) *mock_adaptors.MockOIDC {
mockOIDC := mock_adaptors.NewMockOIDC(ctrl)
mockOIDC.EXPECT().
Authenticate(ctx, in, gomock.Any()).
Do(func(_ context.Context, _ adaptors.OIDCAuthenticateIn, cb adaptors.OIDCAuthenticateCallback) {
cb.ShowLocalServerURL("http://localhost:10000")
}).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
RefreshToken: "YOUR_REFRESH_TOKEN",
}, nil)
return mockOIDC
}
t.Run("Defaults", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.googleOIDCConfig,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/google").
Return(f.googleKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.googleKubeConfigWithToken, "/path/to/google")
oidcIn := adaptors.OIDCAuthenticateIn{
Config: f.googleOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
}
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: newMockOIDC(ctrl, ctx, oidcIn),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
ListenPort: []int{10000},
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("KubeConfigFilename", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.googleOIDCConfig,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("/path/to/kubeconfig").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/google").
Return(f.googleKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.googleKubeConfigWithToken, "/path/to/google")
oidcIn := adaptors.OIDCAuthenticateIn{
Config: f.googleOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
}
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: newMockOIDC(ctrl, ctx, oidcIn),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfigFilename: "/path/to/kubeconfig",
ListenPort: []int{10000},
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("KubeContextName", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.keycloakOIDCConfig,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/keycloak").
Return(f.keycloakKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.keycloakKubeConfigWithToken, "/path/to/keycloak")
oidcIn := adaptors.OIDCAuthenticateIn{
Config: f.keycloakOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
}
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: newMockOIDC(ctrl, ctx, oidcIn),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeContextName: "keycloakContext",
ListenPort: []int{10000},
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("KubeUserName", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.keycloakOIDCConfig,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/keycloak").
Return(f.keycloakKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.keycloakKubeConfigWithToken, "/path/to/keycloak")
oidcIn := adaptors.OIDCAuthenticateIn{
Config: f.keycloakOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
}
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: newMockOIDC(ctrl, ctx, oidcIn),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeUserName: "keycloak",
ListenPort: []int{10000},
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("SkipTLSVerify", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.googleOIDCConfig,
SkipTLSVerify: true,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/google").
Return(f.googleKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.googleKubeConfigWithToken, "/path/to/google")
oidcIn := adaptors.OIDCAuthenticateIn{
Config: f.googleOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
}
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: newMockOIDC(ctrl, ctx, oidcIn),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
ListenPort: []int{10000},
SkipTLSVerify: true,
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("SkipOpenBrowser", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.googleOIDCConfig,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/google").
Return(f.googleKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.googleKubeConfigWithToken, "/path/to/google")
oidcIn := adaptors.OIDCAuthenticateIn{
Config: f.googleOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
SkipOpenBrowser: true,
}
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: newMockOIDC(ctrl, ctx, oidcIn),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
ListenPort: []int{10000},
SkipOpenBrowser: true,
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("KubeConfig/ValidToken", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
f.googleOIDCConfig.SetIDToken("VALID_TOKEN")
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.googleOIDCConfig,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockOIDC := mock_adaptors.NewMockOIDC(ctrl)
mockOIDC.EXPECT().
Verify(ctx, adaptors.OIDCVerifyIn{
Config: f.googleOIDCConfig,
Client: httpClient,
}).
Return(&oidc.IDToken{}, nil)
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: mockOIDC,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
ListenPort: []int{10000},
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("KubeConfig/InvalidToken", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
f.googleOIDCConfig.SetIDToken("EXPIRED_TOKEN")
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.googleOIDCConfig,
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/google").
Return(f.googleKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.googleKubeConfigWithToken, "/path/to/google")
mockOIDC := newMockOIDC(ctrl, ctx, adaptors.OIDCAuthenticateIn{
Config: f.googleOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
})
mockOIDC.EXPECT().
Verify(ctx, adaptors.OIDCVerifyIn{
Config: f.googleOIDCConfig,
Client: httpClient,
}).
Return(nil, errors.New("token is expired"))
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: mockOIDC,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
ListenPort: []int{10000},
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("Certificates", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
f := newLoginTestFixture()
f.googleOIDCConfig["idp-certificate-authority"] = "/path/to/cert2"
f.googleOIDCConfig["idp-certificate-authority-data"] = "base64encoded"
f.googleOIDCConfigWithToken["idp-certificate-authority"] = "/path/to/cert2"
f.googleOIDCConfigWithToken["idp-certificate-authority-data"] = "base64encoded"
mockHTTP := mock_adaptors.NewMockHTTP(ctrl)
mockHTTP.EXPECT().
NewClient(adaptors.HTTPClientConfig{
OIDCConfig: f.googleOIDCConfig,
CertificateAuthorityFilename: "/path/to/cert1",
}).
Return(httpClient, nil)
mockKubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
mockKubeConfig.EXPECT().
LoadByDefaultRules("").
Return(f.mergedKubeConfig, nil)
mockKubeConfig.EXPECT().
LoadFromFile("/path/to/google").
Return(f.googleKubeConfig, nil)
mockKubeConfig.EXPECT().
WriteToFile(f.googleKubeConfigWithToken, "/path/to/google")
oidcIn := adaptors.OIDCAuthenticateIn{
Config: f.googleOIDCConfig,
LocalServerPort: []int{10000},
Client: httpClient,
}
u := Login{
KubeConfig: mockKubeConfig,
HTTP: mockHTTP,
OIDC: newMockOIDC(ctrl, ctx, oidcIn),
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
ListenPort: []int{10000},
CertificateAuthorityFilename: "/path/to/cert1",
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
}

View File

@@ -0,0 +1,47 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/int128/kubelogin/usecases/interfaces (interfaces: Login)
// Package mock_usecases is a generated GoMock package.
package mock_usecases
import (
context "context"
gomock "github.com/golang/mock/gomock"
interfaces "github.com/int128/kubelogin/usecases/interfaces"
reflect "reflect"
)
// MockLogin is a mock of Login interface
type MockLogin struct {
ctrl *gomock.Controller
recorder *MockLoginMockRecorder
}
// MockLoginMockRecorder is the mock recorder for MockLogin
type MockLoginMockRecorder struct {
mock *MockLogin
}
// NewMockLogin creates a new mock instance
func NewMockLogin(ctrl *gomock.Controller) *MockLogin {
mock := &MockLogin{ctrl: ctrl}
mock.recorder = &MockLoginMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockLogin) EXPECT() *MockLoginMockRecorder {
return m.recorder
}
// Do mocks base method
func (m *MockLogin) Do(arg0 context.Context, arg1 interfaces.LoginIn) error {
ret := m.ctrl.Call(m, "Do", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// Do indicates an expected call of Do
func (mr *MockLoginMockRecorder) Do(arg0, arg1 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockLogin)(nil).Do), arg0, arg1)
}