mirror of
https://github.com/int128/kubelogin.git
synced 2026-02-19 19:09:50 +00:00
Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe2fbcbc53 | ||
|
|
812a965739 | ||
|
|
6de1fca64c | ||
|
|
0eb8cdc95f | ||
|
|
995c0997d5 | ||
|
|
18b2437819 | ||
|
|
5d5a33b8ea | ||
|
|
a614943642 | ||
|
|
d223175b92 | ||
|
|
6075c9dbe7 | ||
|
|
be43c2ab82 | ||
|
|
512df0c4e4 | ||
|
|
5d5292637f | ||
|
|
76f61300d6 | ||
|
|
f7f1985a89 | ||
|
|
3d47c88a8d | ||
|
|
c7ea97ff23 | ||
|
|
af18e734ea | ||
|
|
b5ae469b41 | ||
|
|
94f480fdc9 | ||
|
|
7acb6e3a7b | ||
|
|
29e9c39a41 | ||
|
|
dd86168e4b | ||
|
|
1d48eab6b3 | ||
|
|
1e655a14b8 | ||
|
|
8a4d1f5169 | ||
|
|
6f417cd30c | ||
|
|
7ba08f4254 | ||
|
|
e778bbdadc | ||
|
|
74108adf00 | ||
|
|
0257b24156 | ||
|
|
b8c29985e7 | ||
|
|
4683a005c7 | ||
|
|
cc48fb4cf7 | ||
|
|
ec7f7a062a | ||
|
|
e9ae98dfaf | ||
|
|
0c582e97ad | ||
|
|
5a71247214 | ||
|
|
4a084756c3 | ||
|
|
cf4e310b2e | ||
|
|
4007e7f61a | ||
|
|
2700e439b9 | ||
|
|
dbf6238029 | ||
|
|
93e893bc36 | ||
|
|
5dc06ae574 | ||
|
|
ab1023757b | ||
|
|
e26dbd118e | ||
|
|
fccef52a73 | ||
|
|
581284c626 | ||
|
|
b5922f9419 | ||
|
|
7a0ca206d1 | ||
|
|
0bca9ef54b | ||
|
|
2fb551bf1b | ||
|
|
0bc117ddc7 | ||
|
|
8c640f6c73 | ||
|
|
8a5efac337 | ||
|
|
d6e0c761ac | ||
|
|
8925226afe | ||
|
|
89a0f9a79e | ||
|
|
74bb4c62c5 | ||
|
|
25c7c1e703 | ||
|
|
6b1e11f071 | ||
|
|
554daf7655 | ||
|
|
d67d16b333 | ||
|
|
3d0973054b | ||
|
|
bf02210f2a | ||
|
|
53e8284b63 | ||
|
|
d9b8d99fae | ||
|
|
3e30346c9b | ||
|
|
1e80481145 | ||
|
|
9242b1917b | ||
|
|
306bf09485 | ||
|
|
4ad77cd5f8 | ||
|
|
c8967faf6b | ||
|
|
315d6151d7 | ||
|
|
1ff03fdfb3 | ||
|
|
5e0fc7f399 | ||
|
|
9423a65f46 | ||
|
|
45417a18fd | ||
|
|
760416fd04 | ||
|
|
0a4ebb26c2 | ||
|
|
de9f7a2a01 | ||
|
|
0006cdda2d | ||
|
|
c89a8a1823 | ||
|
|
4f566a7b32 | ||
|
|
5158159bdd | ||
|
|
3a2aa0c6c0 | ||
|
|
56b17efae1 | ||
|
|
3e5be43d8a | ||
|
|
1ffa927432 | ||
|
|
5c6b461f37 | ||
|
|
6f96ccae62 | ||
|
|
dc88948d88 | ||
|
|
31609a3ed3 | ||
|
|
9685507d7d | ||
|
|
1c66099496 | ||
|
|
681faa07ca | ||
|
|
79d8056c35 | ||
|
|
4138991339 | ||
|
|
f650827a5f | ||
|
|
56904e15b1 | ||
|
|
dd05c11359 | ||
|
|
ce61e09acf | ||
|
|
e4057db5b5 | ||
|
|
bb288b69d3 | ||
|
|
e220267de5 | ||
|
|
391754e1ce | ||
|
|
10c7b6a84f | ||
|
|
2176105a91 | ||
|
|
3c79a614ff | ||
|
|
f6dec8e3db | ||
|
|
1ea9027677 | ||
|
|
cfa38455ab | ||
|
|
0412f6a1b0 | ||
|
|
afc7974b79 | ||
|
|
97b0bdd53c | ||
|
|
ebe5feaed2 | ||
|
|
4427bc7985 | ||
|
|
8643d695fe | ||
|
|
f0cff5a54b | ||
|
|
8237928af3 | ||
|
|
01b270755b | ||
|
|
e97b4de40b | ||
|
|
5063550468 | ||
|
|
d5989ca256 | ||
|
|
c508a1b717 | ||
|
|
e133ea8541 | ||
|
|
3f2e84a1ea | ||
|
|
7011f03094 | ||
|
|
6aef98cef7 | ||
|
|
93bb1d39b9 | ||
|
|
c8116e2eae | ||
|
|
f2de8dd987 | ||
|
|
915fb35bc8 | ||
|
|
51ccd70af3 | ||
|
|
c6df597fb0 | ||
|
|
ee78f6f735 | ||
|
|
6e484a2b89 | ||
|
|
8050db7e05 | ||
|
|
cd54ca0df0 | ||
|
|
45f83b0b0e | ||
|
|
51b7ca1600 | ||
|
|
83f85a9b53 | ||
|
|
d82c8a2dd1 | ||
|
|
072bee6992 | ||
|
|
5c07850a68 | ||
|
|
5c8c80f055 | ||
|
|
bc7bfabfb2 | ||
|
|
73112546de | ||
|
|
356f0d519d | ||
|
|
e84d29bc6b | ||
|
|
ae80ebf148 | ||
|
|
5942c82b5f | ||
|
|
a5f9c698ea | ||
|
|
40ef2c25b8 | ||
|
|
2422c46271 | ||
|
|
6ca0ee8013 | ||
|
|
9ac252667a | ||
|
|
000711f52e | ||
|
|
e465c4852b | ||
|
|
003badb0bc | ||
|
|
0873a193a5 | ||
|
|
5e80b1858e | ||
|
|
c816281657 | ||
|
|
0db49860f9 | ||
|
|
d70c9db036 | ||
|
|
675b5e5fff | ||
|
|
e3bfc321a2 | ||
|
|
b34d0fb32f | ||
|
|
4c61a71ed4 | ||
|
|
8a02ed0fb0 | ||
|
|
3485c5408e | ||
|
|
fb99977e98 | ||
|
|
39b441a7c2 | ||
|
|
cde5becf67 | ||
|
|
460b14a159 | ||
|
|
8436fe3494 | ||
|
|
9d2319ee2f | ||
|
|
75277378fc | ||
|
|
89a1046ce3 | ||
|
|
15d40413e4 | ||
|
|
8525ba5142 | ||
|
|
dbddd6a07f | ||
|
|
839877b45e | ||
|
|
99ed86e22e | ||
|
|
a78b746c29 | ||
|
|
187bbc203c | ||
|
|
d4b5e511bb | ||
|
|
33241b8721 | ||
|
|
b72cb63826 | ||
|
|
63fda1db0f | ||
|
|
da95fe470f | ||
|
|
4b08a49a51 | ||
|
|
9c74f3748b | ||
|
|
17e03f2abc | ||
|
|
ebef81f9d7 | ||
|
|
8d0d82fb71 | ||
|
|
b600e54a12 | ||
|
|
75317f88a1 | ||
|
|
5a794e8ceb | ||
|
|
1fe1ec4c20 | ||
|
|
7676ffbfab | ||
|
|
7e1e6a096b | ||
|
|
4d3d1c3b78 | ||
|
|
1ebdfc0e4f | ||
|
|
9c67c52b34 | ||
|
|
550396e1dd | ||
|
|
34f0578b59 | ||
|
|
604d118b68 | ||
|
|
91959e8a56 |
@@ -2,39 +2,26 @@ version: 2
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
- image: circleci/golang:1.10
|
||||
working_directory: /go/src/github.com/int128/kubelogin
|
||||
- image: circleci/golang:1.13.4
|
||||
steps:
|
||||
- run: mkdir -p ~/bin
|
||||
- run: echo 'export PATH="$HOME/bin:$PATH"' >> $BASH_ENV
|
||||
- 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
|
||||
- run: make ci-setup-linux-amd64
|
||||
- run: make VERSION=$CIRCLE_TAG ci
|
||||
- run: |
|
||||
if [ "$CIRCLE_TAG" ]; then
|
||||
make VERSION=$CIRCLE_TAG GITHUB_USERNAME=$CIRCLE_PROJECT_USERNAME GITHUB_REPONAME=$CIRCLE_PROJECT_REPONAME release
|
||||
fi
|
||||
- store_artifacts:
|
||||
path: gotest.log
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
all:
|
||||
jobs:
|
||||
- build:
|
||||
context: open-source
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- release:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: /.*/
|
||||
requires:
|
||||
- build
|
||||
|
||||
@@ -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
|
||||
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [int128] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,2 +1,10 @@
|
||||
/dist
|
||||
/.kubeconfig
|
||||
/.idea
|
||||
|
||||
/.kubeconfig*
|
||||
|
||||
/dist/output
|
||||
/coverage.out
|
||||
/gotest.log
|
||||
|
||||
/kubelogin
|
||||
/kubectl-oidc_login
|
||||
|
||||
@@ -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"
|
||||
66
Makefile
Normal file
66
Makefile
Normal file
@@ -0,0 +1,66 @@
|
||||
# CI must provide the following variables (on tag push)
|
||||
# VERSION
|
||||
# GITHUB_USERNAME
|
||||
# GITHUB_REPONAME
|
||||
|
||||
TARGET := kubelogin
|
||||
VERSION ?= latest
|
||||
LDFLAGS := -X main.version=$(VERSION)
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(wildcard **/*.go)
|
||||
go build -o $@ -ldflags "$(LDFLAGS)"
|
||||
|
||||
.PHONY: ci
|
||||
ci:
|
||||
$(MAKE) check
|
||||
bash -c "bash <(curl -s https://codecov.io/bash)"
|
||||
$(MAKE) dist
|
||||
|
||||
.PHONY: check
|
||||
check:
|
||||
golangci-lint run
|
||||
go test -v -race -cover -coverprofile=coverage.out ./... > gotest.log
|
||||
|
||||
.PHONY: dist
|
||||
dist: dist/output
|
||||
dist/output:
|
||||
# make the zip files for GitHub Releases
|
||||
VERSION=$(VERSION) CGO_ENABLED=0 goxzst -d dist/output -i "LICENSE" -o "$(TARGET)" -t "dist/kubelogin.rb dist/oidc-login.yaml dist/Dockerfile" -- -ldflags "$(LDFLAGS)"
|
||||
# test the zip file
|
||||
zipinfo dist/output/kubelogin_linux_amd64.zip
|
||||
# make the krew yaml structure
|
||||
mkdir -p dist/output/plugins
|
||||
mv dist/output/oidc-login.yaml dist/output/plugins/oidc-login.yaml
|
||||
|
||||
.PHONY: release
|
||||
release: dist
|
||||
# publish the binaries
|
||||
ghcp release -u "$(GITHUB_USERNAME)" -r "$(GITHUB_REPONAME)" -t "$(VERSION)" dist/output/
|
||||
# publish the Homebrew formula
|
||||
ghcp commit -u "$(GITHUB_USERNAME)" -r "homebrew-$(GITHUB_REPONAME)" -b "bump-$(VERSION)" -m "Bump the version to $(VERSION)" -C dist/output/ kubelogin.rb
|
||||
ghcp pull-request -u "$(GITHUB_USERNAME)" -r "homebrew-$(GITHUB_REPONAME)" -b "bump-$(VERSION)" --title "Bump the version to $(VERSION)"
|
||||
# publish the Dockerfile
|
||||
ghcp commit -u "$(GITHUB_USERNAME)" -r "$(GITHUB_REPONAME)-docker" -b "bump-$(VERSION)" -m "Bump the version to $(VERSION)" -C dist/output/ Dockerfile
|
||||
ghcp pull-request -u "$(GITHUB_USERNAME)" -r "$(GITHUB_REPONAME)-docker" -b "bump-$(VERSION)" --title "Bump the version to $(VERSION)"
|
||||
# publish the Krew manifest
|
||||
ghcp fork-commit -u kubernetes-sigs -r krew-index -b "oidc-login-$(VERSION)" -m "Bump oidc-login to $(VERSION)" -C dist/output/ plugins/oidc-login.yaml
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
-rm $(TARGET)
|
||||
-rm -r dist/output/
|
||||
-rm coverage.out gotest.log
|
||||
|
||||
.PHONY: ci-setup-linux-amd64
|
||||
ci-setup-linux-amd64:
|
||||
mkdir -p ~/bin
|
||||
# https://github.com/golangci/golangci-lint
|
||||
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b ~/bin v1.21.0
|
||||
# https://github.com/int128/goxzst
|
||||
curl -sfL -o /tmp/goxzst.zip https://github.com/int128/goxzst/releases/download/v0.3.0/goxzst_linux_amd64.zip
|
||||
unzip /tmp/goxzst.zip -d ~/bin
|
||||
# https://github.com/int128/ghcp
|
||||
curl -sfL -o /tmp/ghcp.zip https://github.com/int128/ghcp/releases/download/v1.8.0/ghcp_linux_amd64.zip
|
||||
unzip /tmp/ghcp.zip -d ~/bin
|
||||
455
README.md
455
README.md
@@ -1,283 +1,276 @@
|
||||
# kubelogin [](https://circleci.com/gh/int128/kubelogin)
|
||||
# kubelogin [](https://circleci.com/gh/int128/kubelogin) [](https://goreportcard.com/report/github.com/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 is a kubectl plugin for [Kubernetes OpenID Connect (OIDC) authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens), also known as `kubectl oidc-login`.
|
||||
|
||||
This may work with various OIDC providers such as Keycloak, Google Identity Platform and Azure AD.
|
||||
Here is an example of Kubernetes authentication with the Google Identity Platform:
|
||||
|
||||
<img alt="screencast" src="https://user-images.githubusercontent.com/321266/70971501-7bcebc80-20e4-11ea-8afc-539dcaea0aa8.gif" width="652" height="455">
|
||||
|
||||
Kubelogin is designed to run as a [client-go credential plugin](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins).
|
||||
When you run kubectl, kubelogin opens the browser and you can log in to the provider.
|
||||
Then kubelogin gets a token from the provider and kubectl access Kubernetes APIs with the token.
|
||||
Take a look at the diagram:
|
||||
|
||||

|
||||
|
||||
|
||||
## 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).
|
||||
### Setup
|
||||
|
||||
After initial setup or when the token has been expired, just run `kubelogin`:
|
||||
|
||||
```
|
||||
% 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
|
||||
```
|
||||
|
||||
It opens the browser and you can log in to the provider.
|
||||
After you logged in to the provider, it closes the browser automatically.
|
||||
|
||||
Then it writes the ID token and refresh token to the kubeconfig.
|
||||
|
||||
```
|
||||
2018/08/27 15:03:07 GET /
|
||||
2018/08/27 15:03:08 GET /?state=a51081925f20c043&session_state=5637cbdf-ffdc-4fab-9fc7-68a3e6f2e73f&code=ey...
|
||||
2018/08/27 15:03:09 Got token for subject=cf228a73-47fe-4986-a2a8-b2ced80a884b
|
||||
2018/08/27 15:03:09 Updated /home/user/.kube/config
|
||||
```
|
||||
|
||||
Please see the later section for details.
|
||||
|
||||
|
||||
## Getting Started with Google Account
|
||||
|
||||
### 1. Setup Google API
|
||||
|
||||
Open [Google APIs Console](https://console.developers.google.com/apis/credentials) and create an OAuth client as follows:
|
||||
|
||||
- Application Type: Web application
|
||||
- Redirect URL: `http://localhost:8000/`
|
||||
|
||||
### 2. Setup Kubernetes cluster
|
||||
|
||||
Configure your Kubernetes API Server accepts [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens).
|
||||
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
|
||||
|
||||
```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.
|
||||
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
|
||||
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
|
||||
# Homebrew
|
||||
brew install int128/kubelogin/kubelogin
|
||||
|
||||
# Krew
|
||||
kubectl krew install oidc-login
|
||||
|
||||
# GitHub Releases
|
||||
curl -LO https://github.com/int128/kubelogin/releases/download/v1.15.0/kubelogin_linux_amd64.zip
|
||||
unzip kubelogin_linux_amd64.zip
|
||||
ln -s kubelogin kubectl-oidc_login
|
||||
```
|
||||
|
||||
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:
|
||||
You need to set up the OIDC provider, cluster role binding, Kubernetes API server and kubeconfig.
|
||||
The kubeconfig looks like:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
- name: hello.k8s.local
|
||||
- name: oidc
|
||||
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
|
||||
exec:
|
||||
apiVersion: client.authentication.k8s.io/v1beta1
|
||||
command: kubectl
|
||||
args:
|
||||
- oidc-login
|
||||
- get-token
|
||||
- --oidc-issuer-url=ISSUER_URL
|
||||
- --oidc-client-id=YOUR_CLIENT_ID
|
||||
- --oidc-client-secret=YOUR_CLIENT_SECRET
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
See [the setup guide](docs/setup.md) for more.
|
||||
|
||||
|
||||
## Getting Started with Keycloak
|
||||
### Run
|
||||
|
||||
### 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.
|
||||
Run kubectl.
|
||||
|
||||
```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
|
||||
kubectl get pods
|
||||
```
|
||||
|
||||
Download [the latest release](https://github.com/int128/kubelogin/releases) and save it.
|
||||
Kubectl executes kubelogin before calling the Kubernetes APIs.
|
||||
Kubelogin automatically opens the browser and you can log in to the provider.
|
||||
|
||||
Run `kubelogin` and make sure you can access to the cluster.
|
||||
See the previous section for details.
|
||||
<img src="docs/keycloak-login.png" alt="keycloak-login" width="455" height="329">
|
||||
|
||||
|
||||
## Configuration
|
||||
After authentication, kubelogin returns the credentials to kubectl and finally kubectl calls the Kubernetes APIs with the credential.
|
||||
|
||||
```
|
||||
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
|
||||
% kubectl get pods
|
||||
Open http://localhost:8000 for authentication
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
echoserver-86c78fdccd-nzmd5 1/1 Running 0 26d
|
||||
```
|
||||
|
||||
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).
|
||||
Kubelogin writes the ID token and refresh token to the token cache file.
|
||||
|
||||
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.
|
||||
If the cached ID token is valid, kubelogin just returns it.
|
||||
If the cached ID token has expired, kubelogin will refresh the token using the refresh token.
|
||||
If the refresh token has expired, kubelogin will perform reauthentication.
|
||||
|
||||
|
||||
### Kubeconfig path
|
||||
### Troubleshoot
|
||||
|
||||
You can set the environment variable `KUBECONFIG` to point the config file.
|
||||
Default to `~/.kube/config`.
|
||||
You can log out by removing the token cache directory (default `~/.kube/cache/oidc-login`).
|
||||
Kubelogin will perform authentication if the token cache file does not exist.
|
||||
|
||||
```sh
|
||||
export KUBECONFIG="$PWD/.kubeconfig"
|
||||
You can dump the claims of token by passing `-v1` option.
|
||||
|
||||
```
|
||||
I1212 10:14:17.754394 2517 get_token.go:91] the ID token has the claim: sub=********
|
||||
I1212 10:14:17.754434 2517 get_token.go:91] the ID token has the claim: at_hash=********
|
||||
I1212 10:14:17.754449 2517 get_token.go:91] the ID token has the claim: nonce=********
|
||||
I1212 10:14:17.754459 2517 get_token.go:91] the ID token has the claim: iat=1576113256
|
||||
I1212 10:14:17.754467 2517 get_token.go:91] the ID token has the claim: exp=1576116856
|
||||
I1212 10:14:17.754484 2517 get_token.go:91] the ID token has the claim: iss=https://accounts.google.com
|
||||
I1212 10:14:17.754497 2517 get_token.go:91] the ID token has the claim: azp=********.apps.googleusercontent.com
|
||||
I1212 10:14:17.754506 2517 get_token.go:91] the ID token has the claim: aud=********.apps.googleusercontent.com
|
||||
```
|
||||
|
||||
### Team onboarding
|
||||
|
||||
You can share the kubeconfig to your team members for easy setup.
|
||||
## Usage
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
% kubectl oidc-login get-token -h
|
||||
Run as a kubectl credential plugin
|
||||
|
||||
Usage:
|
||||
kubelogin get-token [flags]
|
||||
|
||||
Flags:
|
||||
--oidc-issuer-url string Issuer URL of the provider (mandatory)
|
||||
--oidc-client-id string Client ID of the provider (mandatory)
|
||||
--oidc-client-secret string Client secret of the provider
|
||||
--oidc-extra-scope strings Scopes to request to the provider
|
||||
--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
|
||||
--token-cache-dir string Path to a directory for caching tokens (default "~/.kube/cache/oidc-login")
|
||||
--grant-type string The authorization grant type to use. One of (auto|authcode|authcode-keyboard|password) (default "auto")
|
||||
--listen-address strings Address to bind to the local server. If multiple addresses are given, it will try binding in order (default [127.0.0.1:8000,127.0.0.1:18000])
|
||||
--listen-port ints (Deprecated: use --listen-address)
|
||||
--skip-open-browser If true, it does not open the browser on authentication
|
||||
--username string If set, perform the resource owner password credentials grant
|
||||
--password string If set, use the password instead of asking it
|
||||
-h, --help help for get-token
|
||||
|
||||
Global Flags:
|
||||
--add_dir_header If true, adds the file directory to the header
|
||||
--alsologtostderr log to standard error as well as files
|
||||
--log_backtrace_at traceLocation when logging hits line file:N, emit a stack trace (default :0)
|
||||
--log_dir string If non-empty, write log files in this directory
|
||||
--log_file string If non-empty, use this log file
|
||||
--log_file_max_size uint Defines the maximum size a log file can grow to. Unit is megabytes. If the value is 0, the maximum file size is unlimited. (default 1800)
|
||||
--logtostderr log to standard error instead of files (default true)
|
||||
--skip_headers If true, avoid header prefixes in the log messages
|
||||
--skip_log_headers If true, avoid headers when opening log files
|
||||
--stderrthreshold severity logs at or above this threshold go to stderr (default 2)
|
||||
-v, --v Level number for the log level verbosity
|
||||
--vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging
|
||||
```
|
||||
|
||||
See also the options of [standalone mode](docs/standalone-mode.md).
|
||||
|
||||
### Extra scopes
|
||||
|
||||
You can set the extra scopes to request to the provider by `--oidc-extra-scope`.
|
||||
|
||||
```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
|
||||
- --oidc-extra-scope=email
|
||||
- --oidc-extra-scope=profile
|
||||
```
|
||||
|
||||
If you are using kops, export the kubeconfig and edit it.
|
||||
### CA Certificates
|
||||
|
||||
```sh
|
||||
KUBECONFIG=.kubeconfig kops export kubecfg hello.k8s.local
|
||||
vim .kubeconfig
|
||||
You can use your self-signed certificate for the provider.
|
||||
|
||||
```yaml
|
||||
- --certificate-authority=/home/user/.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).
|
||||
|
||||
|
||||
### Authentication flows
|
||||
|
||||
#### Authorization code flow
|
||||
|
||||
Kubelogin performs the authorization code flow by default.
|
||||
|
||||
It starts the local server at port 8000 or 18000 by default.
|
||||
You need to register the following redirect URIs to the provider:
|
||||
|
||||
- `http://localhost:8000`
|
||||
- `http://localhost:18000` (used if port 8000 is already in use)
|
||||
|
||||
You can change the listening address.
|
||||
|
||||
```yaml
|
||||
- --listen-address=127.0.0.1:12345
|
||||
- --listen-address=127.0.0.1:23456
|
||||
```
|
||||
|
||||
#### Authorization code flow with keyboard interactive
|
||||
|
||||
If you cannot access the browser, instead use the authorization code flow with keyboard interactive.
|
||||
|
||||
```yaml
|
||||
- --grant-type=authcode-keyboard
|
||||
```
|
||||
|
||||
Kubelogin will show the URL and prompt.
|
||||
Open the URL in the browser and then copy the code shown.
|
||||
|
||||
```
|
||||
% kubectl get pods
|
||||
Open https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&client_id=...
|
||||
Enter code: YOUR_CODE
|
||||
```
|
||||
|
||||
Note that this flow uses the redirect URI `urn:ietf:wg:oauth:2.0:oob` and
|
||||
some OIDC providers do not support it.
|
||||
|
||||
#### Resource owner password credentials grant flow
|
||||
|
||||
Kubelogin performs the resource owner password credentials grant flow
|
||||
when `--grant-type=password` or `--username` is set.
|
||||
|
||||
Note that most OIDC providers do not support this flow.
|
||||
Keycloak supports this flow but you need to explicitly enable the "Direct Access Grants" feature in the client settings.
|
||||
|
||||
You can set the username and password.
|
||||
|
||||
```yaml
|
||||
- --username=USERNAME
|
||||
- --password=PASSWORD
|
||||
```
|
||||
|
||||
If the password is not set, kubelogin will show the prompt for the password.
|
||||
|
||||
```yaml
|
||||
- --username=USERNAME
|
||||
```
|
||||
|
||||
```
|
||||
% kubectl get pods
|
||||
Password:
|
||||
```
|
||||
|
||||
If the username is not set, kubelogin will show the prompt for the username and password.
|
||||
|
||||
```yaml
|
||||
- --grant-type=password
|
||||
```
|
||||
|
||||
```
|
||||
% kubectl get pods
|
||||
Username: foo
|
||||
Password:
|
||||
```
|
||||
|
||||
|
||||
## Related works
|
||||
|
||||
### Kubernetes Dashboard
|
||||
|
||||
You can access the Kubernetes Dashboard using kubelogin and [kauthproxy](https://github.com/int128/kauthproxy).
|
||||
|
||||
|
||||
## Contributions
|
||||
|
||||
This is an open source software licensed under Apache License 2.0.
|
||||
Feel free to open issues and pull requests.
|
||||
Feel free to open issues and pull requests for improving code and documents.
|
||||
|
||||
### Build and Test
|
||||
### Development
|
||||
|
||||
Go 1.13 or later is required.
|
||||
|
||||
```sh
|
||||
go get github.com/int128/kubelogin
|
||||
# Run lint and tests
|
||||
make check
|
||||
|
||||
# Compile and run the command
|
||||
make
|
||||
./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).
|
||||
|
||||
110
auth/authcode.go
110
auth/authcode.go
@@ -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)
|
||||
}
|
||||
}
|
||||
70
auth/oidc.go
70
auth/oidc.go
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
94
cli/cli.go
94
cli/cli.go
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
42
cli/tls.go
42
cli/tls.go
@@ -1,42 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
|
||||
"github.com/int128/kubelogin/kubeconfig"
|
||||
)
|
||||
|
||||
func (c *CLI) tlsConfig(authProvider *kubeconfig.OIDCAuthProvider) (*tls.Config, error) {
|
||||
p := x509.NewCertPool()
|
||||
if 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
|
||||
}
|
||||
13
dist/Dockerfile
vendored
Normal file
13
dist/Dockerfile
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM alpine:3.11
|
||||
|
||||
ARG KUBELOGIN_VERSION="{{ env "VERSION" }}"
|
||||
ARG KUBELOGIN_SHA256="{{ sha256 .linux_amd64_archive }}"
|
||||
|
||||
# Download the release and test the checksum
|
||||
RUN wget -O /kubelogin.zip "https://github.com/int128/kubelogin/releases/download/$KUBELOGIN_VERSION/kubelogin_linux_amd64.zip" && \
|
||||
unzip /kubelogin.zip && \
|
||||
rm /kubelogin.zip && \
|
||||
echo "$KUBELOGIN_SHA256 /kubelogin" | sha256sum -c -
|
||||
|
||||
USER daemon
|
||||
ENTRYPOINT ["/kubelogin"]
|
||||
15
dist/kubelogin.rb
vendored
Normal file
15
dist/kubelogin.rb
vendored
Normal 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
|
||||
64
dist/oidc-login.yaml
vendored
Normal file
64
dist/oidc-login.yaml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
apiVersion: krew.googlecontainertools.github.com/v1alpha2
|
||||
kind: Plugin
|
||||
metadata:
|
||||
name: oidc-login
|
||||
spec:
|
||||
homepage: https://github.com/int128/kubelogin
|
||||
shortDescription: Log in to the OpenID Connect provider
|
||||
description: |
|
||||
This is a kubectl plugin for Kubernetes OpenID Connect (OIDC) authentication.
|
||||
|
||||
## Credential plugin mode
|
||||
kubectl executes oidc-login before calling the Kubernetes APIs.
|
||||
oidc-login automatically opens the browser and you can log in to the provider.
|
||||
After authentication, kubectl gets the token from oidc-login and you can access the cluster.
|
||||
See https://github.com/int128/kubelogin#credential-plugin-mode for more.
|
||||
|
||||
## Standalone mode
|
||||
Run `kubectl oidc-login`.
|
||||
It automatically opens the browser and you can log in to the provider.
|
||||
After authentication, it writes the token to the kubeconfig and you can access the cluster.
|
||||
See https://github.com/int128/kubelogin#standalone-mode for more.
|
||||
|
||||
caveats: |
|
||||
You need to setup the OIDC provider, Kubernetes API server, role binding and kubeconfig.
|
||||
See https://github.com/int128/kubelogin for more.
|
||||
|
||||
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: .
|
||||
- from: LICENSE
|
||||
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: .
|
||||
- from: LICENSE
|
||||
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: .
|
||||
- from: LICENSE
|
||||
to: .
|
||||
selector:
|
||||
matchLabels:
|
||||
os: windows
|
||||
arch: amd64
|
||||
3
docs/credential-plugin-diagram.svg
Normal file
3
docs/credential-plugin-diagram.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/keycloak-login.png
Normal file
BIN
docs/keycloak-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
234
docs/setup.md
Normal file
234
docs/setup.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Kubernetes OpenID Connection authentication
|
||||
|
||||
This document guides how to set up the Kubernetes OpenID Connect (OIDC) authentication.
|
||||
Let's see the following steps:
|
||||
|
||||
1. Set up the OIDC provider
|
||||
1. Verify authentication
|
||||
1. Bind a cluster role
|
||||
1. Set up the Kubernetes API server
|
||||
1. Set up the kubeconfig
|
||||
1. Verify cluster access
|
||||
|
||||
|
||||
## 1. Set up the OIDC provider
|
||||
|
||||
### Google Identity Platform
|
||||
|
||||
You can log in with a Google account.
|
||||
|
||||
Open [Google APIs Console](https://console.developers.google.com/apis/credentials) and create an OAuth client with the following setting:
|
||||
|
||||
- Application Type: Other
|
||||
|
||||
Check the client ID and secret.
|
||||
Replace the following variables in the later sections.
|
||||
|
||||
Variable | Value
|
||||
------------------------|------
|
||||
`ISSUER_URL` | `https://accounts.google.com`
|
||||
`YOUR_CLIENT_ID` | `xxx.apps.googleusercontent.com`
|
||||
`YOUR_CLIENT_SECRET` | random string
|
||||
|
||||
### Keycloak
|
||||
|
||||
You can log in with a user of Keycloak.
|
||||
Make sure you have an administrator role of the Keycloak realm.
|
||||
|
||||
Open the Keycloak and create an OIDC client as follows:
|
||||
|
||||
- Client ID: `YOUR_CLIENT_ID`
|
||||
- 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: `YOUR_CLIENT_ID`
|
||||
- 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"]}`.
|
||||
|
||||
Replace the following variables in the later sections.
|
||||
|
||||
Variable | Value
|
||||
------------------------|------
|
||||
`ISSUER_URL` | `https://keycloak.example.com/auth/realms/YOUR_REALM`
|
||||
`YOUR_CLIENT_ID` | `YOUR_CLIENT_ID`
|
||||
`YOUR_CLIENT_SECRET` | random string
|
||||
|
||||
### Dex with GitHub
|
||||
|
||||
You can log in with a GitHub account.
|
||||
|
||||
Open [GitHub OAuth Apps](https://github.com/settings/developers) and create an application with the following setting:
|
||||
|
||||
- Application name: (any)
|
||||
- Homepage URL: `https://dex.example.com`
|
||||
- Authorization callback URL: `https://dex.example.com/callback`
|
||||
|
||||
Deploy the [dex](https://github.com/dexidp/dex) with the following config:
|
||||
|
||||
```yaml
|
||||
issuer: https://dex.example.com
|
||||
connectors:
|
||||
- type: github
|
||||
id: github
|
||||
name: GitHub
|
||||
config:
|
||||
clientID: YOUR_GITHUB_CLIENT_ID
|
||||
clientSecret: YOUR_GITHUB_CLIENT_SECRET
|
||||
redirectURI: https://dex.example.com/callback
|
||||
staticClients:
|
||||
- id: YOUR_CLIENT_ID
|
||||
name: Kubernetes
|
||||
redirectURIs:
|
||||
- http://localhost:8000
|
||||
- http://localhost:18000
|
||||
secret: YOUR_DEX_CLIENT_SECRET
|
||||
```
|
||||
|
||||
Replace the following variables in the later sections.
|
||||
|
||||
Variable | Value
|
||||
------------------------|------
|
||||
`ISSUER_URL` | `https://dex.example.com`
|
||||
`YOUR_CLIENT_ID` | `YOUR_CLIENT_ID`
|
||||
`YOUR_CLIENT_SECRET` | `YOUR_DEX_CLIENT_SECRET`
|
||||
|
||||
### Okta
|
||||
|
||||
You can log in with an Okta user.
|
||||
Okta supports [the authorization code flow with PKCE](https://developer.okta.com/docs/guides/implement-auth-code-pkce/overview/)
|
||||
and this section explains how to set up it.
|
||||
|
||||
Open your Okta organization and create an application with the following options:
|
||||
|
||||
- Application type: Native
|
||||
- Initiate login URI: `http://localhost:8000`
|
||||
- Login redirect URIs:
|
||||
- `http://localhost:8000`
|
||||
- `http://localhost:18000` (used if the port 8000 is already in use)
|
||||
- Allowed grant types: Authorization Code
|
||||
- Client authentication: Use PKCE (for public clients)
|
||||
|
||||
Replace the following variables in the later sections.
|
||||
|
||||
Variable | Value
|
||||
------------------------|------
|
||||
`ISSUER_URL` | `https://YOUR_ORGANIZATION.okta.com`
|
||||
`YOUR_CLIENT_ID` | random string
|
||||
|
||||
You do not need to set `YOUR_CLIENT_SECRET`.
|
||||
|
||||
|
||||
## 2. Verify authentication
|
||||
|
||||
Run the following command:
|
||||
|
||||
```sh
|
||||
kubectl oidc-login setup \
|
||||
--oidc-issuer-url=ISSUER_URL \
|
||||
--oidc-client-id=YOUR_CLIENT_ID \
|
||||
--oidc-client-secret=YOUR_CLIENT_SECRET
|
||||
```
|
||||
|
||||
It will open the browser and you can log in to the provider.
|
||||
Then it will show the instruction.
|
||||
|
||||
|
||||
## 3. Bind a cluster role
|
||||
|
||||
In this tutorial, bind the `cluster-admin` role to you.
|
||||
Apply the following manifest:
|
||||
|
||||
```yaml
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: oidc-cluster-admin
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cluster-admin
|
||||
subjects:
|
||||
- kind: User
|
||||
name: ISSUER_URL#YOUR_SUBJECT
|
||||
```
|
||||
|
||||
```sh
|
||||
kubectl apply -f oidc-cluster-admin.yaml
|
||||
```
|
||||
|
||||
As well as you can create a custom cluster role and bind it.
|
||||
|
||||
|
||||
## 4. Set up the Kubernetes API server
|
||||
|
||||
Add the following options to the kube-apiserver:
|
||||
|
||||
```
|
||||
--oidc-issuer-url=ISSUER_URL
|
||||
--oidc-client-id=YOUR_CLIENT_ID
|
||||
```
|
||||
|
||||
See [OpenID Connect Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens) for details.
|
||||
|
||||
If you are using [kops](https://github.com/kubernetes/kops), run `kops edit cluster` and append the following settings:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
kubeAPIServer:
|
||||
oidcIssuerURL: ISSUER_URL
|
||||
oidcClientID: YOUR_CLIENT_ID
|
||||
```
|
||||
|
||||
If you are using [kube-aws](https://github.com/kubernetes-incubator/kube-aws), append the following settings to the `cluster.yaml`:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
enabled: true
|
||||
issuerUrl: ISSUER_URL
|
||||
clientId: YOUR_CLIENT_ID
|
||||
```
|
||||
|
||||
|
||||
## 5. Set up the kubeconfig
|
||||
|
||||
Add the following user to the kubeconfig:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
- name: oidc
|
||||
user:
|
||||
exec:
|
||||
apiVersion: client.authentication.k8s.io/v1beta1
|
||||
command: kubectl
|
||||
args:
|
||||
- oidc-login
|
||||
- get-token
|
||||
- --oidc-issuer-url=ISSUER_URL
|
||||
- --oidc-client-id=YOUR_CLIENT_ID
|
||||
- --oidc-client-secret=YOUR_CLIENT_SECRET
|
||||
```
|
||||
|
||||
You can share the kubeconfig to your team members for on-boarding.
|
||||
|
||||
|
||||
## 6. Verify cluster access
|
||||
|
||||
Make sure you can access the Kubernetes cluster.
|
||||
|
||||
```
|
||||
% kubectl get nodes
|
||||
Open http://localhost:8000 for authentication
|
||||
You got a valid token until 2019-05-16 22:03:13 +0900 JST
|
||||
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
|
||||
```
|
||||
180
docs/standalone-mode.md
Normal file
180
docs/standalone-mode.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Standalone mode
|
||||
|
||||
You can run kubelogin as a standalone command.
|
||||
In this mode, you need to manually run the command before running kubectl.
|
||||
|
||||
Configure the kubeconfig like:
|
||||
|
||||
```yaml
|
||||
- name: keycloak
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
client-id: YOUR_CLIENT_ID
|
||||
client-secret: YOUR_CLIENT_SECRET
|
||||
idp-issuer-url: https://issuer.example.com
|
||||
name: oidc
|
||||
```
|
||||
|
||||
Run kubelogin:
|
||||
|
||||
```sh
|
||||
kubelogin
|
||||
|
||||
# or run as a kubectl plugin
|
||||
kubectl oidc-login
|
||||
```
|
||||
|
||||
It automatically opens the browser and you can log in to the provider.
|
||||
|
||||
<img src="keycloak-login.png" alt="keycloak-login" width="455" height="329">
|
||||
|
||||
After authentication, kubelogin writes the ID token and refresh token to the kubeconfig.
|
||||
|
||||
```
|
||||
% kubelogin
|
||||
Open http://localhost:8000 for authentication
|
||||
You got a valid token until 2019-05-18 10:28:51 +0900 JST
|
||||
Updated ~/.kubeconfig
|
||||
```
|
||||
|
||||
Now you can access the cluster.
|
||||
|
||||
```
|
||||
% kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
echoserver-86c78fdccd-nzmd5 1/1 Running 0 26d
|
||||
```
|
||||
|
||||
Your kubeconfig looks like:
|
||||
|
||||
```yaml
|
||||
users:
|
||||
- name: keycloak
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
client-id: YOUR_CLIENT_ID
|
||||
client-secret: YOUR_CLIENT_SECRET
|
||||
idp-issuer-url: https://issuer.example.com
|
||||
id-token: ey... # kubelogin will add or update the ID token here
|
||||
refresh-token: ey... # kubelogin will add or update the refresh token here
|
||||
name: oidc
|
||||
```
|
||||
|
||||
If the ID token is valid, kubelogin does nothing.
|
||||
|
||||
```
|
||||
% kubelogin
|
||||
You already have a valid token until 2019-05-18 10:28:51 +0900 JST
|
||||
```
|
||||
|
||||
If the ID token has expired, kubelogin will refresh the token using the refresh token in the kubeconfig.
|
||||
If the refresh token has expired, kubelogin will proceed the authentication.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Kubelogin supports the following options:
|
||||
|
||||
```
|
||||
% kubectl oidc-login -h
|
||||
Login to the OpenID Connect provider.
|
||||
|
||||
You need to set up the OIDC provider, role binding, Kubernetes API server and kubeconfig.
|
||||
Run the following command to show the setup instruction:
|
||||
|
||||
kubectl oidc-login setup
|
||||
|
||||
See https://github.com/int128/kubelogin for more.
|
||||
|
||||
Usage:
|
||||
main [flags]
|
||||
main [command]
|
||||
|
||||
Available Commands:
|
||||
get-token Run as a kubectl credential plugin
|
||||
help Help about any command
|
||||
setup Show the setup instruction
|
||||
version Print the version information
|
||||
|
||||
Flags:
|
||||
--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
|
||||
--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
|
||||
--grant-type string The authorization grant type to use. One of (auto|authcode|authcode-keyboard|password) (default "auto")
|
||||
--listen-address strings Address to bind to the local server. If multiple addresses are given, it will try binding in order (default [127.0.0.1:8000,127.0.0.1:18000])
|
||||
--listen-port ints (Deprecated: use --listen-address)
|
||||
--skip-open-browser If true, it does not open the browser on authentication
|
||||
--username string If set, perform the resource owner password credentials grant
|
||||
--password string If set, use the password instead of asking it
|
||||
--add_dir_header If true, adds the file directory to the header
|
||||
--alsologtostderr log to standard error as well as files
|
||||
--log_backtrace_at traceLocation when logging hits line file:N, emit a stack trace (default :0)
|
||||
--log_dir string If non-empty, write log files in this directory
|
||||
--log_file string If non-empty, use this log file
|
||||
--log_file_max_size uint Defines the maximum size a log file can grow to. Unit is megabytes. If the value is 0, the maximum file size is unlimited. (default 1800)
|
||||
--logtostderr log to standard error instead of files (default true)
|
||||
--skip_headers If true, avoid header prefixes in the log messages
|
||||
--skip_log_headers If true, avoid headers when opening log files
|
||||
--stderrthreshold severity logs at or above this threshold go to stderr (default 2)
|
||||
-v, --v Level number for the log level verbosity
|
||||
--vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging
|
||||
-h, --help help for main
|
||||
--version version for main
|
||||
```
|
||||
|
||||
### Kubeconfig
|
||||
|
||||
You can set path to the kubeconfig file by the option or the environment variable just like kubectl.
|
||||
It defaults to `~/.kube/config`.
|
||||
|
||||
```sh
|
||||
# by the option
|
||||
kubelogin --kubeconfig /path/to/kubeconfig
|
||||
|
||||
# by the environment variable
|
||||
KUBECONFIG="/path/to/kubeconfig1:/path/to/kubeconfig2" kubelogin
|
||||
```
|
||||
|
||||
If you set multiple files, kubelogin will find the file which has the current authentication (i.e. `user` and `auth-provider`) and write a token to it.
|
||||
|
||||
Kubelogin 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` | 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.
|
||||
|
||||
### Extra scopes
|
||||
|
||||
You can set the extra scopes to request to the provider by `extra-scopes` in the kubeconfig.
|
||||
|
||||
```sh
|
||||
kubectl config set-credentials keycloak --auth-provider-arg extra-scopes=email
|
||||
```
|
||||
|
||||
Currently kubectl does not accept multiple scopes, so 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
|
||||
```
|
||||
|
||||
### CA Certificates
|
||||
|
||||
You can use your self-signed certificates for the provider.
|
||||
|
||||
```sh
|
||||
kubectl config set-credentials keycloak \
|
||||
--auth-provider-arg idp-certificate-authority=$HOME/.kube/keycloak-ca.pem
|
||||
```
|
||||
@@ -1,49 +0,0 @@
|
||||
package authserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Addr is address to listen.
|
||||
const Addr = "localhost:9000"
|
||||
|
||||
// CACert is path to the CA certificate.
|
||||
// This should be generated by Makefile before test.
|
||||
const CACert = "authserver/testdata/ca.crt"
|
||||
|
||||
// ServerCert is path to the server certificate.
|
||||
// This should be generated by Makefile before test.
|
||||
const ServerCert = "authserver/testdata/server.crt"
|
||||
|
||||
// ServerKey is path to the server key.
|
||||
// This should be generated by Makefile before test.
|
||||
const ServerKey = "authserver/testdata/server.key"
|
||||
|
||||
// Config represents server configuration.
|
||||
type Config struct {
|
||||
Issuer string
|
||||
Scope string
|
||||
Cert string
|
||||
Key string
|
||||
}
|
||||
|
||||
// Start starts a HTTP server.
|
||||
func (c *Config) Start(t *testing.T) *http.Server {
|
||||
s := &http.Server{
|
||||
Addr: Addr,
|
||||
Handler: newHandler(t, c),
|
||||
}
|
||||
go func() {
|
||||
var err error
|
||||
if c.Cert != "" && c.Key != "" {
|
||||
err = s.ListenAndServeTLS(c.Cert, c.Key)
|
||||
} else {
|
||||
err = s.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
return s
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package authserver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
jwt "github.com/dgrijalva/jwt-go"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
discovery *template.Template
|
||||
token *template.Template
|
||||
jwks *template.Template
|
||||
authCode string
|
||||
|
||||
Issuer string
|
||||
Scope string // Default to openid
|
||||
IDToken string
|
||||
PrivateKey struct{ N, E string }
|
||||
}
|
||||
|
||||
func newHandler(t *testing.T, c *Config) *handler {
|
||||
h := handler{
|
||||
discovery: readTemplate(t, "oidc-discovery.json"),
|
||||
token: readTemplate(t, "oidc-token.json"),
|
||||
jwks: readTemplate(t, "oidc-jwks.json"),
|
||||
authCode: "3d24a8bd-35e6-457d-999e-e04bb1dfcec7",
|
||||
Issuer: c.Issuer,
|
||||
Scope: c.Scope,
|
||||
}
|
||||
if h.Scope == "" {
|
||||
h.Scope = "openid"
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.StandardClaims{
|
||||
Issuer: c.Issuer,
|
||||
Audience: "kubernetes",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
k, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not generate a key pair: %s", err)
|
||||
}
|
||||
h.IDToken, err = token.SignedString(k)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not generate an ID token: %s", err)
|
||||
}
|
||||
h.PrivateKey.E = base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes())
|
||||
h.PrivateKey.N = base64.RawURLEncoding.EncodeToString(k.N.Bytes())
|
||||
return &h
|
||||
}
|
||||
|
||||
func readTemplate(t *testing.T, name string) *template.Template {
|
||||
t.Helper()
|
||||
tpl, err := template.ParseFiles("authserver/testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not read template %s: %s", name, err)
|
||||
}
|
||||
return tpl
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.serveHTTP(w, r); err != nil {
|
||||
log.Printf("[auth-server] Error: %s", err)
|
||||
w.WriteHeader(500)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
m := r.Method
|
||||
p := r.URL.Path
|
||||
log.Printf("[auth-server] %s %s", m, r.RequestURI)
|
||||
switch {
|
||||
case m == "GET" && p == "/.well-known/openid-configuration":
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := h.discovery.Execute(w, h); err != nil {
|
||||
return err
|
||||
}
|
||||
case m == "GET" && p == "/protocol/openid-connect/auth":
|
||||
// Authentication Response
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
|
||||
q := r.URL.Query()
|
||||
if h.Scope != q.Get("scope") {
|
||||
return fmt.Errorf("scope wants %s but %s", h.Scope, q.Get("scope"))
|
||||
}
|
||||
to := fmt.Sprintf("%s?state=%s&code=%s", q.Get("redirect_uri"), q.Get("state"), h.authCode)
|
||||
http.Redirect(w, r, to, 302)
|
||||
case m == "POST" && p == "/protocol/openid-connect/token":
|
||||
// Token Response
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.authCode != r.Form.Get("code") {
|
||||
return fmt.Errorf("code wants %s but %s", h.authCode, r.Form.Get("code"))
|
||||
}
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := h.token.Execute(w, h); err != nil {
|
||||
return err
|
||||
}
|
||||
case m == "GET" && p == "/protocol/openid-connect/certs":
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := h.jwks.Execute(w, h); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
http.Error(w, "Not Found", 404)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
4
e2e/authserver/testdata/.gitignore
vendored
4
e2e/authserver/testdata/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
/CA
|
||||
*.key
|
||||
*.csr
|
||||
*.crt
|
||||
85
e2e/authserver/testdata/oidc-discovery.json
vendored
85
e2e/authserver/testdata/oidc-discovery.json
vendored
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"issuer": "{{ .Issuer }}",
|
||||
"authorization_endpoint": "{{ .Issuer }}/protocol/openid-connect/auth",
|
||||
"token_endpoint": "{{ .Issuer }}/protocol/openid-connect/token",
|
||||
"token_introspection_endpoint": "{{ .Issuer }}/protocol/openid-connect/token/introspect",
|
||||
"userinfo_endpoint": "{{ .Issuer }}/protocol/openid-connect/userinfo",
|
||||
"end_session_endpoint": "{{ .Issuer }}/protocol/openid-connect/logout",
|
||||
"jwks_uri": "{{ .Issuer }}/protocol/openid-connect/certs",
|
||||
"check_session_iframe": "{{ .Issuer }}/protocol/openid-connect/login-status-iframe.html",
|
||||
"grant_types_supported": [
|
||||
"authorization_code",
|
||||
"implicit",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"client_credentials"
|
||||
],
|
||||
"response_types_supported": [
|
||||
"code",
|
||||
"none",
|
||||
"id_token",
|
||||
"token",
|
||||
"id_token token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"code id_token token"
|
||||
],
|
||||
"subject_types_supported": [
|
||||
"public",
|
||||
"pairwise"
|
||||
],
|
||||
"id_token_signing_alg_values_supported": [
|
||||
"RS256"
|
||||
],
|
||||
"userinfo_signing_alg_values_supported": [
|
||||
"RS256"
|
||||
],
|
||||
"request_object_signing_alg_values_supported": [
|
||||
"none",
|
||||
"RS256"
|
||||
],
|
||||
"response_modes_supported": [
|
||||
"query",
|
||||
"fragment",
|
||||
"form_post"
|
||||
],
|
||||
"registration_endpoint": "{{ .Issuer }}/clients-registrations/openid-connect",
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"private_key_jwt",
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"client_secret_jwt"
|
||||
],
|
||||
"token_endpoint_auth_signing_alg_values_supported": [
|
||||
"RS256"
|
||||
],
|
||||
"claims_supported": [
|
||||
"sub",
|
||||
"iss",
|
||||
"auth_time",
|
||||
"name",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"preferred_username",
|
||||
"email"
|
||||
],
|
||||
"claim_types_supported": [
|
||||
"normal"
|
||||
],
|
||||
"claims_parameter_supported": false,
|
||||
"scopes_supported": [
|
||||
"openid",
|
||||
"offline_access",
|
||||
"phone",
|
||||
"address",
|
||||
"email",
|
||||
"profile"
|
||||
],
|
||||
"request_parameter_supported": true,
|
||||
"request_uri_parameter_supported": true,
|
||||
"code_challenge_methods_supported": [
|
||||
"plain",
|
||||
"S256"
|
||||
],
|
||||
"tls_client_certificate_bound_access_tokens": true
|
||||
}
|
||||
12
e2e/authserver/testdata/oidc-jwks.json
vendored
12
e2e/authserver/testdata/oidc-jwks.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": "xxx",
|
||||
"n": "{{ .PrivateKey.N }}",
|
||||
"e": "{{ .PrivateKey.E }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
e2e/authserver/testdata/oidc-token.json
vendored
7
e2e/authserver/testdata/oidc-token.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"access_token": "7eaae8ab-8f69-45d9-ab7c-73560cd9444d",
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "44df4c82-5ce7-4260-b54d-1da0d396ef2a",
|
||||
"expires_in": 3600,
|
||||
"id_token": "{{ .IDToken }}"
|
||||
}
|
||||
147
e2e/e2e_test.go
147
e2e/e2e_test.go
@@ -1,147 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/int128/kubelogin/cli"
|
||||
"github.com/int128/kubelogin/e2e/authserver"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// End-to-end test.
|
||||
//
|
||||
// 1. Start the auth server at port 9000.
|
||||
// 2. Run the CLI.
|
||||
// 3. Open a request for port 8000.
|
||||
// 4. Wait for the CLI.
|
||||
// 5. Shutdown the auth server.
|
||||
func TestE2E(t *testing.T) {
|
||||
data := map[string]struct {
|
||||
kubeconfigValues kubeconfigValues
|
||||
cli cli.CLI
|
||||
serverConfig authserver.Config
|
||||
clientTLS *tls.Config
|
||||
}{
|
||||
"NoTLS": {
|
||||
kubeconfigValues{Issuer: "http://localhost:9000"},
|
||||
cli.CLI{},
|
||||
authserver.Config{Issuer: "http://localhost:9000"},
|
||||
&tls.Config{},
|
||||
},
|
||||
"ExtraScope": {
|
||||
kubeconfigValues{
|
||||
Issuer: "http://localhost:9000",
|
||||
ExtraScopes: "profile groups",
|
||||
},
|
||||
cli.CLI{},
|
||||
authserver.Config{
|
||||
Issuer: "http://localhost:9000",
|
||||
Scope: "profile groups openid",
|
||||
},
|
||||
&tls.Config{},
|
||||
},
|
||||
"SkipTLSVerify": {
|
||||
kubeconfigValues{Issuer: "https://localhost:9000"},
|
||||
cli.CLI{SkipTLSVerify: true},
|
||||
authserver.Config{
|
||||
Issuer: "https://localhost:9000",
|
||||
Cert: authserver.ServerCert,
|
||||
Key: authserver.ServerKey,
|
||||
},
|
||||
&tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
"CACert": {
|
||||
kubeconfigValues{
|
||||
Issuer: "https://localhost:9000",
|
||||
IDPCertificateAuthority: authserver.CACert,
|
||||
},
|
||||
cli.CLI{},
|
||||
authserver.Config{
|
||||
Issuer: "https://localhost:9000",
|
||||
Cert: authserver.ServerCert,
|
||||
Key: authserver.ServerKey,
|
||||
},
|
||||
&tls.Config{RootCAs: readCert(t, authserver.CACert)},
|
||||
},
|
||||
"CACertData": {
|
||||
kubeconfigValues{
|
||||
Issuer: "https://localhost:9000",
|
||||
IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString(read(t, authserver.CACert)),
|
||||
},
|
||||
cli.CLI{},
|
||||
authserver.Config{
|
||||
Issuer: "https://localhost:9000",
|
||||
Cert: authserver.ServerCert,
|
||||
Key: authserver.ServerKey,
|
||||
},
|
||||
&tls.Config{RootCAs: readCert(t, authserver.CACert)},
|
||||
},
|
||||
}
|
||||
|
||||
for name, c := range data {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
server := c.serverConfig.Start(t)
|
||||
defer server.Shutdown(ctx)
|
||||
kubeconfig := createKubeconfig(t, &c.kubeconfigValues)
|
||||
defer os.Remove(kubeconfig)
|
||||
c.cli.KubeConfig = kubeconfig
|
||||
c.cli.SkipOpenBrowser = true
|
||||
|
||||
var eg errgroup.Group
|
||||
eg.Go(func() error {
|
||||
return c.cli.Run(ctx)
|
||||
})
|
||||
if err := openBrowserRequest(c.clientTLS); err != nil {
|
||||
cancel()
|
||||
t.Error(err)
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
t.Fatalf("CLI returned error: %s", err)
|
||||
}
|
||||
verifyKubeconfig(t, kubeconfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowserRequest(tlsConfig *tls.Config) error {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
client := http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
|
||||
res, err := client.Get("http://localhost:8000/")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not send a request: %s", err)
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
return fmt.Errorf("StatusCode wants 200 but %d", res.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func read(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := ioutil.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not read %s: %s", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func readCert(t *testing.T, name string) *x509.CertPool {
|
||||
t.Helper()
|
||||
p := x509.NewCertPool()
|
||||
b := read(t, name)
|
||||
if !p.AppendCertsFromPEM(b) {
|
||||
t.Fatalf("Could not append cert from %s", name)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type kubeconfigValues struct {
|
||||
Issuer string
|
||||
ExtraScopes string
|
||||
IDPCertificateAuthority string
|
||||
IDPCertificateAuthorityData string
|
||||
}
|
||||
|
||||
func createKubeconfig(t *testing.T, v *kubeconfigValues) string {
|
||||
t.Helper()
|
||||
f, err := ioutil.TempFile("", "kubeconfig")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
tpl, err := template.ParseFiles("testdata/kubeconfig.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tpl.Execute(f, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func verifyKubeconfig(t *testing.T, kubeconfig string) {
|
||||
b, err := ioutil.ReadFile(kubeconfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Index(string(b), "id-token: ey") == -1 {
|
||||
t.Errorf("kubeconfig wants id-token but %s", string(b))
|
||||
}
|
||||
if strings.Index(string(b), "refresh-token: 44df4c82-5ce7-4260-b54d-1da0d396ef2a") == -1 {
|
||||
t.Errorf("kubeconfig wants refresh-token but %s", string(b))
|
||||
}
|
||||
}
|
||||
311
e2e_test/credetial_plugin_test.go
Normal file
311
e2e_test/credetial_plugin_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/int128/kubelogin/e2e_test/idp"
|
||||
"github.com/int128/kubelogin/e2e_test/idp/mock_idp"
|
||||
"github.com/int128/kubelogin/e2e_test/keys"
|
||||
"github.com/int128/kubelogin/e2e_test/localserver"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/credentialplugin/mock_credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger/mock_logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/tokencache"
|
||||
"github.com/int128/kubelogin/pkg/di"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
)
|
||||
|
||||
// Run the integration tests of the credential plugin use-case.
|
||||
//
|
||||
// 1. Start the auth server.
|
||||
// 2. Run the Cmd.
|
||||
// 3. Open a request for the local server.
|
||||
// 4. Verify the output.
|
||||
//
|
||||
func TestCredentialPlugin(t *testing.T) {
|
||||
cacheDir, err := ioutil.TempDir("", "kube")
|
||||
if err != nil {
|
||||
t.Fatalf("could not create a cache dir: %s", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(cacheDir); err != nil {
|
||||
t.Errorf("could not clean up the cache dir: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("NoTLS", func(t *testing.T) {
|
||||
testCredentialPlugin(t, cacheDir, keys.None, nil)
|
||||
})
|
||||
t.Run("TLS", func(t *testing.T) {
|
||||
testCredentialPlugin(t, cacheDir, keys.Server, []string{"--certificate-authority", keys.Server.CACertPath})
|
||||
})
|
||||
}
|
||||
|
||||
func testCredentialPlugin(t *testing.T, cacheDir string, idpTLS keys.Keys, extraArgs []string) {
|
||||
timeout := 1 * time.Second
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
credentialPluginInteraction := mock_credentialplugin.NewMockInterface(ctrl)
|
||||
assertCredentialPluginOutput(t, credentialPluginInteraction, &idToken)
|
||||
|
||||
args := []string{
|
||||
"--token-cache-dir", cacheDir,
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
runGetTokenCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), credentialPluginInteraction, args)
|
||||
})
|
||||
|
||||
t.Run("ResourceOwnerPasswordCredentials", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "", tokenExpiryFuture)
|
||||
setupMockIDPForROPC(service, serverURL, "openid", "USER", "PASS", idToken)
|
||||
credentialPluginInteraction := mock_credentialplugin.NewMockInterface(ctrl)
|
||||
assertCredentialPluginOutput(t, credentialPluginInteraction, &idToken)
|
||||
|
||||
args := []string{
|
||||
"--token-cache-dir", cacheDir,
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
runGetTokenCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), credentialPluginInteraction, args)
|
||||
})
|
||||
|
||||
t.Run("HasValidToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
setupTokenCache(t, cacheDir,
|
||||
tokencache.Key{
|
||||
IssuerURL: serverURL,
|
||||
ClientID: "kubernetes",
|
||||
CACertFilename: idpTLS.CACertPath,
|
||||
}, tokencache.Value{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
credentialPluginInteraction := mock_credentialplugin.NewMockInterface(ctrl)
|
||||
assertCredentialPluginOutput(t, credentialPluginInteraction, &idToken)
|
||||
|
||||
args := []string{
|
||||
"--token-cache-dir", cacheDir,
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
runGetTokenCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), credentialPluginInteraction, args)
|
||||
assertTokenCache(t, cacheDir,
|
||||
tokencache.Key{
|
||||
IssuerURL: serverURL,
|
||||
ClientID: "kubernetes",
|
||||
CACertFilename: idpTLS.CACertPath,
|
||||
}, tokencache.Value{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("HasValidRefreshToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
validIDToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
expiredIDToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryPast)
|
||||
|
||||
setupMockIDPForDiscovery(service, serverURL)
|
||||
service.EXPECT().Refresh("VALID_REFRESH_TOKEN").
|
||||
Return(idp.NewTokenResponse(validIDToken, "NEW_REFRESH_TOKEN"), nil)
|
||||
|
||||
setupTokenCache(t, cacheDir,
|
||||
tokencache.Key{
|
||||
IssuerURL: serverURL,
|
||||
ClientID: "kubernetes",
|
||||
CACertFilename: idpTLS.CACertPath,
|
||||
}, tokencache.Value{
|
||||
IDToken: expiredIDToken,
|
||||
RefreshToken: "VALID_REFRESH_TOKEN",
|
||||
})
|
||||
credentialPluginInteraction := mock_credentialplugin.NewMockInterface(ctrl)
|
||||
assertCredentialPluginOutput(t, credentialPluginInteraction, &validIDToken)
|
||||
|
||||
args := []string{
|
||||
"--token-cache-dir", cacheDir,
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
runGetTokenCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), credentialPluginInteraction, args)
|
||||
assertTokenCache(t, cacheDir,
|
||||
tokencache.Key{
|
||||
IssuerURL: serverURL,
|
||||
ClientID: "kubernetes",
|
||||
CACertFilename: idpTLS.CACertPath,
|
||||
}, tokencache.Value{
|
||||
IDToken: validIDToken,
|
||||
RefreshToken: "NEW_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("HasExpiredRefreshToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
validIDToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
expiredIDToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryPast)
|
||||
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &validIDToken)
|
||||
service.EXPECT().Refresh("EXPIRED_REFRESH_TOKEN").
|
||||
Return(nil, &idp.ErrorResponse{Code: "invalid_request", Description: "token has expired"}).
|
||||
MaxTimes(2) // package oauth2 will retry refreshing the token
|
||||
|
||||
setupTokenCache(t, cacheDir,
|
||||
tokencache.Key{
|
||||
IssuerURL: serverURL,
|
||||
ClientID: "kubernetes",
|
||||
CACertFilename: idpTLS.CACertPath,
|
||||
}, tokencache.Value{
|
||||
IDToken: expiredIDToken,
|
||||
RefreshToken: "EXPIRED_REFRESH_TOKEN",
|
||||
})
|
||||
credentialPluginInteraction := mock_credentialplugin.NewMockInterface(ctrl)
|
||||
assertCredentialPluginOutput(t, credentialPluginInteraction, &validIDToken)
|
||||
|
||||
args := []string{
|
||||
"--token-cache-dir", cacheDir,
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
runGetTokenCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), credentialPluginInteraction, args)
|
||||
assertTokenCache(t, cacheDir,
|
||||
tokencache.Key{
|
||||
IssuerURL: serverURL,
|
||||
ClientID: "kubernetes",
|
||||
CACertFilename: idpTLS.CACertPath,
|
||||
}, tokencache.Value{
|
||||
IDToken: validIDToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ExtraScopes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "email profile openid", &idToken)
|
||||
|
||||
credentialPluginInteraction := mock_credentialplugin.NewMockInterface(ctrl)
|
||||
assertCredentialPluginOutput(t, credentialPluginInteraction, &idToken)
|
||||
|
||||
args := []string{
|
||||
"--token-cache-dir", cacheDir,
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
"--oidc-extra-scope", "email",
|
||||
"--oidc-extra-scope", "profile",
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
runGetTokenCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), credentialPluginInteraction, args)
|
||||
})
|
||||
}
|
||||
|
||||
func assertCredentialPluginOutput(t *testing.T, credentialPluginInteraction *mock_credentialplugin.MockInterface, idToken *string) {
|
||||
credentialPluginInteraction.EXPECT().
|
||||
Write(gomock.Any()).
|
||||
Do(func(out credentialplugin.Output) {
|
||||
if out.Token != *idToken {
|
||||
t.Errorf("Token wants %s but %s", *idToken, out.Token)
|
||||
}
|
||||
if out.Expiry != tokenExpiryFuture {
|
||||
t.Errorf("Expiry wants %v but %v", tokenExpiryFuture, out.Expiry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runGetTokenCmd(t *testing.T, ctx context.Context, localServerReadyFunc authentication.LocalServerReadyFunc, interaction credentialplugin.Interface, args []string) {
|
||||
t.Helper()
|
||||
cmd := di.NewCmdForHeadless(mock_logger.New(t), localServerReadyFunc, interaction)
|
||||
exitCode := cmd.Run(ctx, append([]string{
|
||||
"kubelogin", "get-token",
|
||||
"--v=1",
|
||||
"--skip-open-browser",
|
||||
"--listen-address", "127.0.0.1:0",
|
||||
}, args...), "HEAD")
|
||||
if exitCode != 0 {
|
||||
t.Errorf("exit status wants 0 but %d", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func setupTokenCache(t *testing.T, cacheDir string, k tokencache.Key, v tokencache.Value) {
|
||||
var r tokencache.Repository
|
||||
err := r.Save(cacheDir, k, v)
|
||||
if err != nil {
|
||||
t.Errorf("could not set up the token cache: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTokenCache(t *testing.T, cacheDir string, k tokencache.Key, want tokencache.Value) {
|
||||
var r tokencache.Repository
|
||||
got, err := r.FindByKey(cacheDir, k)
|
||||
if err != nil {
|
||||
t.Errorf("could not set up the token cache: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(&want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
91
e2e_test/helpers_test.go
Normal file
91
e2e_test/helpers_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/int128/kubelogin/e2e_test/idp"
|
||||
"github.com/int128/kubelogin/e2e_test/idp/mock_idp"
|
||||
"github.com/int128/kubelogin/e2e_test/keys"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
)
|
||||
|
||||
var (
|
||||
tokenExpiryFuture = time.Now().Add(time.Hour).Round(time.Second)
|
||||
tokenExpiryPast = time.Now().Add(-time.Hour).Round(time.Second)
|
||||
)
|
||||
|
||||
func newIDToken(t *testing.T, issuer, nonce string, expiry time.Time) string {
|
||||
t.Helper()
|
||||
var claims struct {
|
||||
jwt.StandardClaims
|
||||
Nonce string `json:"nonce"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
claims.StandardClaims = jwt.StandardClaims{
|
||||
Issuer: issuer,
|
||||
Audience: "kubernetes",
|
||||
Subject: "SUBJECT",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: expiry.Unix(),
|
||||
}
|
||||
claims.Nonce = nonce
|
||||
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 setupMockIDPForDiscovery(service *mock_idp.MockService, serverURL string) {
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
}
|
||||
|
||||
func setupMockIDPForCodeFlow(t *testing.T, service *mock_idp.MockService, serverURL, scope string, idToken *string) {
|
||||
var nonce string
|
||||
setupMockIDPForDiscovery(service, serverURL)
|
||||
service.EXPECT().AuthenticateCode(scope, gomock.Any()).
|
||||
DoAndReturn(func(_, gotNonce string) (string, error) {
|
||||
nonce = gotNonce
|
||||
return "YOUR_AUTH_CODE", nil
|
||||
})
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
DoAndReturn(func(string) (*idp.TokenResponse, error) {
|
||||
*idToken = newIDToken(t, serverURL, nonce, tokenExpiryFuture)
|
||||
return idp.NewTokenResponse(*idToken, "YOUR_REFRESH_TOKEN"), nil
|
||||
})
|
||||
}
|
||||
|
||||
func setupMockIDPForROPC(service *mock_idp.MockService, serverURL, scope, username, password, idToken string) {
|
||||
setupMockIDPForDiscovery(service, serverURL)
|
||||
service.EXPECT().AuthenticatePassword(username, password, scope).
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
}
|
||||
|
||||
func openBrowserOnReadyFunc(t *testing.T, ctx context.Context, k keys.Keys) authentication.LocalServerReadyFunc {
|
||||
return func(url string) {
|
||||
client := http.Client{Transport: &http.Transport{TLSClientConfig: k.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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
e2e_test/idp/handler.go
Normal file
142
e2e_test/idp/handler.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Package idp provides a test double of the identity provider of OpenID Connect.
|
||||
package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func NewHandler(t *testing.T, service Service) *Handler {
|
||||
return &Handler{t, service}
|
||||
}
|
||||
|
||||
// Handler provides a HTTP handler for the identity provider of OpenID Connect.
|
||||
// You need to implement the Service interface.
|
||||
// Note that this skips some security checks and is only for testing.
|
||||
type Handler struct {
|
||||
t *testing.T
|
||||
service Service
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
wr := &responseWriterRecorder{w, 200}
|
||||
err := h.serveHTTP(wr, r)
|
||||
if err == nil {
|
||||
h.t.Logf("%d %s %s", wr.statusCode, r.Method, r.RequestURI)
|
||||
return
|
||||
}
|
||||
if errResp := new(ErrorResponse); xerrors.As(err, &errResp) {
|
||||
h.t.Logf("400 %s %s: %s", r.Method, r.RequestURI, err)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(400)
|
||||
e := json.NewEncoder(w)
|
||||
if err := e.Encode(errResp); err != nil {
|
||||
h.t.Errorf("idp/handler: could not write the response: %s", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
h.t.Logf("500 %s %s: %s", r.Method, r.RequestURI, err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
|
||||
type responseWriterRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (w *responseWriterRecorder) WriteHeader(statusCode int) {
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
w.statusCode = statusCode
|
||||
}
|
||||
|
||||
func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
m := r.Method
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
case m == "GET" && p == "/.well-known/openid-configuration":
|
||||
discoveryResponse := h.service.Discovery()
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
if err := e.Encode(discoveryResponse); err != nil {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
case m == "GET" && p == "/certs":
|
||||
certificatesResponse := h.service.GetCertificates()
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
if err := e.Encode(certificatesResponse); err != nil {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
case m == "GET" && p == "/auth":
|
||||
// 3.1.2.1. Authentication Request
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
q := r.URL.Query()
|
||||
redirectURI, scope, state, nonce := q.Get("redirect_uri"), q.Get("scope"), q.Get("state"), q.Get("nonce")
|
||||
code, err := h.service.AuthenticateCode(scope, nonce)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("authentication error: %w", err)
|
||||
}
|
||||
to := fmt.Sprintf("%s?state=%s&code=%s", redirectURI, state, code)
|
||||
http.Redirect(w, r, to, 302)
|
||||
case m == "POST" && p == "/token":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return xerrors.Errorf("could not parse the form: %w", err)
|
||||
}
|
||||
grantType := r.Form.Get("grant_type")
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
// 3.1.3.1. Token Request
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
|
||||
code := r.Form.Get("code")
|
||||
tokenResponse, err := h.service.Exchange(code)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("token request error: %w", err)
|
||||
}
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
if err := e.Encode(tokenResponse); err != nil {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
case "password":
|
||||
// 4.3. Resource Owner Password Credentials Grant
|
||||
// https://tools.ietf.org/html/rfc6749#section-4.3
|
||||
username, password, scope := r.Form.Get("username"), r.Form.Get("password"), r.Form.Get("scope")
|
||||
tokenResponse, err := h.service.AuthenticatePassword(username, password, scope)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("authentication error: %w", err)
|
||||
}
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
if err := e.Encode(tokenResponse); err != nil {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
case "refresh_token":
|
||||
// 12.1. Refresh Request
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken
|
||||
refreshToken := r.Form.Get("refresh_token")
|
||||
tokenResponse, err := h.service.Refresh(refreshToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("token refresh error: %w", err)
|
||||
}
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
e := json.NewEncoder(w)
|
||||
if err := e.Encode(tokenResponse); err != nil {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
default:
|
||||
// 5.2. Error Response
|
||||
// https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
return &ErrorResponse{
|
||||
Code: "invalid_grant",
|
||||
Description: fmt.Sprintf("unknown grant_type %s", grantType),
|
||||
}
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
122
e2e_test/idp/mock_idp/mock_service.go
Normal file
122
e2e_test/idp/mock_idp/mock_service.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/e2e_test/idp (interfaces: Service)
|
||||
|
||||
// Package mock_idp is a generated GoMock package.
|
||||
package mock_idp
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
idp "github.com/int128/kubelogin/e2e_test/idp"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockService is a mock of Service interface
|
||||
type MockService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockServiceMockRecorder
|
||||
}
|
||||
|
||||
// MockServiceMockRecorder is the mock recorder for MockService
|
||||
type MockServiceMockRecorder struct {
|
||||
mock *MockService
|
||||
}
|
||||
|
||||
// NewMockService creates a new mock instance
|
||||
func NewMockService(ctrl *gomock.Controller) *MockService {
|
||||
mock := &MockService{ctrl: ctrl}
|
||||
mock.recorder = &MockServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockService) EXPECT() *MockServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AuthenticateCode mocks base method
|
||||
func (m *MockService) AuthenticateCode(arg0, arg1 string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AuthenticateCode", arg0, arg1)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AuthenticateCode indicates an expected call of AuthenticateCode
|
||||
func (mr *MockServiceMockRecorder) AuthenticateCode(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateCode", reflect.TypeOf((*MockService)(nil).AuthenticateCode), arg0, arg1)
|
||||
}
|
||||
|
||||
// AuthenticatePassword mocks base method
|
||||
func (m *MockService) AuthenticatePassword(arg0, arg1, arg2 string) (*idp.TokenResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AuthenticatePassword", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*idp.TokenResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AuthenticatePassword indicates an expected call of AuthenticatePassword
|
||||
func (mr *MockServiceMockRecorder) AuthenticatePassword(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticatePassword", reflect.TypeOf((*MockService)(nil).AuthenticatePassword), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// Discovery mocks base method
|
||||
func (m *MockService) Discovery() *idp.DiscoveryResponse {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Discovery")
|
||||
ret0, _ := ret[0].(*idp.DiscoveryResponse)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Discovery indicates an expected call of Discovery
|
||||
func (mr *MockServiceMockRecorder) Discovery() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Discovery", reflect.TypeOf((*MockService)(nil).Discovery))
|
||||
}
|
||||
|
||||
// Exchange mocks base method
|
||||
func (m *MockService) Exchange(arg0 string) (*idp.TokenResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Exchange", arg0)
|
||||
ret0, _ := ret[0].(*idp.TokenResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Exchange indicates an expected call of Exchange
|
||||
func (mr *MockServiceMockRecorder) Exchange(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exchange", reflect.TypeOf((*MockService)(nil).Exchange), arg0)
|
||||
}
|
||||
|
||||
// GetCertificates mocks base method
|
||||
func (m *MockService) GetCertificates() *idp.CertificatesResponse {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetCertificates")
|
||||
ret0, _ := ret[0].(*idp.CertificatesResponse)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetCertificates indicates an expected call of GetCertificates
|
||||
func (mr *MockServiceMockRecorder) GetCertificates() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificates", reflect.TypeOf((*MockService)(nil).GetCertificates))
|
||||
}
|
||||
|
||||
// Refresh mocks base method
|
||||
func (m *MockService) Refresh(arg0 string) (*idp.TokenResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Refresh", arg0)
|
||||
ret0, _ := ret[0].(*idp.TokenResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Refresh indicates an expected call of Refresh
|
||||
func (mr *MockServiceMockRecorder) Refresh(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockService)(nil).Refresh), arg0)
|
||||
}
|
||||
120
e2e_test/idp/service.go
Normal file
120
e2e_test/idp/service.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package idp
|
||||
|
||||
//go:generate mockgen -destination mock_idp/mock_service.go github.com/int128/kubelogin/e2e_test/idp Service
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Service provides discovery and authentication methods.
|
||||
// If an implemented method returns an ErrorResponse,
|
||||
// the handler will respond 400 and corresponding json of the ErrorResponse.
|
||||
// Otherwise, the handler will respond 500 and fail the current test.
|
||||
type Service interface {
|
||||
Discovery() *DiscoveryResponse
|
||||
GetCertificates() *CertificatesResponse
|
||||
AuthenticateCode(scope, nonce string) (code string, err error)
|
||||
Exchange(code string) (*TokenResponse, error)
|
||||
AuthenticatePassword(username, password, scope string) (*TokenResponse, error)
|
||||
Refresh(refreshToken string) (*TokenResponse, error)
|
||||
}
|
||||
|
||||
type DiscoveryResponse struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
}
|
||||
|
||||
// NewDiscoveryResponse returns a DiscoveryResponse for the local server.
|
||||
// This is based on https://accounts.google.com/.well-known/openid-configuration.
|
||||
func NewDiscoveryResponse(issuer string) *DiscoveryResponse {
|
||||
return &DiscoveryResponse{
|
||||
Issuer: issuer,
|
||||
AuthorizationEndpoint: issuer + "/auth",
|
||||
TokenEndpoint: issuer + "/token",
|
||||
JwksURI: issuer + "/certs",
|
||||
UserinfoEndpoint: issuer + "/userinfo",
|
||||
RevocationEndpoint: issuer + "/revoke",
|
||||
ResponseTypesSupported: []string{"code id_token"},
|
||||
SubjectTypesSupported: []string{"public"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
ScopesSupported: []string{"openid", "email", "profile"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"},
|
||||
CodeChallengeMethodsSupported: []string{"plain", "S256"},
|
||||
ClaimsSupported: []string{"aud", "email", "exp", "iat", "iss", "name", "sub"},
|
||||
}
|
||||
}
|
||||
|
||||
type CertificatesResponse struct {
|
||||
Keys []*CertificatesResponseKey `json:"keys"`
|
||||
}
|
||||
|
||||
type CertificatesResponseKey struct {
|
||||
Kty string `json:"kty"`
|
||||
Alg string `json:"alg"`
|
||||
Use string `json:"use"`
|
||||
Kid string `json:"kid"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
}
|
||||
|
||||
// NewCertificatesResponse returns a CertificatesResponse using the key pair.
|
||||
// This is used for verifying a signature of ID token.
|
||||
func NewCertificatesResponse(idTokenKeyPair *rsa.PrivateKey) *CertificatesResponse {
|
||||
return &CertificatesResponse{
|
||||
Keys: []*CertificatesResponseKey{
|
||||
{
|
||||
Kty: "RSA",
|
||||
Alg: "RS256",
|
||||
Use: "sig",
|
||||
Kid: "dummy",
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(idTokenKeyPair.E)).Bytes()),
|
||||
N: base64.RawURLEncoding.EncodeToString(idTokenKeyPair.N.Bytes()),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
// NewTokenResponse returns a TokenResponse.
|
||||
func NewTokenResponse(idToken, refreshToken string) *TokenResponse {
|
||||
return &TokenResponse{
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
AccessToken: "YOUR_ACCESS_TOKEN",
|
||||
IDToken: idToken,
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error response described in the following section:
|
||||
// 5.2 Error Response
|
||||
// https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
type ErrorResponse struct {
|
||||
Code string `json:"error"`
|
||||
Description string `json:"error_description"`
|
||||
}
|
||||
|
||||
func (err *ErrorResponse) Error() string {
|
||||
return fmt.Sprintf("%s(%s)", err.Code, err.Description)
|
||||
}
|
||||
67
e2e_test/keys/keys.go
Normal file
67
e2e_test/keys/keys.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package keys
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
// Keys represents a pair of certificate and key.
|
||||
type Keys struct {
|
||||
CertPath string
|
||||
KeyPath string
|
||||
CACertPath string
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// None represents non-TLS.
|
||||
var None Keys
|
||||
|
||||
// Server is a Keys for TLS server.
|
||||
// These files should be generated by Makefile before test.
|
||||
var Server = Keys{
|
||||
CertPath: "keys/testdata/server.crt",
|
||||
KeyPath: "keys/testdata/server.key",
|
||||
CACertPath: "keys/testdata/ca.crt",
|
||||
TLSConfig: newTLSConfig("keys/testdata/ca.crt"),
|
||||
}
|
||||
|
||||
// JWSKey is path to the key for signing ID tokens.
|
||||
// This file should be generated by Makefile before test.
|
||||
const JWSKey = "keys/testdata/jws.key"
|
||||
|
||||
// JWSKeyPair is the key pair loaded from JWSKey.
|
||||
var JWSKeyPair = readPrivateKey(JWSKey)
|
||||
|
||||
func newTLSConfig(name string) *tls.Config {
|
||||
b, err := ioutil.ReadFile(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
p := x509.NewCertPool()
|
||||
if !p.AppendCertsFromPEM(b) {
|
||||
panic("could not append the CA cert")
|
||||
}
|
||||
return &tls.Config{RootCAs: p}
|
||||
}
|
||||
|
||||
func readPrivateKey(name string) *rsa.PrivateKey {
|
||||
b, err := ioutil.ReadFile(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
block, rest := pem.Decode(b)
|
||||
if block == nil {
|
||||
panic("could not decode PEM")
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
panic("PEM should contain single key but multiple keys")
|
||||
}
|
||||
k, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
1
e2e_test/keys/testdata/.gitignore
vendored
Normal file
1
e2e_test/keys/testdata/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/CA
|
||||
@@ -1,13 +1,15 @@
|
||||
EXPIRY := 3650
|
||||
|
||||
all: ca.key ca.crt server.key server.crt jws.key
|
||||
|
||||
.PHONY: clean
|
||||
|
||||
all: server.crt ca.crt
|
||||
|
||||
clean:
|
||||
rm -v ca.* server.*
|
||||
-rm -v ca.* server.* jws.*
|
||||
|
||||
ca.key:
|
||||
openssl genrsa -out $@ 1024
|
||||
|
||||
.INTERMEDIATE: ca.csr
|
||||
ca.csr: openssl.cnf ca.key
|
||||
openssl req -config openssl.cnf \
|
||||
-new \
|
||||
@@ -17,7 +19,9 @@ ca.csr: openssl.cnf ca.key
|
||||
openssl req -noout -text -in $@
|
||||
|
||||
ca.crt: ca.csr ca.key
|
||||
openssl x509 -req \
|
||||
openssl x509 \
|
||||
-req \
|
||||
-days $(EXPIRY) \
|
||||
-signkey ca.key \
|
||||
-in ca.csr \
|
||||
-out $@
|
||||
@@ -26,6 +30,7 @@ ca.crt: ca.csr ca.key
|
||||
server.key:
|
||||
openssl genrsa -out $@ 1024
|
||||
|
||||
.INTERMEDIATE: server.csr
|
||||
server.csr: openssl.cnf server.key
|
||||
openssl req -config openssl.cnf \
|
||||
-new \
|
||||
@@ -41,6 +46,7 @@ server.crt: openssl.cnf server.csr ca.key ca.crt
|
||||
touch CA/index.txt.attr
|
||||
echo 00 > CA/serial
|
||||
openssl ca -config openssl.cnf \
|
||||
-days $(EXPIRY) \
|
||||
-extensions v3_req \
|
||||
-batch \
|
||||
-cert ca.crt \
|
||||
@@ -48,3 +54,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
|
||||
11
e2e_test/keys/testdata/ca.crt
vendored
Normal file
11
e2e_test/keys/testdata/ca.crt
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBnTCCAQYCCQC/aR7GRyndljANBgkqhkiG9w0BAQUFADATMREwDwYDVQQDDAhI
|
||||
ZWxsbyBDQTAeFw0xOTA5MjUxNDQ0NTFaFw0yOTA5MjIxNDQ0NTFaMBMxETAPBgNV
|
||||
BAMMCEhlbGxvIENBMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDnSTDsRx4U
|
||||
JmaTWHOAZasfN2O37wMcRez7LDM2qfQ8nlXnEAAZ4Pc51itOycWN1nclNVb489i9
|
||||
J8ALgRKzNumSkfl1sCgJoDds75AC3oRRCbhnEP3Lu4mysxyOtYZNsdST8GBCP0m4
|
||||
2tWa4W2ditpA44uU4x8opAX2qY919nVLNwIDAQABMA0GCSqGSIb3DQEBBQUAA4GB
|
||||
AE/gsgTC4jzYC3icZdhALJTe3JsZ7geN702dE95zSI5LXAzzHJ/j8wGmorQjrMs2
|
||||
iNPjVOdTU6cVWa1Ba29wWakVyVCUqDmDiWHaVhM/Qyyxo6mVlZGFwSnto3zq/h4y
|
||||
KMFJ8lUtFCYMrzo5wqgj2xOjVrN77F6F4XWZbMufh50G
|
||||
-----END CERTIFICATE-----
|
||||
15
e2e_test/keys/testdata/ca.key
vendored
Normal file
15
e2e_test/keys/testdata/ca.key
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXQIBAAKBgQDnSTDsRx4UJmaTWHOAZasfN2O37wMcRez7LDM2qfQ8nlXnEAAZ
|
||||
4Pc51itOycWN1nclNVb489i9J8ALgRKzNumSkfl1sCgJoDds75AC3oRRCbhnEP3L
|
||||
u4mysxyOtYZNsdST8GBCP0m42tWa4W2ditpA44uU4x8opAX2qY919nVLNwIDAQAB
|
||||
AoGAaYmTYm29QvKW4et9oPxDjpYG0bqlz7P0xFRR9kKtKTATAMHjWeu2xFR/JI+b
|
||||
rvJLIdZqHmWe5AmMb3NxZgfLonEB71ohaKQha1L8Vc7aoedRheJvqqaNr+ZxoCMO
|
||||
8xcjsaMYxLEVt0tg6XyKyEhi1/hOufFZ4BSng4oQbrpaNIkCQQD6hPEzzPZtMEEe
|
||||
eRdwTVUIStKFMQbRdwZ5Oc7pyDk2U+SFRJiqkBkqnmFekcf2UgbBQxem+GMhWNgE
|
||||
LItKy/wVAkEA7FiHxbzn2msaE3hZCWudtnXqmJNuPO0zJ5icXe2svwmwPfLA/rm9
|
||||
iazCuyzyK67J8IG9QgIjQFYXtQbMr2chGwJBAMm3dghBx0LQEf8Zfdf9TLSqmqyI
|
||||
d3b+IgZGl+cCQ58NGfp863ibIsiAUuK0+4/JKItBHLBjXF6jjPx/aYFGkqkCQH4w
|
||||
GnXCEYx1qJuCow87jR4xQQsrlC0lfC2E9t/TmWr6UkYRCWg3ZXJPcj0bl0Upcppd
|
||||
ut22ZHniPZAizEBOcMcCQQDnMEOxufxhMsx2NC8yON/noewLINqKcbkMsl6DjvJl
|
||||
+wLbQmzJ0j+uIBgdpsj4rWnEr7GxoL2eWG44QDUBco79
|
||||
-----END RSA PRIVATE KEY-----
|
||||
15
e2e_test/keys/testdata/jws.key
vendored
Normal file
15
e2e_test/keys/testdata/jws.key
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXwIBAAKBgQCZukkN1GxMlNXkpOZxCnvCF874/rn1sNKzO98fOwmBRPG2+m/c
|
||||
yqBY7t2L2nihqz3+GZTiHmzSrBzMAGPVW1qGmk9KYg3m7akz9SiCxdoUkgM9MCCp
|
||||
X/s8IhgtXkyoKFPcGdwHblDl/3aJG02b6TAQD8vTNQAKoKw7L0FST+pvRwIDAQAB
|
||||
AoGBAJT1fXR5MbfDQL+dSe6fSex5RYTgzzDTdldW3I1Wl487Tz0OzvYTIe0LCIJL
|
||||
4DhHxnpCL5IsCSbav8ytVA+ZxczHpEW6UxbalXt5UfgFu0joTrdoGxDcVWgUCW3J
|
||||
Olbln0lOP55wViKh509gt45Za3VxJrNul3khVfVj7qGG9cKBAkEAxwtT8LxwqTYF
|
||||
nqoeZvPp15JAqlgdk38ttJa4KEqvpBTSxNIXkL9T5gJ+irKZAzxlz/U7bhn5mw6E
|
||||
3xFiljOXpQJBAMW3XRFOjgNBXNjbt81wREF5LdZl9EI8cRMSH6xljt2uwSqw4EG3
|
||||
76gFvccUd+WnfspFQZVypSSD4pWzsAqh13sCQQCA9BLW5Y7r4ab0a2y08JNwaT1h
|
||||
3yKSO5QF6pu25uQyHpeKkj5YNcyKONV40EqXsRqZB10QcN2omlh1GJNRkm1NAkEA
|
||||
qV3lr4mnRUqcinfM/4MINT3k8h/sGUFFa5y+3SMyOtwURMm3kRRLi5c/dmYmPug4
|
||||
SHUDNU48AQeo9awzRShWOQJBAJdw+cfRgi4fo3HY33uZdFa1T9G+qTA2ijhco6O3
|
||||
8tOc0yOFEtPNXM87MsHGIQP3ZCLfIY1gs2O3WCTFbPxR4rc=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -10,7 +10,6 @@ new_certs_dir = $dir
|
||||
default_md = sha256
|
||||
policy = policy_match
|
||||
serial = $dir/serial
|
||||
default_days = 365
|
||||
|
||||
[ policy_match ]
|
||||
countryName = optional
|
||||
52
e2e_test/keys/testdata/server.crt
vendored
Normal file
52
e2e_test/keys/testdata/server.crt
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
Certificate:
|
||||
Data:
|
||||
Version: 3 (0x2)
|
||||
Serial Number: 0 (0x0)
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Issuer: CN=Hello CA
|
||||
Validity
|
||||
Not Before: Aug 18 06:00:06 2019 GMT
|
||||
Not After : Aug 15 06:00:06 2029 GMT
|
||||
Subject: CN=localhost
|
||||
Subject Public Key Info:
|
||||
Public Key Algorithm: rsaEncryption
|
||||
Public-Key: (1024 bit)
|
||||
Modulus:
|
||||
00:d6:4e:eb:3a:cb:25:f9:7e:92:22:f2:63:99:da:
|
||||
08:05:8b:a3:e7:d3:fd:71:3e:bd:da:c5:d5:63:b7:
|
||||
d3:7b:f8:cd:1a:2e:5c:a2:4f:48:98:c2:b4:da:e8:
|
||||
1e:d3:d7:8f:d8:ee:a9:70:d0:9d:4f:f4:8d:95:e5:
|
||||
8e:9a:71:b6:80:aa:0b:cb:28:1d:f6:0d:7e:aa:78:
|
||||
bf:30:e6:58:d7:6b:92:8f:19:1c:7d:95:f8:d5:2f:
|
||||
8c:58:49:98:88:05:50:88:80:a9:77:c4:16:b4:c1:
|
||||
00:45:1e:d3:d0:ed:98:4d:f7:a3:5d:f1:82:cb:a5:
|
||||
4d:19:64:4d:43:db:13:d4:17
|
||||
Exponent: 65537 (0x10001)
|
||||
X509v3 extensions:
|
||||
X509v3 Basic Constraints:
|
||||
CA:FALSE
|
||||
X509v3 Key Usage:
|
||||
Digital Signature, Non Repudiation, Key Encipherment
|
||||
X509v3 Subject Alternative Name:
|
||||
DNS:localhost
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
5a:5c:5e:8b:de:82:86:f4:98:40:0e:cf:c5:51:fe:89:46:49:
|
||||
f0:26:d2:a5:06:e3:91:43:c1:f8:b2:ad:b7:a1:23:13:1a:80:
|
||||
45:00:51:70:b6:06:63:c6:a8:c8:22:5d:1b:00:e0:4a:8c:2e:
|
||||
ce:b4:da:b1:89:8a:d2:d0:e3:eb:0f:16:34:45:a1:bd:64:5c:
|
||||
48:41:8c:0a:bf:66:be:1c:a8:35:47:ce:b0:dc:c8:4f:5e:c1:
|
||||
ec:ef:21:fb:45:55:95:e3:99:40:46:0b:6c:8a:b3:d5:f0:bf:
|
||||
39:a4:ba:c4:d7:58:88:58:08:07:98:59:6e:ca:9c:08:e4:c4:
|
||||
4f:db
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBzTCCATagAwIBAgIBADANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhIZWxs
|
||||
byBDQTAeFw0xOTA4MTgwNjAwMDZaFw0yOTA4MTUwNjAwMDZaMBQxEjAQBgNVBAMM
|
||||
CWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA1k7rOssl+X6S
|
||||
IvJjmdoIBYuj59P9cT692sXVY7fTe/jNGi5cok9ImMK02uge09eP2O6pcNCdT/SN
|
||||
leWOmnG2gKoLyygd9g1+qni/MOZY12uSjxkcfZX41S+MWEmYiAVQiICpd8QWtMEA
|
||||
RR7T0O2YTfejXfGCy6VNGWRNQ9sT1BcCAwEAAaMwMC4wCQYDVR0TBAIwADALBgNV
|
||||
HQ8EBAMCBeAwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4GB
|
||||
AFpcXovegob0mEAOz8VR/olGSfAm0qUG45FDwfiyrbehIxMagEUAUXC2BmPGqMgi
|
||||
XRsA4EqMLs602rGJitLQ4+sPFjRFob1kXEhBjAq/Zr4cqDVHzrDcyE9ewezvIftF
|
||||
VZXjmUBGC2yKs9XwvzmkusTXWIhYCAeYWW7KnAjkxE/b
|
||||
-----END CERTIFICATE-----
|
||||
15
e2e_test/keys/testdata/server.key
vendored
Normal file
15
e2e_test/keys/testdata/server.key
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXgIBAAKBgQDWTus6yyX5fpIi8mOZ2ggFi6Pn0/1xPr3axdVjt9N7+M0aLlyi
|
||||
T0iYwrTa6B7T14/Y7qlw0J1P9I2V5Y6acbaAqgvLKB32DX6qeL8w5ljXa5KPGRx9
|
||||
lfjVL4xYSZiIBVCIgKl3xBa0wQBFHtPQ7ZhN96Nd8YLLpU0ZZE1D2xPUFwIDAQAB
|
||||
AoGBAJhNR7Dl1JwFzndViWE6aP7/6UEFEBWeADDs7aTLbFmrTJ+xmRWkgLRHk14L
|
||||
HnVwuYLywaoyJ8o9wy1nEbxC2e4zWZ94d351MQf3/komCXDBzEsktfAcNsAFnMmS
|
||||
HZuGXfhi0FYWoftpIGxUmEBmQRcq0ctycbLves6TY3y+oajpAkEA8UHmSr/zsM3E
|
||||
XQXPp2BCAvRrTH/njk4R0jwB29Bi89gt/XDD4uvfWbHw7TZxnZuCpWisnxpMPIwa
|
||||
1rjqIQmhEwJBAONncQUOxwYCIuvraIhV0QtkIUa+YpTvAxP8ZNXx+agtHmHG2TTf
|
||||
kGv2YddvjxXZItN/FZOzUGm9OptaeLRTpW0CQHO8CEzNnoqve0agtgf2LlSaiiqt
|
||||
pRhoLTZsYPvhEMcnapCNGvtt6bxul0REfOZ9poPRHhZJGE9naqydEnv80Y8CQQC3
|
||||
pxLfws95SsBpR/VkJepuCK/XMmrrXRxfR7coEgROjiG7VZyV1vgMOS9Ljg1A19wI
|
||||
cto6LtcCjpCGZsqU1/kBAkEAv2tXBts3vuIjguZNMz7KLWmu3zG2SQRaqdEZwL+R
|
||||
DQmD5tbI6gEtd5OmgmSiW8A4mpfgFYvG7Um2fwi7TTXtSA==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
80
e2e_test/kubeconfig/kubeconfig.go
Normal file
80
e2e_test/kubeconfig/kubeconfig.go
Normal file
@@ -0,0 +1,80 @@
|
||||
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
|
||||
RefreshToken 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)
|
||||
}
|
||||
}
|
||||
4
e2e_test/kubeconfig/testdata/dummy.yaml
vendored
Normal file
4
e2e_test/kubeconfig/testdata/dummy.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
current-context: dummy
|
||||
kind: Config
|
||||
preferences: {}
|
||||
@@ -27,5 +27,11 @@ users:
|
||||
#{{ end }}
|
||||
#{{ if .IDPCertificateAuthorityData }}
|
||||
idp-certificate-authority-data: {{ .IDPCertificateAuthorityData }}
|
||||
#{{ end }}
|
||||
#{{ if .IDToken }}
|
||||
id-token: {{ .IDToken }}
|
||||
#{{ end }}
|
||||
#{{ if .RefreshToken }}
|
||||
refresh-token: {{ .RefreshToken }}
|
||||
#{{ end }}
|
||||
name: oidc
|
||||
85
e2e_test/localserver/authserver.go
Normal file
85
e2e_test/localserver/authserver.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Package localserver provides a http server running on localhost.
|
||||
// This is only for testing.
|
||||
//
|
||||
package localserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/int128/kubelogin/e2e_test/keys"
|
||||
)
|
||||
|
||||
type Shutdowner interface {
|
||||
Shutdown(t *testing.T, ctx context.Context)
|
||||
}
|
||||
|
||||
type shutdowner struct {
|
||||
l net.Listener
|
||||
s *http.Server
|
||||
}
|
||||
|
||||
func (s *shutdowner) Shutdown(t *testing.T, ctx context.Context) {
|
||||
// s.Shutdown() closes the lister as well,
|
||||
// so we do not need to call l.Close() explicitly
|
||||
if err := s.s.Shutdown(ctx); err != nil {
|
||||
t.Errorf("Could not shutdown the server: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts an authentication server.
|
||||
// If k is non-nil, it starts a TLS server.
|
||||
func Start(t *testing.T, h http.Handler, k keys.Keys) (string, Shutdowner) {
|
||||
if k == keys.None {
|
||||
return startNoTLS(t, h)
|
||||
}
|
||||
return startTLS(t, h, k)
|
||||
}
|
||||
|
||||
func startNoTLS(t *testing.T, h http.Handler) (string, Shutdowner) {
|
||||
t.Helper()
|
||||
l, port := newLocalhostListener(t)
|
||||
url := "http://localhost:" + port
|
||||
s := &http.Server{
|
||||
Handler: h,
|
||||
}
|
||||
go func() {
|
||||
err := s.Serve(l)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
return url, &shutdowner{l, s}
|
||||
}
|
||||
|
||||
func startTLS(t *testing.T, h http.Handler, k keys.Keys) (string, Shutdowner) {
|
||||
t.Helper()
|
||||
l, port := newLocalhostListener(t)
|
||||
url := "https://localhost:" + port
|
||||
s := &http.Server{
|
||||
Handler: h,
|
||||
}
|
||||
go func() {
|
||||
err := s.ServeTLS(l, k.CertPath, k.KeyPath)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
return url, &shutdowner{l, s}
|
||||
}
|
||||
|
||||
func newLocalhostListener(t *testing.T) (net.Listener, string) {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create a listener: %s", err)
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not parse the address %s: %s", addr, err)
|
||||
}
|
||||
return l, port
|
||||
}
|
||||
283
e2e_test/standalone_test.go
Normal file
283
e2e_test/standalone_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/int128/kubelogin/e2e_test/idp"
|
||||
"github.com/int128/kubelogin/e2e_test/idp/mock_idp"
|
||||
"github.com/int128/kubelogin/e2e_test/keys"
|
||||
"github.com/int128/kubelogin/e2e_test/kubeconfig"
|
||||
"github.com/int128/kubelogin/e2e_test/localserver"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger/mock_logger"
|
||||
"github.com/int128/kubelogin/pkg/di"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
)
|
||||
|
||||
// Run the integration tests of the Login use-case.
|
||||
//
|
||||
// 1. Start the auth server.
|
||||
// 2. Run the Cmd.
|
||||
// 3. Open a request for the local server.
|
||||
// 4. Verify the kubeconfig.
|
||||
//
|
||||
func TestStandalone(t *testing.T) {
|
||||
t.Run("NoTLS", func(t *testing.T) {
|
||||
testStandalone(t, keys.None)
|
||||
})
|
||||
t.Run("TLS", func(t *testing.T) {
|
||||
testStandalone(t, keys.Server)
|
||||
})
|
||||
}
|
||||
|
||||
func testStandalone(t *testing.T, idpTLS keys.Keys) {
|
||||
timeout := 5 * time.Second
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ResourceOwnerPasswordCredentials", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "", tokenExpiryFuture)
|
||||
setupMockIDPForROPC(service, serverURL, "openid", "USER", "PASS", idToken)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
}
|
||||
runRootCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("HasValidToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("HasValidRefreshToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().Refresh("VALID_REFRESH_TOKEN").
|
||||
Return(idp.NewTokenResponse(idToken, "NEW_REFRESH_TOKEN"), nil)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDToken: newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryPast), // expired
|
||||
RefreshToken: "VALID_REFRESH_TOKEN",
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "NEW_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("HasExpiredRefreshToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
service.EXPECT().Refresh("EXPIRED_REFRESH_TOKEN").
|
||||
Return(nil, &idp.ErrorResponse{Code: "invalid_request", Description: "token has expired"}).
|
||||
MaxTimes(2) // package oauth2 will retry refreshing the token
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDToken: newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryPast), // expired
|
||||
RefreshToken: "EXPIRED_REFRESH_TOKEN",
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("env_KUBECONFIG", func(t *testing.T) {
|
||||
// do not run this in parallel due to change of the env var
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
setenv(t, "KUBECONFIG", kubeConfigFilename+string(os.PathListSeparator)+"kubeconfig/testdata/dummy.yaml")
|
||||
defer unsetenv(t, "KUBECONFIG")
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ExtraScopes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "profile groups openid", &idToken)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
ExtraScopes: "profile,groups",
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, openBrowserOnReadyFunc(t, ctx, idpTLS), args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func runRootCmd(t *testing.T, ctx context.Context, localServerReadyFunc authentication.LocalServerReadyFunc, args []string) {
|
||||
t.Helper()
|
||||
cmd := di.NewCmdForHeadless(mock_logger.New(t), localServerReadyFunc, nil)
|
||||
exitCode := cmd.Run(ctx, append([]string{
|
||||
"kubelogin",
|
||||
"--v=1",
|
||||
"--listen-address", "127.0.0.1:0",
|
||||
"--skip-open-browser",
|
||||
}, args...), "HEAD")
|
||||
if exitCode != 0 {
|
||||
t.Errorf("exit status wants 0 but %d", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
25
go.mod
Normal file
25
go.mod
Normal file
@@ -0,0 +1,25 @@
|
||||
module github.com/int128/kubelogin
|
||||
|
||||
go 1.12
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc v2.1.0+incompatible
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/golang/mock v1.4.0
|
||||
github.com/google/go-cmp v0.4.0
|
||||
github.com/google/wire v0.4.0
|
||||
github.com/int128/oauth2cli v1.8.1
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
|
||||
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
|
||||
github.com/spf13/cobra v0.0.5
|
||||
github.com/spf13/pflag v1.0.5
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543
|
||||
gopkg.in/square/go-jose.v2 v2.3.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8
|
||||
k8s.io/apimachinery v0.17.2
|
||||
k8s.io/client-go v0.17.2
|
||||
k8s.io/klog v1.0.0
|
||||
)
|
||||
260
go.sum
Normal file
260
go.sum
Normal file
@@ -0,0 +1,260 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=
|
||||
github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
|
||||
github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
|
||||
github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
|
||||
github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
|
||||
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
|
||||
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-oidc v2.1.0+incompatible h1:sdJrfw8akMnCuUlaZU3tE/uYXFgfqom8DBE9so9EBsM=
|
||||
github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
|
||||
github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
|
||||
github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=
|
||||
github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg=
|
||||
github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc=
|
||||
github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=
|
||||
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I=
|
||||
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.4.0 h1:Rd1kQnQu0Hq3qvJppYSG0HtP+f5LPPUiDswTLiEegLg=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
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/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
|
||||
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.4.0 h1:kXcsA/rIGzJImVqPdhfnr6q0xsS9gU0515q1EPpJ9fE=
|
||||
github.com/google/wire v0.4.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
|
||||
github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imdario/mergo v0.3.5 h1:JboBksRwiiAJWvIYJVo46AfV+IAIKZpfrSzVKj42R4Q=
|
||||
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/int128/listener v1.0.0 h1:a9H3m4jbXgXpxJUK3fxWrh37Iic/UU/kYOGE0WtjbbI=
|
||||
github.com/int128/listener v1.0.0/go.mod h1:sho0rrH7mNRRZH4hYOYx+xwRDGmtRndaUiu2z9iumes=
|
||||
github.com/int128/oauth2cli v1.8.1 h1:Vkmfx0w225l4qUpJ1ZWGw1elw7hnXAybSiYoYyh1iBw=
|
||||
github.com/int128/oauth2cli v1.8.1/go.mod h1:MkxKWhHUaPOaq/92Z5ifdCWySAKJKo04hUXaKA7OgDE=
|
||||
github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok=
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
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 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
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/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
|
||||
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/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/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-20190213061140-3a22650c66bd/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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 h1:rjwSpXsdiK0dV8/Naq3kAw9ymfAeJIyd0upUIElB+lI=
|
||||
golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
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/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
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.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4=
|
||||
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
k8s.io/api v0.17.2 h1:NF1UFXcKN7/OOv1uxdRz3qfra8AHsPav5M93hlV9+Dc=
|
||||
k8s.io/api v0.17.2/go.mod h1:BS9fjjLc4CMuqfSO8vgbHPKMt5+SF0ET6u/RVDihTo4=
|
||||
k8s.io/apimachinery v0.17.2 h1:hwDQQFbdRlpnnsR64Asdi55GyCaIP/3WQpMmbNBeWr4=
|
||||
k8s.io/apimachinery v0.17.2/go.mod h1:b9qmWdKlLuU9EBh+06BtLcSf/Mu89rWL33naRxs1uZg=
|
||||
k8s.io/client-go v0.17.2 h1:ndIfkfXEGrNhLIgkr0+qhRguSD3u6DCmonepn1O6NYc=
|
||||
k8s.io/client-go v0.17.2/go.mod h1:QAzRgsa0C2xl4/eVpeVAZMvikCn8Nm81yqVx3Kk9XYI=
|
||||
k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
|
||||
k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f h1:GiPwtSzdP43eI1hpPCbROQCCIgCuiMMNF8YUVLF3vJo=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
|
||||
rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI=
|
||||
sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
|
||||
// IDPIssuerURL returns the idp-issuer-url.
|
||||
func (c *OIDCAuthProvider) IDPIssuerURL() string {
|
||||
return c.Config["idp-issuer-url"]
|
||||
}
|
||||
|
||||
// ClientID returns the client-id.
|
||||
func (c *OIDCAuthProvider) ClientID() string {
|
||||
return c.Config["client-id"]
|
||||
}
|
||||
|
||||
// ClientSecret returns the client-secret.
|
||||
func (c *OIDCAuthProvider) ClientSecret() string {
|
||||
return c.Config["client-secret"]
|
||||
}
|
||||
|
||||
// IDPCertificateAuthority returns the idp-certificate-authority.
|
||||
func (c *OIDCAuthProvider) IDPCertificateAuthority() string {
|
||||
return c.Config["idp-certificate-authority"]
|
||||
}
|
||||
|
||||
// IDPCertificateAuthorityData returns the idp-certificate-authority-data.
|
||||
func (c *OIDCAuthProvider) IDPCertificateAuthorityData() string {
|
||||
return c.Config["idp-certificate-authority-data"]
|
||||
}
|
||||
|
||||
// ExtraScopes returns the extra-scopes.
|
||||
func (c *OIDCAuthProvider) ExtraScopes() []string {
|
||||
if c.Config["extra-scopes"] == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(c.Config["extra-scopes"], ",")
|
||||
}
|
||||
|
||||
// SetIDToken replaces the id-token.
|
||||
func (c *OIDCAuthProvider) SetIDToken(idToken string) {
|
||||
c.Config["id-token"] = idToken
|
||||
}
|
||||
|
||||
// SetRefreshToken replaces the refresh-token.
|
||||
func (c *OIDCAuthProvider) SetRefreshToken(refreshToken string) {
|
||||
c.Config["refresh-token"] = refreshToken
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
// Read parses the file and returns the Config.
|
||||
func Read(path string) (*api.Config, error) {
|
||||
return clientcmd.LoadFromFile(path)
|
||||
}
|
||||
|
||||
// Write writes the config to the file.
|
||||
func Write(config *api.Config, path string) error {
|
||||
return clientcmd.WriteToFile(*config, path)
|
||||
}
|
||||
15
main.go
15
main.go
@@ -2,22 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/int128/kubelogin/cli"
|
||||
"github.com/int128/kubelogin/pkg/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 {
|
||||
log.Fatalf("Error: %s", err)
|
||||
}
|
||||
os.Exit(di.NewCmd().Run(context.Background(), os.Args, version))
|
||||
}
|
||||
|
||||
71
pkg/adaptors/certpool/cert.go
Normal file
71
pkg/adaptors/certpool/cert.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package certpool provides loading certificates from files or base64 encoded string.
|
||||
package certpool
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/google/wire"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_certpool/mock_certpool.go github.com/int128/kubelogin/pkg/adaptors/certpool Interface
|
||||
|
||||
// Set provides an implementation and interface.
|
||||
var Set = wire.NewSet(
|
||||
wire.Value(NewFunc(New)),
|
||||
wire.Struct(new(CertPool), "*"),
|
||||
wire.Bind(new(Interface), new(*CertPool)),
|
||||
)
|
||||
|
||||
type NewFunc func() Interface
|
||||
|
||||
// New returns an instance which implements the Interface.
|
||||
func New() Interface {
|
||||
return &CertPool{pool: x509.NewCertPool()}
|
||||
}
|
||||
|
||||
type Interface interface {
|
||||
AddFile(filename string) error
|
||||
AddBase64Encoded(s string) error
|
||||
SetRootCAs(cfg *tls.Config)
|
||||
}
|
||||
|
||||
// CertPool represents a pool of certificates.
|
||||
type CertPool struct {
|
||||
pool *x509.CertPool
|
||||
}
|
||||
|
||||
// SetRootCAs sets cfg.RootCAs if it has any certificate.
|
||||
// Otherwise do nothing.
|
||||
func (p *CertPool) SetRootCAs(cfg *tls.Config) {
|
||||
if len(p.pool.Subjects()) > 0 {
|
||||
cfg.RootCAs = p.pool
|
||||
}
|
||||
}
|
||||
|
||||
// AddFile loads the certificate from the file.
|
||||
func (p *CertPool) AddFile(filename string) error {
|
||||
b, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not read %s: %w", filename, err)
|
||||
}
|
||||
if !p.pool.AppendCertsFromPEM(b) {
|
||||
return xerrors.Errorf("could not append certificate from %s", filename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddBase64Encoded loads the certificate from the base64 encoded string.
|
||||
func (p *CertPool) AddBase64Encoded(s string) error {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not decode base64: %w", err)
|
||||
}
|
||||
if !p.pool.AppendCertsFromPEM(b) {
|
||||
return xerrors.Errorf("could not append certificate")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
pkg/adaptors/certpool/cert_test.go
Normal file
58
pkg/adaptors/certpool/cert_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package certpool
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCertPool_AddFile(t *testing.T) {
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
p := New()
|
||||
if err := p.AddFile("testdata/ca1.crt"); err != nil {
|
||||
t.Errorf("AddFile error: %s", err)
|
||||
}
|
||||
var cfg tls.Config
|
||||
p.SetRootCAs(&cfg)
|
||||
if n := len(cfg.RootCAs.Subjects()); n != 1 {
|
||||
t.Errorf("n wants 1 but was %d", n)
|
||||
}
|
||||
})
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
p := New()
|
||||
err := p.AddFile("testdata/Makefile")
|
||||
if err == nil {
|
||||
t.Errorf("AddFile wants an error but was nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCertPool_AddBase64Encoded(t *testing.T) {
|
||||
p := New()
|
||||
if err := p.AddBase64Encoded(readFile(t, "testdata/ca2.crt.base64")); err != nil {
|
||||
t.Errorf("AddBase64Encoded error: %s", err)
|
||||
}
|
||||
var cfg tls.Config
|
||||
p.SetRootCAs(&cfg)
|
||||
if n := len(cfg.RootCAs.Subjects()); n != 1 {
|
||||
t.Errorf("n wants 1 but was %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertPool_SetRootCAs(t *testing.T) {
|
||||
p := New()
|
||||
var cfg tls.Config
|
||||
p.SetRootCAs(&cfg)
|
||||
if cfg.RootCAs != nil {
|
||||
t.Errorf("cfg.RootCAs wants nil but was %+v", cfg.RootCAs)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, filename string) string {
|
||||
t.Helper()
|
||||
b, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile error: %s", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
74
pkg/adaptors/certpool/mock_certpool/mock_certpool.go
Normal file
74
pkg/adaptors/certpool/mock_certpool/mock_certpool.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/pkg/adaptors/certpool (interfaces: Interface)
|
||||
|
||||
// Package mock_certpool is a generated GoMock package.
|
||||
package mock_certpool
|
||||
|
||||
import (
|
||||
tls "crypto/tls"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
|
||||
mock := &MockInterface{ctrl: ctrl}
|
||||
mock.recorder = &MockInterfaceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AddBase64Encoded mocks base method
|
||||
func (m *MockInterface) AddBase64Encoded(arg0 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AddBase64Encoded", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AddBase64Encoded indicates an expected call of AddBase64Encoded
|
||||
func (mr *MockInterfaceMockRecorder) AddBase64Encoded(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddBase64Encoded", reflect.TypeOf((*MockInterface)(nil).AddBase64Encoded), arg0)
|
||||
}
|
||||
|
||||
// AddFile mocks base method
|
||||
func (m *MockInterface) AddFile(arg0 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AddFile", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AddFile indicates an expected call of AddFile
|
||||
func (mr *MockInterfaceMockRecorder) AddFile(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddFile", reflect.TypeOf((*MockInterface)(nil).AddFile), arg0)
|
||||
}
|
||||
|
||||
// SetRootCAs mocks base method
|
||||
func (m *MockInterface) SetRootCAs(arg0 *tls.Config) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetRootCAs", arg0)
|
||||
}
|
||||
|
||||
// SetRootCAs indicates an expected call of SetRootCAs
|
||||
func (mr *MockInterfaceMockRecorder) SetRootCAs(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRootCAs", reflect.TypeOf((*MockInterface)(nil).SetRootCAs), arg0)
|
||||
}
|
||||
28
pkg/adaptors/certpool/testdata/Makefile
vendored
Normal file
28
pkg/adaptors/certpool/testdata/Makefile
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
all: ca1.crt ca1.crt.base64 ca2.crt ca2.crt.base64 ca3.crt ca3.crt.base64
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
-rm -v *.key *.csr *.crt *.base64
|
||||
|
||||
%.key:
|
||||
openssl genrsa -out $@ 1024
|
||||
|
||||
%.csr: %.key
|
||||
openssl req \
|
||||
-new \
|
||||
-key $< \
|
||||
-subj "/CN=Hello" \
|
||||
-days 3650 \
|
||||
-out $@
|
||||
openssl req -noout -text -in $@
|
||||
|
||||
%.crt: %.csr %.key
|
||||
openssl x509 -req \
|
||||
-signkey $*.key \
|
||||
-in $*.csr \
|
||||
-days 3650 \
|
||||
-out $@
|
||||
openssl x509 -text -in $@
|
||||
|
||||
%.crt.base64: %.crt
|
||||
base64 -i $< -o $@
|
||||
11
pkg/adaptors/certpool/testdata/ca1.crt
vendored
Normal file
11
pkg/adaptors/certpool/testdata/ca1.crt
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBlzCCAQACCQCDf7Inwu3vkzANBgkqhkiG9w0BAQUFADAQMQ4wDAYDVQQDDAVI
|
||||
ZWxsbzAeFw0xOTA2MjQxMDQ0NDhaFw0yOTA2MjExMDQ0NDhaMBAxDjAMBgNVBAMM
|
||||
BUhlbGxvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCitZv5Go16nuHDRa2u
|
||||
nT5m1Q9tkr668pnhcP0TkyjD+oEB0lUz2SJEZEvOd1XVRRrPMSXrtybo9p0TqSGp
|
||||
Ig1gORWis/j/IR1sYdFutLKhtp6k1HvUiNosdO/K8K/AbO4QPWTGBAcqg//QkMKd
|
||||
ccgLY2PYczK/t8+6C7JYEHe5AwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACkPsyme
|
||||
JFlj75fO54NH5WXZxBtoY7kV3yd5oO88BngE8ittaHuauQkkw/sC5x733SsJlPlF
|
||||
trah4CDMjq5d/okIbIJFKe7NGLi82f9zJ+o1fjDp97UvZHC0zhUx+RiEu3iZRfYM
|
||||
31Ht7QG63V5ScV3Zmi1nzfQc4jn8d40kXXcn
|
||||
-----END CERTIFICATE-----
|
||||
1
pkg/adaptors/certpool/testdata/ca1.crt.base64
vendored
Normal file
1
pkg/adaptors/certpool/testdata/ca1.crt.base64
vendored
Normal file
@@ -0,0 +1 @@
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJsekNDQVFBQ0NRQ0RmN0lud3Uzdmt6QU5CZ2txaGtpRzl3MEJBUVVGQURBUU1RNHdEQVlEVlFRRERBVkkKWld4c2J6QWVGdzB4T1RBMk1qUXhNRFEwTkRoYUZ3MHlPVEEyTWpFeE1EUTBORGhhTUJBeERqQU1CZ05WQkFNTQpCVWhsYkd4dk1JR2ZNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0R05BRENCaVFLQmdRQ2l0WnY1R28xNm51SERSYTJ1Cm5UNW0xUTl0a3I2NjhwbmhjUDBUa3lqRCtvRUIwbFV6MlNKRVpFdk9kMVhWUlJyUE1TWHJ0eWJvOXAwVHFTR3AKSWcxZ09SV2lzL2ovSVIxc1lkRnV0TEtodHA2azFIdlVpTm9zZE8vSzhLL0FiTzRRUFdUR0JBY3FnLy9Ra01LZApjY2dMWTJQWWN6Sy90OCs2QzdKWUVIZTVBd0lEQVFBQk1BMEdDU3FHU0liM0RRRUJCUVVBQTRHQkFDa1BzeW1lCkpGbGo3NWZPNTROSDVXWFp4QnRvWTdrVjN5ZDVvTzg4Qm5nRThpdHRhSHVhdVFra3cvc0M1eDczM1NzSmxQbEYKdHJhaDRDRE1qcTVkL29rSWJJSkZLZTdOR0xpODJmOXpKK28xZmpEcDk3VXZaSEMwemhVeCtSaUV1M2laUmZZTQozMUh0N1FHNjNWNVNjVjNabWkxbnpmUWM0am44ZDQwa1hYY24KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
|
||||
11
pkg/adaptors/certpool/testdata/ca2.crt
vendored
Normal file
11
pkg/adaptors/certpool/testdata/ca2.crt
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBlzCCAQACCQCuudlGZuJvODANBgkqhkiG9w0BAQUFADAQMQ4wDAYDVQQDDAVI
|
||||
ZWxsbzAeFw0xOTA2MjQxMDQ0NDhaFw0yOTA2MjExMDQ0NDhaMBAxDjAMBgNVBAMM
|
||||
BUhlbGxvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHnrC3q5aCXhULGTTg
|
||||
w7psUbrH3gpHEExxlw6Zj+UBZHFhxOccGYfHPvqKwfRAKfqkP6VzLdYsfF0fuMOX
|
||||
ZzFk2hB1eAdl2dsFIn4hMll+jDdo9x+7NKvAXgsFF174ZMVTW26aAME8s4OrNuZT
|
||||
Fdrp7byuUkwUbSzDC/B/ct9MFQIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAGXsF0IA
|
||||
yP3g1UTLpld2P38dvMXLGN6gwn0S0oh7AQMckJ35yh8CN/2rAkBVujyvGILLhh2/
|
||||
teoIjM2BcZsrsKZ+Jkr177fRIunsd7a+v18M/3/pVvxPZdnztXspycxIacd7yVbG
|
||||
5wjN+X7rkoBLhd+BT9+W9O/i+Cu7K89JOO64
|
||||
-----END CERTIFICATE-----
|
||||
1
pkg/adaptors/certpool/testdata/ca2.crt.base64
vendored
Normal file
1
pkg/adaptors/certpool/testdata/ca2.crt.base64
vendored
Normal file
@@ -0,0 +1 @@
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJsekNDQVFBQ0NRQ3V1ZGxHWnVKdk9EQU5CZ2txaGtpRzl3MEJBUVVGQURBUU1RNHdEQVlEVlFRRERBVkkKWld4c2J6QWVGdzB4T1RBMk1qUXhNRFEwTkRoYUZ3MHlPVEEyTWpFeE1EUTBORGhhTUJBeERqQU1CZ05WQkFNTQpCVWhsYkd4dk1JR2ZNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0R05BRENCaVFLQmdRREhuckMzcTVhQ1hoVUxHVFRnCnc3cHNVYnJIM2dwSEVFeHhsdzZaaitVQlpIRmh4T2NjR1lmSFB2cUt3ZlJBS2Zxa1A2VnpMZFlzZkYwZnVNT1gKWnpGazJoQjFlQWRsMmRzRkluNGhNbGwrakRkbzl4KzdOS3ZBWGdzRkYxNzRaTVZUVzI2YUFNRThzNE9yTnVaVApGZHJwN2J5dVVrd1ViU3pEQy9CL2N0OU1GUUlEQVFBQk1BMEdDU3FHU0liM0RRRUJCUVVBQTRHQkFHWHNGMElBCnlQM2cxVVRMcGxkMlAzOGR2TVhMR042Z3duMFMwb2g3QVFNY2tKMzV5aDhDTi8yckFrQlZ1anl2R0lMTGhoMi8KdGVvSWpNMkJjWnNyc0taK0prcjE3N2ZSSXVuc2Q3YSt2MThNLzMvcFZ2eFBaZG56dFhzcHljeElhY2Q3eVZiRwo1d2pOK1g3cmtvQkxoZCtCVDkrVzlPL2krQ3U3Szg5Sk9PNjQKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
|
||||
11
pkg/adaptors/certpool/testdata/ca3.crt
vendored
Normal file
11
pkg/adaptors/certpool/testdata/ca3.crt
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBlzCCAQACCQDyFQsG5rDcJTANBgkqhkiG9w0BAQUFADAQMQ4wDAYDVQQDDAVI
|
||||
ZWxsbzAeFw0xOTA2MjQxMDQ0NDhaFw0yOTA2MjExMDQ0NDhaMBAxDjAMBgNVBAMM
|
||||
BUhlbGxvMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDUBsXHnAjPL3ejyNRs
|
||||
INI0cp4Sv4HzgXmL6nypzTdEOT3UcqfvZYj3dr4FWZytxb6XgvyvIzoV++GS22cf
|
||||
arXwwv0Z6CWiJXI+WQFdsQRQoAt4ucIa046b18p6mCiHfaH98aCOq9K3sxTfNOm3
|
||||
kWAi6oFzB5C+6HalQ+rWFSVWHQIDAQABMA0GCSqGSIb3DQEBBQUAA4GBALQfsBMR
|
||||
pxD9vzTmhw+rc0HKei9QMViIC3KYPdzvCCe0lMjrWzvcmTtUyCNJm2J2GwBVfyok
|
||||
zeUskYjinppBy/ZmzpWTeqTLOoeozgAh/Jgya5cPh01BP+pPFYmcQ5wOZHK5PPSP
|
||||
jvfqMeYs8TjXJRjdBKcMuZAN/8g2Ubtn+QbM
|
||||
-----END CERTIFICATE-----
|
||||
1
pkg/adaptors/certpool/testdata/ca3.crt.base64
vendored
Normal file
1
pkg/adaptors/certpool/testdata/ca3.crt.base64
vendored
Normal file
@@ -0,0 +1 @@
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJsekNDQVFBQ0NRRHlGUXNHNXJEY0pUQU5CZ2txaGtpRzl3MEJBUVVGQURBUU1RNHdEQVlEVlFRRERBVkkKWld4c2J6QWVGdzB4T1RBMk1qUXhNRFEwTkRoYUZ3MHlPVEEyTWpFeE1EUTBORGhhTUJBeERqQU1CZ05WQkFNTQpCVWhsYkd4dk1JR2ZNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0R05BRENCaVFLQmdRRFVCc1hIbkFqUEwzZWp5TlJzCklOSTBjcDRTdjRIemdYbUw2bnlwelRkRU9UM1VjcWZ2WllqM2RyNEZXWnl0eGI2WGd2eXZJem9WKytHUzIyY2YKYXJYd3d2MFo2Q1dpSlhJK1dRRmRzUVJRb0F0NHVjSWEwNDZiMThwNm1DaUhmYUg5OGFDT3E5SzNzeFRmTk9tMwprV0FpNm9GekI1Qys2SGFsUStyV0ZTVldIUUlEQVFBQk1BMEdDU3FHU0liM0RRRUJCUVVBQTRHQkFMUWZzQk1SCnB4RDl2elRtaHcrcmMwSEtlaTlRTVZpSUMzS1lQZHp2Q0NlMGxNanJXenZjbVR0VXlDTkptMkoyR3dCVmZ5b2sKemVVc2tZamlucHBCeS9abXpwV1RlcVRMT29lb3pnQWgvSmd5YTVjUGgwMUJQK3BQRlltY1E1d09aSEs1UFBTUApqdmZxTWVZczhUalhKUmpkQktjTXVaQU4vOGcyVWJ0bitRYk0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
|
||||
70
pkg/adaptors/cmd/cmd.go
Normal file
70
pkg/adaptors/cmd/cmd.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/client-go/util/homedir"
|
||||
)
|
||||
|
||||
// Set provides an implementation and interface for Cmd.
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Cmd), "*"),
|
||||
wire.Bind(new(Interface), new(*Cmd)),
|
||||
wire.Struct(new(Root), "*"),
|
||||
wire.Struct(new(GetToken), "*"),
|
||||
wire.Struct(new(Setup), "*"),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
Run(ctx context.Context, args []string, version string) int
|
||||
}
|
||||
|
||||
var defaultListenAddress = []string{"127.0.0.1:8000", "127.0.0.1:18000"}
|
||||
var defaultTokenCacheDir = homedir.HomeDir() + "/.kube/cache/oidc-login"
|
||||
|
||||
// Cmd provides interaction with command line interface (CLI).
|
||||
type Cmd struct {
|
||||
Root *Root
|
||||
GetToken *GetToken
|
||||
Setup *Setup
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
// Run parses the command line arguments and executes the specified use-case.
|
||||
// It returns an exit code, that is 0 on success or 1 on error.
|
||||
func (cmd *Cmd) Run(ctx context.Context, args []string, version string) int {
|
||||
executable := filepath.Base(args[0])
|
||||
|
||||
rootCmd := cmd.Root.New(ctx, executable)
|
||||
rootCmd.Version = version
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
getTokenCmd := cmd.GetToken.New(ctx)
|
||||
rootCmd.AddCommand(getTokenCmd)
|
||||
|
||||
setupCmd := cmd.Setup.New(ctx)
|
||||
rootCmd.AddCommand(setupCmd)
|
||||
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version information",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(*cobra.Command, []string) {
|
||||
cmd.Logger.Printf("%s version %s", executable, version)
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
|
||||
rootCmd.SetArgs(args[1:])
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
cmd.Logger.Printf("error: %s", err)
|
||||
cmd.Logger.V(1).Infof("stacktrace: %+v", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
360
pkg/adaptors/cmd/cmd_test.go
Normal file
360
pkg/adaptors/cmd/cmd_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger/mock_logger"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
"github.com/int128/kubelogin/pkg/usecases/credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/usecases/credentialplugin/mock_credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/usecases/standalone"
|
||||
"github.com/int128/kubelogin/pkg/usecases/standalone/mock_standalone"
|
||||
)
|
||||
|
||||
func TestCmd_Run(t *testing.T) {
|
||||
const executable = "kubelogin"
|
||||
const version = "HEAD"
|
||||
|
||||
t.Run("root", func(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
args []string
|
||||
in standalone.Input
|
||||
}{
|
||||
"Defaults": {
|
||||
args: []string{executable},
|
||||
in: standalone.Input{
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeOption: &authentication.AuthCodeOption{
|
||||
BindAddress: defaultListenAddress,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"when --listen-port is set, it should convert the port to address": {
|
||||
args: []string{
|
||||
executable,
|
||||
"--listen-port", "10080",
|
||||
"--listen-port", "20080",
|
||||
},
|
||||
in: standalone.Input{
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeOption: &authentication.AuthCodeOption{
|
||||
BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"when --listen-port is set, it should ignore --listen-address flags": {
|
||||
args: []string{
|
||||
executable,
|
||||
"--listen-port", "10080",
|
||||
"--listen-port", "20080",
|
||||
"--listen-address", "127.0.0.1:30080",
|
||||
"--listen-address", "127.0.0.1:40080",
|
||||
},
|
||||
in: standalone.Input{
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeOption: &authentication.AuthCodeOption{
|
||||
BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"FullOptions": {
|
||||
args: []string{executable,
|
||||
"--kubeconfig", "/path/to/kubeconfig",
|
||||
"--context", "hello.k8s.local",
|
||||
"--user", "google",
|
||||
"--certificate-authority", "/path/to/cacert",
|
||||
"--insecure-skip-tls-verify",
|
||||
"-v1",
|
||||
"--grant-type", "authcode",
|
||||
"--listen-address", "127.0.0.1:10080",
|
||||
"--listen-address", "127.0.0.1:20080",
|
||||
"--skip-open-browser",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
},
|
||||
in: standalone.Input{
|
||||
KubeconfigFilename: "/path/to/kubeconfig",
|
||||
KubeconfigContext: "hello.k8s.local",
|
||||
KubeconfigUser: "google",
|
||||
CACertFilename: "/path/to/cacert",
|
||||
SkipTLSVerify: true,
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeOption: &authentication.AuthCodeOption{
|
||||
BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"},
|
||||
SkipOpenBrowser: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=authcode-keyboard": {
|
||||
args: []string{executable,
|
||||
"--grant-type", "authcode-keyboard",
|
||||
},
|
||||
in: standalone.Input{
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeKeyboardOption: &authentication.AuthCodeKeyboardOption{},
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=password": {
|
||||
args: []string{executable,
|
||||
"--grant-type", "password",
|
||||
"--listen-address", "127.0.0.1:10080",
|
||||
"--listen-address", "127.0.0.1:20080",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
},
|
||||
in: standalone.Input{
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
ROPCOption: &authentication.ROPCOption{
|
||||
Username: "USER",
|
||||
Password: "PASS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=auto": {
|
||||
args: []string{executable,
|
||||
"--listen-address", "127.0.0.1:10080",
|
||||
"--listen-address", "127.0.0.1:20080",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
},
|
||||
in: standalone.Input{
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
ROPCOption: &authentication.ROPCOption{
|
||||
Username: "USER",
|
||||
Password: "PASS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, c := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
ctx := context.TODO()
|
||||
mockStandalone := mock_standalone.NewMockInterface(ctrl)
|
||||
mockStandalone.EXPECT().
|
||||
Do(ctx, c.in)
|
||||
cmd := Cmd{
|
||||
Root: &Root{
|
||||
Standalone: mockStandalone,
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
Logger: mock_logger.New(t),
|
||||
}
|
||||
exitCode := cmd.Run(ctx, c.args, 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{
|
||||
Root: &Root{
|
||||
Standalone: mock_standalone.NewMockInterface(ctrl),
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
Logger: mock_logger.New(t),
|
||||
}
|
||||
exitCode := cmd.Run(context.TODO(), []string{executable, "some"}, version)
|
||||
if exitCode != 1 {
|
||||
t.Errorf("exitCode wants 1 but %d", exitCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get-token", func(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
args []string
|
||||
in credentialplugin.Input
|
||||
}{
|
||||
"Defaults": {
|
||||
args: []string{executable,
|
||||
"get-token",
|
||||
"--oidc-issuer-url", "https://issuer.example.com",
|
||||
"--oidc-client-id", "YOUR_CLIENT_ID",
|
||||
},
|
||||
in: credentialplugin.Input{
|
||||
TokenCacheDir: defaultTokenCacheDir,
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeOption: &authentication.AuthCodeOption{
|
||||
BindAddress: []string{"127.0.0.1:8000", "127.0.0.1:18000"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"FullOptions": {
|
||||
args: []string{executable,
|
||||
"get-token",
|
||||
"--oidc-issuer-url", "https://issuer.example.com",
|
||||
"--oidc-client-id", "YOUR_CLIENT_ID",
|
||||
"--oidc-client-secret", "YOUR_CLIENT_SECRET",
|
||||
"--oidc-extra-scope", "email",
|
||||
"--oidc-extra-scope", "profile",
|
||||
"--certificate-authority", "/path/to/cacert",
|
||||
"--insecure-skip-tls-verify",
|
||||
"-v1",
|
||||
"--grant-type", "authcode",
|
||||
"--listen-address", "127.0.0.1:10080",
|
||||
"--listen-address", "127.0.0.1:20080",
|
||||
"--skip-open-browser",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
},
|
||||
in: credentialplugin.Input{
|
||||
TokenCacheDir: defaultTokenCacheDir,
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||
ExtraScopes: []string{"email", "profile"},
|
||||
CACertFilename: "/path/to/cacert",
|
||||
SkipTLSVerify: true,
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeOption: &authentication.AuthCodeOption{
|
||||
BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"},
|
||||
SkipOpenBrowser: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=authcode-keyboard": {
|
||||
args: []string{executable,
|
||||
"get-token",
|
||||
"--oidc-issuer-url", "https://issuer.example.com",
|
||||
"--oidc-client-id", "YOUR_CLIENT_ID",
|
||||
"--grant-type", "authcode-keyboard",
|
||||
},
|
||||
in: credentialplugin.Input{
|
||||
TokenCacheDir: defaultTokenCacheDir,
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
AuthCodeKeyboardOption: &authentication.AuthCodeKeyboardOption{},
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=password": {
|
||||
args: []string{executable,
|
||||
"get-token",
|
||||
"--oidc-issuer-url", "https://issuer.example.com",
|
||||
"--oidc-client-id", "YOUR_CLIENT_ID",
|
||||
"--grant-type", "password",
|
||||
"--listen-address", "127.0.0.1:10080",
|
||||
"--listen-address", "127.0.0.1:20080",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
},
|
||||
in: credentialplugin.Input{
|
||||
TokenCacheDir: defaultTokenCacheDir,
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
ROPCOption: &authentication.ROPCOption{
|
||||
Username: "USER",
|
||||
Password: "PASS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=auto": {
|
||||
args: []string{executable,
|
||||
"get-token",
|
||||
"--oidc-issuer-url", "https://issuer.example.com",
|
||||
"--oidc-client-id", "YOUR_CLIENT_ID",
|
||||
"--listen-address", "127.0.0.1:10080",
|
||||
"--listen-address", "127.0.0.1:20080",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
},
|
||||
in: credentialplugin.Input{
|
||||
TokenCacheDir: defaultTokenCacheDir,
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
GrantOptionSet: authentication.GrantOptionSet{
|
||||
ROPCOption: &authentication.ROPCOption{
|
||||
Username: "USER",
|
||||
Password: "PASS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, c := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
ctx := context.TODO()
|
||||
getToken := mock_credentialplugin.NewMockInterface(ctrl)
|
||||
getToken.EXPECT().
|
||||
Do(ctx, c.in)
|
||||
cmd := Cmd{
|
||||
Root: &Root{
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
GetToken: &GetToken{
|
||||
GetToken: getToken,
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
Logger: mock_logger.New(t),
|
||||
}
|
||||
exitCode := cmd.Run(ctx, c.args, version)
|
||||
if exitCode != 0 {
|
||||
t.Errorf("exitCode wants 0 but %d", exitCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("MissingMandatoryOptions", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
ctx := context.TODO()
|
||||
cmd := Cmd{
|
||||
Root: &Root{
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
GetToken: &GetToken{
|
||||
GetToken: mock_credentialplugin.NewMockInterface(ctrl),
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
Logger: mock_logger.New(t),
|
||||
}
|
||||
exitCode := cmd.Run(ctx, []string{executable, "get-token"}, version)
|
||||
if exitCode != 1 {
|
||||
t.Errorf("exitCode wants 1 but %d", exitCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TooManyArgs", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
ctx := context.TODO()
|
||||
cmd := Cmd{
|
||||
Root: &Root{
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
GetToken: &GetToken{
|
||||
GetToken: mock_credentialplugin.NewMockInterface(ctrl),
|
||||
Logger: mock_logger.New(t),
|
||||
},
|
||||
Logger: mock_logger.New(t),
|
||||
}
|
||||
exitCode := cmd.Run(ctx, []string{executable, "get-token", "foo"}, version)
|
||||
if exitCode != 1 {
|
||||
t.Errorf("exitCode wants 1 but %d", exitCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
82
pkg/adaptors/cmd/get_token.go
Normal file
82
pkg/adaptors/cmd/get_token.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/usecases/credentialplugin"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// getTokenOptions represents the options for get-token command.
|
||||
type getTokenOptions struct {
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
ExtraScopes []string
|
||||
CertificateAuthority string
|
||||
SkipTLSVerify bool
|
||||
TokenCacheDir string
|
||||
authenticationOptions authenticationOptions
|
||||
}
|
||||
|
||||
func (o *getTokenOptions) register(f *pflag.FlagSet) {
|
||||
f.SortFlags = false
|
||||
f.StringVar(&o.IssuerURL, "oidc-issuer-url", "", "Issuer URL of the provider (mandatory)")
|
||||
f.StringVar(&o.ClientID, "oidc-client-id", "", "Client ID of the provider (mandatory)")
|
||||
f.StringVar(&o.ClientSecret, "oidc-client-secret", "", "Client secret of the provider")
|
||||
f.StringSliceVar(&o.ExtraScopes, "oidc-extra-scope", nil, "Scopes to request to the provider")
|
||||
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.StringVar(&o.TokenCacheDir, "token-cache-dir", defaultTokenCacheDir, "Path to a directory for caching tokens")
|
||||
o.authenticationOptions.register(f)
|
||||
}
|
||||
|
||||
type GetToken struct {
|
||||
GetToken credentialplugin.Interface
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
func (cmd *GetToken) New(ctx context.Context) *cobra.Command {
|
||||
var o getTokenOptions
|
||||
c := &cobra.Command{
|
||||
Use: "get-token [flags]",
|
||||
Short: "Run as a kubectl credential plugin",
|
||||
Args: func(c *cobra.Command, args []string) error {
|
||||
if err := cobra.NoArgs(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if o.IssuerURL == "" {
|
||||
return xerrors.New("--oidc-issuer-url is missing")
|
||||
}
|
||||
if o.ClientID == "" {
|
||||
return xerrors.New("--oidc-client-id is missing")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
grantOptionSet, err := o.authenticationOptions.grantOptionSet()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("error: %w", err)
|
||||
}
|
||||
in := credentialplugin.Input{
|
||||
IssuerURL: o.IssuerURL,
|
||||
ClientID: o.ClientID,
|
||||
ClientSecret: o.ClientSecret,
|
||||
ExtraScopes: o.ExtraScopes,
|
||||
CACertFilename: o.CertificateAuthority,
|
||||
SkipTLSVerify: o.SkipTLSVerify,
|
||||
TokenCacheDir: o.TokenCacheDir,
|
||||
GrantOptionSet: grantOptionSet,
|
||||
}
|
||||
if err := cmd.GetToken.Do(ctx, in); err != nil {
|
||||
return xerrors.Errorf("error: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
o.register(c.Flags())
|
||||
return c
|
||||
}
|
||||
142
pkg/adaptors/cmd/root.go
Normal file
142
pkg/adaptors/cmd/root.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/adaptors/kubeconfig"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
"github.com/int128/kubelogin/pkg/usecases/standalone"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const longDescription = `Login to the OpenID Connect provider.
|
||||
|
||||
You need to set up the OIDC provider, role binding, Kubernetes API server and kubeconfig.
|
||||
Run the following command to show the setup instruction:
|
||||
|
||||
kubectl oidc-login setup
|
||||
|
||||
See https://github.com/int128/kubelogin for more.
|
||||
`
|
||||
|
||||
// rootOptions represents the options for the root command.
|
||||
type rootOptions struct {
|
||||
Kubeconfig string
|
||||
Context string
|
||||
User string
|
||||
CertificateAuthority string
|
||||
SkipTLSVerify bool
|
||||
authenticationOptions authenticationOptions
|
||||
}
|
||||
|
||||
func (o *rootOptions) register(f *pflag.FlagSet) {
|
||||
f.SortFlags = false
|
||||
f.StringVar(&o.Kubeconfig, "kubeconfig", "", "Path to the kubeconfig file")
|
||||
f.StringVar(&o.Context, "context", "", "The name of the kubeconfig context to use")
|
||||
f.StringVar(&o.User, "user", "", "The name of the kubeconfig user to use. Prior to --context")
|
||||
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")
|
||||
o.authenticationOptions.register(f)
|
||||
}
|
||||
|
||||
type authenticationOptions struct {
|
||||
GrantType string
|
||||
ListenAddress []string
|
||||
ListenPort []int // deprecated
|
||||
SkipOpenBrowser bool
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// determineListenAddress returns the addresses from the flags.
|
||||
// Note that --listen-address is always given due to the default value.
|
||||
// If --listen-port is not set, it returns --listen-address.
|
||||
// If --listen-port is set, it returns the strings of --listen-port.
|
||||
func (o *authenticationOptions) determineListenAddress() []string {
|
||||
if len(o.ListenPort) == 0 {
|
||||
return o.ListenAddress
|
||||
}
|
||||
var a []string
|
||||
for _, p := range o.ListenPort {
|
||||
a = append(a, fmt.Sprintf("127.0.0.1:%d", p))
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
var allGrantType = strings.Join([]string{
|
||||
"auto",
|
||||
"authcode",
|
||||
"authcode-keyboard",
|
||||
"password",
|
||||
}, "|")
|
||||
|
||||
func (o *authenticationOptions) register(f *pflag.FlagSet) {
|
||||
f.StringVar(&o.GrantType, "grant-type", "auto", fmt.Sprintf("The authorization grant type to use. One of (%s)", allGrantType))
|
||||
f.StringSliceVar(&o.ListenAddress, "listen-address", defaultListenAddress, "Address to bind to the local server. If multiple addresses are given, it will try binding in order")
|
||||
//TODO: remove the deprecated flag
|
||||
f.IntSliceVar(&o.ListenPort, "listen-port", nil, "(Deprecated: use --listen-address)")
|
||||
f.BoolVar(&o.SkipOpenBrowser, "skip-open-browser", false, "If true, it does not open the browser on authentication")
|
||||
f.StringVar(&o.Username, "username", "", "If set, perform the resource owner password credentials grant")
|
||||
f.StringVar(&o.Password, "password", "", "If set, use the password instead of asking it")
|
||||
}
|
||||
|
||||
func (o *authenticationOptions) grantOptionSet() (s authentication.GrantOptionSet, err error) {
|
||||
switch {
|
||||
case o.GrantType == "authcode" || (o.GrantType == "auto" && o.Username == ""):
|
||||
s.AuthCodeOption = &authentication.AuthCodeOption{
|
||||
BindAddress: o.determineListenAddress(),
|
||||
SkipOpenBrowser: o.SkipOpenBrowser,
|
||||
}
|
||||
case o.GrantType == "authcode-keyboard":
|
||||
s.AuthCodeKeyboardOption = &authentication.AuthCodeKeyboardOption{}
|
||||
case o.GrantType == "password" || (o.GrantType == "auto" && o.Username != ""):
|
||||
s.ROPCOption = &authentication.ROPCOption{
|
||||
Username: o.Username,
|
||||
Password: o.Password,
|
||||
}
|
||||
default:
|
||||
err = xerrors.Errorf("grant-type must be one of (%s)", allGrantType)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type Root struct {
|
||||
Standalone standalone.Interface
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
func (cmd *Root) New(ctx context.Context, executable string) *cobra.Command {
|
||||
var o rootOptions
|
||||
rootCmd := &cobra.Command{
|
||||
Use: executable,
|
||||
Short: "Login to the OpenID Connect provider",
|
||||
Long: longDescription,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
grantOptionSet, err := o.authenticationOptions.grantOptionSet()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("invalid option: %w", err)
|
||||
}
|
||||
in := standalone.Input{
|
||||
KubeconfigFilename: o.Kubeconfig,
|
||||
KubeconfigContext: kubeconfig.ContextName(o.Context),
|
||||
KubeconfigUser: kubeconfig.UserName(o.User),
|
||||
CACertFilename: o.CertificateAuthority,
|
||||
SkipTLSVerify: o.SkipTLSVerify,
|
||||
GrantOptionSet: grantOptionSet,
|
||||
}
|
||||
if err := cmd.Standalone.Do(ctx, in); err != nil {
|
||||
return xerrors.Errorf("error: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
o.register(rootCmd.Flags())
|
||||
cmd.Logger.AddFlags(rootCmd.PersistentFlags())
|
||||
return rootCmd
|
||||
}
|
||||
73
pkg/adaptors/cmd/setup.go
Normal file
73
pkg/adaptors/cmd/setup.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/usecases/setup"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// setupOptions represents the options for setup command.
|
||||
type setupOptions struct {
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
ExtraScopes []string
|
||||
CertificateAuthority string
|
||||
SkipTLSVerify bool
|
||||
authenticationOptions authenticationOptions
|
||||
}
|
||||
|
||||
func (o *setupOptions) register(f *pflag.FlagSet) {
|
||||
f.SortFlags = false
|
||||
f.StringVar(&o.IssuerURL, "oidc-issuer-url", "", "Issuer URL of the provider")
|
||||
f.StringVar(&o.ClientID, "oidc-client-id", "", "Client ID of the provider")
|
||||
f.StringVar(&o.ClientSecret, "oidc-client-secret", "", "Client secret of the provider")
|
||||
f.StringSliceVar(&o.ExtraScopes, "oidc-extra-scope", nil, "Scopes to request to the provider")
|
||||
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")
|
||||
o.authenticationOptions.register(f)
|
||||
}
|
||||
|
||||
type Setup struct {
|
||||
Setup setup.Interface
|
||||
}
|
||||
|
||||
func (cmd *Setup) New(ctx context.Context) *cobra.Command {
|
||||
var o setupOptions
|
||||
c := &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Show the setup instruction",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
grantOptionSet, err := o.authenticationOptions.grantOptionSet()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("error: %w", err)
|
||||
}
|
||||
in := setup.Stage2Input{
|
||||
IssuerURL: o.IssuerURL,
|
||||
ClientID: o.ClientID,
|
||||
ClientSecret: o.ClientSecret,
|
||||
ExtraScopes: o.ExtraScopes,
|
||||
CACertFilename: o.CertificateAuthority,
|
||||
SkipTLSVerify: o.SkipTLSVerify,
|
||||
GrantOptionSet: grantOptionSet,
|
||||
}
|
||||
if c.Flags().Lookup("listen-address").Changed {
|
||||
in.ListenAddressArgs = o.authenticationOptions.ListenAddress
|
||||
}
|
||||
if in.IssuerURL == "" || in.ClientID == "" {
|
||||
cmd.Setup.DoStage1()
|
||||
return nil
|
||||
}
|
||||
if err := cmd.Setup.DoStage2(ctx, in); err != nil {
|
||||
return xerrors.Errorf("error: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
o.register(c.Flags())
|
||||
return c
|
||||
}
|
||||
51
pkg/adaptors/credentialplugin/credential_plugin.go
Normal file
51
pkg/adaptors/credentialplugin/credential_plugin.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package credentialplugin provides interaction with kubectl for a credential plugin.
|
||||
package credentialplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/wire"
|
||||
"golang.org/x/xerrors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_credentialplugin/mock_credentialplugin.go github.com/int128/kubelogin/pkg/adaptors/credentialplugin Interface
|
||||
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Interaction), "*"),
|
||||
wire.Bind(new(Interface), new(*Interaction)),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
Write(out Output) error
|
||||
}
|
||||
|
||||
// Output represents an output object of the credential plugin.
|
||||
type Output struct {
|
||||
Token string
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
type Interaction struct{}
|
||||
|
||||
// Write writes the ExecCredential to standard output for kubectl.
|
||||
func (*Interaction) Write(out Output) error {
|
||||
ec := &v1beta1.ExecCredential{
|
||||
TypeMeta: v1.TypeMeta{
|
||||
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||
Kind: "ExecCredential",
|
||||
},
|
||||
Status: &v1beta1.ExecCredentialStatus{
|
||||
Token: out.Token,
|
||||
ExpirationTimestamp: &v1.Time{Time: out.Expiry},
|
||||
},
|
||||
}
|
||||
e := json.NewEncoder(os.Stdout)
|
||||
if err := e.Encode(ec); err != nil {
|
||||
return xerrors.Errorf("could not write the ExecCredential: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/pkg/adaptors/credentialplugin (interfaces: Interface)
|
||||
|
||||
// Package mock_credentialplugin is a generated GoMock package.
|
||||
package mock_credentialplugin
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
credentialplugin "github.com/int128/kubelogin/pkg/adaptors/credentialplugin"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
|
||||
mock := &MockInterface{ctrl: ctrl}
|
||||
mock.recorder = &MockInterfaceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Write mocks base method
|
||||
func (m *MockInterface) Write(arg0 credentialplugin.Output) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Write", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Write indicates an expected call of Write
|
||||
func (mr *MockInterfaceMockRecorder) Write(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockInterface)(nil).Write), arg0)
|
||||
}
|
||||
83
pkg/adaptors/env/env.go
vendored
Normal file
83
pkg/adaptors/env/env.go
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package env provides environment dependent facilities.
|
||||
package env
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/pkg/browser"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_env/mock_env.go github.com/int128/kubelogin/pkg/adaptors/env Interface
|
||||
|
||||
func init() {
|
||||
// In credential plugin mode, some browser launcher writes a message to stdout
|
||||
// and it may break the credential json for client-go.
|
||||
// This prevents the browser launcher from breaking the credential json.
|
||||
browser.Stdout = os.Stderr
|
||||
}
|
||||
|
||||
// Set provides an implementation and interface for Env.
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Env), "*"),
|
||||
wire.Bind(new(Interface), new(*Env)),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
ReadString(prompt string) (string, error)
|
||||
ReadPassword(prompt string) (string, error)
|
||||
OpenBrowser(url string) error
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// Env provides environment specific facilities.
|
||||
type Env struct{}
|
||||
|
||||
// ReadString reads a string from the stdin.
|
||||
func (*Env) ReadString(prompt string) (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
|
||||
return "", xerrors.Errorf("could not write the prompt: %w", err)
|
||||
}
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
s, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("could not read from stdin: %w", err)
|
||||
}
|
||||
s = strings.TrimRight(s, "\r\n")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ReadPassword reads a password from the stdin without echo back.
|
||||
func (*Env) ReadPassword(prompt string) (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
|
||||
return "", xerrors.Errorf("could not write the prompt: %w", err)
|
||||
}
|
||||
b, err := terminal.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("could not read from stdin: %w", err)
|
||||
}
|
||||
if _, err := fmt.Fprintln(os.Stderr); err != nil {
|
||||
return "", xerrors.Errorf("could not write a new line: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// OpenBrowser opens the default browser.
|
||||
func (env *Env) OpenBrowser(url string) error {
|
||||
if err := browser.OpenURL(url); err != nil {
|
||||
return xerrors.Errorf("could not open the browser: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now returns the current time.
|
||||
func (*Env) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
92
pkg/adaptors/env/mock_env/mock_env.go
vendored
Normal file
92
pkg/adaptors/env/mock_env/mock_env.go
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/pkg/adaptors/env (interfaces: Interface)
|
||||
|
||||
// Package mock_env is a generated GoMock package.
|
||||
package mock_env
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
|
||||
mock := &MockInterface{ctrl: ctrl}
|
||||
mock.recorder = &MockInterfaceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Now mocks base method
|
||||
func (m *MockInterface) Now() time.Time {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Now")
|
||||
ret0, _ := ret[0].(time.Time)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Now indicates an expected call of Now
|
||||
func (mr *MockInterfaceMockRecorder) Now() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Now", reflect.TypeOf((*MockInterface)(nil).Now))
|
||||
}
|
||||
|
||||
// OpenBrowser mocks base method
|
||||
func (m *MockInterface) OpenBrowser(arg0 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "OpenBrowser", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// OpenBrowser indicates an expected call of OpenBrowser
|
||||
func (mr *MockInterfaceMockRecorder) OpenBrowser(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenBrowser", reflect.TypeOf((*MockInterface)(nil).OpenBrowser), arg0)
|
||||
}
|
||||
|
||||
// ReadPassword mocks base method
|
||||
func (m *MockInterface) ReadPassword(arg0 string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ReadPassword", arg0)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ReadPassword indicates an expected call of ReadPassword
|
||||
func (mr *MockInterfaceMockRecorder) ReadPassword(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadPassword", reflect.TypeOf((*MockInterface)(nil).ReadPassword), arg0)
|
||||
}
|
||||
|
||||
// ReadString mocks base method
|
||||
func (m *MockInterface) ReadString(arg0 string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ReadString", arg0)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ReadString indicates an expected call of ReadString
|
||||
func (mr *MockInterfaceMockRecorder) ReadString(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadString", reflect.TypeOf((*MockInterface)(nil).ReadString), arg0)
|
||||
}
|
||||
68
pkg/adaptors/jwtdecoder/decoder.go
Normal file
68
pkg/adaptors/jwtdecoder/decoder.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package jwtdecoder provides decoding a JWT.
|
||||
package jwtdecoder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/domain/oidc"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_jwtdecoder/mock_jwtdecoder.go github.com/int128/kubelogin/pkg/adaptors/jwtdecoder Interface
|
||||
|
||||
// Set provides an implementation and interface.
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Decoder), "*"),
|
||||
wire.Bind(new(Interface), new(*Decoder)),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
Decode(s string) (*oidc.Claims, error)
|
||||
}
|
||||
|
||||
type Decoder struct{}
|
||||
|
||||
// Decode returns the claims of the JWT.
|
||||
// Note that this method does not verify the signature and always trust it.
|
||||
func (d *Decoder) Decode(s string) (*oidc.Claims, error) {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, xerrors.Errorf("token contains an invalid number of segments")
|
||||
}
|
||||
b, err := jwt.DecodeSegment(parts[1])
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not decode the token: %w", err)
|
||||
}
|
||||
var claims jwt.StandardClaims
|
||||
if err := json.NewDecoder(bytes.NewBuffer(b)).Decode(&claims); err != nil {
|
||||
return nil, xerrors.Errorf("could not decode the json of token: %w", err)
|
||||
}
|
||||
var rawClaims map[string]interface{}
|
||||
if err := json.NewDecoder(bytes.NewBuffer(b)).Decode(&rawClaims); err != nil {
|
||||
return nil, xerrors.Errorf("could not decode the json of token: %w", err)
|
||||
}
|
||||
return &oidc.Claims{
|
||||
Subject: claims.Subject,
|
||||
Expiry: time.Unix(claims.ExpiresAt, 0),
|
||||
Pretty: dumpRawClaims(rawClaims),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dumpRawClaims(rawClaims map[string]interface{}) map[string]string {
|
||||
claims := make(map[string]string)
|
||||
for k, v := range rawClaims {
|
||||
switch v := v.(type) {
|
||||
case float64:
|
||||
claims[k] = fmt.Sprintf("%.f", v)
|
||||
default:
|
||||
claims[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
87
pkg/adaptors/jwtdecoder/decoder_test.go
Normal file
87
pkg/adaptors/jwtdecoder/decoder_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package jwtdecoder
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
)
|
||||
|
||||
func TestDecoder_Decode(t *testing.T) {
|
||||
var decoder Decoder
|
||||
|
||||
t.Run("ValidToken", func(t *testing.T) {
|
||||
expiry := time.Now().Round(time.Second)
|
||||
idToken := newIDToken(t, "https://issuer.example.com", expiry)
|
||||
decodedToken, err := decoder.Decode(idToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode error: %s", err)
|
||||
}
|
||||
if decodedToken.Expiry != expiry {
|
||||
t.Errorf("Expiry wants %s but %s", expiry, decodedToken.Expiry)
|
||||
}
|
||||
t.Logf("Pretty=%+v", decodedToken.Pretty)
|
||||
})
|
||||
t.Run("InvalidToken", func(t *testing.T) {
|
||||
decodedToken, err := decoder.Decode("HEADER.INVALID_TOKEN.SIGNATURE")
|
||||
if err == nil {
|
||||
t.Errorf("error wants non-nil but nil")
|
||||
} else {
|
||||
t.Logf("expected error: %+v", err)
|
||||
}
|
||||
if decodedToken != nil {
|
||||
t.Errorf("decodedToken wants nil but %+v", decodedToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newIDToken(t *testing.T, issuer string, expiry time.Time) string {
|
||||
t.Helper()
|
||||
claims := struct {
|
||||
jwt.StandardClaims
|
||||
Nonce string `json:"nonce"`
|
||||
Groups []string `json:"groups"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
}{
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Issuer: issuer,
|
||||
Audience: "kubernetes",
|
||||
Subject: "SUBJECT",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: expiry.Unix(),
|
||||
},
|
||||
Nonce: "NONCE",
|
||||
Groups: []string{"admin", "users"},
|
||||
EmailVerified: false,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
s, err := token.SignedString(readPrivateKey(t, "testdata/jws.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("Could not sign the claims: %s", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func readPrivateKey(t *testing.T, name string) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
b, err := ioutil.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("could not read the file: %s", err)
|
||||
}
|
||||
block, rest := pem.Decode(b)
|
||||
if block == nil {
|
||||
t.Fatalf("could not decode PEM")
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
t.Fatalf("PEM should contain single key but multiple keys")
|
||||
}
|
||||
k, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse the key: %s", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
49
pkg/adaptors/jwtdecoder/mock_jwtdecoder/mock_jwtdecoder.go
Normal file
49
pkg/adaptors/jwtdecoder/mock_jwtdecoder/mock_jwtdecoder.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/pkg/adaptors/jwtdecoder (interfaces: Interface)
|
||||
|
||||
// Package mock_jwtdecoder is a generated GoMock package.
|
||||
package mock_jwtdecoder
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
oidc "github.com/int128/kubelogin/pkg/domain/oidc"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
|
||||
mock := &MockInterface{ctrl: ctrl}
|
||||
mock.recorder = &MockInterfaceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Decode mocks base method
|
||||
func (m *MockInterface) Decode(arg0 string) (*oidc.Claims, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Decode", arg0)
|
||||
ret0, _ := ret[0].(*oidc.Claims)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Decode indicates an expected call of Decode
|
||||
func (mr *MockInterfaceMockRecorder) Decode(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Decode", reflect.TypeOf((*MockInterface)(nil).Decode), arg0)
|
||||
}
|
||||
8
pkg/adaptors/jwtdecoder/testdata/Makefile
vendored
Normal file
8
pkg/adaptors/jwtdecoder/testdata/Makefile
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
all: jws.key
|
||||
|
||||
jws.key:
|
||||
openssl genrsa -out $@ 1024
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
-rm -v jws.key
|
||||
15
pkg/adaptors/jwtdecoder/testdata/jws.key
vendored
Normal file
15
pkg/adaptors/jwtdecoder/testdata/jws.key
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXAIBAAKBgQCrH34yA/f/sBOUlkYnRtd2jgDZ3WivhidqvoQaa73xqTazbkn6
|
||||
GZ9r7jx0CGLRV2bmErj2WoyT54yrhezrKh0YXAHlrwLdsmV4dwiV0lOfUJd9P/vF
|
||||
e2hiAWv4CcO9ZuNkTsrxM5W8Wdj2tjqOvsIn4We+HWPkpknT7VtT5RrumwIDAQAB
|
||||
AoGAFqy5oA7+kZbXQV0YNqQgcMkoO7Ym5Ps1xeMwxf94z8jIQsZebxFuGnMa95UU
|
||||
4wBd1ias85fUANUxwpigaBjQee5Hk+dnfUe1snUWYNm9H6tKrXEF8ajer3a2knEv
|
||||
GfK0CSEumFougfW2xG88ChGTS60wc+MIRfXERCvWpGm/5EECQQDdv5IBSi89g/R1
|
||||
5AGZKFCoqr6Zw5bWEKPzCCYJZzncR1ER9vP2AnMExM8Io/87WYvmpZIUrXJvQYm8
|
||||
hkfVOcBZAkEAxY4VcqmRWru3zmnbj21MwcwtgESaONkWsHeYs1C/Y/3zt7TuelYz
|
||||
ZJ9aUuUsaiJLEs9Y26nMt0L0snWGr2noEwJBANaDp1PWFyMUTt3pB17JcFXqb15C
|
||||
pt1I1cGapWk9Uez1lMijNNhNAEWhuoKqW5Nnif5DN7EHJYfZR8x3vm/YYWkCQHAA
|
||||
0iAkCwjKDLe2RIjYiwAE5ncmbdl1GuwJokVnrlrei+LHbb1mSdTuk6MT006JCs8r
|
||||
R1GivzHXgCv9fdLN1IkCQHxRvv9RPND80eEkdMv4qu0s22OLRhLQ/pb+YeT5Cjjv
|
||||
pJYWKrvXdRZcuNde9JiiTgK2UW1wM8KeD/EGvK2yF6M=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
45
pkg/adaptors/kubeconfig/kubeconfig.go
Normal file
45
pkg/adaptors/kubeconfig/kubeconfig.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_kubeconfig/mock_kubeconfig.go github.com/int128/kubelogin/pkg/adaptors/kubeconfig Interface
|
||||
|
||||
// Set provides an implementation and interface for Kubeconfig.
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Kubeconfig), "*"),
|
||||
wire.Bind(new(Interface), new(*Kubeconfig)),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
GetCurrentAuthProvider(explicitFilename string, contextName ContextName, userName UserName) (*AuthProvider, error)
|
||||
UpdateAuthProvider(auth *AuthProvider) error
|
||||
}
|
||||
|
||||
// ContextName represents name of a context.
|
||||
type ContextName string
|
||||
|
||||
// UserName represents name of a user.
|
||||
type UserName string
|
||||
|
||||
// AuthProvider represents the authentication provider,
|
||||
// i.e. context, user and auth-provider in a kubeconfig.
|
||||
type AuthProvider struct {
|
||||
LocationOfOrigin string // Path to the kubeconfig file which contains the user
|
||||
UserName UserName // User name
|
||||
ContextName ContextName // (optional) Context name
|
||||
IDPIssuerURL string // idp-issuer-url
|
||||
ClientID string // client-id
|
||||
ClientSecret string // (optional) client-secret
|
||||
IDPCertificateAuthority string // (optional) idp-certificate-authority
|
||||
IDPCertificateAuthorityData string // (optional) idp-certificate-authority-data
|
||||
ExtraScopes []string // (optional) extra-scopes
|
||||
IDToken string // (optional) id-token
|
||||
RefreshToken string // (optional) refresh-token
|
||||
}
|
||||
|
||||
type Kubeconfig struct {
|
||||
Logger logger.Interface
|
||||
}
|
||||
80
pkg/adaptors/kubeconfig/load.go
Normal file
80
pkg/adaptors/kubeconfig/load.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
func (*Kubeconfig) GetCurrentAuthProvider(explicitFilename string, contextName ContextName, userName UserName) (*AuthProvider, error) {
|
||||
config, err := loadByDefaultRules(explicitFilename)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not load kubeconfig: %w", err)
|
||||
}
|
||||
auth, err := findCurrentAuthProvider(config, contextName, userName)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not find the current auth provider: %w", err)
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func loadByDefaultRules(explicitFilename string) (*api.Config, error) {
|
||||
rules := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||
rules.ExplicitPath = explicitFilename
|
||||
config, err := rules.Load()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("error while loading config: %w", err)
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
|
||||
// findCurrentAuthProvider resolves the current auth provider.
|
||||
// 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 findCurrentAuthProvider(config *api.Config, contextName ContextName, userName UserName) (*AuthProvider, error) {
|
||||
if userName == "" {
|
||||
if contextName == "" {
|
||||
contextName = ContextName(config.CurrentContext)
|
||||
}
|
||||
contextNode, ok := config.Contexts[string(contextName)]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("context %s does not exist", contextName)
|
||||
}
|
||||
userName = UserName(contextNode.AuthInfo)
|
||||
}
|
||||
userNode, ok := config.AuthInfos[string(userName)]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("user %s does not exist", userName)
|
||||
}
|
||||
if userNode.AuthProvider == nil {
|
||||
return nil, xerrors.New("auth-provider is missing")
|
||||
}
|
||||
if userNode.AuthProvider.Name != "oidc" {
|
||||
return nil, xerrors.Errorf("auth-provider.name must be oidc but is %s", userNode.AuthProvider.Name)
|
||||
}
|
||||
if userNode.AuthProvider.Config == nil {
|
||||
return nil, xerrors.New("auth-provider.config is missing")
|
||||
}
|
||||
|
||||
m := userNode.AuthProvider.Config
|
||||
var extraScopes []string
|
||||
if m["extra-scopes"] != "" {
|
||||
extraScopes = strings.Split(m["extra-scopes"], ",")
|
||||
}
|
||||
return &AuthProvider{
|
||||
LocationOfOrigin: userNode.LocationOfOrigin,
|
||||
UserName: userName,
|
||||
ContextName: contextName,
|
||||
IDPIssuerURL: m["idp-issuer-url"],
|
||||
ClientID: m["client-id"],
|
||||
ClientSecret: m["client-secret"],
|
||||
IDPCertificateAuthority: m["idp-certificate-authority"],
|
||||
IDPCertificateAuthorityData: m["idp-certificate-authority-data"],
|
||||
ExtraScopes: extraScopes,
|
||||
IDToken: m["id-token"],
|
||||
RefreshToken: m["refresh-token"],
|
||||
}, nil
|
||||
}
|
||||
218
pkg/adaptors/kubeconfig/load_test.go
Normal file
218
pkg/adaptors/kubeconfig/load_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
func Test_loadByDefaultRules(t *testing.T) {
|
||||
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 := 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 := 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)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_findCurrentAuthProvider(t *testing.T) {
|
||||
t.Run("CurrentContext", func(t *testing.T) {
|
||||
got, err := findCurrentAuthProvider(&api.Config{
|
||||
CurrentContext: "theContext",
|
||||
Contexts: map[string]*api.Context{
|
||||
"theContext": {
|
||||
AuthInfo: "theUser",
|
||||
},
|
||||
},
|
||||
AuthInfos: map[string]*api.AuthInfo{
|
||||
"theUser": {
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
AuthProvider: &api.AuthProviderConfig{
|
||||
Name: "oidc",
|
||||
Config: map[string]string{
|
||||
"idp-issuer-url": "https://accounts.google.com",
|
||||
"client-id": "GOOGLE_CLIENT_ID",
|
||||
"client-secret": "GOOGLE_CLIENT_SECRET",
|
||||
"idp-certificate-authority": "/path/to/cert",
|
||||
"idp-certificate-authority-data": "BASE64",
|
||||
"extra-scopes": "email,profile",
|
||||
"id-token": "YOUR_ID_TOKEN",
|
||||
"refresh-token": "YOUR_REFRESH_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not find the current auth: %s", err)
|
||||
}
|
||||
want := &AuthProvider{
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
UserName: "theUser",
|
||||
ContextName: "theContext",
|
||||
IDPIssuerURL: "https://accounts.google.com",
|
||||
ClientID: "GOOGLE_CLIENT_ID",
|
||||
ClientSecret: "GOOGLE_CLIENT_SECRET",
|
||||
IDPCertificateAuthority: "/path/to/cert",
|
||||
IDPCertificateAuthorityData: "BASE64",
|
||||
ExtraScopes: []string{"email", "profile"},
|
||||
IDToken: "YOUR_ID_TOKEN",
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ByContextName", func(t *testing.T) {
|
||||
got, err := findCurrentAuthProvider(&api.Config{
|
||||
Contexts: map[string]*api.Context{
|
||||
"theContext": {
|
||||
AuthInfo: "theUser",
|
||||
},
|
||||
},
|
||||
AuthInfos: map[string]*api.AuthInfo{
|
||||
"theUser": {
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
AuthProvider: &api.AuthProviderConfig{
|
||||
Name: "oidc",
|
||||
Config: map[string]string{
|
||||
"idp-issuer-url": "https://accounts.google.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "theContext", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not find the current auth: %s", err)
|
||||
}
|
||||
want := &AuthProvider{
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
UserName: "theUser",
|
||||
ContextName: "theContext",
|
||||
IDPIssuerURL: "https://accounts.google.com",
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ByUserName", func(t *testing.T) {
|
||||
got, err := findCurrentAuthProvider(&api.Config{
|
||||
AuthInfos: map[string]*api.AuthInfo{
|
||||
"theUser": {
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
AuthProvider: &api.AuthProviderConfig{
|
||||
Name: "oidc",
|
||||
Config: map[string]string{
|
||||
"idp-issuer-url": "https://accounts.google.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "", "theUser")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not find the current auth: %s", err)
|
||||
}
|
||||
want := &AuthProvider{
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
UserName: "theUser",
|
||||
IDPIssuerURL: "https://accounts.google.com",
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoConfig", func(t *testing.T) {
|
||||
_, err := findCurrentAuthProvider(&api.Config{
|
||||
AuthInfos: map[string]*api.AuthInfo{
|
||||
"theUser": {
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
AuthProvider: &api.AuthProviderConfig{
|
||||
Name: "oidc",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "", "theUser")
|
||||
if err == nil {
|
||||
t.Fatalf("wants error but nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotOIDC", func(t *testing.T) {
|
||||
_, err := findCurrentAuthProvider(&api.Config{
|
||||
AuthInfos: map[string]*api.AuthInfo{
|
||||
"theUser": {
|
||||
LocationOfOrigin: "/path/to/kubeconfig",
|
||||
AuthProvider: &api.AuthProviderConfig{
|
||||
Name: "some",
|
||||
Config: map[string]string{"foo": "bar"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "", "theUser")
|
||||
if err == nil {
|
||||
t.Fatalf("wants error but nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
63
pkg/adaptors/kubeconfig/mock_kubeconfig/mock_kubeconfig.go
Normal file
63
pkg/adaptors/kubeconfig/mock_kubeconfig/mock_kubeconfig.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/pkg/adaptors/kubeconfig (interfaces: Interface)
|
||||
|
||||
// Package mock_kubeconfig is a generated GoMock package.
|
||||
package mock_kubeconfig
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
kubeconfig "github.com/int128/kubelogin/pkg/adaptors/kubeconfig"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
|
||||
mock := &MockInterface{ctrl: ctrl}
|
||||
mock.recorder = &MockInterfaceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetCurrentAuthProvider mocks base method
|
||||
func (m *MockInterface) GetCurrentAuthProvider(arg0 string, arg1 kubeconfig.ContextName, arg2 kubeconfig.UserName) (*kubeconfig.AuthProvider, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetCurrentAuthProvider", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*kubeconfig.AuthProvider)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetCurrentAuthProvider indicates an expected call of GetCurrentAuthProvider
|
||||
func (mr *MockInterfaceMockRecorder) GetCurrentAuthProvider(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentAuthProvider", reflect.TypeOf((*MockInterface)(nil).GetCurrentAuthProvider), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// UpdateAuthProvider mocks base method
|
||||
func (m *MockInterface) UpdateAuthProvider(arg0 *kubeconfig.AuthProvider) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateAuthProvider", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdateAuthProvider indicates an expected call of UpdateAuthProvider
|
||||
func (mr *MockInterfaceMockRecorder) UpdateAuthProvider(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthProvider", reflect.TypeOf((*MockInterface)(nil).UpdateAuthProvider), arg0)
|
||||
}
|
||||
17
pkg/adaptors/kubeconfig/testdata/kubeconfig.google.yaml
vendored
Normal file
17
pkg/adaptors/kubeconfig/testdata/kubeconfig.google.yaml
vendored
Normal 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
|
||||
16
pkg/adaptors/kubeconfig/testdata/kubeconfig.keycloak.yaml
vendored
Normal file
16
pkg/adaptors/kubeconfig/testdata/kubeconfig.keycloak.yaml
vendored
Normal 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
|
||||
50
pkg/adaptors/kubeconfig/write.go
Normal file
50
pkg/adaptors/kubeconfig/write.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
func (*Kubeconfig) UpdateAuthProvider(p *AuthProvider) error {
|
||||
config, err := clientcmd.LoadFromFile(p.LocationOfOrigin)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not load %s: %w", p.LocationOfOrigin, err)
|
||||
}
|
||||
userNode, ok := config.AuthInfos[string(p.UserName)]
|
||||
if !ok {
|
||||
return xerrors.Errorf("user %s does not exist", p.UserName)
|
||||
}
|
||||
if userNode.AuthProvider == nil {
|
||||
return xerrors.Errorf("auth-provider is missing")
|
||||
}
|
||||
if userNode.AuthProvider.Name != "oidc" {
|
||||
return xerrors.Errorf("auth-provider must be oidc but is %s", userNode.AuthProvider.Name)
|
||||
}
|
||||
copyAuthProviderConfig(p, userNode.AuthProvider.Config)
|
||||
if err := clientcmd.WriteToFile(*config, p.LocationOfOrigin); err != nil {
|
||||
return xerrors.Errorf("could not update %s: %w", p.LocationOfOrigin, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyAuthProviderConfig(p *AuthProvider, m map[string]string) {
|
||||
setOrDeleteKey(m, "idp-issuer-url", p.IDPIssuerURL)
|
||||
setOrDeleteKey(m, "client-id", p.ClientID)
|
||||
setOrDeleteKey(m, "client-secret", p.ClientSecret)
|
||||
setOrDeleteKey(m, "idp-certificate-authority", p.IDPCertificateAuthority)
|
||||
setOrDeleteKey(m, "idp-certificate-authority-data", p.IDPCertificateAuthorityData)
|
||||
extraScopes := strings.Join(p.ExtraScopes, ",")
|
||||
setOrDeleteKey(m, "extra-scopes", extraScopes)
|
||||
setOrDeleteKey(m, "id-token", p.IDToken)
|
||||
setOrDeleteKey(m, "refresh-token", p.RefreshToken)
|
||||
}
|
||||
|
||||
func setOrDeleteKey(m map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
delete(m, key)
|
||||
return
|
||||
}
|
||||
m[key] = value
|
||||
}
|
||||
136
pkg/adaptors/kubeconfig/write_test.go
Normal file
136
pkg/adaptors/kubeconfig/write_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestKubeconfig_UpdateAuth(t *testing.T) {
|
||||
var k Kubeconfig
|
||||
|
||||
t.Run("MinimumKeys", func(t *testing.T) {
|
||||
f := newKubeconfigFile(t)
|
||||
defer func() {
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
t.Errorf("Could not remove the temp file: %s", err)
|
||||
}
|
||||
}()
|
||||
if err := k.UpdateAuthProvider(&AuthProvider{
|
||||
LocationOfOrigin: f.Name(),
|
||||
UserName: "google",
|
||||
IDPIssuerURL: "https://accounts.google.com",
|
||||
ClientID: "GOOGLE_CLIENT_ID",
|
||||
ClientSecret: "GOOGLE_CLIENT_SECRET",
|
||||
IDToken: "YOUR_ID_TOKEN",
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
}); err != nil {
|
||||
t.Fatalf("Could not update auth: %s", err)
|
||||
}
|
||||
b, err := ioutil.ReadFile(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Could not read kubeconfig: %s", err)
|
||||
}
|
||||
|
||||
got := string(b)
|
||||
want := `apiVersion: v1
|
||||
clusters: null
|
||||
contexts: null
|
||||
current-context: ""
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: google
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
client-id: GOOGLE_CLIENT_ID
|
||||
client-secret: GOOGLE_CLIENT_SECRET
|
||||
id-token: YOUR_ID_TOKEN
|
||||
idp-issuer-url: https://accounts.google.com
|
||||
refresh-token: YOUR_REFRESH_TOKEN
|
||||
name: oidc
|
||||
`
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("kubeconfig mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FullKeys", func(t *testing.T) {
|
||||
f := newKubeconfigFile(t)
|
||||
defer func() {
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
t.Errorf("Could not remove the temp file: %s", err)
|
||||
}
|
||||
}()
|
||||
if err := k.UpdateAuthProvider(&AuthProvider{
|
||||
LocationOfOrigin: f.Name(),
|
||||
UserName: "google",
|
||||
IDPIssuerURL: "https://accounts.google.com",
|
||||
ClientID: "GOOGLE_CLIENT_ID",
|
||||
ClientSecret: "GOOGLE_CLIENT_SECRET",
|
||||
IDPCertificateAuthority: "/path/to/cert",
|
||||
IDPCertificateAuthorityData: "BASE64",
|
||||
ExtraScopes: []string{"email", "profile"},
|
||||
IDToken: "YOUR_ID_TOKEN",
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
}); err != nil {
|
||||
t.Fatalf("Could not update auth: %s", err)
|
||||
}
|
||||
b, err := ioutil.ReadFile(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Could not read kubeconfig: %s", err)
|
||||
}
|
||||
|
||||
got := string(b)
|
||||
want := `apiVersion: v1
|
||||
clusters: null
|
||||
contexts: null
|
||||
current-context: ""
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: google
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
client-id: GOOGLE_CLIENT_ID
|
||||
client-secret: GOOGLE_CLIENT_SECRET
|
||||
extra-scopes: email,profile
|
||||
id-token: YOUR_ID_TOKEN
|
||||
idp-certificate-authority: /path/to/cert
|
||||
idp-certificate-authority-data: BASE64
|
||||
idp-issuer-url: https://accounts.google.com
|
||||
refresh-token: YOUR_REFRESH_TOKEN
|
||||
name: oidc
|
||||
`
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("kubeconfig mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newKubeconfigFile(t *testing.T) *os.File {
|
||||
content := `apiVersion: v1
|
||||
clusters: []
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: google
|
||||
user:
|
||||
auth-provider:
|
||||
config:
|
||||
idp-issuer-url: https://accounts.google.com
|
||||
name: oidc`
|
||||
f, err := ioutil.TempFile("", "kubeconfig")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create a file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("Could not write kubeconfig: %s", err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
60
pkg/adaptors/logger/logger.go
Normal file
60
pkg/adaptors/logger/logger.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/spf13/pflag"
|
||||
"k8s.io/klog"
|
||||
)
|
||||
|
||||
// Set provides an implementation and interface for Logger.
|
||||
var Set = wire.NewSet(
|
||||
New,
|
||||
)
|
||||
|
||||
// New returns a Logger with the standard log.Logger and klog.
|
||||
func New() Interface {
|
||||
return &Logger{
|
||||
goLogger: log.New(os.Stderr, "", 0),
|
||||
}
|
||||
}
|
||||
|
||||
type Interface interface {
|
||||
AddFlags(f *pflag.FlagSet)
|
||||
Printf(format string, args ...interface{})
|
||||
V(level int) Verbose
|
||||
IsEnabled(level int) bool
|
||||
}
|
||||
|
||||
type Verbose interface {
|
||||
Infof(format string, args ...interface{})
|
||||
}
|
||||
|
||||
type goLogger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// Logger provides logging facility using log.Logger and klog.
|
||||
type Logger struct {
|
||||
goLogger
|
||||
}
|
||||
|
||||
// AddFlags adds the flags such as -v.
|
||||
func (*Logger) AddFlags(f *pflag.FlagSet) {
|
||||
gf := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
klog.InitFlags(gf)
|
||||
f.AddGoFlagSet(gf)
|
||||
}
|
||||
|
||||
// V returns a logger enabled only if the level is enabled.
|
||||
func (*Logger) V(level int) Verbose {
|
||||
return klog.V(klog.Level(level))
|
||||
}
|
||||
|
||||
// IsEnabled returns true if the level is enabled.
|
||||
func (*Logger) IsEnabled(level int) bool {
|
||||
return bool(klog.V(klog.Level(level)))
|
||||
}
|
||||
46
pkg/adaptors/logger/mock_logger/logger.go
Normal file
46
pkg/adaptors/logger/mock_logger/logger.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package mock_logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func New(t testingLogger) *Logger {
|
||||
return &Logger{t}
|
||||
}
|
||||
|
||||
type testingLogger interface {
|
||||
Logf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// Logger provides logging facility using testing.T.
|
||||
type Logger struct {
|
||||
t testingLogger
|
||||
}
|
||||
|
||||
func (*Logger) AddFlags(f *pflag.FlagSet) {
|
||||
f.IntP("v", "v", 0, "dummy flag used in the tests")
|
||||
}
|
||||
|
||||
func (l *Logger) Printf(format string, args ...interface{}) {
|
||||
l.t.Logf(format, args...)
|
||||
}
|
||||
|
||||
type Verbose struct {
|
||||
t testingLogger
|
||||
level int
|
||||
}
|
||||
|
||||
func (v *Verbose) Infof(format string, args ...interface{}) {
|
||||
v.t.Logf(fmt.Sprintf("I%d] ", v.level)+format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) V(level int) logger.Verbose {
|
||||
return &Verbose{l.t, level}
|
||||
}
|
||||
|
||||
func (*Logger) IsEnabled(level int) bool {
|
||||
return true
|
||||
}
|
||||
63
pkg/adaptors/oidcclient/factory.go
Normal file
63
pkg/adaptors/oidcclient/factory.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Package oidcclient provides a client of OpenID Connect.
|
||||
package oidcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/certpool"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient/logging"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
type NewFunc func(ctx context.Context, config Config) (Interface, error)
|
||||
|
||||
// Config represents a configuration of OpenID Connect client.
|
||||
type Config struct {
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
ExtraScopes []string // optional
|
||||
CertPool certpool.Interface
|
||||
SkipTLSVerify bool
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
// New returns an instance of adaptors.Interface with the given configuration.
|
||||
func New(ctx context.Context, config Config) (Interface, error) {
|
||||
var tlsConfig tls.Config
|
||||
tlsConfig.InsecureSkipVerify = config.SkipTLSVerify
|
||||
config.CertPool.SetRootCAs(&tlsConfig)
|
||||
baseTransport := &http.Transport{
|
||||
TLSClientConfig: &tlsConfig,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
loggingTransport := &logging.Transport{
|
||||
Base: baseTransport,
|
||||
Logger: config.Logger,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: loggingTransport,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
provider, err := oidc.NewProvider(ctx, config.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not discovery the issuer: %w", err)
|
||||
}
|
||||
return &client{
|
||||
httpClient: httpClient,
|
||||
provider: provider,
|
||||
oauth2Config: oauth2.Config{
|
||||
Endpoint: provider.Endpoint(),
|
||||
ClientID: config.ClientID,
|
||||
ClientSecret: config.ClientSecret,
|
||||
Scopes: append(config.ExtraScopes, oidc.ScopeOpenID),
|
||||
},
|
||||
logger: config.Logger,
|
||||
}, nil
|
||||
}
|
||||
42
pkg/adaptors/oidcclient/logging/transport.go
Normal file
42
pkg/adaptors/oidcclient/logging/transport.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
levelDumpHeaders = 2
|
||||
levelDumpBody = 3
|
||||
)
|
||||
|
||||
type Transport struct {
|
||||
Base http.RoundTripper
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if !t.Logger.IsEnabled(levelDumpHeaders) {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
|
||||
reqDump, err := httputil.DumpRequestOut(req, t.Logger.IsEnabled(levelDumpBody))
|
||||
if err != nil {
|
||||
t.Logger.V(levelDumpHeaders).Infof("could not dump the request: %s", err)
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
t.Logger.V(levelDumpHeaders).Infof("%s", string(reqDump))
|
||||
resp, err := t.Base.RoundTrip(req)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
respDump, err := httputil.DumpResponse(resp, t.Logger.IsEnabled(levelDumpBody))
|
||||
if err != nil {
|
||||
t.Logger.V(levelDumpHeaders).Infof("could not dump the response: %s", err)
|
||||
return resp, err
|
||||
}
|
||||
t.Logger.V(levelDumpHeaders).Infof("%s", string(respDump))
|
||||
return resp, err
|
||||
}
|
||||
49
pkg/adaptors/oidcclient/logging/transport_test.go
Normal file
49
pkg/adaptors/oidcclient/logging/transport_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger/mock_logger"
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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 := &Transport{
|
||||
Base: &mockTransport{resp: resp},
|
||||
Logger: mock_logger.New(t),
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
109
pkg/adaptors/oidcclient/mock_oidcclient/mock_oidcclient.go
Normal file
109
pkg/adaptors/oidcclient/mock_oidcclient/mock_oidcclient.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/pkg/adaptors/oidcclient (interfaces: Interface)
|
||||
|
||||
// Package mock_oidcclient is a generated GoMock package.
|
||||
package mock_oidcclient
|
||||
|
||||
import (
|
||||
context "context"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
oidcclient "github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
|
||||
mock := &MockInterface{ctrl: ctrl}
|
||||
mock.recorder = &MockInterfaceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// ExchangeAuthCode mocks base method
|
||||
func (m *MockInterface) ExchangeAuthCode(arg0 context.Context, arg1 oidcclient.ExchangeAuthCodeInput) (*oidcclient.TokenSet, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ExchangeAuthCode", arg0, arg1)
|
||||
ret0, _ := ret[0].(*oidcclient.TokenSet)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ExchangeAuthCode indicates an expected call of ExchangeAuthCode
|
||||
func (mr *MockInterfaceMockRecorder) ExchangeAuthCode(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExchangeAuthCode", reflect.TypeOf((*MockInterface)(nil).ExchangeAuthCode), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetAuthCodeURL mocks base method
|
||||
func (m *MockInterface) GetAuthCodeURL(arg0 oidcclient.AuthCodeURLInput) string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAuthCodeURL", arg0)
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetAuthCodeURL indicates an expected call of GetAuthCodeURL
|
||||
func (mr *MockInterfaceMockRecorder) GetAuthCodeURL(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthCodeURL", reflect.TypeOf((*MockInterface)(nil).GetAuthCodeURL), arg0)
|
||||
}
|
||||
|
||||
// GetTokenByAuthCode mocks base method
|
||||
func (m *MockInterface) GetTokenByAuthCode(arg0 context.Context, arg1 oidcclient.GetTokenByAuthCodeInput, arg2 chan<- string) (*oidcclient.TokenSet, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTokenByAuthCode", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*oidcclient.TokenSet)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTokenByAuthCode indicates an expected call of GetTokenByAuthCode
|
||||
func (mr *MockInterfaceMockRecorder) GetTokenByAuthCode(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTokenByAuthCode", reflect.TypeOf((*MockInterface)(nil).GetTokenByAuthCode), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// GetTokenByROPC mocks base method
|
||||
func (m *MockInterface) GetTokenByROPC(arg0 context.Context, arg1, arg2 string) (*oidcclient.TokenSet, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTokenByROPC", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*oidcclient.TokenSet)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTokenByROPC indicates an expected call of GetTokenByROPC
|
||||
func (mr *MockInterfaceMockRecorder) GetTokenByROPC(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTokenByROPC", reflect.TypeOf((*MockInterface)(nil).GetTokenByROPC), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// Refresh mocks base method
|
||||
func (m *MockInterface) Refresh(arg0 context.Context, arg1 string) (*oidcclient.TokenSet, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Refresh", arg0, arg1)
|
||||
ret0, _ := ret[0].(*oidcclient.TokenSet)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Refresh indicates an expected call of Refresh
|
||||
func (mr *MockInterfaceMockRecorder) Refresh(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockInterface)(nil).Refresh), arg0, arg1)
|
||||
}
|
||||
198
pkg/adaptors/oidcclient/oidcclient.go
Normal file
198
pkg/adaptors/oidcclient/oidcclient.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package oidcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
oidcModel "github.com/int128/kubelogin/pkg/domain/oidc"
|
||||
"github.com/int128/oauth2cli"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_oidcclient/mock_oidcclient.go github.com/int128/kubelogin/pkg/adaptors/oidcclient Interface
|
||||
|
||||
var Set = wire.NewSet(
|
||||
wire.Value(NewFunc(New)),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
GetAuthCodeURL(in AuthCodeURLInput) string
|
||||
ExchangeAuthCode(ctx context.Context, in ExchangeAuthCodeInput) (*TokenSet, error)
|
||||
GetTokenByAuthCode(ctx context.Context, in GetTokenByAuthCodeInput, localServerReadyChan chan<- string) (*TokenSet, error)
|
||||
GetTokenByROPC(ctx context.Context, username, password string) (*TokenSet, error)
|
||||
Refresh(ctx context.Context, refreshToken string) (*TokenSet, error)
|
||||
}
|
||||
|
||||
type AuthCodeURLInput struct {
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
RedirectURI string
|
||||
}
|
||||
|
||||
type ExchangeAuthCodeInput struct {
|
||||
Code string
|
||||
CodeVerifier string
|
||||
Nonce string
|
||||
RedirectURI string
|
||||
}
|
||||
|
||||
type GetTokenByAuthCodeInput struct {
|
||||
BindAddress []string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
CodeVerifier string
|
||||
}
|
||||
|
||||
// TokenSet represents an output DTO of
|
||||
// Interface.GetTokenByAuthCode, Interface.GetTokenByROPC and Interface.Refresh.
|
||||
type TokenSet struct {
|
||||
IDToken string
|
||||
RefreshToken string
|
||||
IDTokenClaims oidcModel.Claims
|
||||
}
|
||||
|
||||
type client struct {
|
||||
httpClient *http.Client
|
||||
provider *oidc.Provider
|
||||
oauth2Config oauth2.Config
|
||||
logger logger.Interface
|
||||
}
|
||||
|
||||
func (c *client) wrapContext(ctx context.Context) context.Context {
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// GetTokenByAuthCode performs the authorization code flow.
|
||||
func (c *client) GetTokenByAuthCode(ctx context.Context, in GetTokenByAuthCodeInput, localServerReadyChan chan<- string) (*TokenSet, error) {
|
||||
ctx = c.wrapContext(ctx)
|
||||
config := oauth2cli.Config{
|
||||
OAuth2Config: c.oauth2Config,
|
||||
AuthCodeOptions: []oauth2.AuthCodeOption{
|
||||
oauth2.AccessTypeOffline,
|
||||
oidc.Nonce(in.Nonce),
|
||||
oauth2.SetAuthURLParam("code_challenge", in.CodeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", in.CodeChallengeMethod),
|
||||
},
|
||||
TokenRequestOptions: []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("code_verifier", in.CodeVerifier),
|
||||
},
|
||||
LocalServerBindAddress: in.BindAddress,
|
||||
LocalServerReadyChan: localServerReadyChan,
|
||||
}
|
||||
token, err := oauth2cli.GetToken(ctx, config)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not get a token: %w", err)
|
||||
}
|
||||
return c.verifyToken(ctx, token, in.Nonce)
|
||||
}
|
||||
|
||||
// GetAuthCodeURL returns the URL of authentication request for the authorization code flow.
|
||||
func (c *client) GetAuthCodeURL(in AuthCodeURLInput) string {
|
||||
cfg := c.oauth2Config
|
||||
cfg.RedirectURL = in.RedirectURI
|
||||
return cfg.AuthCodeURL(in.State,
|
||||
oauth2.AccessTypeOffline,
|
||||
oidc.Nonce(in.Nonce),
|
||||
oauth2.SetAuthURLParam("code_challenge", in.CodeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", in.CodeChallengeMethod),
|
||||
)
|
||||
}
|
||||
|
||||
// ExchangeAuthCode exchanges the authorization code and token.
|
||||
func (c *client) ExchangeAuthCode(ctx context.Context, in ExchangeAuthCodeInput) (*TokenSet, error) {
|
||||
ctx = c.wrapContext(ctx)
|
||||
cfg := c.oauth2Config
|
||||
cfg.RedirectURL = in.RedirectURI
|
||||
token, err := cfg.Exchange(ctx, in.Code, oauth2.SetAuthURLParam("code_verifier", in.CodeVerifier))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not exchange the authorization code: %w", err)
|
||||
}
|
||||
return c.verifyToken(ctx, token, in.Nonce)
|
||||
}
|
||||
|
||||
// GetTokenByROPC performs the resource owner password credentials flow.
|
||||
func (c *client) GetTokenByROPC(ctx context.Context, username, password string) (*TokenSet, error) {
|
||||
ctx = c.wrapContext(ctx)
|
||||
token, err := c.oauth2Config.PasswordCredentialsToken(ctx, username, password)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not get a token: %w", err)
|
||||
}
|
||||
return c.verifyToken(ctx, token, "")
|
||||
}
|
||||
|
||||
// Refresh sends a refresh token request and returns a token set.
|
||||
func (c *client) Refresh(ctx context.Context, refreshToken string) (*TokenSet, error) {
|
||||
ctx = c.wrapContext(ctx)
|
||||
currentToken := &oauth2.Token{
|
||||
Expiry: time.Now(),
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
source := c.oauth2Config.TokenSource(ctx, currentToken)
|
||||
token, err := source.Token()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not refresh the token: %w", err)
|
||||
}
|
||||
return c.verifyToken(ctx, token, "")
|
||||
}
|
||||
|
||||
// verifyToken verifies the token with the certificates of the provider and the nonce.
|
||||
// If the nonce is an empty string, it does not verify the nonce.
|
||||
func (c *client) verifyToken(ctx context.Context, token *oauth2.Token, nonce string) (*TokenSet, error) {
|
||||
idTokenString, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("id_token is missing in the token response: %s", token)
|
||||
}
|
||||
verifier := c.provider.Verifier(&oidc.Config{ClientID: c.oauth2Config.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, idTokenString)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not verify the ID token: %w", err)
|
||||
}
|
||||
if nonce != "" && nonce != idToken.Nonce {
|
||||
return nil, xerrors.Errorf("nonce did not match (wants %s but was %s)", nonce, idToken.Nonce)
|
||||
}
|
||||
claims, err := dumpClaims(idToken)
|
||||
if err != nil {
|
||||
c.logger.V(1).Infof("incomplete claims of the ID token: %w", err)
|
||||
}
|
||||
return &TokenSet{
|
||||
IDToken: idTokenString,
|
||||
IDTokenClaims: claims,
|
||||
RefreshToken: token.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dumpClaims(token *oidc.IDToken) (oidcModel.Claims, error) {
|
||||
var rawClaims map[string]interface{}
|
||||
err := token.Claims(&rawClaims)
|
||||
pretty := dumpRawClaims(rawClaims)
|
||||
return oidcModel.Claims{
|
||||
Subject: token.Subject,
|
||||
Expiry: token.Expiry,
|
||||
Pretty: pretty,
|
||||
}, err
|
||||
}
|
||||
|
||||
func dumpRawClaims(rawClaims map[string]interface{}) map[string]string {
|
||||
claims := make(map[string]string)
|
||||
for k, v := range rawClaims {
|
||||
switch v := v.(type) {
|
||||
case float64:
|
||||
claims[k] = fmt.Sprintf("%.f", v)
|
||||
default:
|
||||
claims[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
63
pkg/adaptors/tokencache/mock_tokencache/mock_tokencache.go
Normal file
63
pkg/adaptors/tokencache/mock_tokencache/mock_tokencache.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/pkg/adaptors/tokencache (interfaces: Interface)
|
||||
|
||||
// Package mock_tokencache is a generated GoMock package.
|
||||
package mock_tokencache
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
tokencache "github.com/int128/kubelogin/pkg/adaptors/tokencache"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
|
||||
mock := &MockInterface{ctrl: ctrl}
|
||||
mock.recorder = &MockInterfaceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// FindByKey mocks base method
|
||||
func (m *MockInterface) FindByKey(arg0 string, arg1 tokencache.Key) (*tokencache.Value, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "FindByKey", arg0, arg1)
|
||||
ret0, _ := ret[0].(*tokencache.Value)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// FindByKey indicates an expected call of FindByKey
|
||||
func (mr *MockInterfaceMockRecorder) FindByKey(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByKey", reflect.TypeOf((*MockInterface)(nil).FindByKey), arg0, arg1)
|
||||
}
|
||||
|
||||
// Save mocks base method
|
||||
func (m *MockInterface) Save(arg0 string, arg1 tokencache.Key, arg2 tokencache.Value) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Save", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Save indicates an expected call of Save
|
||||
func (mr *MockInterfaceMockRecorder) Save(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockInterface)(nil).Save), arg0, arg1, arg2)
|
||||
}
|
||||
96
pkg/adaptors/tokencache/tokencache.go
Normal file
96
pkg/adaptors/tokencache/tokencache.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package tokencache
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/gob"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/wire"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_tokencache/mock_tokencache.go github.com/int128/kubelogin/pkg/adaptors/tokencache Interface
|
||||
|
||||
// Set provides an implementation and interface for Kubeconfig.
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Repository), "*"),
|
||||
wire.Bind(new(Interface), new(*Repository)),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
FindByKey(dir string, key Key) (*Value, error)
|
||||
Save(dir string, key Key, value Value) error
|
||||
}
|
||||
|
||||
// Key represents a key of a token cache.
|
||||
type Key struct {
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
ExtraScopes []string
|
||||
CACertFilename string
|
||||
SkipTLSVerify bool
|
||||
}
|
||||
|
||||
// Value represents a value of a token cache.
|
||||
type Value struct {
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// Repository provides access to the token cache on the local filesystem.
|
||||
// Filename of a token cache is sha256 digest of the issuer, zero-character and client ID.
|
||||
type Repository struct{}
|
||||
|
||||
func (r *Repository) FindByKey(dir string, key Key) (*Value, error) {
|
||||
filename, err := computeFilename(key)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not compute the key: %w", err)
|
||||
}
|
||||
p := filepath.Join(dir, filename)
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not open file %s: %w", p, err)
|
||||
}
|
||||
defer f.Close()
|
||||
d := json.NewDecoder(f)
|
||||
var c Value
|
||||
if err := d.Decode(&c); err != nil {
|
||||
return nil, xerrors.Errorf("could not decode json file %s: %w", p, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Save(dir string, key Key, value Value) error {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return xerrors.Errorf("could not create directory %s: %w", dir, err)
|
||||
}
|
||||
filename, err := computeFilename(key)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not compute the key: %w", err)
|
||||
}
|
||||
p := filepath.Join(dir, filename)
|
||||
f, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not create file %s: %w", p, err)
|
||||
}
|
||||
defer f.Close()
|
||||
e := json.NewEncoder(f)
|
||||
if err := e.Encode(&value); err != nil {
|
||||
return xerrors.Errorf("could not encode json to file %s: %w", p, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func computeFilename(key Key) (string, error) {
|
||||
s := sha256.New()
|
||||
e := gob.NewEncoder(s)
|
||||
if err := e.Encode(&key); err != nil {
|
||||
return "", xerrors.Errorf("could not encode the key: %w", err)
|
||||
}
|
||||
h := hex.EncodeToString(s.Sum(nil))
|
||||
return h, nil
|
||||
}
|
||||
97
pkg/adaptors/tokencache/tokencache_test.go
Normal file
97
pkg/adaptors/tokencache/tokencache_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package tokencache
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestRepository_FindByKey(t *testing.T) {
|
||||
var r Repository
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
dir, err := ioutil.TempDir("", "kube")
|
||||
if err != nil {
|
||||
t.Fatalf("could not create a temp dir: %s", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Errorf("could not clean up the temp dir: %s", err)
|
||||
}
|
||||
}()
|
||||
key := Key{
|
||||
IssuerURL: "YOUR_ISSUER",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||
ExtraScopes: []string{"openid", "email"},
|
||||
CACertFilename: "/path/to/cert",
|
||||
SkipTLSVerify: false,
|
||||
}
|
||||
json := `{"id_token":"YOUR_ID_TOKEN","refresh_token":"YOUR_REFRESH_TOKEN"}`
|
||||
filename, err := computeFilename(key)
|
||||
if err != nil {
|
||||
t.Errorf("could not compute the key: %s", err)
|
||||
}
|
||||
p := filepath.Join(dir, filename)
|
||||
if err := ioutil.WriteFile(p, []byte(json), 0600); err != nil {
|
||||
t.Fatalf("could not write to the temp file: %s", err)
|
||||
}
|
||||
|
||||
value, err := r.FindByKey(dir, key)
|
||||
if err != nil {
|
||||
t.Errorf("err wants nil but %+v", err)
|
||||
}
|
||||
want := &Value{IDToken: "YOUR_ID_TOKEN", RefreshToken: "YOUR_REFRESH_TOKEN"}
|
||||
if diff := cmp.Diff(want, value); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRepository_Save(t *testing.T) {
|
||||
var r Repository
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
dir, err := ioutil.TempDir("", "kube")
|
||||
if err != nil {
|
||||
t.Fatalf("could not create a temp dir: %s", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Errorf("could not clean up the temp dir: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
key := Key{
|
||||
IssuerURL: "YOUR_ISSUER",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||
ExtraScopes: []string{"openid", "email"},
|
||||
CACertFilename: "/path/to/cert",
|
||||
SkipTLSVerify: false,
|
||||
}
|
||||
value := Value{IDToken: "YOUR_ID_TOKEN", RefreshToken: "YOUR_REFRESH_TOKEN"}
|
||||
if err := r.Save(dir, key, value); err != nil {
|
||||
t.Errorf("err wants nil but %+v", err)
|
||||
}
|
||||
|
||||
filename, err := computeFilename(key)
|
||||
if err != nil {
|
||||
t.Errorf("could not compute the key: %s", err)
|
||||
}
|
||||
p := filepath.Join(dir, filename)
|
||||
b, err := ioutil.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("could not read the token cache file: %s", err)
|
||||
}
|
||||
want := `{"id_token":"YOUR_ID_TOKEN","refresh_token":"YOUR_REFRESH_TOKEN"}
|
||||
`
|
||||
got := string(b)
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
55
pkg/di/di.go
Normal file
55
pkg/di/di.go
Normal file
@@ -0,0 +1,55 @@
|
||||
//+build wireinject
|
||||
|
||||
// Package di provides dependency injection.
|
||||
package di
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/certpool"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/cmd"
|
||||
credentialPluginAdaptor "github.com/int128/kubelogin/pkg/adaptors/credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/env"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/jwtdecoder"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/kubeconfig"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/tokencache"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
credentialPluginUseCase "github.com/int128/kubelogin/pkg/usecases/credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/usecases/setup"
|
||||
"github.com/int128/kubelogin/pkg/usecases/standalone"
|
||||
)
|
||||
|
||||
// NewCmd returns an instance of adaptors.Cmd.
|
||||
func NewCmd() cmd.Interface {
|
||||
wire.Build(
|
||||
NewCmdForHeadless,
|
||||
|
||||
// dependencies for production
|
||||
logger.Set,
|
||||
wire.Value(authentication.DefaultLocalServerReadyFunc),
|
||||
credentialPluginAdaptor.Set,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCmdForHeadless returns an instance of adaptors.Cmd for headless testing.
|
||||
func NewCmdForHeadless(logger.Interface, authentication.LocalServerReadyFunc, credentialPluginAdaptor.Interface) cmd.Interface {
|
||||
wire.Build(
|
||||
// use-cases
|
||||
authentication.Set,
|
||||
standalone.Set,
|
||||
credentialPluginUseCase.Set,
|
||||
setup.Set,
|
||||
|
||||
// adaptors
|
||||
cmd.Set,
|
||||
env.Set,
|
||||
kubeconfig.Set,
|
||||
tokencache.Set,
|
||||
oidcclient.Set,
|
||||
jwtdecoder.Set,
|
||||
certpool.Set,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
110
pkg/di/wire_gen.go
Normal file
110
pkg/di/wire_gen.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate wire
|
||||
//+build !wireinject
|
||||
|
||||
package di
|
||||
|
||||
import (
|
||||
"github.com/int128/kubelogin/pkg/adaptors/certpool"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/cmd"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/env"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/jwtdecoder"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/kubeconfig"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/tokencache"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
credentialplugin2 "github.com/int128/kubelogin/pkg/usecases/credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/usecases/setup"
|
||||
"github.com/int128/kubelogin/pkg/usecases/standalone"
|
||||
)
|
||||
|
||||
// Injectors from di.go:
|
||||
|
||||
func NewCmd() cmd.Interface {
|
||||
loggerInterface := logger.New()
|
||||
localServerReadyFunc := _wireLocalServerReadyFuncValue
|
||||
interaction := &credentialplugin.Interaction{}
|
||||
cmdInterface := NewCmdForHeadless(loggerInterface, localServerReadyFunc, interaction)
|
||||
return cmdInterface
|
||||
}
|
||||
|
||||
var (
|
||||
_wireLocalServerReadyFuncValue = authentication.DefaultLocalServerReadyFunc
|
||||
)
|
||||
|
||||
func NewCmdForHeadless(loggerInterface logger.Interface, localServerReadyFunc authentication.LocalServerReadyFunc, credentialpluginInterface credentialplugin.Interface) cmd.Interface {
|
||||
newFunc := _wireNewFuncValue
|
||||
decoder := &jwtdecoder.Decoder{}
|
||||
envEnv := &env.Env{}
|
||||
authCode := &authentication.AuthCode{
|
||||
Env: envEnv,
|
||||
Logger: loggerInterface,
|
||||
LocalServerReadyFunc: localServerReadyFunc,
|
||||
}
|
||||
authCodeKeyboard := &authentication.AuthCodeKeyboard{
|
||||
Env: envEnv,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
ropc := &authentication.ROPC{
|
||||
Env: envEnv,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
authenticationAuthentication := &authentication.Authentication{
|
||||
NewOIDCClient: newFunc,
|
||||
JWTDecoder: decoder,
|
||||
Logger: loggerInterface,
|
||||
Env: envEnv,
|
||||
AuthCode: authCode,
|
||||
AuthCodeKeyboard: authCodeKeyboard,
|
||||
ROPC: ropc,
|
||||
}
|
||||
kubeconfigKubeconfig := &kubeconfig.Kubeconfig{
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
certpoolNewFunc := _wireCertpoolNewFuncValue
|
||||
standaloneStandalone := &standalone.Standalone{
|
||||
Authentication: authenticationAuthentication,
|
||||
Kubeconfig: kubeconfigKubeconfig,
|
||||
NewCertPool: certpoolNewFunc,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
root := &cmd.Root{
|
||||
Standalone: standaloneStandalone,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
repository := &tokencache.Repository{}
|
||||
getToken := &credentialplugin2.GetToken{
|
||||
Authentication: authenticationAuthentication,
|
||||
TokenCacheRepository: repository,
|
||||
NewCertPool: certpoolNewFunc,
|
||||
Interaction: credentialpluginInterface,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
cmdGetToken := &cmd.GetToken{
|
||||
GetToken: getToken,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
setupSetup := &setup.Setup{
|
||||
Authentication: authenticationAuthentication,
|
||||
NewCertPool: certpoolNewFunc,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
cmdSetup := &cmd.Setup{
|
||||
Setup: setupSetup,
|
||||
}
|
||||
cmdCmd := &cmd.Cmd{
|
||||
Root: root,
|
||||
GetToken: cmdGetToken,
|
||||
Setup: cmdSetup,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
return cmdCmd
|
||||
}
|
||||
|
||||
var (
|
||||
_wireNewFuncValue = oidcclient.NewFunc(oidcclient.New)
|
||||
_wireCertpoolNewFuncValue = certpool.NewFunc(certpool.New)
|
||||
)
|
||||
65
pkg/domain/oidc/oidc.go
Normal file
65
pkg/domain/oidc/oidc.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func NewState() (string, error) {
|
||||
b, err := random32()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("could not generate a random: %w", err)
|
||||
}
|
||||
return base64URLEncode(b), nil
|
||||
}
|
||||
|
||||
func NewNonce() (string, error) {
|
||||
b, err := random32()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("could not generate a random: %w", err)
|
||||
}
|
||||
return base64URLEncode(b), nil
|
||||
}
|
||||
|
||||
type PKCEParams struct {
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
CodeVerifier string
|
||||
}
|
||||
|
||||
func NewPKCEParams() (*PKCEParams, error) {
|
||||
b, err := random32()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not generate a random: %w", err)
|
||||
}
|
||||
s := computeS256(b)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func random32() ([]byte, error) {
|
||||
b := make([]byte, 32)
|
||||
if err := binary.Read(rand.Reader, binary.LittleEndian, b); err != nil {
|
||||
return nil, xerrors.Errorf("could not read: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func computeS256(b []byte) PKCEParams {
|
||||
v := base64URLEncode(b)
|
||||
s := sha256.New()
|
||||
_, _ = s.Write([]byte(v))
|
||||
return PKCEParams{
|
||||
CodeChallenge: base64URLEncode(s.Sum(nil)),
|
||||
CodeChallengeMethod: "S256",
|
||||
CodeVerifier: v,
|
||||
}
|
||||
}
|
||||
|
||||
func base64URLEncode(b []byte) string {
|
||||
return strings.TrimRight(base64.URLEncoding.EncodeToString(b), "=")
|
||||
}
|
||||
25
pkg/domain/oidc/oidc_test.go
Normal file
25
pkg/domain/oidc/oidc_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_computeS256(t *testing.T) {
|
||||
// Testdata described at:
|
||||
// https://tools.ietf.org/html/rfc7636#appendix-B
|
||||
b := []byte{
|
||||
116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173,
|
||||
187, 186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83,
|
||||
132, 141, 121,
|
||||
}
|
||||
p := computeS256(b)
|
||||
if want := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; want != p.CodeVerifier {
|
||||
t.Errorf("CodeVerifier wants %s but was %s", want, p.CodeVerifier)
|
||||
}
|
||||
if want := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; want != p.CodeChallenge {
|
||||
t.Errorf("CodeChallenge wants %s but was %s", want, p.CodeChallenge)
|
||||
}
|
||||
if p.CodeChallengeMethod != "S256" {
|
||||
t.Errorf("CodeChallengeMethod wants S256 but was %s", p.CodeChallengeMethod)
|
||||
}
|
||||
}
|
||||
20
pkg/domain/oidc/token.go
Normal file
20
pkg/domain/oidc/token.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package oidc
|
||||
|
||||
import "time"
|
||||
|
||||
// Claims represents claims of an ID token.
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Expiry time.Time
|
||||
Pretty map[string]string // string representation for debug and logging
|
||||
}
|
||||
|
||||
// TimeProvider provides the current time.
|
||||
type TimeProvider interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// IsExpired returns true if the token is expired.
|
||||
func (c *Claims) IsExpired(timeProvider TimeProvider) bool {
|
||||
return c.Expiry.Before(timeProvider.Now())
|
||||
}
|
||||
36
pkg/domain/oidc/token_test.go
Normal file
36
pkg/domain/oidc/token_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/domain/oidc"
|
||||
)
|
||||
|
||||
type timeProvider time.Time
|
||||
|
||||
func (tp timeProvider) Now() time.Time {
|
||||
return time.Time(tp)
|
||||
}
|
||||
|
||||
func TestClaims_IsExpired(t *testing.T) {
|
||||
claims := oidc.Claims{
|
||||
Expiry: time.Date(2019, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
}
|
||||
|
||||
t.Run("Expired", func(t *testing.T) {
|
||||
tp := timeProvider(time.Date(2019, 1, 2, 4, 0, 0, 0, time.UTC))
|
||||
got := claims.IsExpired(tp)
|
||||
if got != true {
|
||||
t.Errorf("IsExpired() wants true but false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotExpired", func(t *testing.T) {
|
||||
tp := timeProvider(time.Date(2019, 1, 2, 0, 0, 0, 0, time.UTC))
|
||||
got := claims.IsExpired(tp)
|
||||
if got != false {
|
||||
t.Errorf("IsExpired() wants false but true")
|
||||
}
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user