Compare commits

..

11 Commits

Author SHA1 Message Date
Hidetake Iwata
fc31d467e4 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:47:32 +09:00
Hidetake Iwata
6ae8e36683 Fix error of go get and remove golint (#69)
* Remove golint

* Fix error of go get github.com/int128/ghcp
2019-04-19 10:46:07 +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
32 changed files with 1032 additions and 305 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
@@ -10,12 +10,13 @@ jobs:
- run: |
curl -L -o ~/bin/kubectl https://storage.googleapis.com/kubernetes-release/release/v1.14.0/bin/linux/amd64/kubectl
chmod +x ~/bin/kubectl
- run: |
curl -L -o ~/bin/ghcp https://github.com/int128/ghcp/releases/download/v1.3.0/ghcp_linux_amd64
chmod +x ~/bin/ghcp
- run: |
go get -v \
golang.org/x/lint/golint \
github.com/int128/goxzst \
github.com/tcnksm/ghr \
github.com/int128/ghcp
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

View File

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

View File

@@ -13,19 +13,18 @@ You need to setup the following components:
- Role for your group or user
- kubectl authentication
You can install [the latest release](https://github.com/int128/kubelogin/releases) by the following ways:
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 (experimental)
curl -LO https://github.com/int128/kubelogin/releases/download/v1.9.0/oidc-login.yaml
kubectl krew install --manifest oidc-login.yaml
# Krew
kubectl krew install oidc-login
# GitHub Releases
curl -LO https://github.com/int128/kubelogin/releases/download/v1.9.0/kubelogin_linux_amd64.zip
curl -LO https://github.com/int128/kubelogin/releases/download/v1.9.1/kubelogin_linux_amd64.zip
unzip kubelogin_linux_amd64.zip
ln -s kubelogin kubectl-oidc_login
```
@@ -34,11 +33,10 @@ 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
Using current-context: hello.k8s.local
Open http://localhost:8000 for authorization
Got a token for subject 0123456789 (valid until 2019-04-12 11:00:49 +0900 JST)
Updated ~/.kube/config
```
or run as a kubectl plugin:
@@ -70,6 +68,7 @@ Application Options:
--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]
-v, --v= If set to 1 or greater, show debug log (default: 0)
Help Options:
-h, --help Show this help message

View File

@@ -30,16 +30,17 @@ func (cmd *Cmd) Run(ctx context.Context, args []string, version string) int {
version)
args, err := parser.ParseArgs(args[1:])
if err != nil {
cmd.Logger.Logf("Error: %s", err)
cmd.Logger.Printf("Error: %s", err)
return 1
}
if len(args) > 0 {
cmd.Logger.Logf("Error: too many arguments")
cmd.Logger.Printf("Error: too many arguments")
return 1
}
cmd.Logger.SetLevel(adaptors.LogLevel(o.Verbose))
kubeConfig, err := o.ExpandKubeConfig()
if err != nil {
cmd.Logger.Logf("Error: invalid option: %s", err)
cmd.Logger.Printf("Error: invalid option: %s", err)
return 1
}
@@ -50,7 +51,7 @@ func (cmd *Cmd) Run(ctx context.Context, args []string, version string) int {
SkipOpenBrowser: o.SkipOpenBrowser,
}
if err := cmd.Login.Do(ctx, in); err != nil {
cmd.Logger.Logf("Error: %s", err)
cmd.Logger.Printf("Error: %s", err)
return 1
}
return 0
@@ -61,6 +62,7 @@ type cmdOptions struct {
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."`
Verbose int `long:"v" short:"v" default:"0" description:"If set to 1 or greater, show debug log"`
}
// ExpandKubeConfig returns an expanded KubeConfig path.

View File

@@ -5,6 +5,8 @@ import (
"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"
"github.com/mitchellh/go-homedir"
@@ -26,9 +28,13 @@ func TestCmd_Run(t *testing.T) {
ListenPort: 8000,
})
logger := mock_adaptors.NewLogger(t, ctrl)
logger.EXPECT().
SetLevel(adaptors.LogLevel(0))
cmd := Cmd{
Login: login,
Logger: t,
Logger: logger,
}
exitCode := cmd.Run(ctx, []string{executable}, version)
if exitCode != 0 {
@@ -50,14 +56,19 @@ func TestCmd_Run(t *testing.T) {
SkipOpenBrowser: true,
})
logger := mock_adaptors.NewLogger(t, ctrl)
logger.EXPECT().
SetLevel(adaptors.LogLevel(1))
cmd := Cmd{
Login: login,
Logger: t,
Logger: logger,
}
exitCode := cmd.Run(ctx, []string{executable,
"--listen-port", "10080",
"--insecure-skip-tls-verify",
"--skip-open-browser",
"-v1",
}, version)
if exitCode != 0 {
t.Errorf("exitCode wants 0 but %d", exitCode)
@@ -69,7 +80,7 @@ func TestCmd_Run(t *testing.T) {
defer ctrl.Finish()
cmd := Cmd{
Login: mock_usecases.NewMockLogin(ctrl),
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
exitCode := cmd.Run(context.TODO(), []string{executable, "some"}, version)
if exitCode != 1 {

View File

@@ -8,14 +8,19 @@ import (
"net/http"
"github.com/int128/kubelogin/adaptors/interfaces"
"github.com/int128/kubelogin/infrastructure"
"github.com/pkg/errors"
"go.uber.org/dig"
)
func NewHTTP() adaptors.HTTP {
return &HTTP{}
func NewHTTP(i HTTP) adaptors.HTTP {
return &i
}
type HTTP struct{}
type HTTP struct {
dig.In
Logger adaptors.Logger
}
func (*HTTP) NewClientConfig() adaptors.HTTPClientConfig {
return &httpClientConfig{
@@ -23,11 +28,14 @@ func (*HTTP) NewClientConfig() adaptors.HTTPClientConfig {
}
}
func (*HTTP) NewClient(config adaptors.HTTPClientConfig) (*http.Client, error) {
func (h *HTTP) NewClient(config adaptors.HTTPClientConfig) (*http.Client, error) {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config.TLSConfig(),
Proxy: http.ProxyFromEnvironment,
Transport: &infrastructure.LoggingTransport{
Base: &http.Transport{
TLSClientConfig: config.TLSConfig(),
Proxy: http.ProxyFromEnvironment,
},
Logger: h.Logger,
},
}, nil
}

View File

@@ -9,7 +9,7 @@ import (
"k8s.io/client-go/tools/clientcmd/api"
)
//go:generate mockgen -package mock_adaptors -destination ../mock_adaptors/mock_adaptors.go github.com/int128/kubelogin/adaptors/interfaces KubeConfig,HTTP,HTTPClientConfig,OIDC
//go:generate mockgen -package mock_adaptors -destination ../mock_adaptors/mock_adaptors.go github.com/int128/kubelogin/adaptors/interfaces KubeConfig,HTTP,HTTPClientConfig,OIDC,Logger
type Cmd interface {
Run(ctx context.Context, args []string, version string) int
@@ -34,7 +34,8 @@ type HTTPClientConfig interface {
}
type OIDC interface {
Authenticate(ctx context.Context, in OIDCAuthenticateIn) (*OIDCAuthenticateOut, error)
Authenticate(ctx context.Context, in OIDCAuthenticateIn, cb OIDCAuthenticateCallback) (*OIDCAuthenticateOut, error)
VerifyIDToken(ctx context.Context, in OIDCVerifyTokenIn) (*oidc.IDToken, error)
}
type OIDCAuthenticateIn struct {
@@ -47,12 +48,35 @@ type OIDCAuthenticateIn struct {
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 Logger interface {
Logf(format string, v ...interface{})
type OIDCVerifyTokenIn struct {
IDToken string
Issuer string
ClientID string
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

View File

@@ -2,16 +2,48 @@ 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{}
return &Logger{
stdLogger: log.New(os.Stderr, "", 0),
debugLogger: log.New(os.Stderr, "", log.Ltime|log.Lmicroseconds),
}
}
type Logger struct{}
func (*Logger) Logf(format string, v ...interface{}) {
log.Printf(format, v...)
// 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

@@ -1,5 +1,5 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/int128/kubelogin/adaptors/interfaces (interfaces: KubeConfig,HTTP,HTTPClientConfig,OIDC)
// Source: github.com/int128/kubelogin/adaptors/interfaces (interfaces: KubeConfig,HTTP,HTTPClientConfig,OIDC,Logger)
// Package mock_adaptors is a generated GoMock package.
package mock_adaptors
@@ -7,6 +7,7 @@ package mock_adaptors
import (
context "context"
tls "crypto/tls"
go_oidc "github.com/coreos/go-oidc"
gomock "github.com/golang/mock/gomock"
interfaces "github.com/int128/kubelogin/adaptors/interfaces"
api "k8s.io/client-go/tools/clientcmd/api"
@@ -203,14 +204,102 @@ func (m *MockOIDC) EXPECT() *MockOIDCMockRecorder {
}
// Authenticate mocks base method
func (m *MockOIDC) Authenticate(arg0 context.Context, arg1 interfaces.OIDCAuthenticateIn) (*interfaces.OIDCAuthenticateOut, error) {
ret := m.ctrl.Call(m, "Authenticate", arg0, arg1)
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 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authenticate", reflect.TypeOf((*MockOIDC)(nil).Authenticate), arg0, arg1)
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)
}
// VerifyIDToken mocks base method
func (m *MockOIDC) VerifyIDToken(arg0 context.Context, arg1 interfaces.OIDCVerifyTokenIn) (*go_oidc.IDToken, error) {
ret := m.ctrl.Call(m, "VerifyIDToken", arg0, arg1)
ret0, _ := ret[0].(*go_oidc.IDToken)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// VerifyIDToken indicates an expected call of VerifyIDToken
func (mr *MockOIDCMockRecorder) VerifyIDToken(arg0, arg1 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyIDToken", reflect.TypeOf((*MockOIDC)(nil).VerifyIDToken), 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)
}

View File

@@ -16,7 +16,7 @@ func NewOIDC() adaptors.OIDC {
type OIDC struct{}
func (*OIDC) Authenticate(ctx context.Context, in adaptors.OIDCAuthenticateIn) (*adaptors.OIDCAuthenticateOut, error) {
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)
}
@@ -31,9 +31,10 @@ func (*OIDC) Authenticate(ctx context.Context, in adaptors.OIDCAuthenticateIn) (
ClientSecret: in.ClientSecret,
Scopes: append(in.ExtraScopes, oidc.ScopeOpenID),
},
LocalServerPort: in.LocalServerPort,
SkipOpenBrowser: in.SkipOpenBrowser,
AuthCodeOptions: []oauth2.AuthCodeOption{oauth2.AccessTypeOffline},
LocalServerPort: in.LocalServerPort,
SkipOpenBrowser: in.SkipOpenBrowser,
AuthCodeOptions: []oauth2.AuthCodeOption{oauth2.AccessTypeOffline},
ShowLocalServerURL: cb.ShowLocalServerURL,
}
token, err := flow.GetToken(ctx)
if err != nil {
@@ -54,3 +55,19 @@ func (*OIDC) Authenticate(ctx context.Context, in adaptors.OIDCAuthenticateIn) (
RefreshToken: token.RefreshToken,
}, nil
}
func (*OIDC) VerifyIDToken(ctx context.Context, in adaptors.OIDCVerifyTokenIn) (*oidc.IDToken, error) {
if in.Client != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, in.Client)
}
provider, err := oidc.NewProvider(ctx, in.Issuer)
if err != nil {
return nil, errors.Wrapf(err, "could not discovery the OIDC issuer")
}
verifier := provider.Verifier(&oidc.Config{ClientID: in.ClientID})
verifiedIDToken, err := verifier.Verify(ctx, in.IDToken)
if err != nil {
return nil, errors.Wrapf(err, "could not verify the id_token")
}
return verifiedIDToken, nil
}

View File

@@ -1,43 +1,32 @@
package authserver
import (
"crypto/rsa"
"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
Issuer string
Scope string
TLSServerCert string
TLSServerKey string
IDToken string
RefreshToken string
IDTokenKeyPair *rsa.PrivateKey
}
// Start starts a HTTP server.
func (c *Config) Start(t *testing.T) *http.Server {
func Start(t *testing.T, c Config) *http.Server {
s := &http.Server{
Addr: Addr,
Addr: "localhost:9000",
Handler: newHandler(t, c),
}
go func() {
var err error
if c.Cert != "" && c.Key != "" {
err = s.ListenAndServeTLS(c.Cert, c.Key)
if c.TLSServerCert != "" && c.TLSServerKey != "" {
err = s.ListenAndServeTLS(c.TLSServerCert, c.TLSServerKey)
} else {
err = s.ListenAndServe()
}

View File

@@ -1,76 +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)
}
}
@@ -78,12 +67,12 @@ 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
@@ -98,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 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 }}"
}

View File

@@ -3,170 +3,228 @@ package adaptors_test
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"io/ioutil"
"net/http"
"os"
"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"
"github.com/pkg/errors"
"golang.org/x/sync/errgroup"
)
// End-to-end test.
// Run the integration tests.
//
// 1. Start the auth server at port 9000.
// 2. Run the CLI.
// 2. Run the Cmd.
// 3. Open a request for port 8000.
// 4. Wait for the CLI.
// 4. Wait for the Cmd.
// 5. Shutdown the auth server.
//
func TestCmd_Run(t *testing.T) {
data := map[string]struct {
kubeconfigValues kubeconfig.Values
args []string
serverConfig authserver.Config
clientTLS *tls.Config
}{
"NoTLS": {
kubeconfig.Values{Issuer: "http://localhost:9000"},
[]string{"kubelogin"},
authserver.Config{Issuer: "http://localhost:9000"},
&tls.Config{},
},
"ExtraScope": {
kubeconfig.Values{
Issuer: "http://localhost:9000",
ExtraScopes: "profile groups",
},
[]string{"kubelogin"},
authserver.Config{
Issuer: "http://localhost:9000",
Scope: "profile groups openid",
},
&tls.Config{},
},
"SkipTLSVerify": {
kubeconfig.Values{Issuer: "https://localhost:9000"},
[]string{"kubelogin", "--insecure-skip-tls-verify"},
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,
},
[]string{"kubelogin"},
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)),
},
[]string{"kubelogin"},
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: "cmd_test.go",
},
[]string{"kubelogin"},
authserver.Config{Issuer: "http://localhost:9000"},
&tls.Config{},
},
"InvalidCACertDataShouldBeSkipped": {
kubeconfig.Values{
Issuer: "http://localhost:9000",
IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte("foo")),
},
[]string{"kubelogin"},
authserver.Config{Issuer: "http://localhost:9000"},
&tls.Config{},
},
}
timeout := 1 * time.Second
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)
t.Run("NoTLS", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
args := append(c.args, "--kubeconfig", kcfg, "--skip-open-browser")
var eg errgroup.Group
eg.Go(func() error {
return di.Invoke(func(cmd adaptors.Cmd) {
exitCode := cmd.Run(ctx, args, "HEAD")
if exitCode != 0 {
t.Errorf("exit status wants 0 but %d", exitCode)
}
})
})
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)
idToken := newIDToken(t, "http://localhost:9000")
serverConfig := authserver.Config{
Issuer: "http://localhost:9000",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
}
server := authserver.Start(t, serverConfig)
defer server.Shutdown(ctx)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: "http://localhost:9000",
})
defer os.Remove(kubeConfigFilename)
startBrowserRequest(t, ctx, nil)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser")
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("ExtraScopes", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "http://localhost:9000")
serverConfig := authserver.Config{
Issuer: "http://localhost:9000",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
Scope: "profile groups openid",
}
server := authserver.Start(t, serverConfig)
defer server.Shutdown(ctx)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: "http://localhost:9000",
ExtraScopes: "profile,groups",
})
defer os.Remove(kubeConfigFilename)
startBrowserRequest(t, ctx, nil)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser")
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("CACert", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "https://localhost:9000")
serverConfig := authserver.Config{
Issuer: "https://localhost:9000",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
TLSServerCert: keys.TLSServerCert,
TLSServerKey: keys.TLSServerKey,
}
server := authserver.Start(t, serverConfig)
defer server.Shutdown(ctx)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: "https://localhost:9000",
IDPCertificateAuthority: keys.TLSCACert,
})
defer os.Remove(kubeConfigFilename)
startBrowserRequest(t, ctx, keys.TLSCACertAsConfig)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser")
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("CACertData", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
idToken := newIDToken(t, "https://localhost:9000")
serverConfig := authserver.Config{
Issuer: "https://localhost:9000",
IDToken: idToken,
IDTokenKeyPair: keys.JWSKeyPair,
RefreshToken: "REFRESH_TOKEN",
TLSServerCert: keys.TLSServerCert,
TLSServerKey: keys.TLSServerKey,
}
server := authserver.Start(t, serverConfig)
defer server.Shutdown(ctx)
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: "https://localhost:9000",
IDPCertificateAuthorityData: keys.TLSCACertAsBase64,
})
defer os.Remove(kubeConfigFilename)
startBrowserRequest(t, ctx, keys.TLSCACertAsConfig)
runCmd(t, ctx, "--kubeconfig", kubeConfigFilename, "--skip-open-browser")
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
IDToken: idToken,
RefreshToken: "REFRESH_TOKEN",
})
})
t.Run("AlreadyHaveValidToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
serverConfig := authserver.Config{
Issuer: "http://localhost:9000",
IDTokenKeyPair: keys.JWSKeyPair,
}
server := authserver.Start(t, serverConfig)
defer server.Shutdown(ctx)
idToken := newIDToken(t, "http://localhost:9000")
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
Issuer: "http://localhost:9000",
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 openBrowserRequest(tlsConfig *tls.Config) error {
time.Sleep(50 * time.Millisecond)
func startBrowserRequest(t *testing.T, ctx context.Context, tlsConfig *tls.Config) {
t.Helper()
client := http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
res, err := client.Get("http://localhost:8000/")
req, err := http.NewRequest("GET", "http://localhost:8000/", nil)
if err != nil {
return errors.Wrapf(err, "could not send a request")
t.Errorf("could not create a request: %s", err)
return
}
if res.StatusCode != 200 {
return errors.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
req = req.WithContext(ctx)
go func() {
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)
}
}()
}

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

@@ -3,8 +3,10 @@ package kubeconfig
import (
"html/template"
"io/ioutil"
"strings"
"os"
"testing"
"gopkg.in/yaml.v2"
)
// Values represents values in .kubeconfig template.
@@ -13,6 +15,7 @@ type Values struct {
ExtraScopes string
IDPCertificateAuthority string
IDPCertificateAuthorityData string
IDToken string
}
// Create creates a kubeconfig file and returns path to it.
@@ -33,16 +36,44 @@ func Create(t *testing.T, v *Values) string {
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) {
b, err := ioutil.ReadFile(kubeconfig)
func Verify(t *testing.T, kubeconfig string, want AuthProviderConfig) {
t.Helper()
f, err := os.Open(kubeconfig)
if err != nil {
t.Fatal(err)
t.Errorf("could not open kubeconfig: %s", err)
return
}
if strings.Index(string(b), "id-token: ey") == -1 {
t.Errorf("kubeconfig wants id-token but %s", string(b))
defer f.Close()
var y struct {
Users []struct {
User struct {
AuthProvider struct {
Config AuthProviderConfig `yaml:"config"`
} `yaml:"auth-provider"`
} `yaml:"user"`
} `yaml:"users"`
}
if strings.Index(string(b), "refresh-token: 44df4c82-5ce7-4260-b54d-1da0d396ef2a") == -1 {
t.Errorf("kubeconfig wants refresh-token but %s", string(b))
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

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

@@ -16,13 +16,21 @@ var constructors = []interface{}{
adaptors.NewKubeConfig,
adaptors.NewOIDC,
adaptors.NewHTTP,
}
var extraConstructors = []interface{}{
adaptors.NewLogger,
}
// Invoke runs the function with an adaptors.Cmd instance.
// 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 constructors {
for _, constructor := range append(constructors, extra...) {
if err := c.Provide(constructor); err != nil {
return errors.Wrapf(err, "could not provide the constructor")
}

4
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/golang/mock v1.2.0
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/int128/oauth2cli v1.2.1
github.com/jessevdk/go-flags v1.4.0
github.com/json-iterator/go v1.1.6 // indirect
github.com/mitchellh/go-homedir v1.1.0
@@ -26,7 +26,7 @@ require (
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

2
go.sum
View File

@@ -18,6 +18,8 @@ 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/int128/oauth2cli v1.2.1 h1:rhYQ++Kijz/sleAfzy2u2qEsQJCQSHVYjANgOM/LfLA=
github.com/int128/oauth2cli v1.2.1/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/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs=

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

@@ -64,6 +64,11 @@ func (c *OIDCAuthProvider) ExtraScopes() []string {
return strings.Split(c.Config["extra-scopes"], ",")
}
// IDToken returns the id-token.
func (c *OIDCAuthProvider) IDToken() string {
return c.Config["id-token"]
}
// SetIDToken replaces the id-token.
func (c *OIDCAuthProvider) SetIDToken(idToken string) {
c.Config["id-token"] = idToken

View File

@@ -5,7 +5,7 @@ class Kubelogin < Formula
version "{{ env "VERSION" }}"
sha256 "{{ .darwin_amd64_zip_sha256 }}"
def install
bin.install "kubelogin_darwin_amd64" => "kubelogin"
bin.install "kubelogin" => "kubelogin"
ln_s bin/"kubelogin", bin/"kubectl-oidc_login"
end
test do

View File

@@ -3,6 +3,7 @@ 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"
@@ -30,15 +31,18 @@ type Login struct {
}
func (u *Login) Do(ctx context.Context, in usecases.LoginIn) error {
u.Logger.Debugf(1, "WARNING: Log may contain your secrets, e.g. token or password")
u.Logger.Debugf(1, "Loading %s", in.KubeConfig)
cfg, err := u.KubeConfig.LoadFromFile(in.KubeConfig)
if err != nil {
return errors.Wrapf(err, "could not read the kubeconfig")
}
u.Logger.Logf("Using current-context: %s", cfg.CurrentContext)
u.Logger.Printf("Using current-context: %s", cfg.CurrentContext)
authProvider, err := kubeconfig.FindOIDCAuthProvider(cfg)
if err != nil {
u.Logger.Logf(oidcConfigErrorMessage, cfg.CurrentContext)
u.Logger.Printf(oidcConfigErrorMessage, cfg.CurrentContext)
return errors.Wrapf(err, "could not find an oidc auth-provider in the kubeconfig")
}
@@ -46,16 +50,16 @@ func (u *Login) Do(ctx context.Context, in usecases.LoginIn) error {
clientConfig.SetSkipTLSVerify(in.SkipTLSVerify)
if authProvider.IDPCertificateAuthority() != "" {
filename := authProvider.IDPCertificateAuthority()
u.Logger.Logf("Using the certificate %s", filename)
u.Logger.Printf("Using the certificate %s", filename)
if err := clientConfig.AddCertificateFromFile(filename); err != nil {
u.Logger.Logf("Skip the certificate %s: %s", filename, err)
u.Logger.Printf("Skip the certificate %s: %s", filename, err)
}
}
if authProvider.IDPCertificateAuthorityData() != "" {
encoded := authProvider.IDPCertificateAuthorityData()
u.Logger.Logf("Using certificate of idp-certificate-authority-data")
u.Logger.Printf("Using the certificate of idp-certificate-authority-data")
if err := clientConfig.AddEncodedCertificate(encoded); err != nil {
u.Logger.Logf("Skip the certificate of idp-certificate-authority-data: %s", err)
u.Logger.Printf("Skip the certificate of idp-certificate-authority-data: %s", err)
}
}
hc, err := u.HTTP.NewClient(clientConfig)
@@ -63,25 +67,67 @@ func (u *Login) Do(ctx context.Context, in usecases.LoginIn) error {
return errors.Wrapf(err, "could not create a HTTP client")
}
out, err := u.OIDC.Authenticate(ctx, adaptors.OIDCAuthenticateIn{
Issuer: authProvider.IDPIssuerURL(),
ClientID: authProvider.ClientID(),
ClientSecret: authProvider.ClientSecret(),
ExtraScopes: authProvider.ExtraScopes(),
Client: hc,
LocalServerPort: in.ListenPort,
SkipOpenBrowser: in.SkipOpenBrowser,
})
if token := u.verifyIDToken(ctx, adaptors.OIDCVerifyTokenIn{
IDToken: authProvider.IDToken(),
Issuer: authProvider.IDPIssuerURL(),
ClientID: authProvider.ClientID(),
Client: hc,
}); token != nil {
u.Logger.Printf("You already have a valid token until %s", token.Expiry)
u.dumpIDToken(token)
return nil
}
out, err := u.OIDC.Authenticate(ctx,
adaptors.OIDCAuthenticateIn{
Issuer: authProvider.IDPIssuerURL(),
ClientID: authProvider.ClientID(),
ClientSecret: authProvider.ClientSecret(),
ExtraScopes: authProvider.ExtraScopes(),
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 token from OIDC provider")
}
u.Logger.Logf("Got a token for subject=%s", out.VerifiedIDToken.Subject)
u.Logger.Printf("You got a valid token until %s", out.VerifiedIDToken.Expiry)
u.dumpIDToken(out.VerifiedIDToken)
authProvider.SetIDToken(out.IDToken)
authProvider.SetRefreshToken(out.RefreshToken)
u.Logger.Debugf(1, "Writing the ID token and refresh token to %s", in.KubeConfig)
if err := u.KubeConfig.WriteToFile(cfg, in.KubeConfig); err != nil {
return errors.Wrapf(err, "could not update the kubeconfig")
}
u.Logger.Logf("Updated %s", in.KubeConfig)
u.Logger.Printf("Updated %s", in.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) verifyIDToken(ctx context.Context, in adaptors.OIDCVerifyTokenIn) *oidc.IDToken {
if in.IDToken == "" {
return nil
}
token, err := u.OIDC.VerifyIDToken(ctx, in)
if err != nil {
u.Logger.Debugf(1, "Could not verify the ID token in the kubeconfig: %s", err)
return nil
}
return token
}

View File

@@ -91,6 +91,9 @@ func TestLogin_Do(t *testing.T) {
ExtraScopes: []string{},
LocalServerPort: 10000,
Client: httpClient,
}, gomock.Any()).
Do(func(_ context.Context, _ adaptors.OIDCAuthenticateIn, cb adaptors.OIDCAuthenticateCallback) {
cb.ShowLocalServerURL("http://localhost:10000")
}).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
@@ -102,7 +105,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
@@ -133,7 +136,7 @@ func TestLogin_Do(t *testing.T) {
ExtraScopes: []string{},
LocalServerPort: 10000,
Client: httpClient,
}).
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
@@ -144,7 +147,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
@@ -177,7 +180,7 @@ func TestLogin_Do(t *testing.T) {
LocalServerPort: 10000,
Client: httpClient,
SkipOpenBrowser: true,
}).
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
@@ -188,7 +191,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
@@ -199,6 +202,98 @@ func TestLogin_Do(t *testing.T) {
}
})
t.Run("KubeConfig/ValidToken", func(t *testing.T) {
inConfig := newInConfig()
inConfig.AuthInfos["google"].AuthProvider.Config["id-token"] = "VALID"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
kubeConfig := mock_adaptors.NewMockKubeConfig(ctrl)
kubeConfig.EXPECT().
LoadFromFile("/path/to/kubeconfig").
Return(inConfig, nil)
httpClientConfig := mock_adaptors.NewMockHTTPClientConfig(ctrl)
httpClientConfig.EXPECT().
SetSkipTLSVerify(false)
mockOIDC := mock_adaptors.NewMockOIDC(ctrl)
mockOIDC.EXPECT().
VerifyIDToken(ctx, adaptors.OIDCVerifyTokenIn{
IDToken: "VALID",
Issuer: "https://accounts.google.com",
ClientID: "YOUR_CLIENT_ID",
Client: httpClient,
}).
Return(&oidc.IDToken{}, nil)
u := Login{
KubeConfig: kubeConfig,
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
ListenPort: 10000,
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("KubeConfig/InvalidToken", func(t *testing.T) {
inConfig := newInConfig()
inConfig.AuthInfos["google"].AuthProvider.Config["id-token"] = "EXPIRED"
outConfig := newOutConfig(inConfig)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.TODO()
httpClientConfig := mock_adaptors.NewMockHTTPClientConfig(ctrl)
httpClientConfig.EXPECT().
SetSkipTLSVerify(false)
mockOIDC := mock_adaptors.NewMockOIDC(ctrl)
mockOIDC.EXPECT().
VerifyIDToken(ctx, adaptors.OIDCVerifyTokenIn{
IDToken: "EXPIRED",
Issuer: "https://accounts.google.com",
ClientID: "YOUR_CLIENT_ID",
Client: httpClient,
}).
Return(nil, errors.New("token is expired"))
mockOIDC.EXPECT().
Authenticate(ctx, adaptors.OIDCAuthenticateIn{
Issuer: "https://accounts.google.com",
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
ExtraScopes: []string{},
LocalServerPort: 10000,
Client: httpClient,
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
RefreshToken: "YOUR_REFRESH_TOKEN",
}, nil)
u := Login{
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
ListenPort: 10000,
}); err != nil {
t.Errorf("Do returned error: %+v", err)
}
})
t.Run("KubeConfig/extra-scopes", func(t *testing.T) {
inConfig := newInConfig()
inConfig.AuthInfos["google"].AuthProvider.Config["extra-scopes"] = "email,profile"
@@ -221,7 +316,7 @@ func TestLogin_Do(t *testing.T) {
ExtraScopes: []string{"email", "profile"},
LocalServerPort: 10000,
Client: httpClient,
}).
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
@@ -232,7 +327,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
@@ -266,7 +361,7 @@ func TestLogin_Do(t *testing.T) {
ExtraScopes: []string{},
LocalServerPort: 10000,
Client: httpClient,
}).
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
@@ -277,7 +372,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
@@ -312,7 +407,7 @@ func TestLogin_Do(t *testing.T) {
ExtraScopes: []string{},
LocalServerPort: 10000,
Client: httpClient,
}).
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
@@ -323,7 +418,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
@@ -357,7 +452,7 @@ func TestLogin_Do(t *testing.T) {
ExtraScopes: []string{},
LocalServerPort: 10000,
Client: httpClient,
}).
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
@@ -368,7 +463,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",
@@ -403,7 +498,7 @@ func TestLogin_Do(t *testing.T) {
ExtraScopes: []string{},
LocalServerPort: 10000,
Client: httpClient,
}).
}, gomock.Any()).
Return(&adaptors.OIDCAuthenticateOut{
VerifiedIDToken: &oidc.IDToken{Subject: "SUBJECT"},
IDToken: "YOUR_ID_TOKEN",
@@ -414,7 +509,7 @@ func TestLogin_Do(t *testing.T) {
KubeConfig: newMockKubeConfig(ctrl, inConfig, outConfig),
HTTP: newMockHTTP(ctrl, httpClientConfig),
OIDC: mockOIDC,
Logger: t,
Logger: mock_adaptors.NewLogger(t, ctrl),
}
if err := u.Do(ctx, usecases.LoginIn{
KubeConfig: "/path/to/kubeconfig",