mirror of
https://github.com/int128/kubelogin.git
synced 2026-02-19 19:09:50 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8fbd81be1 |
@@ -6,12 +6,10 @@ jobs:
|
||||
working_directory: /go/src/github.com/int128/kubelogin
|
||||
steps:
|
||||
- checkout
|
||||
- run: go get -v -t -d ./...
|
||||
- run: go get -v -t -d
|
||||
- run: go get github.com/golang/lint/golint
|
||||
- run: golint
|
||||
- run: go build -v
|
||||
- run: make -C e2e/authserver/testdata
|
||||
- run: go test -v ./...
|
||||
|
||||
release:
|
||||
docker:
|
||||
@@ -19,7 +17,7 @@ jobs:
|
||||
working_directory: /go/src/github.com/int128/kubelogin
|
||||
steps:
|
||||
- checkout
|
||||
- run: go get -v -t -d ./...
|
||||
- run: go get -v -t -d
|
||||
- run: go get github.com/mitchellh/gox
|
||||
- run: go get github.com/tcnksm/ghr
|
||||
- run: gox --osarch 'darwin/amd64 linux/amd64 windows/amd64 windows/386' -output 'dist/{{.Dir}}_{{.OS}}_{{.Arch}}'
|
||||
|
||||
@@ -8,8 +8,3 @@ indent_size = 2
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1 @@
|
||||
/kubelogin
|
||||
/.kubeconfig
|
||||
|
||||
332
README.md
332
README.md
@@ -1,283 +1,129 @@
|
||||
# kubelogin [](https://circleci.com/gh/int128/kubelogin)
|
||||
|
||||
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.
|
||||
`kubelogin` is a command to get an OpenID Connect (OIDC) token for `kubectl` authentication.
|
||||
|
||||
|
||||
## TL;DR
|
||||
## Getting Started
|
||||
|
||||
You need to setup the OIDC provider and [Kubernetes OIDC authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
|
||||
Download [the latest release](https://github.com/int128/kubelogin/releases) and save it as `/usr/local/bin/kubelogin`.
|
||||
|
||||
After initial setup or when the token has been expired, just run `kubelogin`:
|
||||
Run the command.
|
||||
|
||||
```
|
||||
% 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/03/23 18:01:40 Reading config from /home/user/.kube/config
|
||||
2018/03/23 18:01:40 Using current context: hello.k8s.local
|
||||
2018/03/23 18:01:40 Using issuer: https://keycloak.example.com/auth/realms/hello
|
||||
2018/03/23 18:01:40 Using client ID: kubernetes
|
||||
2018/03/23 18:01:41 Starting OpenID Connect authentication:
|
||||
|
||||
## Automatic (recommended)
|
||||
|
||||
Open the following URL in the web browser:
|
||||
|
||||
http://localhost:8000/
|
||||
|
||||
## Manual
|
||||
|
||||
If you cannot access to localhost, instead open the following URL:
|
||||
|
||||
https://keycloak.example.com/auth/realms/hello/protocol/openid-connect/auth?client_id=kubernetes&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&response_type=code&scope=openid+email&state=********
|
||||
|
||||
Enter the code:
|
||||
```
|
||||
|
||||
It opens the browser and you can log in to the provider.
|
||||
After you logged in to the provider, it closes the browser automatically.
|
||||
And then open http://localhost:8000.
|
||||
If you cannot access to localhost, you can choose manual interaction instead.
|
||||
|
||||
Then it writes the ID token and refresh token to the kubeconfig.
|
||||
|
||||
```
|
||||
2018/08/27 15:03:07 GET /
|
||||
2018/08/27 15:03:08 GET /?state=a51081925f20c043&session_state=5637cbdf-ffdc-4fab-9fc7-68a3e6f2e73f&code=ey...
|
||||
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
|
||||
```
|
||||
|
||||
Please see the later section for details.
|
||||
|
||||
|
||||
## Getting Started with Google Account
|
||||
|
||||
### 1. Setup Google API
|
||||
|
||||
Open [Google APIs Console](https://console.developers.google.com/apis/credentials) and create an OAuth client as follows:
|
||||
|
||||
- Application Type: Web application
|
||||
- Redirect URL: `http://localhost:8000/`
|
||||
|
||||
### 2. Setup Kubernetes cluster
|
||||
|
||||
Configure your Kubernetes API Server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
|
||||
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
|
||||
`kubelogin` will update your `~/.kube/config` with the ID token and refresh token, as follows:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
kubeAPIServer:
|
||||
oidcIssuerURL: https://accounts.google.com
|
||||
oidcClientID: YOUR_CLIENT_ID.apps.googleusercontent.com
|
||||
```
|
||||
|
||||
Here assign the `cluster-admin` role to your user.
|
||||
|
||||
```yaml
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: oidc-admin-group
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cluster-admin
|
||||
subjects:
|
||||
- kind: User
|
||||
name: https://accounts.google.com#1234567890
|
||||
```
|
||||
|
||||
### 3. Setup kubectl and kubelogin
|
||||
|
||||
Setup `kubectl` to authenticate with your identity provider.
|
||||
|
||||
```sh
|
||||
kubectl config set-credentials CLUSTER_NAME \
|
||||
--auth-provider oidc \
|
||||
--auth-provider-arg idp-issuer-url=https://accounts.google.com \
|
||||
--auth-provider-arg client-id=YOUR_CLIENT_ID.apps.googleusercontent.com \
|
||||
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET
|
||||
```
|
||||
|
||||
Download [the latest release](https://github.com/int128/kubelogin/releases) and save it.
|
||||
|
||||
Run `kubelogin` and open http://localhost:8000 in your browser.
|
||||
|
||||
```
|
||||
% kubelogin
|
||||
2018/08/10 10:36:38 Reading .kubeconfig
|
||||
2018/08/10 10:36:38 Using current context: hello.k8s.local
|
||||
2018/08/10 10:36:41 Open http://localhost:8000 for authorization
|
||||
2018/08/10 10:36:45 GET /
|
||||
2018/08/10 10:37:07 GET /?state=...&session_state=...&code=ey...
|
||||
2018/08/10 10:37:08 Updated .kubeconfig
|
||||
```
|
||||
|
||||
Now your `~/.kube/config` should be like:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
- name: hello.k8s.local
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
idp-issuer-url: https://accounts.google.com
|
||||
client-id: YOUR_CLIENT_ID.apps.googleusercontent.com
|
||||
client-secret: YOUR_SECRET
|
||||
id-token: ey... # kubelogin will update ID token here
|
||||
refresh-token: ey... # kubelogin will update refresh token here
|
||||
name: oidc
|
||||
```
|
||||
|
||||
Make sure you can access to the Kubernetes cluster.
|
||||
|
||||
```
|
||||
% kubectl get nodes
|
||||
NAME STATUS ROLES AGE VERSION
|
||||
ip-1-2-3-4.us-west-2.compute.internal Ready node 21d v1.9.6
|
||||
ip-1-2-3-5.us-west-2.compute.internal Ready node 20d v1.9.6
|
||||
```
|
||||
|
||||
|
||||
## Getting Started with Keycloak
|
||||
|
||||
### 1. Setup Keycloak
|
||||
|
||||
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`
|
||||
|
||||
Then create a group `kubernetes:admin` and join to it.
|
||||
|
||||
### 2. Setup Kubernetes cluster
|
||||
|
||||
Configure your Kubernetes API Server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
|
||||
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
kubeAPIServer:
|
||||
oidcIssuerURL: https://keycloak.example.com/auth/realms/YOUR_REALM
|
||||
oidcClientID: kubernetes
|
||||
oidcGroupsClaim: groups
|
||||
```
|
||||
|
||||
Here assign the `cluster-admin` role to the `kubernetes:admin` group.
|
||||
|
||||
```yaml
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: keycloak-admin-group
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cluster-admin
|
||||
subjects:
|
||||
- kind: Group
|
||||
name: /kubernetes:admin
|
||||
```
|
||||
|
||||
### 3. Setup kubectl and kubelogin
|
||||
|
||||
Setup `kubectl` to authenticate with your identity provider.
|
||||
|
||||
```sh
|
||||
kubectl config set-credentials CLUSTER_NAME \
|
||||
--auth-provider oidc \
|
||||
--auth-provider-arg idp-issuer-url=https://keycloak.example.com/auth/realms/YOUR_REALM \
|
||||
--auth-provider-arg client-id=kubernetes \
|
||||
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET
|
||||
```
|
||||
|
||||
Download [the latest release](https://github.com/int128/kubelogin/releases) and save it.
|
||||
|
||||
Run `kubelogin` and make sure you can access to the cluster.
|
||||
See the previous section for details.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
```
|
||||
kubelogin [OPTIONS]
|
||||
|
||||
Application Options:
|
||||
--kubeconfig= Path to the kubeconfig file (default: ~/.kube/config) [$KUBECONFIG]
|
||||
--insecure-skip-tls-verify If set, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure
|
||||
[$KUBELOGIN_INSECURE_SKIP_TLS_VERIFY]
|
||||
--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
|
||||
```
|
||||
|
||||
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
|
||||
----|-----------|------
|
||||
`idp-issuer-url` | IN (Required) | Issuer URL of the provider.
|
||||
`client-id` | IN (Required) | Client ID of the provider.
|
||||
`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.
|
||||
|
||||
|
||||
### Kubeconfig path
|
||||
|
||||
You can set the environment variable `KUBECONFIG` to point the config file.
|
||||
Default to `~/.kube/config`.
|
||||
|
||||
```sh
|
||||
export KUBECONFIG="$PWD/.kubeconfig"
|
||||
```
|
||||
|
||||
### Team onboarding
|
||||
|
||||
You can share the kubeconfig to your team members for easy setup.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: LS...
|
||||
server: https://api.hello.k8s.example.com
|
||||
name: hello.k8s.local
|
||||
# ~/.kube/config (snip)
|
||||
current-context: hello.k8s.local
|
||||
contexts:
|
||||
- context:
|
||||
cluster: hello.k8s.local
|
||||
user: hello.k8s.local
|
||||
name: hello.k8s.local
|
||||
current-context: hello.k8s.local
|
||||
preferences: {}
|
||||
users:
|
||||
- name: hello.k8s.local
|
||||
user:
|
||||
auth-provider:
|
||||
name: oidc
|
||||
config:
|
||||
client-id: YOUR_CLIEND_ID
|
||||
client-secret: YOUR_CLIENT_SECRET
|
||||
idp-issuer-url: YOUR_ISSUER
|
||||
idp-issuer-url: https://keycloak.example.com/auth/realms/hello
|
||||
client-id: kubernetes
|
||||
client-secret: YOUR_SECRET
|
||||
id-token: ey... # kubelogin will update ID token
|
||||
refresh-token: ey... # kubelogin will update refresh token
|
||||
name: oidc
|
||||
```
|
||||
|
||||
If you are using kops, export the kubeconfig and edit it.
|
||||
Make sure you can access to the cluster:
|
||||
|
||||
```
|
||||
% kubectl version
|
||||
Client Version: version.Info{...}
|
||||
Server Version: version.Info{...}
|
||||
```
|
||||
|
||||
|
||||
## Prerequisite
|
||||
|
||||
You have to setup your OIDC identity provider and Kubernetes cluster.
|
||||
|
||||
### 1. Setup OIDC Identity Provider
|
||||
|
||||
This tutorial assumes you have created an OIDC client with the following:
|
||||
|
||||
- Issuer URL: `https://keycloak.example.com/auth/realms/hello`
|
||||
- Client ID: `kubernetes`
|
||||
- Client Secret: `YOUR_CLIENT_SECRET`
|
||||
- Allowed redirect URLs:
|
||||
- `http://localhost:8000/`
|
||||
- `urn:ietf:wg:oauth:2.0:oob`
|
||||
- Groups claim: `groups` (optional for group based access controll)
|
||||
|
||||
### 2. Setup Kubernetes API Server
|
||||
|
||||
Configure the Kubernetes API server allows your identity provider.
|
||||
|
||||
If you are using kops, `kops edit cluster` and append the following settings:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
kubeAPIServer:
|
||||
oidcClientID: kubernetes
|
||||
oidcGroupsClaim: groups
|
||||
oidcIssuerURL: https://keycloak.example.com/auth/realms/hello
|
||||
```
|
||||
|
||||
### 3. Setup kubectl
|
||||
|
||||
Run the following script to configure `kubectl` uses your identity provider:
|
||||
|
||||
```sh
|
||||
KUBECONFIG=.kubeconfig kops export kubecfg hello.k8s.local
|
||||
vim .kubeconfig
|
||||
CLUSTER_NAME=hello.k8s.local
|
||||
|
||||
kubectl config set-credentials $CLUSTER_NAME \
|
||||
--auth-provider oidc \
|
||||
--auth-provider-arg idp-issuer-url=https://keycloak.example.com/auth/realms/hello \
|
||||
--auth-provider-arg client-id=kubernetes \
|
||||
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET
|
||||
|
||||
# Set the context
|
||||
kubectl config set-context $CLUSTER_NAME --cluster $CLUSTER_NAME --user $CLUSTER_NAME
|
||||
```
|
||||
|
||||
In actual team operation, you can distribute the script to your team members for easy setup.
|
||||
|
||||
|
||||
## Contributions
|
||||
|
||||
This is an open source software licensed under Apache License 2.0.
|
||||
Feel free to open issues and pull requests.
|
||||
|
||||
### Build and Test
|
||||
### How to build
|
||||
|
||||
```sh
|
||||
go get github.com/int128/kubelogin
|
||||
```
|
||||
|
||||
```sh
|
||||
cd $GOPATH/src/github.com/int128/kubelogin
|
||||
make -C e2e/authserver/testdata
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
### Release
|
||||
|
||||
CircleCI publishes the build to GitHub.
|
||||
See [.circleci/config.yml](.circleci/config.yml).
|
||||
|
||||
109
auth/authcode.go
109
auth/authcode.go
@@ -1,109 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type authCodeFlow struct {
|
||||
Config *oauth2.Config
|
||||
ServerPort int // HTTP server port
|
||||
SkipOpenBrowser bool // skip opening browser if true
|
||||
}
|
||||
|
||||
func (f *authCodeFlow) getToken(ctx context.Context) (*oauth2.Token, error) {
|
||||
code, err := f.getAuthCode(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not get an auth code: %s", err)
|
||||
}
|
||||
token, err := f.Config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not exchange token: %s", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (f *authCodeFlow) getAuthCode(ctx context.Context) (string, error) {
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Could not generate state parameter: %s", err)
|
||||
}
|
||||
codeCh := make(chan string)
|
||||
defer close(codeCh)
|
||||
errCh := make(chan error)
|
||||
defer close(errCh)
|
||||
server := http.Server{
|
||||
Addr: fmt.Sprintf("localhost:%d", f.ServerPort),
|
||||
Handler: &authCodeHandler{
|
||||
authCodeURL: f.Config.AuthCodeURL(state),
|
||||
gotCode: func(code string, gotState string) {
|
||||
if gotState == state {
|
||||
codeCh <- code
|
||||
} else {
|
||||
errCh <- fmt.Errorf("State does not match, wants %s but %s", state, gotState)
|
||||
}
|
||||
},
|
||||
gotError: func(err error) {
|
||||
errCh <- err
|
||||
},
|
||||
},
|
||||
}
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
log.Printf("Open http://localhost:%d for authorization", f.ServerPort)
|
||||
if !f.SkipOpenBrowser {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
browser.OpenURL(fmt.Sprintf("http://localhost:%d/", f.ServerPort))
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
server.Shutdown(ctx)
|
||||
return "", err
|
||||
case code := <-codeCh:
|
||||
server.Shutdown(ctx)
|
||||
return code, nil
|
||||
case <-ctx.Done():
|
||||
server.Shutdown(ctx)
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
type authCodeHandler struct {
|
||||
authCodeURL string
|
||||
gotCode func(code string, state string)
|
||||
gotError func(err error)
|
||||
}
|
||||
|
||||
func (h *authCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("%s %s", r.Method, r.RequestURI)
|
||||
m := r.Method
|
||||
p := r.URL.Path
|
||||
q := r.URL.Query()
|
||||
switch {
|
||||
case m == "GET" && p == "/" && q.Get("error") != "":
|
||||
h.gotError(fmt.Errorf("OAuth Error: %s %s", q.Get("error"), q.Get("error_description")))
|
||||
http.Error(w, "OAuth Error", 500)
|
||||
|
||||
case m == "GET" && p == "/" && q.Get("code") != "":
|
||||
h.gotCode(q.Get("code"), q.Get("state"))
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body>OK<script>window.close()</script></body></html>`)
|
||||
|
||||
case m == "GET" && p == "/":
|
||||
http.Redirect(w, r, h.authCodeURL, 302)
|
||||
|
||||
default:
|
||||
http.Error(w, "Not Found", 404)
|
||||
}
|
||||
}
|
||||
69
auth/oidc.go
69
auth/oidc.go
@@ -1,69 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
oidc "github.com/coreos/go-oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// TokenSet is a set of tokens and claims.
|
||||
type TokenSet struct {
|
||||
IDToken string
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
// 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
|
||||
ServerPort 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 {
|
||||
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)
|
||||
}
|
||||
oauth2Config := &oauth2.Config{
|
||||
Endpoint: provider.Endpoint(),
|
||||
ClientID: c.ClientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Scopes: append(c.ExtraScopes, oidc.ScopeOpenID),
|
||||
RedirectURL: fmt.Sprintf("http://localhost:%d/", c.ServerPort),
|
||||
}
|
||||
flow := &authCodeFlow{
|
||||
ServerPort: c.ServerPort,
|
||||
SkipOpenBrowser: c.SkipOpenBrowser,
|
||||
Config: oauth2Config,
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func generateState() (string, error) {
|
||||
var n uint64
|
||||
if err := binary.Read(rand.Reader, binary.LittleEndian, &n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", n), nil
|
||||
}
|
||||
88
cli/cli.go
88
cli/cli.go
@@ -1,88 +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(args []string) (*CLI, error) {
|
||||
var cli CLI
|
||||
parser := flags.NewParser(&cli, flags.HelpFlag)
|
||||
args, err := parser.Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return nil, fmt.Errorf("Too many argument")
|
||||
}
|
||||
return &cli, nil
|
||||
}
|
||||
|
||||
// CLI represents an interface of this command.
|
||||
type CLI struct {
|
||||
KubeConfig string `long:"kubeconfig" default:"~/.kube/config" env:"KUBECONFIG" description:"Path to the kubeconfig file"`
|
||||
SkipTLSVerify bool `long:"insecure-skip-tls-verify" env:"KUBELOGIN_INSECURE_SKIP_TLS_VERIFY" description:"If set, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure"`
|
||||
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", c.KubeConfig)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Run performs this command.
|
||||
func (c *CLI) Run(ctx context.Context) error {
|
||||
path, err := c.ExpandKubeConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Reading %s", path)
|
||||
cfg, err := kubeconfig.Read(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not load kubeconfig: %s", err)
|
||||
}
|
||||
log.Printf("Using current context: %s", cfg.CurrentContext)
|
||||
authInfo := kubeconfig.FindCurrentAuthInfo(cfg)
|
||||
if authInfo == nil {
|
||||
return fmt.Errorf("Could not find current context: %s", cfg.CurrentContext)
|
||||
}
|
||||
authProvider, err := kubeconfig.FindOIDCAuthProvider(authInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not find auth-provider: %s", err)
|
||||
}
|
||||
tlsConfig, err := c.tlsConfig(authProvider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not configure TLS: %s", err)
|
||||
}
|
||||
authConfig := &auth.Config{
|
||||
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,
|
||||
}
|
||||
token, err := authConfig.GetTokenSet(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Authentication error: %s", err)
|
||||
}
|
||||
|
||||
authProvider.SetIDToken(token.IDToken)
|
||||
authProvider.SetRefreshToken(token.RefreshToken)
|
||||
kubeconfig.Write(cfg, path)
|
||||
log.Printf("Updated %s", path)
|
||||
return nil
|
||||
}
|
||||
42
cli/tls.go
42
cli/tls.go
@@ -1,42 +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, error) {
|
||||
p := x509.NewCertPool()
|
||||
if authProvider.IDPCertificateAuthority() != "" {
|
||||
b, err := ioutil.ReadFile(authProvider.IDPCertificateAuthority())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not read idp-certificate-authority: %s", err)
|
||||
}
|
||||
if p.AppendCertsFromPEM(b) != true {
|
||||
return nil, fmt.Errorf("Could not load CA certificate from idp-certificate-authority: %s", err)
|
||||
}
|
||||
log.Printf("Using CA certificate: %s", authProvider.IDPCertificateAuthority())
|
||||
}
|
||||
if authProvider.IDPCertificateAuthorityData() != "" {
|
||||
b, err := base64.StdEncoding.DecodeString(authProvider.IDPCertificateAuthorityData())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not decode idp-certificate-authority-data: %s", err)
|
||||
}
|
||||
if p.AppendCertsFromPEM(b) != true {
|
||||
return nil, fmt.Errorf("Could not load CA certificate from idp-certificate-authority-data: %s", err)
|
||||
}
|
||||
log.Printf("Using CA certificate of idp-certificate-authority-data")
|
||||
}
|
||||
|
||||
cfg := &tls.Config{InsecureSkipVerify: c.SkipTLSVerify}
|
||||
if len(p.Subjects()) > 0 {
|
||||
cfg.RootCAs = p
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
46
config.go
Normal file
46
config.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
// GetCurrentAuthInfo returns the current authInfo
|
||||
func GetCurrentAuthInfo(config api.Config) *api.AuthInfo {
|
||||
context := config.Contexts[config.CurrentContext]
|
||||
if context == nil {
|
||||
return nil
|
||||
}
|
||||
authInfo := config.AuthInfos[context.AuthInfo]
|
||||
return authInfo
|
||||
}
|
||||
|
||||
// ReadKubeConfig returns the current config
|
||||
func ReadKubeConfig(path string) (*api.Config, error) {
|
||||
config, err := clientcmd.LoadFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// WriteKubeConfig writes the config
|
||||
func WriteKubeConfig(config api.Config, path string) error {
|
||||
return clientcmd.WriteToFile(config, path)
|
||||
}
|
||||
|
||||
// FindKubeConfig returns env:KUBECONFIG or ~/.kube/config
|
||||
func FindKubeConfig() (string, error) {
|
||||
env := os.Getenv("KUBECONFIG")
|
||||
if env != "" {
|
||||
return env, nil
|
||||
}
|
||||
path, err := homedir.Expand("~/.kube/config")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
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 }
|
||||
}
|
||||
|
||||
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: 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)
|
||||
}
|
||||
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)
|
||||
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 := 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()
|
||||
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
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
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 := 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 := h.jwks.Execute(w, h); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
http.Error(w, "Not Found", 404)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
4
e2e/authserver/testdata/.gitignore
vendored
4
e2e/authserver/testdata/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
/CA
|
||||
*.key
|
||||
*.csr
|
||||
*.crt
|
||||
50
e2e/authserver/testdata/Makefile
vendored
50
e2e/authserver/testdata/Makefile
vendored
@@ -1,50 +0,0 @@
|
||||
.PHONY: clean
|
||||
|
||||
all: server.crt ca.crt
|
||||
|
||||
clean:
|
||||
rm -v ca.* server.*
|
||||
|
||||
ca.key:
|
||||
openssl genrsa -out $@ 1024
|
||||
|
||||
ca.csr: openssl.cnf ca.key
|
||||
openssl req -config openssl.cnf \
|
||||
-new \
|
||||
-key ca.key \
|
||||
-subj "/CN=Hello CA" \
|
||||
-out $@
|
||||
openssl req -noout -text -in $@
|
||||
|
||||
ca.crt: ca.csr ca.key
|
||||
openssl x509 -req \
|
||||
-signkey ca.key \
|
||||
-in ca.csr \
|
||||
-out $@
|
||||
openssl x509 -text -in $@
|
||||
|
||||
server.key:
|
||||
openssl genrsa -out $@ 1024
|
||||
|
||||
server.csr: openssl.cnf server.key
|
||||
openssl req -config openssl.cnf \
|
||||
-new \
|
||||
-key server.key \
|
||||
-subj "/CN=localhost" \
|
||||
-out $@
|
||||
openssl req -noout -text -in $@
|
||||
|
||||
server.crt: openssl.cnf server.csr ca.key ca.crt
|
||||
rm -fr ./CA
|
||||
mkdir -p ./CA
|
||||
touch CA/index.txt
|
||||
touch CA/index.txt.attr
|
||||
echo 00 > CA/serial
|
||||
openssl ca -config openssl.cnf \
|
||||
-extensions v3_req \
|
||||
-batch \
|
||||
-cert ca.crt \
|
||||
-keyfile ca.key \
|
||||
-in server.csr \
|
||||
-out $@
|
||||
openssl x509 -text -in $@
|
||||
85
e2e/authserver/testdata/oidc-discovery.json
vendored
85
e2e/authserver/testdata/oidc-discovery.json
vendored
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"issuer": "{{ .Issuer }}",
|
||||
"authorization_endpoint": "{{ .Issuer }}/protocol/openid-connect/auth",
|
||||
"token_endpoint": "{{ .Issuer }}/protocol/openid-connect/token",
|
||||
"token_introspection_endpoint": "{{ .Issuer }}/protocol/openid-connect/token/introspect",
|
||||
"userinfo_endpoint": "{{ .Issuer }}/protocol/openid-connect/userinfo",
|
||||
"end_session_endpoint": "{{ .Issuer }}/protocol/openid-connect/logout",
|
||||
"jwks_uri": "{{ .Issuer }}/protocol/openid-connect/certs",
|
||||
"check_session_iframe": "{{ .Issuer }}/protocol/openid-connect/login-status-iframe.html",
|
||||
"grant_types_supported": [
|
||||
"authorization_code",
|
||||
"implicit",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"client_credentials"
|
||||
],
|
||||
"response_types_supported": [
|
||||
"code",
|
||||
"none",
|
||||
"id_token",
|
||||
"token",
|
||||
"id_token token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"code id_token token"
|
||||
],
|
||||
"subject_types_supported": [
|
||||
"public",
|
||||
"pairwise"
|
||||
],
|
||||
"id_token_signing_alg_values_supported": [
|
||||
"RS256"
|
||||
],
|
||||
"userinfo_signing_alg_values_supported": [
|
||||
"RS256"
|
||||
],
|
||||
"request_object_signing_alg_values_supported": [
|
||||
"none",
|
||||
"RS256"
|
||||
],
|
||||
"response_modes_supported": [
|
||||
"query",
|
||||
"fragment",
|
||||
"form_post"
|
||||
],
|
||||
"registration_endpoint": "{{ .Issuer }}/clients-registrations/openid-connect",
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"private_key_jwt",
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"client_secret_jwt"
|
||||
],
|
||||
"token_endpoint_auth_signing_alg_values_supported": [
|
||||
"RS256"
|
||||
],
|
||||
"claims_supported": [
|
||||
"sub",
|
||||
"iss",
|
||||
"auth_time",
|
||||
"name",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"preferred_username",
|
||||
"email"
|
||||
],
|
||||
"claim_types_supported": [
|
||||
"normal"
|
||||
],
|
||||
"claims_parameter_supported": false,
|
||||
"scopes_supported": [
|
||||
"openid",
|
||||
"offline_access",
|
||||
"phone",
|
||||
"address",
|
||||
"email",
|
||||
"profile"
|
||||
],
|
||||
"request_parameter_supported": true,
|
||||
"request_uri_parameter_supported": true,
|
||||
"code_challenge_methods_supported": [
|
||||
"plain",
|
||||
"S256"
|
||||
],
|
||||
"tls_client_certificate_bound_access_tokens": true
|
||||
}
|
||||
12
e2e/authserver/testdata/oidc-jwks.json
vendored
12
e2e/authserver/testdata/oidc-jwks.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": "xxx",
|
||||
"n": "{{ .PrivateKey.N }}",
|
||||
"e": "{{ .PrivateKey.E }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
e2e/authserver/testdata/oidc-token.json
vendored
7
e2e/authserver/testdata/oidc-token.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"access_token": "7eaae8ab-8f69-45d9-ab7c-73560cd9444d",
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "44df4c82-5ce7-4260-b54d-1da0d396ef2a",
|
||||
"expires_in": 3600,
|
||||
"id_token": "{{ .IDToken }}"
|
||||
}
|
||||
37
e2e/authserver/testdata/openssl.cnf
vendored
37
e2e/authserver/testdata/openssl.cnf
vendored
@@ -1,37 +0,0 @@
|
||||
[ ca ]
|
||||
default_ca = CA_default
|
||||
|
||||
[ CA_default ]
|
||||
dir = ./CA
|
||||
certs = $dir
|
||||
crl_dir = $dir
|
||||
database = $dir/index.txt
|
||||
new_certs_dir = $dir
|
||||
default_md = sha256
|
||||
policy = policy_match
|
||||
serial = $dir/serial
|
||||
default_days = 365
|
||||
|
||||
[ policy_match ]
|
||||
countryName = optional
|
||||
stateOrProvinceName = optional
|
||||
organizationName = optional
|
||||
organizationalUnitName = optional
|
||||
commonName = supplied
|
||||
emailAddress = optional
|
||||
|
||||
[ req ]
|
||||
distinguished_name = req_distinguished_name
|
||||
req_extensions = v3_req
|
||||
x509_extensions = v3_ca
|
||||
|
||||
[ req_distinguished_name ]
|
||||
commonName = Common Name (e.g. server FQDN or YOUR name)
|
||||
|
||||
[ v3_req ]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = DNS:localhost
|
||||
|
||||
[ v3_ca ]
|
||||
basicConstraints = CA:true
|
||||
147
e2e/e2e_test.go
147
e2e/e2e_test.go
@@ -1,147 +0,0 @@
|
||||
package e2e
|
||||
|
||||
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/e2e/authserver"
|
||||
"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 kubeconfigValues
|
||||
cli cli.CLI
|
||||
serverConfig authserver.Config
|
||||
clientTLS *tls.Config
|
||||
}{
|
||||
"NoTLS": {
|
||||
kubeconfigValues{Issuer: "http://localhost:9000"},
|
||||
cli.CLI{},
|
||||
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},
|
||||
authserver.Config{
|
||||
Issuer: "https://localhost:9000",
|
||||
Cert: authserver.ServerCert,
|
||||
Key: authserver.ServerKey,
|
||||
},
|
||||
&tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
"CACert": {
|
||||
kubeconfigValues{
|
||||
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": {
|
||||
kubeconfigValues{
|
||||
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)},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
kubeconfig := createKubeconfig(t, &c.kubeconfigValues)
|
||||
defer os.Remove(kubeconfig)
|
||||
c.cli.KubeConfig = kubeconfig
|
||||
c.cli.SkipOpenBrowser = true
|
||||
|
||||
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)
|
||||
}
|
||||
verifyKubeconfig(t, kubeconfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type kubeconfigValues struct {
|
||||
Issuer string
|
||||
ExtraScopes string
|
||||
IDPCertificateAuthority string
|
||||
IDPCertificateAuthorityData string
|
||||
}
|
||||
|
||||
func createKubeconfig(t *testing.T, v *kubeconfigValues) string {
|
||||
t.Helper()
|
||||
f, err := ioutil.TempFile("", "kubeconfig")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
tpl, err := template.ParseFiles("testdata/kubeconfig.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tpl.Execute(f, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func verifyKubeconfig(t *testing.T, kubeconfig string) {
|
||||
b, err := ioutil.ReadFile(kubeconfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Index(string(b), "id-token: ey") == -1 {
|
||||
t.Errorf("kubeconfig wants id-token but %s", string(b))
|
||||
}
|
||||
if strings.Index(string(b), "refresh-token: 44df4c82-5ce7-4260-b54d-1da0d396ef2a") == -1 {
|
||||
t.Errorf("kubeconfig wants refresh-token but %s", string(b))
|
||||
}
|
||||
}
|
||||
31
e2e/testdata/kubeconfig.yaml
vendored
31
e2e/testdata/kubeconfig.yaml
vendored
@@ -1,31 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- cluster:
|
||||
server: https://api.hello.k8s.example.com
|
||||
name: hello.k8s.local
|
||||
contexts:
|
||||
- context:
|
||||
cluster: hello.k8s.local
|
||||
user: hello.k8s.local
|
||||
name: hello.k8s.local
|
||||
current-context: hello.k8s.local
|
||||
preferences: {}
|
||||
users:
|
||||
- name: hello.k8s.local
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
client-id: kubernetes
|
||||
client-secret: a3c508c3-73c9-42e2-ab14-487a1bf67c33
|
||||
idp-issuer-url: {{ .Issuer }}
|
||||
#{{ if .ExtraScopes }}
|
||||
extra-scopes: {{ .ExtraScopes }}
|
||||
#{{ end }}
|
||||
#{{ if .IDPCertificateAuthority }}
|
||||
idp-certificate-authority: {{ .IDPCertificateAuthority }}
|
||||
#{{ end }}
|
||||
#{{ if .IDPCertificateAuthorityData }}
|
||||
idp-certificate-authority-data: {{ .IDPCertificateAuthorityData }}
|
||||
#{{ end }}
|
||||
name: oidc
|
||||
@@ -1,75 +0,0 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
// FindCurrentAuthInfo returns the authInfo of current context.
|
||||
// If the current context does not exist, this returns nil.
|
||||
func FindCurrentAuthInfo(config *api.Config) *api.AuthInfo {
|
||||
context := config.Contexts[config.CurrentContext]
|
||||
if context == nil {
|
||||
return nil
|
||||
}
|
||||
return config.AuthInfos[context.AuthInfo]
|
||||
}
|
||||
|
||||
// FindOIDCAuthProvider returns the OIDC authProvider.
|
||||
func FindOIDCAuthProvider(authInfo *api.AuthInfo) (*OIDCAuthProvider, error) {
|
||||
if authInfo.AuthProvider == nil {
|
||||
return nil, fmt.Errorf("auth-provider is not set, did you setup kubectl as listed here: https://github.com/int128/kubelogin")
|
||||
}
|
||||
if authInfo.AuthProvider.Name != "oidc" {
|
||||
return nil, fmt.Errorf("auth-provider `%s` is not supported", authInfo.AuthProvider.Name)
|
||||
}
|
||||
return (*OIDCAuthProvider)(authInfo.AuthProvider), nil
|
||||
}
|
||||
|
||||
// OIDCAuthProvider represents OIDC configuration in the kubeconfig.
|
||||
type OIDCAuthProvider api.AuthProviderConfig
|
||||
|
||||
// IDPIssuerURL returns the idp-issuer-url.
|
||||
func (c *OIDCAuthProvider) IDPIssuerURL() string {
|
||||
return c.Config["idp-issuer-url"]
|
||||
}
|
||||
|
||||
// ClientID returns the client-id.
|
||||
func (c *OIDCAuthProvider) ClientID() string {
|
||||
return c.Config["client-id"]
|
||||
}
|
||||
|
||||
// ClientSecret returns the client-secret.
|
||||
func (c *OIDCAuthProvider) ClientSecret() string {
|
||||
return c.Config["client-secret"]
|
||||
}
|
||||
|
||||
// IDPCertificateAuthority returns the idp-certificate-authority.
|
||||
func (c *OIDCAuthProvider) IDPCertificateAuthority() string {
|
||||
return c.Config["idp-certificate-authority"]
|
||||
}
|
||||
|
||||
// IDPCertificateAuthorityData returns the idp-certificate-authority-data.
|
||||
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
|
||||
}
|
||||
|
||||
// SetRefreshToken replaces the refresh-token.
|
||||
func (c *OIDCAuthProvider) SetRefreshToken(refreshToken string) {
|
||||
c.Config["refresh-token"] = refreshToken
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
// Read parses the file and returns the Config.
|
||||
func Read(path string) (*api.Config, error) {
|
||||
config, err := clientcmd.LoadFromFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not load kubeconfig from %s: %s", path, err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Write writes the config to the file.
|
||||
func Write(config *api.Config, path string) error {
|
||||
return clientcmd.WriteToFile(*config, path)
|
||||
}
|
||||
49
main.go
49
main.go
@@ -1,20 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/int128/kubelogin/cli"
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c, err := cli.Parse(os.Args)
|
||||
kubeConfigPath, err := FindKubeConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := c.Run(ctx); err != nil {
|
||||
|
||||
log.Printf("Reading config from %s", kubeConfigPath)
|
||||
kubeConfig, err := ReadKubeConfig(kubeConfigPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Using current context: %s", kubeConfig.CurrentContext)
|
||||
|
||||
authInfo := GetCurrentAuthInfo(*kubeConfig)
|
||||
if authInfo == nil {
|
||||
log.Fatal("Could not find the current user")
|
||||
}
|
||||
authProvider := authInfo.AuthProvider
|
||||
if authProvider == nil {
|
||||
log.Fatal("auth-provider is not set in the config")
|
||||
}
|
||||
|
||||
switch authProvider.Name {
|
||||
case "oidc":
|
||||
if err := mutateConfigWithOIDC(authProvider); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
WriteKubeConfig(*kubeConfig, kubeConfigPath)
|
||||
log.Printf("Updated %s", kubeConfigPath)
|
||||
|
||||
default:
|
||||
log.Fatalf("Currently auth-provider `%s` is not supported", authProvider.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func mutateConfigWithOIDC(authProvider *api.AuthProviderConfig) error {
|
||||
issuer := authProvider.Config["idp-issuer-url"]
|
||||
clientID := authProvider.Config["client-id"]
|
||||
clientSecret := authProvider.Config["client-secret"]
|
||||
log.Printf("Using issuer: %s", issuer)
|
||||
log.Printf("Using client ID: %s", clientID)
|
||||
oidcToken, err := GetOIDCToken(issuer, clientID, clientSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authProvider.Config["id-token"] = oidcToken.IDToken
|
||||
authProvider.Config["refresh-token"] = oidcToken.RefreshToken
|
||||
return nil
|
||||
}
|
||||
|
||||
130
oidc.go
Normal file
130
oidc.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OIDCToken is a token set
|
||||
type OIDCToken struct {
|
||||
IDToken string
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
// GetOIDCToken returns a token retrieved by auth code grant
|
||||
func GetOIDCToken(issuer string, clientID string, clientSecret string) (*OIDCToken, error) {
|
||||
port := 8000
|
||||
provider, err := oidc.NewProvider(oauth2.NoContext, issuer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
webBrowserConfig := oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: fmt.Sprintf("http://localhost:%d/", port),
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "email"},
|
||||
}
|
||||
|
||||
cliConfig := oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: "urn:ietf:wg:oauth:2.0:oob",
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "email"},
|
||||
}
|
||||
|
||||
showInstructionToGetToken(webBrowserConfig.RedirectURL, cliConfig.AuthCodeURL(state))
|
||||
token, err := getTokenByWebBrowserOrCLI(webBrowserConfig, cliConfig, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("id_token is missing in the token response: %s", token)
|
||||
}
|
||||
|
||||
log.Printf("Verifying ID token...")
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
|
||||
idToken, err := verifier.Verify(oauth2.NoContext, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idTokenClaim := struct {
|
||||
Email string `json:"email"`
|
||||
}{}
|
||||
if err := idToken.Claims(&idTokenClaim); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("You are logged in as %s (%s)", idTokenClaim.Email, idToken.Subject)
|
||||
return &OIDCToken{
|
||||
IDToken: rawIDToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getTokenByWebBrowserOrCLI(webBrowserConfig oauth2.Config, cliConfig oauth2.Config, state string) (*oauth2.Token, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
webBrowserAuthCodeCh := make(chan string)
|
||||
cliAuthCodeCh := make(chan string)
|
||||
errCh := make(chan error)
|
||||
|
||||
go ReceiveAuthCodeFromWebBrowser(ctx, webBrowserConfig.AuthCodeURL(state), state, webBrowserAuthCodeCh, errCh)
|
||||
go ReceiveAuthCodeFromCLI(ctx, cliAuthCodeCh, errCh)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return nil, err
|
||||
|
||||
case authCode := <-webBrowserAuthCodeCh:
|
||||
log.Printf("Exchanging code and token...")
|
||||
return webBrowserConfig.Exchange(ctx, authCode)
|
||||
|
||||
case authCode := <-cliAuthCodeCh:
|
||||
log.Printf("Exchanging code and token...")
|
||||
return cliConfig.Exchange(ctx, authCode)
|
||||
}
|
||||
}
|
||||
|
||||
func showInstructionToGetToken(localhostURL string, cliAuthCodeURL string) {
|
||||
log.Printf("Starting OpenID Connect authentication:")
|
||||
fmt.Printf(`
|
||||
## Automatic (recommended)
|
||||
|
||||
Open the following URL in the web browser:
|
||||
|
||||
%s
|
||||
|
||||
## Manual
|
||||
|
||||
If you cannot access to localhost, instead open the following URL:
|
||||
|
||||
%s
|
||||
|
||||
Enter the code: `, localhostURL, cliAuthCodeURL)
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
var n uint64
|
||||
if err := binary.Read(rand.Reader, binary.LittleEndian, &n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", n), nil
|
||||
}
|
||||
16
oidc_cli.go
Normal file
16
oidc_cli.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ReceiveAuthCodeFromCLI receives an auth code from CLI
|
||||
func ReceiveAuthCodeFromCLI(ctx context.Context, authCodeCh chan<- string, errCh chan<- error) {
|
||||
var authCode string
|
||||
if _, err := fmt.Scanln(&authCode); err != nil {
|
||||
errCh <- err
|
||||
} else {
|
||||
authCodeCh <- authCode
|
||||
}
|
||||
}
|
||||
70
oidc_http.go
Normal file
70
oidc_http.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ReceiveAuthCodeFromWebBrowser starts a server and receives an auth code
|
||||
func ReceiveAuthCodeFromWebBrowser(ctx context.Context, authCodeURL string, state string, authCodeCh chan<- string, errCh chan<- error) {
|
||||
server := http.Server{
|
||||
Addr: ":8000",
|
||||
Handler: &AuthCodeGrantHandler{
|
||||
AuthCodeURL: authCodeURL,
|
||||
State: state,
|
||||
Resolve: func(authCode string) { authCodeCh <- authCode },
|
||||
Reject: func(err error) { errCh <- err },
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
server.Shutdown(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
// AuthCodeGrantHandler handles requests for OIDC auth code grant
|
||||
type AuthCodeGrantHandler struct {
|
||||
AuthCodeURL string
|
||||
State string
|
||||
Resolve func(string)
|
||||
Reject func(error)
|
||||
}
|
||||
|
||||
func (s *AuthCodeGrantHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("%s %s", r.Method, r.RequestURI)
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
errorCode := r.URL.Query().Get("error")
|
||||
errorDescription := r.URL.Query().Get("error_description")
|
||||
switch {
|
||||
case code != "" && state == s.State:
|
||||
s.Resolve(code)
|
||||
fmt.Fprintf(w, "Please back to the command line.")
|
||||
|
||||
case code != "" && state != s.State:
|
||||
s.Reject(fmt.Errorf("OIDC state did not match. expected=%s, actual=%s", s.State, state))
|
||||
fmt.Fprintf(w, "Please back to the command line.")
|
||||
|
||||
case errorCode != "":
|
||||
s.Reject(fmt.Errorf("OIDC error: %s %s", errorCode, errorDescription))
|
||||
fmt.Fprintf(w, "Please back to the command line.")
|
||||
|
||||
default:
|
||||
http.Redirect(w, r, s.AuthCodeURL, 302)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, "Not Found", 404)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user