Compare commits

..

5 Commits
1.4.1 ... 1.5

Author SHA1 Message Date
Hidetake Iwata
9bf8a89577 Merge pull request #13 from int128/extra-scopes
Add extra-scopes support
2018-09-02 14:20:35 +09:00
Hidetake Iwata
a91c020f46 Update README.md 2018-09-02 14:19:23 +09:00
Hidetake Iwata
d4fb49613d Add extra-scopes support 2018-08-31 21:02:34 +09:00
Hidetake Iwata
64b1d52208 Fix test says message if CLI returns error 2018-08-31 15:19:58 +09:00
Hidetake Iwata
a298058e3f Refactor test 2018-08-31 14:59:50 +09:00
15 changed files with 185 additions and 104 deletions

View File

@@ -10,7 +10,7 @@ jobs:
- run: go get github.com/golang/lint/golint
- run: golint
- run: go build -v
- run: make -C e2e/testdata
- run: make -C e2e/authserver/testdata
- run: go test -v ./...
release:

View File

@@ -1,6 +1,6 @@
# kubelogin [![CircleCI](https://circleci.com/gh/int128/kubelogin.svg?style=shield)](https://circleci.com/gh/int128/kubelogin)
This is a helper command for [Kubernetes OpenID Connect (OIDC) authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
This is a command 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.
This may work with various OIDC providers such as Keycloak, Google Identity Platform and Azure AD.
@@ -10,7 +10,7 @@ This may work with various OIDC providers such as Keycloak, Google Identity Plat
You need to setup the OIDC provider and [Kubernetes OIDC authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
After setup or when the token has been expired, just run `kubelogin`:
After initial setup or when the token has been expired, just run `kubelogin`:
```
% kubelogin
@@ -198,7 +198,7 @@ Help Options:
-h, --help Show this help message
```
This supports the following `auth-provider` keys in kubeconfig.
This supports the following keys of `auth-provider` in kubeconfig.
See also [kubectl authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-kubectl).
Key | Direction | Value
@@ -208,6 +208,7 @@ Key | Direction | Value
`client-secret` | IN (Required) | Client Secret of the provider.
`idp-certificate-authority` | IN (Optional) | CA certificate path of the provider.
`idp-certificate-authority-data` | IN (Optional) | Base64 encoded CA certificate of the provider.
`extra-scopes` | IN (Optional) | Scopes to request to the provider (comma separated).
`id-token` | OUT | ID token got from the provider.
`refresh-token` | OUT | Refresh token got from the provider.
@@ -272,10 +273,11 @@ go get github.com/int128/kubelogin
```sh
cd $GOPATH/src/github.com/int128/kubelogin
make -C e2e/testdata
make -C e2e/authserver/testdata
go test -v ./...
```
### Release
CircleCI publishes the build to GitHub. See [.circleci/config.yml](.circleci/config.yml).
CircleCI publishes the build to GitHub.
See [.circleci/config.yml](.circleci/config.yml).

View File

@@ -70,6 +70,7 @@ func (c *CLI) Run(ctx context.Context) error {
Issuer: authProvider.IDPIssuerURL(),
ClientID: authProvider.ClientID(),
ClientSecret: authProvider.ClientSecret(),
ExtraScopes: authProvider.ExtraScopes(),
Client: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}},
ServerPort: 8000,
SkipOpenBrowser: c.SkipOpenBrowser,

View File

@@ -0,0 +1,49 @@
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,45 +1,47 @@
package e2e
package authserver
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"html/template"
"log"
"math/big"
"net/http"
"testing"
"text/template"
"time"
jwt "github.com/dgrijalva/jwt-go"
)
// AuthHandler provides the stub handler for OIDC authentication.
type AuthHandler struct {
// Values in templates
type handler struct {
discovery *template.Template
token *template.Template
jwks *template.Template
authCode string
Issuer string
AuthCode string
Scope string // Default to openid
IDToken string
PrivateKey struct{ N, E string }
// Response templates
discoveryJSON *template.Template
tokenJSON *template.Template
jwksJSON *template.Template
}
// NewAuthHandler returns a new AuthHandler.
func NewAuthHandler(t *testing.T, issuer string) *AuthHandler {
h := &AuthHandler{
Issuer: issuer,
AuthCode: "0b70006b-f62a-4438-aba5-c0b96775d8e5",
discoveryJSON: template.Must(template.ParseFiles("testdata/oidc-discovery.json")),
tokenJSON: template.Must(template.ParseFiles("testdata/oidc-token.json")),
jwksJSON: template.Must(template.ParseFiles("testdata/oidc-jwks.json")),
func newHandler(t *testing.T, c *Config) *handler {
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,
}
if h.Scope == "" {
h.Scope = "openid"
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.StandardClaims{
Issuer: h.Issuer,
Issuer: c.Issuer,
Audience: "kubernetes",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
})
@@ -53,24 +55,43 @@ func NewAuthHandler(t *testing.T, issuer string) *AuthHandler {
}
h.PrivateKey.E = base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes())
h.PrivateKey.N = base64.RawURLEncoding.EncodeToString(k.N.Bytes())
return h
return &h
}
func (s *AuthHandler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
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)
w.WriteHeader(500)
}
}
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)
switch {
case m == "GET" && p == "/.well-known/openid-configuration":
w.Header().Add("Content-Type", "application/json")
if err := s.discoveryJSON.Execute(w, s); err != nil {
if err := h.discovery.Execute(w, h); err != nil {
return err
}
case m == "GET" && p == "/protocol/openid-connect/auth":
// Authentication Response
// http://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
q := r.URL.Query()
to := fmt.Sprintf("%s?state=%s&code=%s", q.Get("redirect_uri"), q.Get("state"), s.AuthCode)
if h.Scope != q.Get("scope") {
return fmt.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)
case m == "POST" && p == "/protocol/openid-connect/token":
// Token Response
@@ -78,16 +99,16 @@ func (s *AuthHandler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
if s.AuthCode != r.Form.Get("code") {
return fmt.Errorf("code wants %s but %s", s.AuthCode, r.Form.Get("code"))
if h.authCode != r.Form.Get("code") {
return fmt.Errorf("code wants %s but %s", h.authCode, r.Form.Get("code"))
}
w.Header().Add("Content-Type", "application/json")
if err := s.tokenJSON.Execute(w, s); err != nil {
if err := h.token.Execute(w, h); err != nil {
return err
}
case m == "GET" && p == "/protocol/openid-connect/certs":
w.Header().Add("Content-Type", "application/json")
if err := s.jwksJSON.Execute(w, s); err != nil {
if err := h.jwks.Execute(w, h); err != nil {
return err
}
default:
@@ -95,10 +116,3 @@ func (s *AuthHandler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
}
return nil
}
func (s *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := s.serveHTTP(w, r); err != nil {
log.Printf("[auth-server] Error: %s", err)
w.WriteHeader(500)
}
}

View File

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

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io/ioutil"
"net/http"
"os"
@@ -12,13 +13,10 @@ import (
"time"
"github.com/int128/kubelogin/cli"
"github.com/int128/kubelogin/e2e/authserver"
"golang.org/x/sync/errgroup"
)
const tlsCACert = "testdata/authserver-ca.crt"
const tlsServerCert = "testdata/authserver.crt"
const tlsServerKey = "testdata/authserver.key"
// End-to-end test.
//
// 1. Start the auth server at port 9000.
@@ -30,38 +28,62 @@ func TestE2E(t *testing.T) {
data := map[string]struct {
kubeconfigValues kubeconfigValues
cli cli.CLI
startServer func(*testing.T, http.Handler) *http.Server
authClientTLS *tls.Config
serverConfig authserver.Config
clientTLS *tls.Config
}{
"NoTLS": {
kubeconfigValues{Issuer: "http://localhost:9000"},
cli.CLI{},
startServer,
authserver.Config{Issuer: "http://localhost:9000"},
&tls.Config{},
},
"ExtraScope": {
kubeconfigValues{
Issuer: "http://localhost:9000",
ExtraScopes: "profile groups",
},
cli.CLI{},
authserver.Config{
Issuer: "http://localhost:9000",
Scope: "profile groups openid",
},
&tls.Config{},
},
"SkipTLSVerify": {
kubeconfigValues{Issuer: "https://localhost:9000"},
cli.CLI{SkipTLSVerify: true},
startServerTLS,
authserver.Config{
Issuer: "https://localhost:9000",
Cert: authserver.ServerCert,
Key: authserver.ServerKey,
},
&tls.Config{InsecureSkipVerify: true},
},
"CACert": {
kubeconfigValues{
Issuer: "https://localhost:9000",
IDPCertificateAuthority: tlsCACert,
IDPCertificateAuthority: authserver.CACert,
},
cli.CLI{},
startServerTLS,
&tls.Config{RootCAs: readCert(t, tlsCACert)},
authserver.Config{
Issuer: "https://localhost:9000",
Cert: authserver.ServerCert,
Key: authserver.ServerKey,
},
&tls.Config{RootCAs: readCert(t, authserver.CACert)},
},
"CACertData": {
kubeconfigValues{
Issuer: "https://localhost:9000",
IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString(read(t, tlsCACert)),
IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString(read(t, authserver.CACert)),
},
cli.CLI{},
startServerTLS,
&tls.Config{RootCAs: readCert(t, tlsCACert)},
authserver.Config{
Issuer: "https://localhost:9000",
Cert: authserver.ServerCert,
Key: authserver.ServerKey,
},
&tls.Config{RootCAs: readCert(t, authserver.CACert)},
},
}
@@ -69,8 +91,8 @@ func TestE2E(t *testing.T) {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
authServer := c.startServer(t, NewAuthHandler(t, c.kubeconfigValues.Issuer))
defer authServer.Shutdown(ctx)
server := c.serverConfig.Start(t)
defer server.Shutdown(ctx)
kubeconfig := createKubeconfig(t, &c.kubeconfigValues)
defer os.Remove(kubeconfig)
c.cli.KubeConfig = kubeconfig
@@ -80,17 +102,10 @@ func TestE2E(t *testing.T) {
eg.Go(func() error {
return c.cli.Run(ctx)
})
time.Sleep(50 * time.Millisecond)
client := http.Client{Transport: &http.Transport{TLSClientConfig: c.authClientTLS}}
res, err := client.Get("http://localhost:8000/")
if err != nil {
t.Fatalf("Could not send a request: %s", err)
if err := openBrowserRequest(c.clientTLS); err != nil {
cancel()
t.Error(err)
}
if res.StatusCode != 200 {
t.Fatalf("StatusCode wants 200 but %d", res.StatusCode)
}
if err := eg.Wait(); err != nil {
t.Fatalf("CLI returned error: %s", err)
}
@@ -99,30 +114,17 @@ func TestE2E(t *testing.T) {
}
}
func startServer(t *testing.T, h http.Handler) *http.Server {
s := &http.Server{
Addr: "localhost:9000",
Handler: h,
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)
}
go func() {
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
t.Error(err)
}
}()
return s
}
func startServerTLS(t *testing.T, h http.Handler) *http.Server {
s := &http.Server{
Addr: "localhost:9000",
Handler: h,
if res.StatusCode != 200 {
return fmt.Errorf("StatusCode wants 200 but %d", res.StatusCode)
}
go func() {
if err := s.ListenAndServeTLS(tlsServerCert, tlsServerKey); err != nil && err != http.ErrServerClosed {
t.Error(err)
}
}()
return s
return nil
}
func read(t *testing.T, name string) []byte {

View File

@@ -9,6 +9,7 @@ import (
type kubeconfigValues struct {
Issuer string
ExtraScopes string
IDPCertificateAuthority string
IDPCertificateAuthorityData string
}

View File

@@ -19,6 +19,9 @@ users:
client-id: kubernetes
client-secret: a3c508c3-73c9-42e2-ab14-487a1bf67c33
idp-issuer-url: {{ .Issuer }}
#{{ if .ExtraScopes }}
extra-scopes: {{ .ExtraScopes }}
#{{ end }}
#{{ if .IDPCertificateAuthority }}
idp-certificate-authority: {{ .IDPCertificateAuthority }}
#{{ end }}

View File

@@ -2,6 +2,7 @@ package kubeconfig
import (
"fmt"
"strings"
"k8s.io/client-go/tools/clientcmd/api"
)
@@ -55,6 +56,14 @@ func (c *OIDCAuthProvider) IDPCertificateAuthorityData() string {
return c.Config["idp-certificate-authority-data"]
}
// ExtraScopes returns the extra-scopes.
func (c *OIDCAuthProvider) ExtraScopes() []string {
if c.Config["extra-scopes"] == "" {
return []string{}
}
return strings.Split(c.Config["extra-scopes"], ",")
}
// SetIDToken replaces the id-token.
func (c *OIDCAuthProvider) SetIDToken(idToken string) {
c.Config["id-token"] = idToken