Compare commits

..

82 Commits
1.6 ... v1.11.0

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

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

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

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

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

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

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

* Refactor: move keys

* Refactor authserver

* Refactor: do not generate key on test runtime

* Refactor: package keys in integration tests

* Refactor: use context on sending browser request

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

* Add integration test for skip login feature
2019-04-10 10:20:13 +09:00
Hidetake Iwata
9ac252667a Update README.md 2019-04-09 13:34:47 +09:00
Hidetake Iwata
000711f52e Add support of HTTP_PROXY, HTTPS_PROXY and NO_PROXY (#51) 2019-04-09 13:17:12 +09:00
Hidetake Iwata
e465c4852b Introduce dig container (#50) 2019-04-09 12:37:44 +09:00
Hidetake Iwata
003badb0bc Fix nil pointer error (#49) 2019-04-09 10:19:25 +09:00
Hidetake Iwata
0873a193a5 Refactor: extract adaptors.Logger 2019-04-08 16:07:26 +09:00
Hidetake Iwata
5e80b1858e Refactor: logs 2019-04-08 16:07:26 +09:00
Hidetake Iwata
c816281657 Refactor: add test of usecases.Login 2019-04-08 13:35:01 +09:00
Hidetake Iwata
0db49860f9 Refactor: extract adaptors.HTTPClientConfig 2019-04-08 13:35:01 +09:00
Hidetake Iwata
d70c9db036 Refactor: extract adaptors.HTTP 2019-04-08 13:35:01 +09:00
Hidetake Iwata
675b5e5fff Refactor: extract adaptors.OIDC 2019-04-08 13:35:01 +09:00
Hidetake Iwata
e3bfc321a2 Refactor: extract adaptors.KubeConfig 2019-04-08 13:35:01 +09:00
Hidetake Iwata
b34d0fb32f Refactor: add di package 2019-04-08 13:35:01 +09:00
Hidetake Iwata
4c61a71ed4 Add test of adaptors.Cmd (#45) 2019-04-07 19:33:53 +09:00
Hidetake Iwata
8a02ed0fb0 Split cli package into adaptors and use-cases (#44) 2019-04-05 14:52:15 +09:00
Hidetake Iwata
3485c5408e Replace with errors.Errorf() and errors.Wrapf() (#43) 2019-04-05 12:12:00 +09:00
Hidetake Iwata
fb99977e98 Update README.md 2019-04-05 10:53:32 +09:00
Hidetake Iwata
39b441a7c2 Run go get at once 2019-04-05 10:40:23 +09:00
Hidetake Iwata
cde5becf67 Fix release in Makefile 2019-04-05 10:37:02 +09:00
Hidetake Iwata
460b14a159 Use CI context 2019-04-05 10:36:12 +09:00
Hidetake Iwata
8436fe3494 Merge pull request #42 from int128/krew
Add yaml for Krew installation
2019-04-05 10:28:23 +09:00
Hidetake Iwata
9d2319ee2f Do not set change log on release 2019-04-05 10:17:42 +09:00
Hidetake Iwata
75277378fc Add yaml for krew installation 2019-04-05 10:17:42 +09:00
Hidetake Iwata
89a1046ce3 Use goxzst for release 2019-04-05 09:58:34 +09:00
Baykonur
15d40413e4 HTTPS_PROXY support for kubelogin (#41)
adding HTTPS_PROXY support to kubelogin
2019-04-04 15:45:47 +09:00
Hidetake Iwata
8525ba5142 Merge pull request #40 from int128/refactor
Rename plugin to kubectl oidc-login
2019-03-29 16:59:55 +09:00
Hidetake Iwata
dbddd6a07f Rename plugin to kubectl oidc-login
See the naming guide:
https://github.com/GoogleContainerTools/krew/blob/master/docs/NAMING_GUIDE.md
2019-03-29 16:43:53 +09:00
Hidetake Iwata
839877b45e Merge pull request #39 from int128/refactor
Move to gox/ghr/ghcp to fix version of k8s modules
2019-03-29 16:29:48 +09:00
Hidetake Iwata
99ed86e22e Move to gox/ghr/ghcp to fix version of k8s modules 2019-03-29 16:27:26 +09:00
Hidetake Iwata
a78b746c29 Exclude /.idea 2019-02-27 10:54:07 +09:00
Hidetake Iwata
187bbc203c Merge pull request #32 from int128/dependabot/go_modules/github.com/mitchellh/go-homedir-1.1.0
Bump github.com/mitchellh/go-homedir from 1.0.0 to 1.1.0
2019-02-27 10:46:11 +09:00
dependabot[bot]
d4b5e511bb Bump github.com/mitchellh/go-homedir from 1.0.0 to 1.1.0
Bumps [github.com/mitchellh/go-homedir](https://github.com/mitchellh/go-homedir) from 1.0.0 to 1.1.0.
- [Release notes](https://github.com/mitchellh/go-homedir/releases)
- [Commits](https://github.com/mitchellh/go-homedir/compare/v1.0.0...v1.1.0)

Signed-off-by: dependabot[bot] <support@dependabot.com>
2019-01-28 20:20:06 +00:00
Hidetake Iwata
33241b8721 Update README.md 2018-11-20 16:46:02 +09:00
Hidetake Iwata
b72cb63826 Fix up #25 2018-11-09 13:26:35 +09:00
Hidetake Iwata
63fda1db0f Merge pull request #25 from int128/kubectl-plugin
Add kubectl plugin support
2018-11-09 11:39:52 +09:00
Hidetake Iwata
da95fe470f Add kubectl plugin support 2018-11-09 11:37:38 +09:00
Hidetake Iwata
4b08a49a51 Add workaround for Go Modules issue 2018-10-31 14:21:33 +09:00
Hidetake Iwata
9c74f3748b Merge pull request #23 from int128/oauth2cli
Import github.com/int128/oauth2cli
2018-10-31 14:16:25 +09:00
Hidetake Iwata
17e03f2abc Import github.com/int128/oauth2cli 2018-10-31 14:13:09 +09:00
Hidetake Iwata
ebef81f9d7 Merge pull request #22 from int128/ignore-cert-errors
Skip invalid CA certs instead of raising error
2018-10-30 09:56:22 +09:00
Hidetake Iwata
8d0d82fb71 Skip invalid CA certs instead of raising error 2018-10-30 09:55:12 +09:00
Hidetake Iwata
b600e54a12 Refactor e2e test 2018-10-29 14:04:10 +09:00
Hidetake Iwata
75317f88a1 Merge pull request #21 from int128/go111
Move to Go Modules
2018-10-29 13:50:01 +09:00
Hidetake Iwata
5a794e8ceb Move to Go Modules 2018-10-29 13:45:32 +09:00
Hidetake Iwata
1fe1ec4c20 Update README.md 2018-10-16 09:47:50 +09:00
Hidetake Iwata
7676ffbfab Fix golint 2018-10-16 09:25:38 +09:00
Hidetake Iwata
7e1e6a096b Refactor docs 2018-10-15 15:52:52 +09:00
Hidetake Iwata
4d3d1c3b78 Add explanation about extra scopes 2018-10-04 16:05:36 +09:00
Hidetake Iwata
1ebdfc0e4f Use "Application Type: Other" on Google IdP 2018-10-03 15:33:56 +09:00
Hidetake Iwata
9c67c52b34 Fix test for #17 2018-09-14 15:06:05 +09:00
Hidetake Iwata
550396e1dd Merge pull request #17 from stang/allow-to-change-listening-port
Allow to change listening port
2018-09-14 11:20:08 +09:00
Stephane Tang
34f0578b59 Allow to change listening port
It's using port 8000 by default, which is identical as the original behavior.

Signed-off-by: Stephane Tang <hi@stang.sh>
2018-09-14 00:03:18 +01:00
Hidetake Iwata
604d118b68 Update README.md 2018-09-07 10:56:26 +09:00
Hidetake Iwata
91959e8a56 Update README.md 2018-09-07 10:56:21 +09:00
60 changed files with 3211 additions and 1015 deletions

View File

@@ -2,39 +2,37 @@ version: 2
jobs:
build:
docker:
- image: circleci/golang:1.10
working_directory: /go/src/github.com/int128/kubelogin
- image: circleci/golang:1.12.3
steps:
- run: |
mkdir -p ~/bin
echo 'export PATH="$HOME/bin:$PATH"' >> $BASH_ENV
- 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: |
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b ~/bin v1.16.0
- run: go get github.com/int128/goxzst
- run: go get github.com/tcnksm/ghr
- checkout
- 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:
- image: circleci/golang:1.10
working_directory: /go/src/github.com/int128/kubelogin
steps:
- checkout
- run: go get -v -t -d ./...
- run: curl -sL https://git.io/goreleaser | bash
# workaround for https://github.com/golang/go/issues/27925
- run: sed -e '/^k8s.io\/client-go /d' -i go.sum
- run: make check
- run: make run
- run: |
if [ "$CIRCLE_TAG" ]; then
make release
fi
workflows:
version: 2
all:
jobs:
- build:
context: open-source
filters:
tags:
only: /.*/
- release:
filters:
branches:
ignore: /.*/
tags:
only: /.*/
requires:
- build

View File

@@ -1,15 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.go]
indent_style = tab
indent_size = 4
[Makefile]
indent_style = tab
indent_size = 4

5
.gitignore vendored
View File

@@ -1,2 +1,5 @@
/.idea
/dist
/.kubeconfig
/kubelogin
/kubectl-oidc_login
/.kubeconfig*

View File

@@ -1,19 +0,0 @@
builds:
- binary: kubelogin
goos:
- windows
- darwin
- linux
goarch:
- amd64
archive:
files:
- none*
brew:
github:
owner: int128
name: homebrew-kubelogin
homepage: https://github.com/int128/kubelogin
description: "kubectl with OpenID Connect (OIDC) authentication"
test: system "#{bin}/kubelogin --help"
install: bin.install "kubelogin"

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
TARGET := kubelogin
TARGET_PLUGIN := kubectl-oidc_login
CIRCLE_TAG ?= HEAD
LDFLAGS := -X main.version=$(CIRCLE_TAG)
.PHONY: check run release clean
all: $(TARGET)
check:
golangci-lint run
$(MAKE) -C adaptors_test/keys/testdata
go test -v -race ./...
$(TARGET): $(wildcard *.go)
go build -o $@ -ldflags "$(LDFLAGS)"
$(TARGET_PLUGIN): $(TARGET)
ln -sf $(TARGET) $@
run: $(TARGET_PLUGIN)
-PATH=.:$(PATH) kubectl oidc-login --help
dist:
VERSION=$(CIRCLE_TAG) goxzst -d dist/gh/ -o "$(TARGET)" -t "kubelogin.rb oidc-login.yaml" -- -ldflags "$(LDFLAGS)"
mv dist/gh/kubelogin.rb dist/
release: dist
ghr -u "$(CIRCLE_PROJECT_USERNAME)" -r "$(CIRCLE_PROJECT_REPONAME)" "$(CIRCLE_TAG)" dist/gh/
ghcp -u "$(CIRCLE_PROJECT_USERNAME)" -r "homebrew-$(CIRCLE_PROJECT_REPONAME)" -m "$(CIRCLE_TAG)" -C dist/ kubelogin.rb
clean:
-rm $(TARGET)
-rm $(TARGET_PLUGIN)
-rm -r dist/

355
README.md
View File

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

100
adaptors/cmd.go Normal file
View File

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

111
adaptors/cmd_test.go Normal file
View File

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

85
adaptors/http.go Normal file
View File

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

View File

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

44
adaptors/kubeconfig.go Normal file
View File

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

View File

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

49
adaptors/logger.go Normal file
View File

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

53
adaptors/logger_test.go Normal file
View File

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

View File

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

View File

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

73
adaptors/oidc.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

306
adaptors_test/cmd_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,110 +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
AuthCodeOptions []oauth2.AuthCodeOption
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, f.AuthCodeOptions...),
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)
}
}

View File

@@ -1,70 +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,
AuthCodeOptions: []oauth2.AuthCodeOption{oauth2.AccessTypeOffline},
}
token, err := flow.getToken(ctx)
if err != nil {
return nil, fmt.Errorf("Could not get a token: %s", err)
}
idToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("id_token is missing in the token response: %s", token)
}
verifier := provider.Verifier(&oidc.Config{ClientID: c.ClientID})
verifiedIDToken, err := verifier.Verify(ctx, idToken)
if err != nil {
return nil, fmt.Errorf("Could not verify the id_token: %s", err)
}
log.Printf("Got token for subject=%s", verifiedIDToken.Subject)
return &TokenSet{
IDToken: idToken,
RefreshToken: token.RefreshToken,
}, nil
}

View File

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

View File

@@ -1,94 +0,0 @@
package cli
import (
"context"
"fmt"
"log"
"net/http"
"github.com/int128/kubelogin/auth"
"github.com/int128/kubelogin/kubeconfig"
flags "github.com/jessevdk/go-flags"
homedir "github.com/mitchellh/go-homedir"
)
// Parse parses command line arguments and returns a CLI instance.
func Parse(osArgs []string, version string) (*CLI, error) {
var cli CLI
parser := flags.NewParser(&cli, flags.HelpFlag)
parser.LongDescription = fmt.Sprintf(`Version %s
This updates the kubeconfig for Kubernetes OpenID Connect (OIDC) authentication.`,
version)
args, err := parser.ParseArgs(osArgs[1:])
if err != nil {
return nil, err
}
if len(args) > 0 {
return nil, fmt.Errorf("Too many argument")
}
return &cli, nil
}
// CLI represents an interface of this command.
type CLI struct {
KubeConfig string `long:"kubeconfig" default:"~/.kube/config" env:"KUBECONFIG" description:"Path to the kubeconfig file"`
SkipTLSVerify bool `long:"insecure-skip-tls-verify" env:"KUBELOGIN_INSECURE_SKIP_TLS_VERIFY" description:"If set, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure"`
SkipOpenBrowser bool `long:"skip-open-browser" env:"KUBELOGIN_SKIP_OPEN_BROWSER" description:"If set, it does not open the browser on authentication."`
}
// ExpandKubeConfig returns an expanded KubeConfig path.
func (c *CLI) ExpandKubeConfig() (string, error) {
d, err := homedir.Expand(c.KubeConfig)
if err != nil {
return "", fmt.Errorf("Could not expand %s: %s", c.KubeConfig, err)
}
return d, nil
}
// Run performs this command.
func (c *CLI) Run(ctx context.Context) error {
log.Printf("Reading %s", c.KubeConfig)
path, err := c.ExpandKubeConfig()
if err != nil {
return err
}
cfg, err := kubeconfig.Read(path)
if err != nil {
return fmt.Errorf("Could not read kubeconfig: %s", err)
}
log.Printf("Using current-context: %s", cfg.CurrentContext)
authProvider, err := kubeconfig.FindOIDCAuthProvider(cfg)
if err != nil {
return fmt.Errorf(`Could not find OIDC configuration in kubeconfig: %s
Did you setup kubectl for OIDC authentication?
kubectl config set-credentials %s \
--auth-provider oidc \
--auth-provider-arg idp-issuer-url=https://issuer.example.com \
--auth-provider-arg client-id=YOUR_CLIENT_ID \
--auth-provider-arg client-secret=YOUR_CLIENT_SECRET`,
err, cfg.CurrentContext)
}
tlsConfig, 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("Could not get token from OIDC provider: %s", err)
}
authProvider.SetIDToken(token.IDToken)
authProvider.SetRefreshToken(token.RefreshToken)
kubeconfig.Write(cfg, path)
log.Printf("Updated %s", c.KubeConfig)
return nil
}

View File

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

View File

@@ -1,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 ca := authProvider.IDPCertificateAuthority(); ca != "" {
b, err := ioutil.ReadFile(ca)
if err != nil {
return nil, fmt.Errorf("Could not read %s: %s", ca, err)
}
if p.AppendCertsFromPEM(b) != true {
return nil, fmt.Errorf("Could not append CA certificate from %s", ca)
}
log.Printf("Using CA certificate: %s", ca)
}
if ca := authProvider.IDPCertificateAuthorityData(); ca != "" {
b, err := base64.StdEncoding.DecodeString(ca)
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 append CA certificate from idp-certificate-authority-data")
}
log.Printf("Using CA certificate: idp-certificate-authority-data")
}
cfg := &tls.Config{InsecureSkipVerify: c.SkipTLSVerify}
if len(p.Subjects()) > 0 {
cfg.RootCAs = p
}
return cfg, nil
}

42
di/di.go Normal file
View File

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

18
di/di_test.go Normal file
View File

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

100
docs/google.md Normal file
View File

@@ -0,0 +1,100 @@
# Getting Started with Google Identity Platform
## Prerequisite
- You have a Google account.
- You have the Cluster Admin role of the Kubernetes cluster.
- You can configure the Kubernetes API server.
- `kubectl` and `kubelogin` are installed to your computer.
## 1. Setup Google API
Open [Google APIs Console](https://console.developers.google.com/apis/credentials) and create an OAuth client with the following setting:
- Application Type: Other
## 2. Setup Kubernetes API server
Configure your Kubernetes API Server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
### kops
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
```yaml
spec:
kubeAPIServer:
oidcIssuerURL: https://accounts.google.com
oidcClientID: YOUR_CLIENT_ID.apps.googleusercontent.com
```
## 3. Setup Kubernetes cluster
Here assign the `cluster-admin` role to you.
```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
```
You can create a custom role and assign it as well.
## 4. Setup kubectl
Configure `kubectl` for the OIDC authentication.
```sh
kubectl config set-credentials 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
```
## 5. Run kubelogin
Run `kubelogin`.
```
% 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
```

114
docs/keycloak.md Normal file
View File

@@ -0,0 +1,114 @@
# Getting Started with Keycloak
## Prerequisite
- You have an administrator role of the Keycloak realm.
- You have an administrator role of the Kubernetes cluster.
- You can configure the Kubernetes API server.
- `kubectl` and `kubelogin` are installed.
## 1. Setup Keycloak
Open the Keycloak and create an OIDC client as follows:
- Client ID: `kubernetes`
- Valid Redirect URLs:
- `http://localhost:8000`
- `http://localhost:18000` (used if the port 8000 is already in use)
- Issuer URL: `https://keycloak.example.com/auth/realms/YOUR_REALM`
You can associate client roles by adding the following mapper:
- Name: `groups`
- Mapper Type: `User Client Role`
- Client ID: `kubernetes`
- Client Role prefix: `kubernetes:`
- Token Claim Name: `groups`
- Add to ID token: on
For example, if you have the `admin` role of the client, you will get a JWT with the claim `{"groups": ["kubernetes:admin"]}`.
## 2. Setup Kubernetes API server
Configure your Kubernetes API server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and add the following spec:
```yaml
spec:
kubeAPIServer:
oidcIssuerURL: https://keycloak.example.com/auth/realms/YOUR_REALM
oidcClientID: kubernetes
oidcGroupsClaim: groups
```
## 3. Setup Kubernetes cluster
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
```
You can create a custom role and assign it as well.
## 4. Setup kubectl
Configure `kubectl` for the OIDC authentication.
```sh
kubectl config set-credentials 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
```
## 5. Run kubelogin
Run `kubelogin`.
```
% 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://keycloak.example.com/auth/realms/YOUR_REALM
client-id: kubernetes
client-secret: YOUR_SECRET
id-token: ey... # kubelogin will update ID token here
refresh-token: ey... # kubelogin will update refresh token here
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
```

40
docs/team_ops.md Normal file
View File

@@ -0,0 +1,40 @@
# Team Operation
## kops
Export the kubeconfig.
```sh
KUBECONFIG=.kubeconfig kops export kubecfg hello.k8s.local
```
Remove the `admin` access from the kubeconfig.
It should look as like:
```yaml
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: LS...
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:
name: oidc
config:
client-id: YOUR_CLIEND_ID
client-secret: YOUR_CLIENT_SECRET
idp-issuer-url: YOUR_ISSUER
```
You can share the kubeconfig to your team members for easy onboarding.

View File

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

View File

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

View File

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

30
go.mod Normal file
View File

@@ -0,0 +1,30 @@
module github.com/int128/kubelogin
require (
github.com/coreos/go-oidc v2.0.0+incompatible
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/gogo/protobuf v1.2.1 // indirect
github.com/golang/mock v1.3.1
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf // indirect
github.com/imdario/mergo v0.3.7 // indirect
github.com/int128/oauth2cli v1.4.0
github.com/json-iterator/go v1.1.6 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/pkg/errors v0.8.1
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
github.com/spf13/pflag v1.0.3
github.com/stretchr/testify v1.3.0 // indirect
go.uber.org/dig v1.7.0
golang.org/x/oauth2 v0.0.0-20190319182350-c85d3e98c914
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
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
k8s.io/klog v0.2.0 // indirect
sigs.k8s.io/yaml v1.1.0 // indirect
)

82
go.sum Normal file
View File

@@ -0,0 +1,82 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/coreos/go-oidc v2.0.0+incompatible h1:+RStIopZ8wooMx+Vs5Bt8zMXxV1ABl5LbakNExNmZIg=
github.com/coreos/go-oidc v2.0.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck=
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI=
github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/int128/oauth2cli v1.4.0 h1:Xt4uk2lIb9Mf9Xyd5o43Hf9iV5izb2jYK3zRX/cPgh0=
github.com/int128/oauth2cli v1.4.0/go.mod h1:81pWOyFVt1TRyZ7lZDtZuAGOE/S/jEpb1mpocRopI6U=
github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
go.uber.org/dig v1.7.0 h1:E5/L92iQTNJTjfgJF2KgU+/JpMaiuvK2DHLBj0+kSZk=
go.uber.org/dig v1.7.0/go.mod h1:z+dSd2TP9Usi48jL8M3v63iSBVkiwtVyMKxMZYYauPg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/oauth2 v0.0.0-20190319182350-c85d3e98c914 h1:jIOcLT9BZzyJ9ce+IwwZ+aF9yeCqzrR+NrD68a/SHKw=
golang.org/x/oauth2 v0.0.0-20190319182350-c85d3e98c914/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/square/go-jose.v2 v2.3.0 h1:nLzhkFyl5bkblqYBoiWJUt5JkWOzmiaBtCxdJAqJd3U=
gopkg.in/square/go-jose.v2 v2.3.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
k8s.io/api v0.0.0-20190222213804-5cb15d344471 h1:MzQGt8qWQCR+39kbYRd0uQqsvSidpYqJLFeWiJ9l4OE=
k8s.io/api v0.0.0-20190222213804-5cb15d344471/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA=
k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628 h1:UYfHH+KEF88OTg+GojQUwFTNxbxwmoktLwutUzR0GPg=
k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0=
k8s.io/client-go v10.0.0+incompatible h1:+xQQxwjrcIPWDMJBAS+1G2FNk1McoPnb53xkvcDiDqE=
k8s.io/client-go v10.0.0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s=
k8s.io/klog v0.2.0 h1:0ElL0OHzF3N+OhoJTL0uca20SxtYt4X4+bzHeqrB83c=
k8s.io/klog v0.2.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=

50
infrastructure/http.go Normal file
View File

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

View File

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

View File

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

View File

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

15
kubelogin.rb Normal file
View File

@@ -0,0 +1,15 @@
class Kubelogin < Formula
desc "A kubectl plugin for Kubernetes OpenID Connect authentication"
homepage "https://github.com/int128/kubelogin"
url "https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_darwin_amd64.zip"
version "{{ env "VERSION" }}"
sha256 "{{ sha256 .darwin_amd64_archive }}"
def install
bin.install "kubelogin" => "kubelogin"
ln_s bin/"kubelogin", bin/"kubectl-oidc_login"
end
test do
system "#{bin}/kubelogin -h"
system "#{bin}/kubectl-oidc_login -h"
end
end

15
main.go
View File

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

57
oidc-login.yaml Normal file
View File

@@ -0,0 +1,57 @@
apiVersion: krew.googlecontainertools.github.com/v1alpha2
kind: Plugin
metadata:
name: oidc-login
spec:
shortDescription: Login for OpenID Connect authentication
description: |
This plugin gets a token from the OIDC provider and writes it to the kubeconfig.
Just run:
% kubectl oidc-login
It opens the browser and you can log in to the provider.
After authentication, it gets an ID token and refresh token and writes them to the kubeconfig.
caveats: |
You need to setup the following components:
* OIDC provider
* Kubernetes API server
* Role for your group or user
* kubectl authentication
See https://github.com/int128/kubelogin for more.
homepage: https://github.com/int128/kubelogin
version: {{ env "VERSION" }}
platforms:
- uri: https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_linux_amd64.zip
sha256: "{{ sha256 .linux_amd64_archive }}"
bin: kubelogin
files:
- from: "kubelogin"
to: "."
selector:
matchLabels:
os: linux
arch: amd64
- uri: https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_darwin_amd64.zip
sha256: "{{ sha256 .darwin_amd64_archive }}"
bin: kubelogin
files:
- from: "kubelogin"
to: "."
selector:
matchLabels:
os: darwin
arch: amd64
- uri: https://github.com/int128/kubelogin/releases/download/{{ env "VERSION" }}/kubelogin_windows_amd64.zip
sha256: "{{ sha256 .windows_amd64_archive }}"
bin: kubelogin.exe
files:
- from: "kubelogin.exe"
to: "."
selector:
matchLabels:
os: windows
arch: amd64

View File

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

123
usecases/login.go Normal file
View File

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

586
usecases/login_test.go Normal file
View File

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

View File

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