mirror of
https://github.com/int128/kubelogin.git
synced 2026-02-28 16:00:19 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b0c101af | ||
|
|
e8161d5a47 | ||
|
|
a3946c7f5f | ||
|
|
6b880febdb | ||
|
|
a51c15aec2 | ||
|
|
77a6b91be8 | ||
|
|
9e27385c0b | ||
|
|
3c50431a09 | ||
|
|
e41fdf3dcd | ||
|
|
dd93a6537d | ||
|
|
822f6c86de | ||
|
|
dd22ccb9c3 | ||
|
|
f6c4a1257d | ||
|
|
a0c62a9ff1 | ||
|
|
0aa3e43e62 | ||
|
|
9028199abb | ||
|
|
c308ccb511 | ||
|
|
ff1aa97d87 | ||
|
|
ca21c6568b | ||
|
|
117a8d35d4 | ||
|
|
5557290105 | ||
|
|
fe85419312 | ||
|
|
d8d810bc0d | ||
|
|
170eeb4ed5 | ||
|
|
01637fbe12 | ||
|
|
e152e95a9f | ||
|
|
bf8eefd045 | ||
|
|
e88138c640 | ||
|
|
9ad520ba22 | ||
|
|
35e8ecab8d | ||
|
|
c5621239e8 | ||
|
|
582ca48092 | ||
|
|
da32d2184d | ||
|
|
3a9768d6de | ||
|
|
16d6fa2fbb | ||
|
|
175275bf3d |
@@ -1,25 +1,17 @@
|
||||
version: 2
|
||||
version: 2.1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
docker:
|
||||
- image: cimg/go:1.14
|
||||
- image: cimg/go:1.14.4
|
||||
steps:
|
||||
- run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.24.0
|
||||
- run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.24.0
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- go-sum-{{ checksum "go.sum" }}
|
||||
- run:
|
||||
command: go get -v github.com/int128/goxzst github.com/int128/ghcp
|
||||
working_directory: .circleci
|
||||
- run: make check
|
||||
- run: bash <(curl -s https://codecov.io/bash)
|
||||
- run: make dist
|
||||
- run: |
|
||||
if [ "$CIRCLE_TAG" ]; then
|
||||
make release
|
||||
fi
|
||||
- save_cache:
|
||||
key: go-sum-{{ checksum "go.sum" }}
|
||||
paths:
|
||||
@@ -27,12 +19,42 @@ jobs:
|
||||
- store_artifacts:
|
||||
path: gotest.log
|
||||
|
||||
crossbuild:
|
||||
macos:
|
||||
xcode: 11.5.0
|
||||
steps:
|
||||
- run: |
|
||||
curl -sSfL https://dl.google.com/go/go1.14.4.darwin-amd64.tar.gz | tar -C /tmp -xz
|
||||
echo 'export PATH="$PATH:/tmp/go/bin:$HOME/go/bin"' >> $BASH_ENV
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- go-macos-{{ checksum "go.sum" }}
|
||||
- run:
|
||||
command: go get -v github.com/int128/goxzst github.com/int128/ghcp
|
||||
working_directory: .circleci
|
||||
- run: make dist
|
||||
- run: |
|
||||
if [ "$CIRCLE_TAG" ]; then
|
||||
make release
|
||||
fi
|
||||
- save_cache:
|
||||
key: go-macos-{{ checksum "go.sum" }}
|
||||
paths:
|
||||
- ~/go/pkg
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
all:
|
||||
build:
|
||||
jobs:
|
||||
- build:
|
||||
context: open-source
|
||||
- test:
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- crossbuild:
|
||||
context: open-source
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
name: acceptance-test
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
name: test
|
||||
system-test:
|
||||
# https://help.github.com/en/actions/automating-your-workflow-with-github-actions/software-installed-on-github-hosted-runners#ubuntu-1804-lts
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
@@ -19,12 +17,13 @@ jobs:
|
||||
go-
|
||||
# https://kind.sigs.k8s.io/docs/user/quick-start/
|
||||
- run: |
|
||||
wget -q -O ./kind "https://github.com/kubernetes-sigs/kind/releases/download/v0.7.0/kind-linux-amd64"
|
||||
wget -q -O ./kind "https://github.com/kubernetes-sigs/kind/releases/download/v0.8.1/kind-linux-amd64"
|
||||
chmod +x ./kind
|
||||
sudo mv ./kind /usr/local/bin/kind
|
||||
kind version
|
||||
# https://packages.ubuntu.com/xenial/libnss3-tools
|
||||
- run: sudo apt update
|
||||
- run: sudo apt install -y libnss3-tools
|
||||
- run: echo '127.0.0.1 dex-server' | sudo tee -a /etc/hosts
|
||||
- run: make -C acceptance_test -j3 setup
|
||||
- run: make -C acceptance_test test
|
||||
- run: make -C system_test -j3 setup
|
||||
- run: make -C system_test test
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/.idea
|
||||
|
||||
/system_test/output/
|
||||
/acceptance_test/output/
|
||||
|
||||
/dist/output
|
||||
|
||||
2
Makefile
2
Makefile
@@ -22,7 +22,7 @@ check:
|
||||
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)" -osarch "$(TARGET_OSARCH)" -t "dist/kubelogin.rb dist/oidc-login.yaml dist/Dockerfile" -- -ldflags "$(LDFLAGS)"
|
||||
VERSION=$(VERSION) goxzst -d dist/output -i "LICENSE" -o "$(TARGET)" -osarch "$(TARGET_OSARCH)" -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
|
||||
|
||||
40
README.md
40
README.md
@@ -1,4 +1,4 @@
|
||||
# kubelogin [](https://circleci.com/gh/int128/kubelogin)  [](https://goreportcard.com/report/github.com/int128/kubelogin)
|
||||
# kubelogin [](https://circleci.com/gh/int128/kubelogin)  [](https://goreportcard.com/report/github.com/int128/kubelogin)
|
||||
|
||||
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`.
|
||||
|
||||
@@ -18,19 +18,14 @@ Take a look at the diagram:
|
||||
|
||||
### Setup
|
||||
|
||||
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:
|
||||
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).
|
||||
|
||||
```sh
|
||||
# Homebrew
|
||||
# Homebrew (macOS and Linux)
|
||||
brew install int128/kubelogin/kubelogin
|
||||
|
||||
# Krew
|
||||
# Krew (macOS, Linux, Windows and ARM)
|
||||
kubectl krew install oidc-login
|
||||
|
||||
# GitHub Releases
|
||||
curl -LO https://github.com/int128/kubelogin/releases/download/v1.19.0/kubelogin_linux_amd64.zip
|
||||
unzip kubelogin_linux_amd64.zip
|
||||
ln -s kubelogin kubectl-oidc_login
|
||||
```
|
||||
|
||||
You need to set up the OIDC provider, cluster role binding, Kubernetes API server and kubeconfig.
|
||||
@@ -51,7 +46,7 @@ users:
|
||||
- --oidc-client-secret=YOUR_CLIENT_SECRET
|
||||
```
|
||||
|
||||
See [the setup guide](docs/setup.md) for more.
|
||||
See [setup guide](docs/setup.md) for more.
|
||||
|
||||
|
||||
### Run
|
||||
@@ -88,21 +83,26 @@ If the refresh token has expired, kubelogin will perform reauthentication.
|
||||
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.
|
||||
|
||||
You can dump the claims of token by passing `-v1` option.
|
||||
You can dump claims of an ID token by `setup` command.
|
||||
|
||||
```
|
||||
I0221 21:54:08.151850 28231 get_token.go:104] you got a token: {
|
||||
```console
|
||||
% kubectl oidc-login setup --oidc-issuer-url https://accounts.google.com --oidc-client-id REDACTED --oidc-client-secret REDACTED
|
||||
authentication in progress...
|
||||
|
||||
## 2. Verify authentication
|
||||
|
||||
You got a token with the following claims:
|
||||
|
||||
{
|
||||
"sub": "********",
|
||||
"iss": "https://accounts.google.com",
|
||||
"aud": "********",
|
||||
"iat": 1582289639,
|
||||
"exp": 1582293239,
|
||||
"jti": "********",
|
||||
"nonce": "********",
|
||||
"at_hash": "********"
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
You can verify kubelogin works with your provider using [acceptance test](acceptance_test).
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -288,7 +288,7 @@ users:
|
||||
- /tmp/.token-cache:/.token-cache
|
||||
- -p
|
||||
- 8000:8000
|
||||
- quay.io/int128/kubelogin:v1.19.0
|
||||
- quay.io/int128/kubelogin
|
||||
- get-token
|
||||
- --token-cache-dir=/.token-cache
|
||||
- --listen-address=0.0.0.0:8000
|
||||
@@ -328,4 +328,4 @@ make
|
||||
./kubelogin
|
||||
```
|
||||
|
||||
See also [the acceptance test](acceptance_test).
|
||||
See also [the system test](system_test).
|
||||
|
||||
@@ -1,100 +1,31 @@
|
||||
CLUSTER_NAME := kubelogin-acceptance-test
|
||||
OUTPUT_DIR := $(CURDIR)/output
|
||||
|
||||
PATH := $(PATH):$(OUTPUT_DIR)/bin
|
||||
export PATH
|
||||
KUBECONFIG := $(OUTPUT_DIR)/kubeconfig.yaml
|
||||
export KUBECONFIG
|
||||
|
||||
# run the login script instead of opening chrome
|
||||
BROWSER := $(OUTPUT_DIR)/bin/chromelogin
|
||||
export BROWSER
|
||||
|
||||
.PHONY: test
|
||||
test: build
|
||||
# see the setup instruction
|
||||
kubectl oidc-login setup \
|
||||
--oidc-issuer-url=https://dex-server:10443/dex \
|
||||
--oidc-client-id=YOUR_CLIENT_ID \
|
||||
--oidc-client-secret=YOUR_CLIENT_SECRET \
|
||||
--oidc-extra-scope=email \
|
||||
--certificate-authority=$(OUTPUT_DIR)/ca.crt
|
||||
# set up the kubeconfig
|
||||
kubectl config set-credentials oidc \
|
||||
--exec-api-version=client.authentication.k8s.io/v1beta1 \
|
||||
--exec-command=kubectl \
|
||||
--exec-arg=oidc-login \
|
||||
--exec-arg=get-token \
|
||||
--exec-arg=--oidc-issuer-url=https://dex-server:10443/dex \
|
||||
--exec-arg=--oidc-client-id=YOUR_CLIENT_ID \
|
||||
--exec-arg=--oidc-client-secret=YOUR_CLIENT_SECRET \
|
||||
--exec-arg=--oidc-extra-scope=email \
|
||||
--exec-arg=--certificate-authority=$(OUTPUT_DIR)/ca.crt
|
||||
# make sure we can access the cluster
|
||||
kubectl --user=oidc cluster-info
|
||||
# switch the current context
|
||||
kubectl config set-context --current --user=oidc
|
||||
# make sure we can access the cluster
|
||||
kubectl cluster-info
|
||||
|
||||
.PHONY: setup
|
||||
setup: build dex cluster setup-chrome
|
||||
|
||||
.PHONY: setup-chrome
|
||||
setup-chrome: $(OUTPUT_DIR)/ca.crt
|
||||
# add the dex server certificate to the trust store
|
||||
mkdir -p ~/.pki/nssdb
|
||||
cd ~/.pki/nssdb && certutil -A -d sql:. -n dex -i $(OUTPUT_DIR)/ca.crt -t "TC,,"
|
||||
|
||||
# build binaries
|
||||
.PHONY: build
|
||||
build: $(OUTPUT_DIR)/bin/kubectl-oidc_login $(OUTPUT_DIR)/bin/chromelogin
|
||||
$(OUTPUT_DIR)/bin/kubectl-oidc_login:
|
||||
go build -o $@ ..
|
||||
$(OUTPUT_DIR)/bin/chromelogin: chromelogin/main.go
|
||||
go build -o $@ ./chromelogin
|
||||
|
||||
# create a Dex server
|
||||
.PHONY: dex
|
||||
dex: $(OUTPUT_DIR)/server.crt $(OUTPUT_DIR)/server.key
|
||||
docker create --name dex-server -p 10443:10443 quay.io/dexidp/dex:v2.21.0 serve /dex.yaml
|
||||
docker cp $(OUTPUT_DIR)/server.crt dex-server:/
|
||||
docker cp $(OUTPUT_DIR)/server.key dex-server:/
|
||||
docker cp dex.yaml dex-server:/
|
||||
docker start dex-server
|
||||
docker logs dex-server
|
||||
|
||||
$(OUTPUT_DIR)/ca.key:
|
||||
mkdir -p $(OUTPUT_DIR)
|
||||
openssl genrsa -out $@ 2048
|
||||
$(OUTPUT_DIR)/ca.csr: $(OUTPUT_DIR)/ca.key
|
||||
openssl req -new -key $(OUTPUT_DIR)/ca.key -out $@ -subj "/CN=dex-ca" -config openssl.cnf
|
||||
$(OUTPUT_DIR)/ca.crt: $(OUTPUT_DIR)/ca.key $(OUTPUT_DIR)/ca.csr
|
||||
openssl x509 -req -in $(OUTPUT_DIR)/ca.csr -signkey $(OUTPUT_DIR)/ca.key -out $@ -days 10
|
||||
$(OUTPUT_DIR)/server.key:
|
||||
mkdir -p $(OUTPUT_DIR)
|
||||
openssl genrsa -out $@ 2048
|
||||
$(OUTPUT_DIR)/server.csr: openssl.cnf $(OUTPUT_DIR)/server.key
|
||||
openssl req -new -key $(OUTPUT_DIR)/server.key -out $@ -subj "/CN=dex-server" -config openssl.cnf
|
||||
$(OUTPUT_DIR)/server.crt: openssl.cnf $(OUTPUT_DIR)/server.csr $(OUTPUT_DIR)/ca.crt $(OUTPUT_DIR)/ca.key
|
||||
openssl x509 -req -in $(OUTPUT_DIR)/server.csr -CA $(OUTPUT_DIR)/ca.crt -CAkey $(OUTPUT_DIR)/ca.key -CAcreateserial -out $@ -sha256 -days 10 -extensions v3_req -extfile openssl.cnf
|
||||
|
||||
# create a Kubernetes cluster
|
||||
.PHONY: cluster
|
||||
cluster: dex create-cluster
|
||||
# add the Dex container IP to /etc/hosts of kube-apiserver
|
||||
docker inspect -f '{{.NetworkSettings.IPAddress}}' dex-server | sed -e 's,$$, dex-server,' | \
|
||||
kubectl -n kube-system exec -i kube-apiserver-$(CLUSTER_NAME)-control-plane -- tee -a /etc/hosts
|
||||
# wait for kube-apiserver oidc initialization
|
||||
# (oidc authenticator will retry oidc discovery every 10s)
|
||||
sleep 10
|
||||
|
||||
.PHONY: create-cluster
|
||||
create-cluster: $(OUTPUT_DIR)/ca.crt
|
||||
cp $(OUTPUT_DIR)/ca.crt /tmp/kubelogin-acceptance-test-dex-ca.crt
|
||||
kind create cluster --name $(CLUSTER_NAME) --config cluster.yaml
|
||||
cluster:
|
||||
# create a cluster
|
||||
mkdir -p $(OUTPUT_DIR)
|
||||
sed -e "s|OIDC_ISSUER_URL|$(OIDC_ISSUER_URL)|" -e "s|OIDC_CLIENT_ID|$(OIDC_CLIENT_ID)|" cluster.yaml > $(OUTPUT_DIR)/cluster.yaml
|
||||
kind create cluster --name $(CLUSTER_NAME) --config $(OUTPUT_DIR)/cluster.yaml
|
||||
# set up access control
|
||||
kubectl create clusterrole cluster-readonly --verb=get,watch,list --resource='*.*'
|
||||
kubectl create clusterrolebinding cluster-readonly --clusterrole=cluster-readonly --user=admin@example.com
|
||||
kubectl create clusterrolebinding cluster-readonly --clusterrole=cluster-readonly --user=$(YOUR_EMAIL)
|
||||
# set up kubectl
|
||||
kubectl config set-credentials oidc \
|
||||
--exec-api-version=client.authentication.k8s.io/v1beta1 \
|
||||
--exec-command=$(CURDIR)/../kubelogin \
|
||||
--exec-arg=get-token \
|
||||
--exec-arg=--token-cache-dir=$(OUTPUT_DIR)/token-cache \
|
||||
--exec-arg=--oidc-issuer-url=$(OIDC_ISSUER_URL) \
|
||||
--exec-arg=--oidc-client-id=$(OIDC_CLIENT_ID) \
|
||||
--exec-arg=--oidc-client-secret=$(OIDC_CLIENT_SECRET) \
|
||||
--exec-arg=--oidc-extra-scope=email
|
||||
# switch the default user
|
||||
kubectl config set-context --current --user=oidc
|
||||
|
||||
# clean up the resources
|
||||
.PHONY: clean
|
||||
@@ -103,7 +34,9 @@ clean:
|
||||
.PHONY: delete-cluster
|
||||
delete-cluster:
|
||||
kind delete cluster --name $(CLUSTER_NAME)
|
||||
.PHONY: delete-dex
|
||||
delete-dex:
|
||||
docker stop dex-server
|
||||
docker rm dex-server
|
||||
|
||||
.PHONY: check
|
||||
check:
|
||||
docker version
|
||||
kind version
|
||||
kubectl version --client
|
||||
|
||||
@@ -1,109 +1,75 @@
|
||||
# kubelogin/acceptance_test
|
||||
|
||||
This is an acceptance test for walkthrough of the OIDC initial setup and plugin behavior using a real Kubernetes cluster and OpenID Connect provider, running on [GitHub Actions](https://github.com/int128/kubelogin/actions?query=workflow%3Aacceptance-test).
|
||||
|
||||
It is intended to verify the following points:
|
||||
|
||||
- User can set up Kubernetes OIDC authentication and this plugin.
|
||||
- User can access a cluster after login.
|
||||
|
||||
It performs the test using the following components:
|
||||
|
||||
- Kubernetes cluster (Kind)
|
||||
- OIDC provider (Dex)
|
||||
- Browser (Chrome)
|
||||
- kubectl command
|
||||
This is a manual test for verifying Kubernetes OIDC authentication with your OIDC provider.
|
||||
|
||||
|
||||
## How it works
|
||||
## Purpose
|
||||
|
||||
Let's take a look at the diagram.
|
||||
This test checks the following points:
|
||||
|
||||

|
||||
|
||||
It prepares the following resources:
|
||||
|
||||
1. Generate a pair of CA certificate and TLS server certificate for Dex.
|
||||
1. Run Dex on a container.
|
||||
1. Create a Kubernetes cluster using Kind.
|
||||
1. Mutate `/etc/hosts` of the CI machine to access Dex.
|
||||
1. Mutate `/etc/hosts` of the kube-apiserver pod to access Dex.
|
||||
|
||||
It performs the test by the following steps:
|
||||
|
||||
1. Run kubectl.
|
||||
1. kubectl automatically runs kubelogin.
|
||||
1. kubelogin automatically runs [chromelogin](chromelogin).
|
||||
1. chromelogin opens the browser, navigates to `http://localhost:8000` and enter the username and password.
|
||||
1. kubelogin gets an authorization code from the browser.
|
||||
1. kubelogin gets a token.
|
||||
1. kubectl accesses an API with the token.
|
||||
1. kube-apiserver verifies the token by Dex.
|
||||
1. Check if kubectl exited with code 0.
|
||||
1. You can set up your OIDC provider using [setup guide](../docs/setup.md).
|
||||
1. The plugin works with your OIDC provider.
|
||||
|
||||
|
||||
## Run locally
|
||||
## Getting Started
|
||||
|
||||
You need to set up the following components:
|
||||
### Prerequisite
|
||||
|
||||
You need to build the plugin into the parent directory.
|
||||
|
||||
```sh
|
||||
make -C ..
|
||||
```
|
||||
|
||||
You need to set up your provider.
|
||||
See [setup guide](../docs/setup.md) for more.
|
||||
|
||||
You need to install the following tools:
|
||||
|
||||
- Docker
|
||||
- Kind
|
||||
- Chrome or Chromium
|
||||
- kubectl
|
||||
|
||||
You need to add the following line to `/etc/hosts` so that the browser can access the Dex.
|
||||
You can check if the tools are available.
|
||||
|
||||
```
|
||||
127.0.0.1 dex-server
|
||||
```sh
|
||||
make check
|
||||
```
|
||||
|
||||
Run the test.
|
||||
### 1. Create a cluster
|
||||
|
||||
```shell script
|
||||
# run the test
|
||||
make
|
||||
Create a cluster.
|
||||
For example, you can create a cluster with Google account authentication.
|
||||
|
||||
# clean up
|
||||
```sh
|
||||
make OIDC_ISSUER_URL=https://accounts.google.com \
|
||||
OIDC_CLIENT_ID=REDACTED.apps.googleusercontent.com \
|
||||
OIDC_CLIENT_SECRET=REDACTED \
|
||||
YOUR_EMAIL=REDACTED@gmail.com
|
||||
```
|
||||
|
||||
It will do the following steps:
|
||||
|
||||
1. Create a cluster.
|
||||
1. Set up access control. It allows read-only access from your email address.
|
||||
1. Set up kubectl to enable the plugin.
|
||||
|
||||
You can change kubectl configuration in generated `output/kubeconfig.yaml`.
|
||||
|
||||
### 2. Run kubectl
|
||||
|
||||
Make sure you can log in to the provider and access the cluster.
|
||||
|
||||
```console
|
||||
% export KUBECONFIG=$PWD/output/kubeconfig.yaml
|
||||
% kubectl get pods -A
|
||||
```
|
||||
|
||||
### Clean up
|
||||
|
||||
To delete the cluster and generated files:
|
||||
|
||||
```sh
|
||||
make delete-cluster
|
||||
make delete-dex
|
||||
make clean
|
||||
```
|
||||
|
||||
|
||||
## Technical consideration
|
||||
|
||||
### Network and DNS
|
||||
|
||||
Consider the following issues:
|
||||
|
||||
- kube-apiserver runs on the host network of the kind container.
|
||||
- kube-apiserver cannot resolve a service name by kube-dns.
|
||||
- kube-apiserver cannot access a cluster IP.
|
||||
- kube-apiserver can access another container via the Docker network.
|
||||
- Chrome requires exactly match of domain name between Dex URL and a server certificate.
|
||||
|
||||
Consequently,
|
||||
|
||||
- kube-apiserver accesses Dex by resolving `/etc/hosts` and via the Docker network.
|
||||
- kubelogin and Chrome accesses Dex by resolving `/etc/hosts` and via the Docker network.
|
||||
|
||||
### TLS server certificate
|
||||
|
||||
Consider the following issues:
|
||||
|
||||
- kube-apiserver requires `--oidc-issuer` is HTTPS URL.
|
||||
- kube-apiserver requires a CA certificate at startup, if `--oidc-ca-file` is given.
|
||||
- kube-apiserver mounts `/usr/local/share/ca-certificates` from the kind container.
|
||||
- It is possible to mount a file from the CI machine.
|
||||
- It is not possible to issue a certificate using Let's Encrypt in runtime.
|
||||
- Chrome requires a valid certificate in `~/.pki/nssdb`.
|
||||
|
||||
As a result,
|
||||
|
||||
- kube-apiserver uses the CA certificate of `/usr/local/share/ca-certificates/dex-ca.crt`. See the `extraMounts` section of [`cluster.yaml`](cluster.yaml).
|
||||
- kubelogin uses the CA certificate in `output/ca.crt`.
|
||||
- Chrome uses the CA certificate in `~/.pki/nssdb`.
|
||||
|
||||
### Test environment
|
||||
|
||||
- Set the issuer URL to kubectl. See [`kubeconfig_oidc.yaml`](kubeconfig_oidc.yaml).
|
||||
- Set the issuer URL to kube-apiserver. See [`cluster.yaml`](cluster.yaml).
|
||||
- Set `BROWSER` environment variable to run [`chromelogin`](chromelogin) by `xdg-open`.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
# https://github.com/dexidp/dex/blob/master/Documentation/kubernetes.md
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
apiVersion: kubeadm.k8s.io/v1beta2
|
||||
@@ -9,12 +8,6 @@ kubeadmConfigPatches:
|
||||
name: config
|
||||
apiServer:
|
||||
extraArgs:
|
||||
oidc-issuer-url: https://dex-server:10443/dex
|
||||
oidc-client-id: YOUR_CLIENT_ID
|
||||
oidc-issuer-url: OIDC_ISSUER_URL
|
||||
oidc-client-id: OIDC_CLIENT_ID
|
||||
oidc-username-claim: email
|
||||
oidc-ca-file: /usr/local/share/ca-certificates/dex-ca.crt
|
||||
nodes:
|
||||
- role: control-plane
|
||||
extraMounts:
|
||||
- hostPath: /tmp/kubelogin-acceptance-test-dex-ca.crt
|
||||
containerPath: /usr/local/share/ca-certificates/dex-ca.crt
|
||||
|
||||
16
dist/kubelogin.rb
vendored
16
dist/kubelogin.rb
vendored
@@ -1,15 +1,27 @@
|
||||
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"
|
||||
baseurl = "https://github.com/int128/kubelogin/releases/download"
|
||||
version "{{ env "VERSION" }}"
|
||||
sha256 "{{ sha256 .darwin_amd64_archive }}"
|
||||
|
||||
if OS.mac?
|
||||
kernel = "darwin"
|
||||
sha256 "{{ sha256 .darwin_amd64_archive }}"
|
||||
elsif OS.linux?
|
||||
kernel = "linux"
|
||||
sha256 "{{ sha256 .linux_amd64_archive }}"
|
||||
end
|
||||
|
||||
url baseurl + "/#{version}/kubelogin_#{kernel}_amd64.zip"
|
||||
|
||||
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
|
||||
|
||||
2
dist/oidc-login.yaml
vendored
2
dist/oidc-login.yaml
vendored
@@ -22,8 +22,6 @@ spec:
|
||||
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
10
go.mod
10
go.mod
@@ -7,20 +7,20 @@ require (
|
||||
github.com/coreos/go-oidc v2.2.1+incompatible
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/golang/mock v1.4.3
|
||||
github.com/google/go-cmp v0.4.0
|
||||
github.com/google/go-cmp v0.5.0
|
||||
github.com/google/wire v0.4.0
|
||||
github.com/int128/oauth2cli v1.11.0
|
||||
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.7
|
||||
github.com/spf13/cobra v1.0.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
|
||||
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.18.1
|
||||
k8s.io/client-go v0.18.1
|
||||
gopkg.in/yaml.v2 v2.3.0
|
||||
k8s.io/apimachinery v0.18.4
|
||||
k8s.io/client-go v0.18.4
|
||||
k8s.io/klog v1.0.0
|
||||
)
|
||||
|
||||
22
go.sum
22
go.sum
@@ -84,6 +84,8 @@ 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.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
|
||||
@@ -182,8 +184,8 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO
|
||||
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.7 h1:FfTH+vuMXOas8jmfb5/M7dzEYx7LpcLb7a0LPe34uOU=
|
||||
github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
|
||||
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
|
||||
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
|
||||
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=
|
||||
@@ -309,20 +311,22 @@ 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.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/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.18.1 h1:pnHr0LH69kvL29eHldoepUDKTuiOejNZI2A1gaxve3Q=
|
||||
k8s.io/api v0.18.1/go.mod h1:3My4jorQWzSs5a+l7Ge6JBbIxChLnY8HnuT58ZWolss=
|
||||
k8s.io/apimachinery v0.18.1 h1:hKPYcQRPLQoG2e7fKkVl0YCvm9TBefXTfGILa9vjVVk=
|
||||
k8s.io/apimachinery v0.18.1/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA=
|
||||
k8s.io/client-go v0.18.1 h1:2+fnu4LwKJjZVOwijkm1UqZG9aQoFsKEpipOzdfcTD8=
|
||||
k8s.io/client-go v0.18.1/go.mod h1:iCikYRiXOj/yRRFE/aWqrpPtDt4P2JVWhtHkmESTcfY=
|
||||
k8s.io/api v0.18.4 h1:8x49nBRxuXGUlDlwlWd3RMY1SayZrzFfxea3UZSkFw4=
|
||||
k8s.io/api v0.18.4/go.mod h1:lOIQAKYgai1+vz9J7YcDZwC26Z0zQewYOGWdyIPUUQ4=
|
||||
k8s.io/apimachinery v0.18.4 h1:ST2beySjhqwJoIFk6p7Hp5v5O0hYY6Gngq/gUYXTPIA=
|
||||
k8s.io/apimachinery v0.18.4/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko=
|
||||
k8s.io/client-go v0.18.4 h1:un55V1Q/B3JO3A76eS0kUSywgGK/WR3BQ8fHQjNa6Zc=
|
||||
k8s.io/client-go v0.18.4/go.mod h1:f5sXwL4yAZRkAtzOxRWUhA/N8XzGCb+nPZI8PfobZ9g=
|
||||
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-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=
|
||||
k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=
|
||||
k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89 h1:d4vVOjXm687F1iLSP2q3lyPPuyvTUt3aVoBpi2DqRsU=
|
||||
k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
|
||||
rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY=
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/int128/kubelogin/integration_test/idp"
|
||||
"github.com/int128/kubelogin/integration_test/idp/mock_idp"
|
||||
"github.com/int128/kubelogin/integration_test/httpdriver"
|
||||
"github.com/int128/kubelogin/integration_test/keypair"
|
||||
"github.com/int128/kubelogin/integration_test/localserver"
|
||||
"github.com/int128/kubelogin/integration_test/oidcserver"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/browser"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/browser/mock_browser"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/credentialpluginwriter"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/credentialpluginwriter/mock_credentialpluginwriter"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/tokencache"
|
||||
"github.com/int128/kubelogin/pkg/di"
|
||||
"github.com/int128/kubelogin/pkg/testing/jwt"
|
||||
"github.com/int128/kubelogin/pkg/testing/clock"
|
||||
"github.com/int128/kubelogin/pkg/testing/logger"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||
)
|
||||
|
||||
// Run the integration tests of the credential plugin use-case.
|
||||
@@ -31,6 +30,8 @@ import (
|
||||
// 4. Verify the output.
|
||||
//
|
||||
func TestCredentialPlugin(t *testing.T) {
|
||||
timeout := 3 * time.Second
|
||||
now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
tokenCacheDir, err := ioutil.TempDir("", "kube")
|
||||
if err != nil {
|
||||
t.Fatalf("could not create a cache dir: %s", err)
|
||||
@@ -41,352 +42,348 @@ func TestCredentialPlugin(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("NoTLS", func(t *testing.T) {
|
||||
testCredentialPlugin(t, credentialPluginTestCase{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
idpTLS: keypair.None,
|
||||
extraArgs: []string{
|
||||
"--token-cache-dir", tokenCacheDir,
|
||||
for name, tc := range map[string]struct {
|
||||
keyPair keypair.KeyPair
|
||||
args []string
|
||||
}{
|
||||
"NoTLS": {},
|
||||
"TLS": {
|
||||
keyPair: keypair.Server,
|
||||
args: []string{"--certificate-authority", keypair.Server.CACertPath},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Run("AuthCode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
sv := oidcserver.New(t, tc.keyPair, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: tc.args,
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
|
||||
t.Run("ROPC", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
sv := oidcserver.New(t, tc.keyPair, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
Username: "USER1",
|
||||
Password: "PASS1",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.Zero(t),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: append([]string{
|
||||
"--username", "USER1",
|
||||
"--password", "PASS1",
|
||||
}, tc.args...),
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
|
||||
t.Run("TokenCacheLifecycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
sv := oidcserver.New(t, tc.keyPair, oidcserver.Config{})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
|
||||
t.Run("NoCache", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
RefreshToken: "REFRESH_TOKEN_1",
|
||||
},
|
||||
})
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: tc.args,
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{})
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.Zero(t),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: tc.args,
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
t.Run("Refresh", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
RefreshToken: "REFRESH_TOKEN_1",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(3 * time.Hour),
|
||||
RefreshToken: "REFRESH_TOKEN_2",
|
||||
},
|
||||
})
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now.Add(2 * time.Hour),
|
||||
stdout: &stdout,
|
||||
args: tc.args,
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(3*time.Hour))
|
||||
})
|
||||
t.Run("RefreshAgain", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
RefreshToken: "REFRESH_TOKEN_2",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(5 * time.Hour),
|
||||
},
|
||||
})
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now.Add(4 * time.Hour),
|
||||
stdout: &stdout,
|
||||
args: tc.args,
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(5*time.Hour))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("PKCE", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
sv := oidcserver.New(t, keypair.None, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
CodeChallengeMethod: "S256",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
CodeChallengeMethodsSupported: []string{"plain", "S256"},
|
||||
},
|
||||
})
|
||||
})
|
||||
t.Run("TLS", func(t *testing.T) {
|
||||
t.Run("CertFile", func(t *testing.T) {
|
||||
testCredentialPlugin(t, credentialPluginTestCase{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
tokenCacheKey: tokencache.Key{CACertFilename: keypair.Server.CACertPath},
|
||||
idpTLS: keypair.Server,
|
||||
extraArgs: []string{
|
||||
"--token-cache-dir", tokenCacheDir,
|
||||
"--certificate-authority", keypair.Server.CACertPath,
|
||||
},
|
||||
})
|
||||
})
|
||||
t.Run("CertData", func(t *testing.T) {
|
||||
testCredentialPlugin(t, credentialPluginTestCase{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
tokenCacheKey: tokencache.Key{CACertData: keypair.Server.CACertBase64},
|
||||
idpTLS: keypair.Server,
|
||||
extraArgs: []string{
|
||||
"--token-cache-dir", tokenCacheDir,
|
||||
"--certificate-authority-data", keypair.Server.CACertBase64,
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, nil),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
}
|
||||
|
||||
type credentialPluginTestCase struct {
|
||||
tokenCacheDir string
|
||||
tokenCacheKey tokencache.Key
|
||||
idpTLS keypair.KeyPair
|
||||
extraArgs []string
|
||||
}
|
||||
|
||||
func testCredentialPlugin(t *testing.T, tc credentialPluginTestCase) {
|
||||
timeout := 1 * time.Second
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
t.Run("TLSData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &cfg.idToken)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, tc.idpTLS)
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, 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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "", tokenExpiryFuture)
|
||||
setupROPCFlow(provider, serverURL, "openid", "USER", "PASS", idToken)
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &idToken)
|
||||
browserMock := mock_browser.NewMockInterface(ctrl)
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
"--username", "USER",
|
||||
"--password", "PASS",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, 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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &idToken)
|
||||
browserMock := mock_browser.NewMockInterface(ctrl)
|
||||
setupTokenCache(t, tc, serverURL, tokencache.Value{
|
||||
IDToken: idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
sv := oidcserver.New(t, keypair.Server, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, args)
|
||||
assertTokenCache(t, tc, serverURL, 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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
validIDToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
expiredIDToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryPast)
|
||||
|
||||
provider.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
provider.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(jwt.PrivateKey))
|
||||
provider.EXPECT().Refresh("VALID_REFRESH_TOKEN").
|
||||
Return(idp.NewTokenResponse(validIDToken, "NEW_REFRESH_TOKEN"), nil)
|
||||
|
||||
setupTokenCache(t, tc, serverURL, tokencache.Value{
|
||||
IDToken: expiredIDToken,
|
||||
RefreshToken: "VALID_REFRESH_TOKEN",
|
||||
})
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &validIDToken)
|
||||
browserMock := mock_browser.NewMockInterface(ctrl)
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, args)
|
||||
assertTokenCache(t, tc, serverURL, 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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
provider.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
|
||||
|
||||
expiredIDToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryPast)
|
||||
setupTokenCache(t, tc, serverURL, tokencache.Value{
|
||||
IDToken: expiredIDToken,
|
||||
RefreshToken: "EXPIRED_REFRESH_TOKEN",
|
||||
})
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &cfg.idToken)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, tc.idpTLS)
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, args)
|
||||
assertTokenCache(t, tc, serverURL, tokencache.Value{
|
||||
IDToken: cfg.idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
defer sv.Shutdown(t, ctx)
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, keypair.Server.TLSConfig),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: []string{"--certificate-authority-data", keypair.Server.CACertBase64},
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "email profile openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &cfg.idToken)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, tc.idpTLS)
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
"--oidc-extra-scope", "email",
|
||||
"--oidc-extra-scope", "profile",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, args)
|
||||
sv := oidcserver.New(t, keypair.None, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "email profile openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, nil),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: []string{
|
||||
"--oidc-extra-scope", "email",
|
||||
"--oidc-extra-scope", "profile",
|
||||
},
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
|
||||
t.Run("RedirectURLHostname", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "openid",
|
||||
redirectURIPrefix: "http://127.0.0.1:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &cfg.idToken)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, tc.idpTLS)
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
"--oidc-redirect-url-hostname", "127.0.0.1",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, args)
|
||||
sv := oidcserver.New(t, keypair.None, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://127.0.0.1:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, nil),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: []string{"--oidc-redirect-url-hostname", "127.0.0.1"},
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
|
||||
t.Run("ExtraParams", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), tc.idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
extraParams: map[string]string{
|
||||
"ttl": "86400",
|
||||
"reauth": "false",
|
||||
sv := oidcserver.New(t, keypair.None, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
ExtraParams: map[string]string{
|
||||
"ttl": "86400",
|
||||
"reauth": "false",
|
||||
},
|
||||
},
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
writerMock := newCredentialPluginWriterMock(t, ctrl, &cfg.idToken)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, tc.idpTLS)
|
||||
|
||||
args := []string{
|
||||
"--oidc-issuer-url", serverURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
"--oidc-auth-request-extra-params", "ttl=86400",
|
||||
"--oidc-auth-request-extra-params", "reauth=false",
|
||||
}
|
||||
args = append(args, tc.extraArgs...)
|
||||
runGetTokenCmd(t, ctx, browserMock, writerMock, args)
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
var stdout bytes.Buffer
|
||||
runGetToken(t, ctx, getTokenConfig{
|
||||
tokenCacheDir: tokenCacheDir,
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, nil),
|
||||
now: now,
|
||||
stdout: &stdout,
|
||||
args: []string{
|
||||
"--oidc-auth-request-extra-params", "ttl=86400",
|
||||
"--oidc-auth-request-extra-params", "reauth=false",
|
||||
},
|
||||
})
|
||||
assertCredentialPluginStdout(t, &stdout, sv.LastTokenResponse().IDToken, now.Add(time.Hour))
|
||||
})
|
||||
}
|
||||
|
||||
func newCredentialPluginWriterMock(t *testing.T, ctrl *gomock.Controller, idToken *string) *mock_credentialpluginwriter.MockInterface {
|
||||
writer := mock_credentialpluginwriter.NewMockInterface(ctrl)
|
||||
writer.EXPECT().
|
||||
Write(gomock.Any()).
|
||||
Do(func(got credentialpluginwriter.Output) {
|
||||
want := credentialpluginwriter.Output{
|
||||
Token: *idToken,
|
||||
Expiry: tokenExpiryFuture,
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
return writer
|
||||
type getTokenConfig struct {
|
||||
tokenCacheDir string
|
||||
issuerURL string
|
||||
httpDriver browser.Interface
|
||||
stdout io.Writer
|
||||
now time.Time
|
||||
args []string
|
||||
}
|
||||
|
||||
func runGetTokenCmd(t *testing.T, ctx context.Context, b browser.Interface, w credentialpluginwriter.Interface, args []string) {
|
||||
t.Helper()
|
||||
cmd := di.NewCmdForHeadless(logger.New(t), b, w)
|
||||
func runGetToken(t *testing.T, ctx context.Context, cfg getTokenConfig) {
|
||||
cmd := di.NewCmdForHeadless(clock.Fake(cfg.now), os.Stdin, cfg.stdout, logger.New(t), cfg.httpDriver)
|
||||
exitCode := cmd.Run(ctx, append([]string{
|
||||
"kubelogin", "get-token",
|
||||
"--v=1",
|
||||
"kubelogin",
|
||||
"get-token",
|
||||
"--token-cache-dir", cfg.tokenCacheDir,
|
||||
"--oidc-issuer-url", cfg.issuerURL,
|
||||
"--oidc-client-id", "kubernetes",
|
||||
"--listen-address", "127.0.0.1:0",
|
||||
}, args...), "HEAD")
|
||||
}, cfg.args...), "latest")
|
||||
if exitCode != 0 {
|
||||
t.Errorf("exit status wants 0 but %d", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func setupTokenCache(t *testing.T, tc credentialPluginTestCase, serverURL string, v tokencache.Value) {
|
||||
k := tc.tokenCacheKey
|
||||
k.IssuerURL = serverURL
|
||||
k.ClientID = "kubernetes"
|
||||
var r tokencache.Repository
|
||||
err := r.Save(tc.tokenCacheDir, k, v)
|
||||
if err != nil {
|
||||
t.Errorf("could not set up the token cache: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTokenCache(t *testing.T, tc credentialPluginTestCase, serverURL string, want tokencache.Value) {
|
||||
k := tc.tokenCacheKey
|
||||
k.IssuerURL = serverURL
|
||||
k.ClientID = "kubernetes"
|
||||
var r tokencache.Repository
|
||||
got, err := r.FindByKey(tc.tokenCacheDir, 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)
|
||||
func assertCredentialPluginStdout(t *testing.T, stdout io.Reader, token string, expiry time.Time) {
|
||||
var got clientauthenticationv1beta1.ExecCredential
|
||||
if err := json.NewDecoder(stdout).Decode(&got); err != nil {
|
||||
t.Errorf("could not decode json of the credential plugin: %s", err)
|
||||
return
|
||||
}
|
||||
want := clientauthenticationv1beta1.ExecCredential{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||
Kind: "ExecCredential",
|
||||
},
|
||||
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||
Token: token,
|
||||
ExpirationTimestamp: &metav1.Time{Time: expiry},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("kubeconfig mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/int128/kubelogin/integration_test/idp"
|
||||
"github.com/int128/kubelogin/integration_test/idp/mock_idp"
|
||||
"github.com/int128/kubelogin/integration_test/keypair"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/browser"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/browser/mock_browser"
|
||||
"github.com/int128/kubelogin/pkg/testing/jwt"
|
||||
)
|
||||
|
||||
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()
|
||||
return jwt.EncodeF(t, func(claims *jwt.Claims) {
|
||||
claims.Issuer = issuer
|
||||
claims.Subject = "SUBJECT"
|
||||
claims.IssuedAt = time.Now().Unix()
|
||||
claims.ExpiresAt = expiry.Unix()
|
||||
claims.Audience = []string{"kubernetes", "system"}
|
||||
claims.Nonce = nonce
|
||||
claims.Groups = []string{"admin", "users"}
|
||||
})
|
||||
}
|
||||
|
||||
type authCodeFlowConfig struct {
|
||||
serverURL string
|
||||
scope string
|
||||
redirectURIPrefix string
|
||||
extraParams map[string]string
|
||||
|
||||
// setupAuthCodeFlow will set this after authentication
|
||||
idToken string
|
||||
}
|
||||
|
||||
func setupAuthCodeFlow(t *testing.T, provider *mock_idp.MockProvider, c *authCodeFlowConfig) {
|
||||
var nonce string
|
||||
provider.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(c.serverURL))
|
||||
provider.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(jwt.PrivateKey))
|
||||
provider.EXPECT().AuthenticateCode(gomock.Any()).
|
||||
DoAndReturn(func(req idp.AuthenticationRequest) (string, error) {
|
||||
if req.Scope != c.scope {
|
||||
t.Errorf("scope wants `%s` but was `%s`", c.scope, req.Scope)
|
||||
}
|
||||
if !strings.HasPrefix(req.RedirectURI, c.redirectURIPrefix) {
|
||||
t.Errorf("redirectURI wants prefix `%s` but was `%s`", c.redirectURIPrefix, req.RedirectURI)
|
||||
}
|
||||
for k, v := range c.extraParams {
|
||||
got := req.RawQuery.Get(k)
|
||||
if got != v {
|
||||
t.Errorf("parameter %s wants `%s` but was `%s`", k, v, got)
|
||||
}
|
||||
}
|
||||
nonce = req.Nonce
|
||||
return "YOUR_AUTH_CODE", nil
|
||||
})
|
||||
provider.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
DoAndReturn(func(string) (*idp.TokenResponse, error) {
|
||||
c.idToken = newIDToken(t, c.serverURL, nonce, tokenExpiryFuture)
|
||||
return idp.NewTokenResponse(c.idToken, "YOUR_REFRESH_TOKEN"), nil
|
||||
})
|
||||
}
|
||||
|
||||
func setupROPCFlow(provider *mock_idp.MockProvider, serverURL, scope, username, password, idToken string) {
|
||||
provider.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
provider.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(jwt.PrivateKey))
|
||||
provider.EXPECT().AuthenticatePassword(username, password, scope).
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
}
|
||||
|
||||
func newBrowserMock(ctx context.Context, t *testing.T, ctrl *gomock.Controller, k keypair.KeyPair) browser.Interface {
|
||||
b := mock_browser.NewMockInterface(ctrl)
|
||||
b.EXPECT().
|
||||
Open(gomock.Any()).
|
||||
Do(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)
|
||||
}
|
||||
})
|
||||
return b
|
||||
}
|
||||
54
integration_test/httpdriver/http_driver.go
Normal file
54
integration_test/httpdriver/http_driver.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Package httpdriver provides a test double of the browser.
|
||||
package httpdriver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// New returns a client to simulate browser access.
|
||||
func New(ctx context.Context, t *testing.T, tlsConfig *tls.Config) *client {
|
||||
return &client{ctx, t, tlsConfig}
|
||||
}
|
||||
|
||||
// Zero returns a client which call is not expected.
|
||||
func Zero(t *testing.T) *zeroClient {
|
||||
return &zeroClient{t}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
ctx context.Context
|
||||
t *testing.T
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func (c *client) Open(url string) error {
|
||||
client := http.Client{Transport: &http.Transport{TLSClientConfig: c.tlsConfig}}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
c.t.Errorf("could not create a request: %s", err)
|
||||
return nil
|
||||
}
|
||||
req = req.WithContext(c.ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.t.Errorf("could not send a request: %s", err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
c.t.Errorf("StatusCode wants 200 but %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type zeroClient struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (c *zeroClient) Open(url string) error {
|
||||
c.t.Errorf("unexpected function call Open(%s)", url)
|
||||
return nil
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/int128/kubelogin/integration_test/idp (interfaces: Provider)
|
||||
|
||||
// Package mock_idp is a generated GoMock package.
|
||||
package mock_idp
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
idp "github.com/int128/kubelogin/integration_test/idp"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockProvider is a mock of Provider interface
|
||||
type MockProvider struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockProviderMockRecorder
|
||||
}
|
||||
|
||||
// MockProviderMockRecorder is the mock recorder for MockProvider
|
||||
type MockProviderMockRecorder struct {
|
||||
mock *MockProvider
|
||||
}
|
||||
|
||||
// NewMockProvider creates a new mock instance
|
||||
func NewMockProvider(ctrl *gomock.Controller) *MockProvider {
|
||||
mock := &MockProvider{ctrl: ctrl}
|
||||
mock.recorder = &MockProviderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// AuthenticateCode mocks base method
|
||||
func (m *MockProvider) AuthenticateCode(arg0 idp.AuthenticationRequest) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AuthenticateCode", arg0)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AuthenticateCode indicates an expected call of AuthenticateCode
|
||||
func (mr *MockProviderMockRecorder) AuthenticateCode(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateCode", reflect.TypeOf((*MockProvider)(nil).AuthenticateCode), arg0)
|
||||
}
|
||||
|
||||
// AuthenticatePassword mocks base method
|
||||
func (m *MockProvider) 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 *MockProviderMockRecorder) AuthenticatePassword(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticatePassword", reflect.TypeOf((*MockProvider)(nil).AuthenticatePassword), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// Discovery mocks base method
|
||||
func (m *MockProvider) 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 *MockProviderMockRecorder) Discovery() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Discovery", reflect.TypeOf((*MockProvider)(nil).Discovery))
|
||||
}
|
||||
|
||||
// Exchange mocks base method
|
||||
func (m *MockProvider) 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 *MockProviderMockRecorder) Exchange(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exchange", reflect.TypeOf((*MockProvider)(nil).Exchange), arg0)
|
||||
}
|
||||
|
||||
// GetCertificates mocks base method
|
||||
func (m *MockProvider) 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 *MockProviderMockRecorder) GetCertificates() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificates", reflect.TypeOf((*MockProvider)(nil).GetCertificates))
|
||||
}
|
||||
|
||||
// Refresh mocks base method
|
||||
func (m *MockProvider) 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 *MockProviderMockRecorder) Refresh(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockProvider)(nil).Refresh), arg0)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package idp
|
||||
// Package handler provides a HTTP handler for the OpenID Connect Provider.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func NewHandler(t *testing.T, provider Provider) *Handler {
|
||||
func New(t *testing.T, provider Provider) *Handler {
|
||||
return &Handler{t, provider}
|
||||
}
|
||||
|
||||
@@ -71,16 +72,16 @@ func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
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, state := q.Get("redirect_uri"), q.Get("state")
|
||||
code, err := h.provider.AuthenticateCode(AuthenticationRequest{
|
||||
RedirectURI: redirectURI,
|
||||
State: state,
|
||||
Scope: q.Get("scope"),
|
||||
Nonce: q.Get("nonce"),
|
||||
RawQuery: q,
|
||||
RedirectURI: redirectURI,
|
||||
State: state,
|
||||
Scope: q.Get("scope"),
|
||||
Nonce: q.Get("nonce"),
|
||||
CodeChallenge: q.Get("code_challenge"),
|
||||
CodeChallengeMethod: q.Get("code_challenge_method"),
|
||||
RawQuery: q,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("authentication error: %w", err)
|
||||
@@ -94,10 +95,10 @@ func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
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.provider.Exchange(code)
|
||||
tokenResponse, err := h.provider.Exchange(TokenRequest{
|
||||
Code: r.Form.Get("code"),
|
||||
CodeVerifier: r.Form.Get("code_verifier"),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("token request error: %w", err)
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
// Package idp provides a test double of an OpenID Connect Provider.
|
||||
package idp
|
||||
|
||||
//go:generate mockgen -destination mock_idp/mock_idp.go github.com/int128/kubelogin/integration_test/idp Provider
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
@@ -19,7 +13,7 @@ type Provider interface {
|
||||
Discovery() *DiscoveryResponse
|
||||
GetCertificates() *CertificatesResponse
|
||||
AuthenticateCode(req AuthenticationRequest) (code string, err error)
|
||||
Exchange(code string) (*TokenResponse, error)
|
||||
Exchange(req TokenRequest) (*TokenResponse, error)
|
||||
AuthenticatePassword(username, password, scope string) (*TokenResponse, error)
|
||||
Refresh(refreshToken string) (*TokenResponse, error)
|
||||
}
|
||||
@@ -40,26 +34,6 @@ type DiscoveryResponse struct {
|
||||
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"`
|
||||
}
|
||||
@@ -73,29 +47,23 @@ type CertificatesResponseKey struct {
|
||||
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()),
|
||||
},
|
||||
},
|
||||
}
|
||||
// AuthenticationRequest represents a type of:
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
type AuthenticationRequest struct {
|
||||
RedirectURI string
|
||||
State string
|
||||
Scope string // space separated string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
RawQuery url.Values
|
||||
}
|
||||
|
||||
type AuthenticationRequest struct {
|
||||
RedirectURI string
|
||||
State string
|
||||
Scope string // space separated string
|
||||
Nonce string
|
||||
RawQuery url.Values
|
||||
// TokenRequest represents a type of:
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
|
||||
type TokenRequest struct {
|
||||
Code string
|
||||
CodeVerifier string
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
@@ -106,17 +74,6 @@ type TokenResponse struct {
|
||||
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
|
||||
@@ -1,7 +1,5 @@
|
||||
// Package localserver provides a http server running on localhost.
|
||||
// This is only for testing.
|
||||
//
|
||||
package localserver
|
||||
// Package http provides a http server running on localhost for testing.
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -17,20 +15,15 @@ type Shutdowner interface {
|
||||
}
|
||||
|
||||
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)
|
||||
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 keypair.KeyPair) (string, Shutdowner) {
|
||||
if k == keypair.None {
|
||||
return startNoTLS(t, h)
|
||||
@@ -38,7 +31,7 @@ func Start(t *testing.T, h http.Handler, k keypair.KeyPair) (string, Shutdowner)
|
||||
return startTLS(t, h, k)
|
||||
}
|
||||
|
||||
func startNoTLS(t *testing.T, h http.Handler) (string, Shutdowner) {
|
||||
func startNoTLS(t *testing.T, h http.Handler) (string, *shutdowner) {
|
||||
t.Helper()
|
||||
l, port := newLocalhostListener(t)
|
||||
url := "http://localhost:" + port
|
||||
@@ -51,10 +44,10 @@ func startNoTLS(t *testing.T, h http.Handler) (string, Shutdowner) {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
return url, &shutdowner{l, s}
|
||||
return url, &shutdowner{s}
|
||||
}
|
||||
|
||||
func startTLS(t *testing.T, h http.Handler, k keypair.KeyPair) (string, Shutdowner) {
|
||||
func startTLS(t *testing.T, h http.Handler, k keypair.KeyPair) (string, *shutdowner) {
|
||||
t.Helper()
|
||||
l, port := newLocalhostListener(t)
|
||||
url := "https://localhost:" + port
|
||||
@@ -67,7 +60,7 @@ func startTLS(t *testing.T, h http.Handler, k keypair.KeyPair) (string, Shutdown
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
return url, &shutdowner{l, s}
|
||||
return url, &shutdowner{s}
|
||||
}
|
||||
|
||||
func newLocalhostListener(t *testing.T) (net.Listener, string) {
|
||||
217
integration_test/oidcserver/server.go
Normal file
217
integration_test/oidcserver/server.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Package oidcserver provides a stub of OpenID Connect provider.
|
||||
package oidcserver
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/int128/kubelogin/integration_test/keypair"
|
||||
"github.com/int128/kubelogin/integration_test/oidcserver/handler"
|
||||
"github.com/int128/kubelogin/integration_test/oidcserver/http"
|
||||
"github.com/int128/kubelogin/pkg/testing/jwt"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
type Server interface {
|
||||
http.Shutdowner
|
||||
IssuerURL() string
|
||||
SetConfig(Config)
|
||||
LastTokenResponse() *handler.TokenResponse
|
||||
}
|
||||
|
||||
// Want represents a set of expected values.
|
||||
type Want struct {
|
||||
Scope string
|
||||
RedirectURIPrefix string
|
||||
CodeChallengeMethod string // optional
|
||||
ExtraParams map[string]string // optional
|
||||
Username string // optional
|
||||
Password string // optional
|
||||
RefreshToken string // optional
|
||||
}
|
||||
|
||||
// Response represents a set of response values.
|
||||
type Response struct {
|
||||
IDTokenExpiry time.Time
|
||||
RefreshToken string
|
||||
RefreshError string // if set, Refresh() will return the error
|
||||
CodeChallengeMethodsSupported []string // optional
|
||||
}
|
||||
|
||||
// Config represents a configuration of the OpenID Connect provider.
|
||||
type Config struct {
|
||||
Want Want
|
||||
Response Response
|
||||
}
|
||||
|
||||
// New starts a HTTP server for the OpenID Connect provider.
|
||||
func New(t *testing.T, k keypair.KeyPair, c Config) Server {
|
||||
sv := server{Config: c, t: t}
|
||||
sv.issuerURL, sv.Shutdowner = http.Start(t, handler.New(t, &sv), k)
|
||||
return &sv
|
||||
}
|
||||
|
||||
type server struct {
|
||||
Config
|
||||
http.Shutdowner
|
||||
t *testing.T
|
||||
issuerURL string
|
||||
lastAuthenticationRequest *handler.AuthenticationRequest
|
||||
lastTokenResponse *handler.TokenResponse
|
||||
}
|
||||
|
||||
func (sv *server) IssuerURL() string {
|
||||
return sv.issuerURL
|
||||
}
|
||||
|
||||
func (sv *server) SetConfig(cfg Config) {
|
||||
sv.Config = cfg
|
||||
}
|
||||
|
||||
func (sv *server) LastTokenResponse() *handler.TokenResponse {
|
||||
return sv.lastTokenResponse
|
||||
}
|
||||
|
||||
func (sv *server) Discovery() *handler.DiscoveryResponse {
|
||||
// based on https://accounts.google.com/.well-known/openid-configuration
|
||||
return &handler.DiscoveryResponse{
|
||||
Issuer: sv.issuerURL,
|
||||
AuthorizationEndpoint: sv.issuerURL + "/auth",
|
||||
TokenEndpoint: sv.issuerURL + "/token",
|
||||
JwksURI: sv.issuerURL + "/certs",
|
||||
UserinfoEndpoint: sv.issuerURL + "/userinfo",
|
||||
RevocationEndpoint: sv.issuerURL + "/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: sv.Config.Response.CodeChallengeMethodsSupported,
|
||||
ClaimsSupported: []string{"aud", "email", "exp", "iat", "iss", "name", "sub"},
|
||||
}
|
||||
}
|
||||
|
||||
func (sv *server) GetCertificates() *handler.CertificatesResponse {
|
||||
idTokenKeyPair := jwt.PrivateKey
|
||||
return &handler.CertificatesResponse{
|
||||
Keys: []*handler.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()),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (sv *server) AuthenticateCode(req handler.AuthenticationRequest) (code string, err error) {
|
||||
if req.Scope != sv.Want.Scope {
|
||||
sv.t.Errorf("scope wants `%s` but was `%s`", sv.Want.Scope, req.Scope)
|
||||
}
|
||||
if !strings.HasPrefix(req.RedirectURI, sv.Want.RedirectURIPrefix) {
|
||||
sv.t.Errorf("redirectURI wants prefix `%s` but was `%s`", sv.Want.RedirectURIPrefix, req.RedirectURI)
|
||||
}
|
||||
if req.CodeChallengeMethod != sv.Want.CodeChallengeMethod {
|
||||
sv.t.Errorf("code_challenge_method wants `%s` but was `%s`", sv.Want.CodeChallengeMethod, req.CodeChallengeMethod)
|
||||
}
|
||||
for k, v := range sv.Want.ExtraParams {
|
||||
got := req.RawQuery.Get(k)
|
||||
if got != v {
|
||||
sv.t.Errorf("parameter %s wants `%s` but was `%s`", k, v, got)
|
||||
}
|
||||
}
|
||||
sv.lastAuthenticationRequest = &req
|
||||
return "YOUR_AUTH_CODE", nil
|
||||
}
|
||||
|
||||
func (sv *server) Exchange(req handler.TokenRequest) (*handler.TokenResponse, error) {
|
||||
if req.Code != "YOUR_AUTH_CODE" {
|
||||
return nil, xerrors.Errorf("code wants %s but was %s", "YOUR_AUTH_CODE", req.Code)
|
||||
}
|
||||
if sv.lastAuthenticationRequest.CodeChallengeMethod == "S256" {
|
||||
// https://tools.ietf.org/html/rfc7636#section-4.6
|
||||
challenge := computeS256Challenge(req.CodeVerifier)
|
||||
if challenge != sv.lastAuthenticationRequest.CodeChallenge {
|
||||
sv.t.Errorf("pkce S256 challenge did not match (want %s but was %s)", sv.lastAuthenticationRequest.CodeChallenge, challenge)
|
||||
}
|
||||
}
|
||||
resp := &handler.TokenResponse{
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
AccessToken: "YOUR_ACCESS_TOKEN",
|
||||
RefreshToken: sv.Response.RefreshToken,
|
||||
IDToken: jwt.EncodeF(sv.t, func(claims *jwt.Claims) {
|
||||
claims.Issuer = sv.issuerURL
|
||||
claims.Subject = "SUBJECT"
|
||||
claims.IssuedAt = sv.Response.IDTokenExpiry.Add(-time.Hour).Unix()
|
||||
claims.ExpiresAt = sv.Response.IDTokenExpiry.Unix()
|
||||
claims.Audience = []string{"kubernetes"}
|
||||
claims.Nonce = sv.lastAuthenticationRequest.Nonce
|
||||
}),
|
||||
}
|
||||
sv.lastTokenResponse = resp
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func computeS256Challenge(verifier string) string {
|
||||
c := sha256.Sum256([]byte(verifier))
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(c[:])
|
||||
}
|
||||
|
||||
func (sv *server) AuthenticatePassword(username, password, scope string) (*handler.TokenResponse, error) {
|
||||
if scope != sv.Want.Scope {
|
||||
sv.t.Errorf("scope wants `%s` but was `%s`", sv.Want.Scope, scope)
|
||||
}
|
||||
if username != sv.Want.Username {
|
||||
sv.t.Errorf("username wants `%s` but was `%s`", sv.Want.Username, username)
|
||||
}
|
||||
if password != sv.Want.Password {
|
||||
sv.t.Errorf("password wants `%s` but was `%s`", sv.Want.Password, password)
|
||||
}
|
||||
resp := &handler.TokenResponse{
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
AccessToken: "YOUR_ACCESS_TOKEN",
|
||||
RefreshToken: sv.Response.RefreshToken,
|
||||
IDToken: jwt.EncodeF(sv.t, func(claims *jwt.Claims) {
|
||||
claims.Issuer = sv.issuerURL
|
||||
claims.Subject = "SUBJECT"
|
||||
claims.IssuedAt = sv.Response.IDTokenExpiry.Add(-time.Hour).Unix()
|
||||
claims.ExpiresAt = sv.Response.IDTokenExpiry.Unix()
|
||||
claims.Audience = []string{"kubernetes"}
|
||||
}),
|
||||
}
|
||||
sv.lastTokenResponse = resp
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (sv *server) Refresh(refreshToken string) (*handler.TokenResponse, error) {
|
||||
if refreshToken != sv.Want.RefreshToken {
|
||||
sv.t.Errorf("refreshToken wants %s but was %s", sv.Want.RefreshToken, refreshToken)
|
||||
}
|
||||
if sv.Response.RefreshError != "" {
|
||||
return nil, &handler.ErrorResponse{Code: "invalid_request", Description: sv.Response.RefreshError}
|
||||
}
|
||||
resp := &handler.TokenResponse{
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
AccessToken: "YOUR_ACCESS_TOKEN",
|
||||
RefreshToken: sv.Response.RefreshToken,
|
||||
IDToken: jwt.EncodeF(sv.t, func(claims *jwt.Claims) {
|
||||
claims.Issuer = sv.issuerURL
|
||||
claims.Subject = "SUBJECT"
|
||||
claims.IssuedAt = sv.Response.IDTokenExpiry.Add(-time.Hour).Unix()
|
||||
claims.ExpiresAt = sv.Response.IDTokenExpiry.Unix()
|
||||
claims.Audience = []string{"kubernetes"}
|
||||
}),
|
||||
}
|
||||
sv.lastTokenResponse = resp
|
||||
return resp, nil
|
||||
}
|
||||
@@ -6,16 +6,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/int128/kubelogin/integration_test/idp"
|
||||
"github.com/int128/kubelogin/integration_test/idp/mock_idp"
|
||||
"github.com/int128/kubelogin/integration_test/httpdriver"
|
||||
"github.com/int128/kubelogin/integration_test/keypair"
|
||||
"github.com/int128/kubelogin/integration_test/kubeconfig"
|
||||
"github.com/int128/kubelogin/integration_test/localserver"
|
||||
"github.com/int128/kubelogin/integration_test/oidcserver"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/browser"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/browser/mock_browser"
|
||||
"github.com/int128/kubelogin/pkg/di"
|
||||
"github.com/int128/kubelogin/pkg/testing/jwt"
|
||||
"github.com/int128/kubelogin/pkg/testing/clock"
|
||||
"github.com/int128/kubelogin/pkg/testing/logger"
|
||||
)
|
||||
|
||||
@@ -27,219 +24,241 @@ import (
|
||||
// 4. Verify the kubeconfig.
|
||||
//
|
||||
func TestStandalone(t *testing.T) {
|
||||
t.Run("NoTLS", func(t *testing.T) {
|
||||
testStandalone(t, keypair.None)
|
||||
})
|
||||
t.Run("TLS", func(t *testing.T) {
|
||||
testStandalone(t, keypair.Server)
|
||||
})
|
||||
}
|
||||
timeout := 3 * time.Second
|
||||
now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func testStandalone(t *testing.T, idpTLS keypair.KeyPair) {
|
||||
timeout := 5 * time.Second
|
||||
for name, tc := range map[string]struct {
|
||||
keyPair keypair.KeyPair
|
||||
args []string
|
||||
}{
|
||||
"NoTLS": {},
|
||||
"TLS": {
|
||||
keyPair: keypair.Server,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Run("AuthCode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
sv := oidcserver.New(t, tc.keyPair, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: sv.IssuerURL(),
|
||||
IDPCertificateAuthority: tc.keyPair.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now,
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: sv.LastTokenResponse().RefreshToken,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
t.Run("ROPC", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
sv := oidcserver.New(t, tc.keyPair, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
Username: "USER1",
|
||||
Password: "PASS1",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: sv.IssuerURL(),
|
||||
IDPCertificateAuthority: tc.keyPair.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.Zero(t),
|
||||
now: now,
|
||||
args: []string{
|
||||
"--username", "USER1",
|
||||
"--password", "PASS1",
|
||||
},
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: sv.LastTokenResponse().RefreshToken,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("TokenLifecycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
sv := oidcserver.New(t, tc.keyPair, oidcserver.Config{})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: sv.IssuerURL(),
|
||||
IDPCertificateAuthority: tc.keyPair.CACertPath,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
t.Run("NoToken", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
RefreshToken: "REFRESH_TOKEN_1",
|
||||
},
|
||||
})
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now,
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: "REFRESH_TOKEN_1",
|
||||
})
|
||||
})
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{})
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.Zero(t),
|
||||
now: now,
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: "REFRESH_TOKEN_1",
|
||||
})
|
||||
})
|
||||
t.Run("Refresh", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
RefreshToken: "REFRESH_TOKEN_1",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(3 * time.Hour),
|
||||
RefreshToken: "REFRESH_TOKEN_2",
|
||||
},
|
||||
})
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now.Add(2 * time.Hour),
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: "REFRESH_TOKEN_2",
|
||||
})
|
||||
})
|
||||
t.Run("RefreshAgain", func(t *testing.T) {
|
||||
sv.SetConfig(oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
RefreshToken: "REFRESH_TOKEN_2",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(5 * time.Hour),
|
||||
},
|
||||
})
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.New(ctx, t, tc.keyPair.TLSConfig),
|
||||
now: now.Add(4 * time.Hour),
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: "REFRESH_TOKEN_2",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("TLSData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, idpTLS)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
sv := oidcserver.New(t, keypair.Server, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
Issuer: sv.IssuerURL(),
|
||||
IDPCertificateAuthorityData: keypair.Server.CACertBase64,
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, browserMock, args)
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.New(ctx, t, keypair.Server.TLSConfig),
|
||||
now: now,
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: cfg.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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
browserMock := mock_browser.NewMockInterface(ctrl)
|
||||
idToken := newIDToken(t, serverURL, "", tokenExpiryFuture)
|
||||
setupROPCFlow(provider, 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, browserMock, 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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
browserMock := mock_browser.NewMockInterface(ctrl)
|
||||
|
||||
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, browserMock, 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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", tokenExpiryFuture)
|
||||
provider.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
provider.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(jwt.PrivateKey))
|
||||
provider.EXPECT().Refresh("VALID_REFRESH_TOKEN").
|
||||
Return(idp.NewTokenResponse(idToken, "NEW_REFRESH_TOKEN"), nil)
|
||||
browserMock := mock_browser.NewMockInterface(ctrl)
|
||||
|
||||
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, browserMock, 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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
provider.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
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, idpTLS)
|
||||
|
||||
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, browserMock, args)
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: cfg.idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: sv.LastTokenResponse().RefreshToken,
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, idpTLS)
|
||||
|
||||
sv := oidcserver.New(t, keypair.None, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
Issuer: sv.IssuerURL(),
|
||||
})
|
||||
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, browserMock, args)
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
httpDriver: httpdriver.New(ctx, t, nil),
|
||||
now: now,
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: cfg.idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: sv.LastTokenResponse().RefreshToken,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -247,46 +266,49 @@ func testStandalone(t *testing.T, idpTLS keypair.KeyPair) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
provider := mock_idp.NewMockProvider(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, provider), idpTLS)
|
||||
defer server.Shutdown(t, ctx)
|
||||
cfg := authCodeFlowConfig{
|
||||
serverURL: serverURL,
|
||||
scope: "profile groups openid",
|
||||
redirectURIPrefix: "http://localhost:",
|
||||
}
|
||||
setupAuthCodeFlow(t, provider, &cfg)
|
||||
browserMock := newBrowserMock(ctx, t, ctrl, idpTLS)
|
||||
|
||||
sv := oidcserver.New(t, keypair.None, oidcserver.Config{
|
||||
Want: oidcserver.Want{
|
||||
Scope: "profile groups openid",
|
||||
RedirectURIPrefix: "http://localhost:",
|
||||
},
|
||||
Response: oidcserver.Response{
|
||||
IDTokenExpiry: now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
defer sv.Shutdown(t, ctx)
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
ExtraScopes: "profile,groups",
|
||||
IDPCertificateAuthority: idpTLS.CACertPath,
|
||||
Issuer: sv.IssuerURL(),
|
||||
ExtraScopes: "profile,groups",
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
|
||||
args := []string{
|
||||
"--kubeconfig", kubeConfigFilename,
|
||||
}
|
||||
runRootCmd(t, ctx, browserMock, args)
|
||||
runStandalone(t, ctx, standaloneConfig{
|
||||
issuerURL: sv.IssuerURL(),
|
||||
kubeConfigFilename: kubeConfigFilename,
|
||||
httpDriver: httpdriver.New(ctx, t, nil),
|
||||
now: now,
|
||||
})
|
||||
kubeconfig.Verify(t, kubeConfigFilename, kubeconfig.AuthProviderConfig{
|
||||
IDToken: cfg.idToken,
|
||||
RefreshToken: "YOUR_REFRESH_TOKEN",
|
||||
IDToken: sv.LastTokenResponse().IDToken,
|
||||
RefreshToken: sv.LastTokenResponse().RefreshToken,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func runRootCmd(t *testing.T, ctx context.Context, b browser.Interface, args []string) {
|
||||
t.Helper()
|
||||
cmd := di.NewCmdForHeadless(logger.New(t), b, nil)
|
||||
type standaloneConfig struct {
|
||||
issuerURL string
|
||||
kubeConfigFilename string
|
||||
httpDriver browser.Interface
|
||||
now time.Time
|
||||
args []string
|
||||
}
|
||||
|
||||
func runStandalone(t *testing.T, ctx context.Context, cfg standaloneConfig) {
|
||||
cmd := di.NewCmdForHeadless(clock.Fake(cfg.now), os.Stdin, os.Stdout, logger.New(t), cfg.httpDriver)
|
||||
exitCode := cmd.Run(ctx, append([]string{
|
||||
"kubelogin",
|
||||
"--v=1",
|
||||
"--kubeconfig", cfg.kubeConfigFilename,
|
||||
"--listen-address", "127.0.0.1:0",
|
||||
}, args...), "HEAD")
|
||||
}, cfg.args...), "HEAD")
|
||||
if exitCode != 0 {
|
||||
t.Errorf("exit status wants 0 but %d", exitCode)
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Clock), "*"),
|
||||
wire.Bind(new(Interface), new(*Clock)),
|
||||
wire.Struct(new(Real), "*"),
|
||||
wire.Bind(new(Interface), new(*Real)),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
type Clock struct{}
|
||||
type Real struct{}
|
||||
|
||||
// Now returns the current time.
|
||||
func (c *Clock) Now() time.Time {
|
||||
func (c *Real) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ package credentialpluginwriter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/stdio"
|
||||
"golang.org/x/xerrors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination mock_credentialpluginwriter/mock_credentialpluginwriter.go github.com/int128/kubelogin/pkg/adaptors/credentialpluginwriter Interface
|
||||
@@ -29,21 +29,23 @@ type Output struct {
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
type Writer struct{}
|
||||
type Writer struct {
|
||||
Stdout stdio.Stdout
|
||||
}
|
||||
|
||||
// Write writes the ExecCredential to standard output for kubectl.
|
||||
func (*Writer) Write(out Output) error {
|
||||
ec := &v1beta1.ExecCredential{
|
||||
TypeMeta: v1.TypeMeta{
|
||||
func (w *Writer) Write(out Output) error {
|
||||
ec := &clientauthenticationv1beta1.ExecCredential{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||
Kind: "ExecCredential",
|
||||
},
|
||||
Status: &v1beta1.ExecCredentialStatus{
|
||||
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||
Token: out.Token,
|
||||
ExpirationTimestamp: &v1.Time{Time: out.Expiry},
|
||||
ExpirationTimestamp: &metav1.Time{Time: out.Expiry},
|
||||
},
|
||||
}
|
||||
e := json.NewEncoder(os.Stdout)
|
||||
e := json.NewEncoder(w.Stdout)
|
||||
if err := e.Encode(ec); err != nil {
|
||||
return xerrors.Errorf("could not write the ExecCredential: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,27 @@ package oidcclient
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/certpool"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/clock"
|
||||
"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)
|
||||
var Set = wire.NewSet(
|
||||
wire.Struct(new(Factory), "*"),
|
||||
wire.Bind(new(FactoryInterface), new(*Factory)),
|
||||
)
|
||||
|
||||
type FactoryInterface interface {
|
||||
New(ctx context.Context, config Config) (Interface, error)
|
||||
}
|
||||
|
||||
// Config represents a configuration of OpenID Connect client.
|
||||
type Config struct {
|
||||
@@ -24,11 +34,15 @@ type Config struct {
|
||||
ExtraScopes []string // optional
|
||||
CertPool certpool.Interface
|
||||
SkipTLSVerify bool
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
Clock clock.Interface
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
// New returns an instance of adaptors.Interface with the given configuration.
|
||||
func New(ctx context.Context, config Config) (Interface, error) {
|
||||
func (f *Factory) New(ctx context.Context, config Config) (Interface, error) {
|
||||
var tlsConfig tls.Config
|
||||
tlsConfig.InsecureSkipVerify = config.SkipTLSVerify
|
||||
config.CertPool.SetRootCAs(&tlsConfig)
|
||||
@@ -38,7 +52,7 @@ func New(ctx context.Context, config Config) (Interface, error) {
|
||||
}
|
||||
loggingTransport := &logging.Transport{
|
||||
Base: baseTransport,
|
||||
Logger: config.Logger,
|
||||
Logger: f.Logger,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: loggingTransport,
|
||||
@@ -49,6 +63,10 @@ func New(ctx context.Context, config Config) (Interface, error) {
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("oidc discovery error: %w", err)
|
||||
}
|
||||
supportedPKCEMethods, err := extractSupportedPKCEMethods(provider)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not determine supported PKCE methods: %w", err)
|
||||
}
|
||||
return &client{
|
||||
httpClient: httpClient,
|
||||
provider: provider,
|
||||
@@ -58,6 +76,18 @@ func New(ctx context.Context, config Config) (Interface, error) {
|
||||
ClientSecret: config.ClientSecret,
|
||||
Scopes: append(config.ExtraScopes, oidc.ScopeOpenID),
|
||||
},
|
||||
logger: config.Logger,
|
||||
clock: f.Clock,
|
||||
logger: f.Logger,
|
||||
supportedPKCEMethods: supportedPKCEMethods,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractSupportedPKCEMethods(provider *oidc.Provider) ([]string, error) {
|
||||
var d struct {
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
}
|
||||
if err := provider.Claims(&d); err != nil {
|
||||
return nil, fmt.Errorf("invalid discovery document: %w", err)
|
||||
}
|
||||
return d.CodeChallengeMethodsSupported, nil
|
||||
}
|
||||
|
||||
@@ -11,30 +11,30 @@ import (
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockInterface is a mock of Interface interface
|
||||
// MockInterface is a mock of Interface interface.
|
||||
type MockInterface struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockInterfaceMockRecorder
|
||||
}
|
||||
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface
|
||||
// MockInterfaceMockRecorder is the mock recorder for MockInterface.
|
||||
type MockInterfaceMockRecorder struct {
|
||||
mock *MockInterface
|
||||
}
|
||||
|
||||
// NewMockInterface creates a new mock instance
|
||||
// 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
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// ExchangeAuthCode mocks base method
|
||||
// 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)
|
||||
@@ -43,13 +43,13 @@ func (m *MockInterface) ExchangeAuthCode(arg0 context.Context, arg1 oidcclient.E
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ExchangeAuthCode indicates an expected call of ExchangeAuthCode
|
||||
// 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
|
||||
// GetAuthCodeURL mocks base method.
|
||||
func (m *MockInterface) GetAuthCodeURL(arg0 oidcclient.AuthCodeURLInput) string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAuthCodeURL", arg0)
|
||||
@@ -57,13 +57,13 @@ func (m *MockInterface) GetAuthCodeURL(arg0 oidcclient.AuthCodeURLInput) string
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetAuthCodeURL indicates an expected call of GetAuthCodeURL
|
||||
// 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
|
||||
// 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)
|
||||
@@ -72,13 +72,13 @@ func (m *MockInterface) GetTokenByAuthCode(arg0 context.Context, arg1 oidcclient
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTokenByAuthCode indicates an expected call of GetTokenByAuthCode
|
||||
// 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
|
||||
// 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)
|
||||
@@ -87,13 +87,13 @@ func (m *MockInterface) GetTokenByROPC(arg0 context.Context, arg1, arg2 string)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTokenByROPC indicates an expected call of GetTokenByROPC
|
||||
// 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
|
||||
// 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)
|
||||
@@ -102,8 +102,22 @@ func (m *MockInterface) Refresh(arg0 context.Context, arg1 string) (*oidcclient.
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Refresh indicates an expected call of Refresh
|
||||
// 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)
|
||||
}
|
||||
|
||||
// SupportedPKCEMethods mocks base method.
|
||||
func (m *MockInterface) SupportedPKCEMethods() []string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SupportedPKCEMethods")
|
||||
ret0, _ := ret[0].([]string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SupportedPKCEMethods indicates an expected call of SupportedPKCEMethods.
|
||||
func (mr *MockInterfaceMockRecorder) SupportedPKCEMethods() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SupportedPKCEMethods", reflect.TypeOf((*MockInterface)(nil).SupportedPKCEMethods))
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/clock"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/domain/jwt"
|
||||
"github.com/int128/kubelogin/pkg/domain/pkce"
|
||||
"github.com/int128/oauth2cli"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -16,43 +17,38 @@ import (
|
||||
|
||||
//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)
|
||||
SupportedPKCEMethods() []string
|
||||
}
|
||||
|
||||
type AuthCodeURLInput struct {
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
PKCEParams pkce.Params
|
||||
RedirectURI string
|
||||
AuthRequestExtraParams map[string]string
|
||||
}
|
||||
|
||||
type ExchangeAuthCodeInput struct {
|
||||
Code string
|
||||
CodeVerifier string
|
||||
Nonce string
|
||||
RedirectURI string
|
||||
Code string
|
||||
PKCEParams pkce.Params
|
||||
Nonce string
|
||||
RedirectURI string
|
||||
}
|
||||
|
||||
type GetTokenByAuthCodeInput struct {
|
||||
BindAddress []string
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
CodeVerifier string
|
||||
PKCEParams pkce.Params
|
||||
RedirectURLHostname string
|
||||
AuthRequestExtraParams map[string]string
|
||||
LocalServerSuccessHTML string
|
||||
}
|
||||
|
||||
// TokenSet represents an output DTO of
|
||||
@@ -64,10 +60,12 @@ type TokenSet struct {
|
||||
}
|
||||
|
||||
type client struct {
|
||||
httpClient *http.Client
|
||||
provider *oidc.Provider
|
||||
oauth2Config oauth2.Config
|
||||
logger logger.Interface
|
||||
httpClient *http.Client
|
||||
provider *oidc.Provider
|
||||
oauth2Config oauth2.Config
|
||||
clock clock.Interface
|
||||
logger logger.Interface
|
||||
supportedPKCEMethods []string
|
||||
}
|
||||
|
||||
func (c *client) wrapContext(ctx context.Context) context.Context {
|
||||
@@ -81,25 +79,15 @@ func (c *client) wrapContext(ctx context.Context) context.Context {
|
||||
func (c *client) GetTokenByAuthCode(ctx context.Context, in GetTokenByAuthCodeInput, localServerReadyChan chan<- string) (*TokenSet, error) {
|
||||
ctx = c.wrapContext(ctx)
|
||||
config := oauth2cli.Config{
|
||||
OAuth2Config: c.oauth2Config,
|
||||
State: in.State,
|
||||
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),
|
||||
},
|
||||
OAuth2Config: c.oauth2Config,
|
||||
State: in.State,
|
||||
AuthCodeOptions: authorizationRequestOptions(in.Nonce, in.PKCEParams, in.AuthRequestExtraParams),
|
||||
TokenRequestOptions: tokenRequestOptions(in.PKCEParams),
|
||||
LocalServerBindAddress: in.BindAddress,
|
||||
LocalServerReadyChan: localServerReadyChan,
|
||||
RedirectURLHostname: in.RedirectURLHostname,
|
||||
LocalServerSuccessHTML: in.LocalServerSuccessHTML,
|
||||
}
|
||||
for key, value := range in.AuthRequestExtraParams {
|
||||
config.AuthCodeOptions = append(config.AuthCodeOptions, oauth2.SetAuthURLParam(key, value))
|
||||
}
|
||||
|
||||
token, err := oauth2cli.GetToken(ctx, config)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("oauth2 error: %w", err)
|
||||
@@ -111,15 +99,7 @@ func (c *client) GetTokenByAuthCode(ctx context.Context, in GetTokenByAuthCodeIn
|
||||
func (c *client) GetAuthCodeURL(in AuthCodeURLInput) string {
|
||||
cfg := c.oauth2Config
|
||||
cfg.RedirectURL = in.RedirectURI
|
||||
opts := []oauth2.AuthCodeOption{
|
||||
oauth2.AccessTypeOffline,
|
||||
oidc.Nonce(in.Nonce),
|
||||
oauth2.SetAuthURLParam("code_challenge", in.CodeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", in.CodeChallengeMethod),
|
||||
}
|
||||
for key, value := range in.AuthRequestExtraParams {
|
||||
opts = append(opts, oauth2.SetAuthURLParam(key, value))
|
||||
}
|
||||
opts := authorizationRequestOptions(in.Nonce, in.PKCEParams, in.AuthRequestExtraParams)
|
||||
return cfg.AuthCodeURL(in.State, opts...)
|
||||
}
|
||||
|
||||
@@ -128,13 +108,44 @@ func (c *client) ExchangeAuthCode(ctx context.Context, in ExchangeAuthCodeInput)
|
||||
ctx = c.wrapContext(ctx)
|
||||
cfg := c.oauth2Config
|
||||
cfg.RedirectURL = in.RedirectURI
|
||||
token, err := cfg.Exchange(ctx, in.Code, oauth2.SetAuthURLParam("code_verifier", in.CodeVerifier))
|
||||
opts := tokenRequestOptions(in.PKCEParams)
|
||||
token, err := cfg.Exchange(ctx, in.Code, opts...)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("exchange error: %w", err)
|
||||
}
|
||||
return c.verifyToken(ctx, token, in.Nonce)
|
||||
}
|
||||
|
||||
func authorizationRequestOptions(n string, p pkce.Params, e map[string]string) []oauth2.AuthCodeOption {
|
||||
o := []oauth2.AuthCodeOption{
|
||||
oauth2.AccessTypeOffline,
|
||||
oidc.Nonce(n),
|
||||
}
|
||||
if !p.IsZero() {
|
||||
o = append(o,
|
||||
oauth2.SetAuthURLParam("code_challenge", p.CodeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", p.CodeChallengeMethod),
|
||||
)
|
||||
}
|
||||
for key, value := range e {
|
||||
o = append(o, oauth2.SetAuthURLParam(key, value))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func tokenRequestOptions(p pkce.Params) (o []oauth2.AuthCodeOption) {
|
||||
if !p.IsZero() {
|
||||
o = append(o, oauth2.SetAuthURLParam("code_verifier", p.CodeVerifier))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SupportedPKCEMethods returns the PKCE methods supported by the provider.
|
||||
// This may return nil if PKCE is not supported.
|
||||
func (c *client) SupportedPKCEMethods() []string {
|
||||
return c.supportedPKCEMethods
|
||||
}
|
||||
|
||||
// GetTokenByROPC performs the resource owner password credentials flow.
|
||||
func (c *client) GetTokenByROPC(ctx context.Context, username, password string) (*TokenSet, error) {
|
||||
ctx = c.wrapContext(ctx)
|
||||
@@ -167,7 +178,7 @@ func (c *client) verifyToken(ctx context.Context, token *oauth2.Token, nonce str
|
||||
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})
|
||||
verifier := c.provider.Verifier(&oidc.Config{ClientID: c.oauth2Config.ClientID, Now: c.clock.Now})
|
||||
verifiedIDToken, err := verifier.Verify(ctx, idToken)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not verify the ID token: %w", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/stdio"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
@@ -26,14 +27,16 @@ type Interface interface {
|
||||
ReadPassword(prompt string) (string, error)
|
||||
}
|
||||
|
||||
type Reader struct{}
|
||||
type Reader struct {
|
||||
Stdin stdio.Stdin
|
||||
}
|
||||
|
||||
// ReadString reads a string from the stdin.
|
||||
func (*Reader) ReadString(prompt string) (string, error) {
|
||||
func (x *Reader) ReadString(prompt string) (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
|
||||
return "", xerrors.Errorf("write error: %w", err)
|
||||
}
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
r := bufio.NewReader(x.Stdin)
|
||||
s, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("read error: %w", err)
|
||||
|
||||
17
pkg/adaptors/stdio/stdio.go
Normal file
17
pkg/adaptors/stdio/stdio.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package stdio wraps os.Stdin and os.Stdout for testing.
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
wire.InterfaceValue(new(Stdin), os.Stdin),
|
||||
wire.InterfaceValue(new(Stdout), os.Stdout),
|
||||
)
|
||||
|
||||
type Stdout io.Writer
|
||||
type Stdin io.Reader
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/reader"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/stdio"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/tokencache"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
"github.com/int128/kubelogin/pkg/usecases/credentialplugin"
|
||||
@@ -27,15 +28,16 @@ func NewCmd() cmd.Interface {
|
||||
NewCmdForHeadless,
|
||||
|
||||
// dependencies for production
|
||||
clock.Set,
|
||||
stdio.Set,
|
||||
logger.Set,
|
||||
browser.Set,
|
||||
credentialpluginwriter.Set,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCmdForHeadless returns an instance of adaptors.Cmd for headless testing.
|
||||
func NewCmdForHeadless(logger.Interface, browser.Interface, credentialpluginwriter.Interface) cmd.Interface {
|
||||
func NewCmdForHeadless(clock.Interface, stdio.Stdin, stdio.Stdout, logger.Interface, browser.Interface) cmd.Interface {
|
||||
wire.Build(
|
||||
// use-cases
|
||||
authentication.Set,
|
||||
@@ -46,11 +48,11 @@ func NewCmdForHeadless(logger.Interface, browser.Interface, credentialpluginwrit
|
||||
// adaptors
|
||||
cmd.Set,
|
||||
reader.Set,
|
||||
clock.Set,
|
||||
kubeconfig.Set,
|
||||
tokencache.Set,
|
||||
oidcclient.Set,
|
||||
certpool.Set,
|
||||
credentialpluginwriter.Set,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,31 +15,44 @@ import (
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/reader"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/stdio"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/tokencache"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
"github.com/int128/kubelogin/pkg/usecases/credentialplugin"
|
||||
"github.com/int128/kubelogin/pkg/usecases/setup"
|
||||
"github.com/int128/kubelogin/pkg/usecases/standalone"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Injectors from di.go:
|
||||
|
||||
func NewCmd() cmd.Interface {
|
||||
clockReal := &clock.Real{}
|
||||
stdin := _wireFileValue
|
||||
stdout := _wireOsFileValue
|
||||
loggerInterface := logger.New()
|
||||
browserBrowser := &browser.Browser{}
|
||||
writer := &credentialpluginwriter.Writer{}
|
||||
cmdInterface := NewCmdForHeadless(loggerInterface, browserBrowser, writer)
|
||||
cmdInterface := NewCmdForHeadless(clockReal, stdin, stdout, loggerInterface, browserBrowser)
|
||||
return cmdInterface
|
||||
}
|
||||
|
||||
func NewCmdForHeadless(loggerInterface logger.Interface, browserInterface browser.Interface, credentialpluginwriterInterface credentialpluginwriter.Interface) cmd.Interface {
|
||||
newFunc := _wireNewFuncValue
|
||||
clockClock := &clock.Clock{}
|
||||
var (
|
||||
_wireFileValue = os.Stdin
|
||||
_wireOsFileValue = os.Stdout
|
||||
)
|
||||
|
||||
func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout stdio.Stdout, loggerInterface logger.Interface, browserInterface browser.Interface) cmd.Interface {
|
||||
factory := &oidcclient.Factory{
|
||||
Clock: clockInterface,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
authCode := &authentication.AuthCode{
|
||||
Browser: browserInterface,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
readerReader := &reader.Reader{}
|
||||
readerReader := &reader.Reader{
|
||||
Stdin: stdin,
|
||||
}
|
||||
authCodeKeyboard := &authentication.AuthCodeKeyboard{
|
||||
Reader: readerReader,
|
||||
Logger: loggerInterface,
|
||||
@@ -49,9 +62,9 @@ func NewCmdForHeadless(loggerInterface logger.Interface, browserInterface browse
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
authenticationAuthentication := &authentication.Authentication{
|
||||
NewOIDCClient: newFunc,
|
||||
OIDCClient: factory,
|
||||
Logger: loggerInterface,
|
||||
Clock: clockClock,
|
||||
Clock: clockInterface,
|
||||
AuthCode: authCode,
|
||||
AuthCodeKeyboard: authCodeKeyboard,
|
||||
ROPC: ropc,
|
||||
@@ -59,11 +72,11 @@ func NewCmdForHeadless(loggerInterface logger.Interface, browserInterface browse
|
||||
kubeconfigKubeconfig := &kubeconfig.Kubeconfig{
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
certpoolNewFunc := _wireCertpoolNewFuncValue
|
||||
newFunc := _wireNewFuncValue
|
||||
standaloneStandalone := &standalone.Standalone{
|
||||
Authentication: authenticationAuthentication,
|
||||
Kubeconfig: kubeconfigKubeconfig,
|
||||
NewCertPool: certpoolNewFunc,
|
||||
NewCertPool: newFunc,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
root := &cmd.Root{
|
||||
@@ -71,11 +84,14 @@ func NewCmdForHeadless(loggerInterface logger.Interface, browserInterface browse
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
repository := &tokencache.Repository{}
|
||||
writer := &credentialpluginwriter.Writer{
|
||||
Stdout: stdout,
|
||||
}
|
||||
getToken := &credentialplugin.GetToken{
|
||||
Authentication: authenticationAuthentication,
|
||||
TokenCacheRepository: repository,
|
||||
NewCertPool: certpoolNewFunc,
|
||||
Writer: credentialpluginwriterInterface,
|
||||
NewCertPool: newFunc,
|
||||
Writer: writer,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
cmdGetToken := &cmd.GetToken{
|
||||
@@ -84,7 +100,7 @@ func NewCmdForHeadless(loggerInterface logger.Interface, browserInterface browse
|
||||
}
|
||||
setupSetup := &setup.Setup{
|
||||
Authentication: authenticationAuthentication,
|
||||
NewCertPool: certpoolNewFunc,
|
||||
NewCertPool: newFunc,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
cmdSetup := &cmd.Setup{
|
||||
@@ -100,6 +116,5 @@ func NewCmdForHeadless(loggerInterface logger.Interface, browserInterface browse
|
||||
}
|
||||
|
||||
var (
|
||||
_wireNewFuncValue = oidcclient.NewFunc(oidcclient.New)
|
||||
_wireCertpoolNewFuncValue = certpool.NewFunc(certpool.New)
|
||||
_wireNewFuncValue = certpool.NewFunc(certpool.New)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
|
||||
@@ -25,21 +24,6 @@ func NewNonce() (string, error) {
|
||||
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 {
|
||||
@@ -48,17 +32,6 @@ func random32() ([]byte, error) {
|
||||
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 base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
74
pkg/domain/pkce/pkce.go
Normal file
74
pkg/domain/pkce/pkce.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Package pkce provides generation of the PKCE parameters.
|
||||
// See also https://tools.ietf.org/html/rfc7636.
|
||||
package pkce
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var Plain Params
|
||||
|
||||
const (
|
||||
// code challenge methods defined as https://tools.ietf.org/html/rfc7636#section-4.3
|
||||
methodS256 = "S256"
|
||||
)
|
||||
|
||||
// Params represents a set of the PKCE parameters.
|
||||
type Params struct {
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
CodeVerifier string
|
||||
}
|
||||
|
||||
func (p Params) IsZero() bool {
|
||||
return p == Params{}
|
||||
}
|
||||
|
||||
// New returns a parameters supported by the provider.
|
||||
// You need to pass the code challenge methods defined in RFC7636.
|
||||
// It returns Plain if no method is available.
|
||||
func New(methods []string) (Params, error) {
|
||||
for _, method := range methods {
|
||||
if method == methodS256 {
|
||||
return NewS256()
|
||||
}
|
||||
}
|
||||
return Plain, nil
|
||||
}
|
||||
|
||||
// NewS256 generates a parameters for S256.
|
||||
func NewS256() (Params, error) {
|
||||
b, err := random32()
|
||||
if err != nil {
|
||||
return Plain, xerrors.Errorf("could not generate a random: %w", err)
|
||||
}
|
||||
return computeS256(b), nil
|
||||
}
|
||||
|
||||
func random32() ([]byte, error) {
|
||||
b := make([]byte, 32)
|
||||
if err := binary.Read(rand.Reader, binary.LittleEndian, b); err != nil {
|
||||
return nil, xerrors.Errorf("read error: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func computeS256(b []byte) Params {
|
||||
v := base64URLEncode(b)
|
||||
s := sha256.New()
|
||||
_, _ = s.Write([]byte(v))
|
||||
return Params{
|
||||
CodeChallenge: base64URLEncode(s.Sum(nil)),
|
||||
CodeChallengeMethod: methodS256,
|
||||
CodeVerifier: v,
|
||||
}
|
||||
}
|
||||
|
||||
func base64URLEncode(b []byte) string {
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b)
|
||||
}
|
||||
61
pkg/domain/pkce/pkce_test.go
Normal file
61
pkg/domain/pkce/pkce_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package pkce
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("S256", func(t *testing.T) {
|
||||
p, err := New([]string{"plain", "S256"})
|
||||
if err != nil {
|
||||
t.Fatalf("New error: %s", err)
|
||||
}
|
||||
if p.CodeChallengeMethod != "S256" {
|
||||
t.Errorf("CodeChallengeMethod wants S256 but was %s", p.CodeChallengeMethod)
|
||||
}
|
||||
if p.CodeChallenge == "" {
|
||||
t.Errorf("CodeChallenge wants non-empty but was empty")
|
||||
}
|
||||
if p.CodeVerifier == "" {
|
||||
t.Errorf("CodeVerifier wants non-empty but was empty")
|
||||
}
|
||||
})
|
||||
t.Run("plain", func(t *testing.T) {
|
||||
p, err := New([]string{"plain"})
|
||||
if err != nil {
|
||||
t.Fatalf("New error: %s", err)
|
||||
}
|
||||
if !p.IsZero() {
|
||||
t.Errorf("IsZero wants true but was false")
|
||||
}
|
||||
})
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
p, err := New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New error: %s", err)
|
||||
}
|
||||
if !p.IsZero() {
|
||||
t.Errorf("IsZero wants true but was false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
35
pkg/templates/authcode_browser.go
Normal file
35
pkg/templates/authcode_browser.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package templates
|
||||
|
||||
// AuthCodeBrowserSuccessHTML is the success page on browser based authentication.
|
||||
const AuthCodeBrowserSuccessHTML = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Authenticated</title>
|
||||
<script>
|
||||
window.close()
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #eee;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
.placeholder {
|
||||
margin: 2em;
|
||||
padding: 2em;
|
||||
background-color: #fff;
|
||||
border-radius: 1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="placeholder">
|
||||
<h1>Authenticated</h1>
|
||||
<p>You have logged in to the cluster. You can close this window.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
29
pkg/templates/httpserver/httpserver.go
Normal file
29
pkg/templates/httpserver/httpserver.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/templates"
|
||||
)
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/AuthCodeBrowserSuccessHTML", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("content-type", "text/html")
|
||||
_, _ = w.Write([]byte(templates.AuthCodeBrowserSuccessHTML))
|
||||
})
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("content-type", "text/html")
|
||||
_, _ = w.Write([]byte(`
|
||||
<html>
|
||||
<body>
|
||||
<ul>
|
||||
<li><a href="AuthCodeBrowserSuccessHTML">AuthCodeBrowserSuccessHTML</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
})
|
||||
log.Printf("http://localhost:8000")
|
||||
log.Fatal(http.ListenAndServe("127.0.0.1:8000", nil))
|
||||
}
|
||||
6
pkg/templates/package.go
Normal file
6
pkg/templates/package.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Package templates provides templates such as HTML and messages.
|
||||
//
|
||||
// You can preview HTML pages by running httpserver package.
|
||||
// go run ./httpserver
|
||||
//
|
||||
package templates
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/domain/oidc"
|
||||
"github.com/int128/kubelogin/pkg/domain/pkce"
|
||||
"github.com/int128/kubelogin/pkg/templates"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
@@ -27,7 +29,7 @@ func (u *AuthCode) Do(ctx context.Context, o *AuthCodeOption, client oidcclient.
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not generate a nonce: %w", err)
|
||||
}
|
||||
p, err := oidc.NewPKCEParams()
|
||||
p, err := pkce.New(client.SupportedPKCEMethods())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not generate PKCE parameters: %w", err)
|
||||
}
|
||||
@@ -35,11 +37,10 @@ func (u *AuthCode) Do(ctx context.Context, o *AuthCodeOption, client oidcclient.
|
||||
BindAddress: o.BindAddress,
|
||||
State: state,
|
||||
Nonce: nonce,
|
||||
CodeChallenge: p.CodeChallenge,
|
||||
CodeChallengeMethod: p.CodeChallengeMethod,
|
||||
CodeVerifier: p.CodeVerifier,
|
||||
PKCEParams: p,
|
||||
RedirectURLHostname: o.RedirectURLHostname,
|
||||
AuthRequestExtraParams: o.AuthRequestExtraParams,
|
||||
LocalServerSuccessHTML: templates.AuthCodeBrowserSuccessHTML,
|
||||
}
|
||||
readyChan := make(chan string, 1)
|
||||
defer close(readyChan)
|
||||
@@ -34,6 +34,7 @@ func TestAuthCode_Do(t *testing.T) {
|
||||
AuthRequestExtraParams: map[string]string{"ttl": "86400", "reauth": "true"},
|
||||
}
|
||||
mockOIDCClient := mock_oidcclient.NewMockInterface(ctrl)
|
||||
mockOIDCClient.EXPECT().SupportedPKCEMethods()
|
||||
mockOIDCClient.EXPECT().
|
||||
GetTokenByAuthCode(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Do(func(_ context.Context, in oidcclient.GetTokenByAuthCodeInput, readyChan chan<- string) {
|
||||
@@ -79,6 +80,7 @@ func TestAuthCode_Do(t *testing.T) {
|
||||
BindAddress: []string{"127.0.0.1:8000"},
|
||||
}
|
||||
mockOIDCClient := mock_oidcclient.NewMockInterface(ctrl)
|
||||
mockOIDCClient.EXPECT().SupportedPKCEMethods()
|
||||
mockOIDCClient.EXPECT().
|
||||
GetTokenByAuthCode(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Do(func(_ context.Context, _ oidcclient.GetTokenByAuthCodeInput, readyChan chan<- string) {
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/reader"
|
||||
"github.com/int128/kubelogin/pkg/domain/oidc"
|
||||
"github.com/int128/kubelogin/pkg/domain/pkce"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
@@ -29,15 +30,14 @@ func (u *AuthCodeKeyboard) Do(ctx context.Context, o *AuthCodeKeyboardOption, cl
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not generate a nonce: %w", err)
|
||||
}
|
||||
p, err := oidc.NewPKCEParams()
|
||||
p, err := pkce.New(client.SupportedPKCEMethods())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not generate PKCE parameters: %w", err)
|
||||
}
|
||||
authCodeURL := client.GetAuthCodeURL(oidcclient.AuthCodeURLInput{
|
||||
State: state,
|
||||
Nonce: nonce,
|
||||
CodeChallenge: p.CodeChallenge,
|
||||
CodeChallengeMethod: p.CodeChallengeMethod,
|
||||
PKCEParams: p,
|
||||
RedirectURI: oobRedirectURI,
|
||||
AuthRequestExtraParams: o.AuthRequestExtraParams,
|
||||
})
|
||||
@@ -48,10 +48,10 @@ func (u *AuthCodeKeyboard) Do(ctx context.Context, o *AuthCodeKeyboardOption, cl
|
||||
}
|
||||
|
||||
tokenSet, err := client.ExchangeAuthCode(ctx, oidcclient.ExchangeAuthCodeInput{
|
||||
Code: code,
|
||||
CodeVerifier: p.CodeVerifier,
|
||||
Nonce: nonce,
|
||||
RedirectURI: oobRedirectURI,
|
||||
Code: code,
|
||||
PKCEParams: p,
|
||||
Nonce: nonce,
|
||||
RedirectURI: oobRedirectURI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not exchange the authorization code: %w", err)
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestAuthCodeKeyboard_Do(t *testing.T) {
|
||||
AuthRequestExtraParams: map[string]string{"ttl": "86400", "reauth": "true"},
|
||||
}
|
||||
mockOIDCClient := mock_oidcclient.NewMockInterface(ctrl)
|
||||
mockOIDCClient.EXPECT().SupportedPKCEMethods()
|
||||
mockOIDCClient.EXPECT().
|
||||
GetAuthCodeURL(nonNil).
|
||||
Do(func(in oidcclient.AuthCodeURLInput) {
|
||||
|
||||
@@ -87,7 +87,7 @@ const passwordPrompt = "Password: "
|
||||
// If the Password is not set, it asks a password by the prompt.
|
||||
//
|
||||
type Authentication struct {
|
||||
NewOIDCClient oidcclient.NewFunc
|
||||
OIDCClient oidcclient.FactoryInterface
|
||||
Logger logger.Interface
|
||||
Clock clock.Interface
|
||||
AuthCode *AuthCode
|
||||
@@ -118,14 +118,13 @@ func (u *Authentication) Do(ctx context.Context, in Input) (*Output, error) {
|
||||
}
|
||||
|
||||
u.Logger.V(1).Infof("initializing an OpenID Connect client")
|
||||
client, err := u.NewOIDCClient(ctx, oidcclient.Config{
|
||||
client, err := u.OIDCClient.New(ctx, oidcclient.Config{
|
||||
IssuerURL: in.IssuerURL,
|
||||
ClientID: in.ClientID,
|
||||
ClientSecret: in.ClientSecret,
|
||||
ExtraScopes: in.ExtraScopes,
|
||||
CertPool: in.CertPool,
|
||||
SkipTLSVerify: in.SkipTLSVerify,
|
||||
Logger: u.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("oidc error: %w", err)
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/logger"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/adaptors/oidcclient/mock_oidcclient"
|
||||
"github.com/int128/kubelogin/pkg/domain/jwt"
|
||||
@@ -18,8 +16,6 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var cmpIgnoreLogger = cmpopts.IgnoreInterfaces(struct{ logger.Interface }{})
|
||||
|
||||
func TestAuthentication_Do(t *testing.T) {
|
||||
timeout := 5 * time.Second
|
||||
expiryTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
@@ -92,16 +88,14 @@ func TestAuthentication_Do(t *testing.T) {
|
||||
IDTokenClaims: dummyClaims,
|
||||
}, nil)
|
||||
u := Authentication{
|
||||
NewOIDCClient: func(_ context.Context, got oidcclient.Config) (oidcclient.Interface, error) {
|
||||
want := oidcclient.Config{
|
||||
OIDCClient: &oidcclientFactory{
|
||||
t: t,
|
||||
client: mockOIDCClient,
|
||||
want: oidcclient.Config{
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||
}
|
||||
if diff := cmp.Diff(want, got, cmpIgnoreLogger); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
return mockOIDCClient, nil
|
||||
},
|
||||
},
|
||||
Logger: testingLogger.New(t),
|
||||
Clock: clock.Fake(expiryTime.Add(+time.Hour)),
|
||||
@@ -139,6 +133,7 @@ func TestAuthentication_Do(t *testing.T) {
|
||||
RefreshToken: "EXPIRED_REFRESH_TOKEN",
|
||||
}
|
||||
mockOIDCClient := mock_oidcclient.NewMockInterface(ctrl)
|
||||
mockOIDCClient.EXPECT().SupportedPKCEMethods()
|
||||
mockOIDCClient.EXPECT().
|
||||
Refresh(ctx, "EXPIRED_REFRESH_TOKEN").
|
||||
Return(nil, xerrors.New("token has expired"))
|
||||
@@ -153,16 +148,14 @@ func TestAuthentication_Do(t *testing.T) {
|
||||
IDTokenClaims: dummyClaims,
|
||||
}, nil)
|
||||
u := Authentication{
|
||||
NewOIDCClient: func(_ context.Context, got oidcclient.Config) (oidcclient.Interface, error) {
|
||||
want := oidcclient.Config{
|
||||
OIDCClient: &oidcclientFactory{
|
||||
t: t,
|
||||
client: mockOIDCClient,
|
||||
want: oidcclient.Config{
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||
}
|
||||
if diff := cmp.Diff(want, got, cmpIgnoreLogger); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
return mockOIDCClient, nil
|
||||
},
|
||||
},
|
||||
Logger: testingLogger.New(t),
|
||||
Clock: clock.Fake(expiryTime.Add(+time.Hour)),
|
||||
@@ -209,16 +202,14 @@ func TestAuthentication_Do(t *testing.T) {
|
||||
IDTokenClaims: dummyClaims,
|
||||
}, nil)
|
||||
u := Authentication{
|
||||
NewOIDCClient: func(_ context.Context, got oidcclient.Config) (oidcclient.Interface, error) {
|
||||
want := oidcclient.Config{
|
||||
OIDCClient: &oidcclientFactory{
|
||||
t: t,
|
||||
client: mockOIDCClient,
|
||||
want: oidcclient.Config{
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||
}
|
||||
if diff := cmp.Diff(want, got, cmpIgnoreLogger); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
return mockOIDCClient, nil
|
||||
},
|
||||
},
|
||||
Logger: testingLogger.New(t),
|
||||
ROPC: &ROPC{
|
||||
@@ -239,3 +230,16 @@ func TestAuthentication_Do(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type oidcclientFactory struct {
|
||||
t *testing.T
|
||||
client oidcclient.Interface
|
||||
want oidcclient.Config
|
||||
}
|
||||
|
||||
func (f *oidcclientFactory) New(_ context.Context, got oidcclient.Config) (oidcclient.Interface, error) {
|
||||
if diff := cmp.Diff(f.want, got); diff != "" {
|
||||
f.t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
return f.client, nil
|
||||
}
|
||||
|
||||
109
system_test/Makefile
Normal file
109
system_test/Makefile
Normal file
@@ -0,0 +1,109 @@
|
||||
CLUSTER_NAME := kubelogin-system-test
|
||||
OUTPUT_DIR := $(CURDIR)/output
|
||||
|
||||
PATH := $(PATH):$(OUTPUT_DIR)/bin
|
||||
export PATH
|
||||
KUBECONFIG := $(OUTPUT_DIR)/kubeconfig.yaml
|
||||
export KUBECONFIG
|
||||
|
||||
# run the login script instead of opening chrome
|
||||
BROWSER := $(OUTPUT_DIR)/bin/chromelogin
|
||||
export BROWSER
|
||||
|
||||
.PHONY: test
|
||||
test: build
|
||||
# see the setup instruction
|
||||
kubectl oidc-login setup \
|
||||
--oidc-issuer-url=https://dex-server:10443/dex \
|
||||
--oidc-client-id=YOUR_CLIENT_ID \
|
||||
--oidc-client-secret=YOUR_CLIENT_SECRET \
|
||||
--oidc-extra-scope=email \
|
||||
--certificate-authority=$(OUTPUT_DIR)/ca.crt
|
||||
# set up the kubeconfig
|
||||
kubectl config set-credentials oidc \
|
||||
--exec-api-version=client.authentication.k8s.io/v1beta1 \
|
||||
--exec-command=kubectl \
|
||||
--exec-arg=oidc-login \
|
||||
--exec-arg=get-token \
|
||||
--exec-arg=--oidc-issuer-url=https://dex-server:10443/dex \
|
||||
--exec-arg=--oidc-client-id=YOUR_CLIENT_ID \
|
||||
--exec-arg=--oidc-client-secret=YOUR_CLIENT_SECRET \
|
||||
--exec-arg=--oidc-extra-scope=email \
|
||||
--exec-arg=--certificate-authority=$(OUTPUT_DIR)/ca.crt
|
||||
# make sure we can access the cluster
|
||||
kubectl --user=oidc cluster-info
|
||||
# switch the current context
|
||||
kubectl config set-context --current --user=oidc
|
||||
# make sure we can access the cluster
|
||||
kubectl cluster-info
|
||||
|
||||
.PHONY: setup
|
||||
setup: build dex cluster setup-chrome
|
||||
|
||||
.PHONY: setup-chrome
|
||||
setup-chrome: $(OUTPUT_DIR)/ca.crt
|
||||
# add the dex server certificate to the trust store
|
||||
mkdir -p ~/.pki/nssdb
|
||||
cd ~/.pki/nssdb && certutil -A -d sql:. -n dex -i $(OUTPUT_DIR)/ca.crt -t "TC,,"
|
||||
|
||||
# build binaries
|
||||
.PHONY: build
|
||||
build: $(OUTPUT_DIR)/bin/kubectl-oidc_login $(OUTPUT_DIR)/bin/chromelogin
|
||||
$(OUTPUT_DIR)/bin/kubectl-oidc_login:
|
||||
go build -o $@ ..
|
||||
$(OUTPUT_DIR)/bin/chromelogin: chromelogin/main.go
|
||||
go build -o $@ ./chromelogin
|
||||
|
||||
# create a Dex server
|
||||
.PHONY: dex
|
||||
dex: $(OUTPUT_DIR)/server.crt $(OUTPUT_DIR)/server.key
|
||||
docker create --name dex-server -p 10443:10443 --network kind quay.io/dexidp/dex:v2.21.0 serve /dex.yaml
|
||||
docker cp $(OUTPUT_DIR)/server.crt dex-server:/
|
||||
docker cp $(OUTPUT_DIR)/server.key dex-server:/
|
||||
docker cp dex.yaml dex-server:/
|
||||
docker start dex-server
|
||||
docker logs dex-server
|
||||
|
||||
$(OUTPUT_DIR)/ca.key:
|
||||
mkdir -p $(OUTPUT_DIR)
|
||||
openssl genrsa -out $@ 2048
|
||||
$(OUTPUT_DIR)/ca.csr: $(OUTPUT_DIR)/ca.key
|
||||
openssl req -new -key $(OUTPUT_DIR)/ca.key -out $@ -subj "/CN=dex-ca" -config openssl.cnf
|
||||
$(OUTPUT_DIR)/ca.crt: $(OUTPUT_DIR)/ca.key $(OUTPUT_DIR)/ca.csr
|
||||
openssl x509 -req -in $(OUTPUT_DIR)/ca.csr -signkey $(OUTPUT_DIR)/ca.key -out $@ -days 10
|
||||
$(OUTPUT_DIR)/server.key:
|
||||
mkdir -p $(OUTPUT_DIR)
|
||||
openssl genrsa -out $@ 2048
|
||||
$(OUTPUT_DIR)/server.csr: openssl.cnf $(OUTPUT_DIR)/server.key
|
||||
openssl req -new -key $(OUTPUT_DIR)/server.key -out $@ -subj "/CN=dex-server" -config openssl.cnf
|
||||
$(OUTPUT_DIR)/server.crt: openssl.cnf $(OUTPUT_DIR)/server.csr $(OUTPUT_DIR)/ca.crt $(OUTPUT_DIR)/ca.key
|
||||
openssl x509 -req -in $(OUTPUT_DIR)/server.csr -CA $(OUTPUT_DIR)/ca.crt -CAkey $(OUTPUT_DIR)/ca.key -CAcreateserial -out $@ -sha256 -days 10 -extensions v3_req -extfile openssl.cnf
|
||||
|
||||
# create a Kubernetes cluster
|
||||
.PHONY: cluster
|
||||
cluster: dex create-cluster
|
||||
# add the Dex container IP to /etc/hosts of kube-apiserver
|
||||
docker inspect -f '{{.NetworkSettings.IPAddress}}' dex-server | sed -e 's,$$, dex-server,' | \
|
||||
kubectl -n kube-system exec -i kube-apiserver-$(CLUSTER_NAME)-control-plane -- tee -a /etc/hosts
|
||||
# wait for kube-apiserver oidc initialization
|
||||
# (oidc authenticator will retry oidc discovery every 10s)
|
||||
sleep 10
|
||||
|
||||
.PHONY: create-cluster
|
||||
create-cluster: $(OUTPUT_DIR)/ca.crt
|
||||
cp $(OUTPUT_DIR)/ca.crt /tmp/kubelogin-system-test-dex-ca.crt
|
||||
kind create cluster --name $(CLUSTER_NAME) --config cluster.yaml
|
||||
kubectl create clusterrole cluster-readonly --verb=get,watch,list --resource='*.*'
|
||||
kubectl create clusterrolebinding cluster-readonly --clusterrole=cluster-readonly --user=admin@example.com
|
||||
|
||||
# clean up the resources
|
||||
.PHONY: clean
|
||||
clean:
|
||||
-rm -r $(OUTPUT_DIR)
|
||||
.PHONY: delete-cluster
|
||||
delete-cluster:
|
||||
kind delete cluster --name $(CLUSTER_NAME)
|
||||
.PHONY: delete-dex
|
||||
delete-dex:
|
||||
docker stop dex-server
|
||||
docker rm dex-server
|
||||
112
system_test/README.md
Normal file
112
system_test/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# kubelogin/system_test
|
||||
|
||||
This is an automated test for verifying behavior of the plugin with a real Kubernetes cluster and OIDC provider.
|
||||
|
||||
|
||||
## Purpose
|
||||
|
||||
This test checks the following points:
|
||||
|
||||
1. User can set up Kubernetes OIDC authentication using [setup guide](../docs/setup.md).
|
||||
1. User can log in to an OIDC provider on a browser.
|
||||
1. User can access the cluster using a token returned from the plugin.
|
||||
|
||||
It depends on the following components:
|
||||
|
||||
- Kubernetes cluster (Kind)
|
||||
- OIDC provider (Dex)
|
||||
- Browser (Chrome)
|
||||
- kubectl command
|
||||
|
||||
|
||||
## How it works
|
||||
|
||||
Let's take a look at the diagram.
|
||||
|
||||

|
||||
|
||||
It prepares the following resources:
|
||||
|
||||
1. Generate a pair of CA certificate and TLS server certificate for Dex.
|
||||
1. Run Dex on a container.
|
||||
1. Create a Kubernetes cluster using Kind.
|
||||
1. Mutate `/etc/hosts` of the CI machine to access Dex.
|
||||
1. Mutate `/etc/hosts` of the kube-apiserver pod to access Dex.
|
||||
|
||||
It performs the test by the following steps:
|
||||
|
||||
1. Run kubectl.
|
||||
1. kubectl automatically runs kubelogin.
|
||||
1. kubelogin automatically runs [chromelogin](chromelogin).
|
||||
1. chromelogin opens the browser, navigates to `http://localhost:8000` and enter the username and password.
|
||||
1. kubelogin gets an authorization code from the browser.
|
||||
1. kubelogin gets a token.
|
||||
1. kubectl accesses an API with the token.
|
||||
1. kube-apiserver verifies the token by Dex.
|
||||
1. Check if kubectl exited with code 0.
|
||||
|
||||
|
||||
## Run locally
|
||||
|
||||
You need to set up the following components:
|
||||
|
||||
- Docker
|
||||
- Kind
|
||||
- Chrome or Chromium
|
||||
|
||||
You need to add the following line to `/etc/hosts` so that the browser can access the Dex.
|
||||
|
||||
```
|
||||
127.0.0.1 dex-server
|
||||
```
|
||||
|
||||
Run the test.
|
||||
|
||||
```shell script
|
||||
# run the test
|
||||
make
|
||||
|
||||
# clean up
|
||||
make delete-cluster
|
||||
make delete-dex
|
||||
```
|
||||
|
||||
|
||||
## Technical consideration
|
||||
|
||||
### Network and DNS
|
||||
|
||||
Consider the following issues:
|
||||
|
||||
- kube-apiserver runs on the host network of the kind container.
|
||||
- kube-apiserver cannot resolve a service name by kube-dns.
|
||||
- kube-apiserver cannot access a cluster IP.
|
||||
- kube-apiserver can access another container via the Docker network.
|
||||
- Chrome requires exactly match of domain name between Dex URL and a server certificate.
|
||||
|
||||
Consequently,
|
||||
|
||||
- kube-apiserver accesses Dex by resolving `/etc/hosts` and via the Docker network.
|
||||
- kubelogin and Chrome accesses Dex by resolving `/etc/hosts` and via the Docker network.
|
||||
|
||||
### TLS server certificate
|
||||
|
||||
Consider the following issues:
|
||||
|
||||
- kube-apiserver requires `--oidc-issuer` is HTTPS URL.
|
||||
- kube-apiserver requires a CA certificate at startup, if `--oidc-ca-file` is given.
|
||||
- kube-apiserver mounts `/usr/local/share/ca-certificates` from the kind container.
|
||||
- It is possible to mount a file from the CI machine.
|
||||
- It is not possible to issue a certificate using Let's Encrypt in runtime.
|
||||
- Chrome requires a valid certificate in `~/.pki/nssdb`.
|
||||
|
||||
As a result,
|
||||
|
||||
- kube-apiserver uses the CA certificate of `/usr/local/share/ca-certificates/dex-ca.crt`. See the `extraMounts` section of [`cluster.yaml`](cluster.yaml).
|
||||
- kubelogin uses the CA certificate in `output/ca.crt`.
|
||||
- Chrome uses the CA certificate in `~/.pki/nssdb`.
|
||||
|
||||
### Test environment
|
||||
|
||||
- Set the issuer URL to kube-apiserver. See [`cluster.yaml`](cluster.yaml).
|
||||
- Set `BROWSER` environment variable to run [`chromelogin`](chromelogin) by `xdg-open`.
|
||||
20
system_test/cluster.yaml
Normal file
20
system_test/cluster.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
# https://github.com/dexidp/dex/blob/master/Documentation/kubernetes.md
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
apiVersion: kubeadm.k8s.io/v1beta2
|
||||
kind: ClusterConfiguration
|
||||
metadata:
|
||||
name: config
|
||||
apiServer:
|
||||
extraArgs:
|
||||
oidc-issuer-url: https://dex-server:10443/dex
|
||||
oidc-client-id: YOUR_CLIENT_ID
|
||||
oidc-username-claim: email
|
||||
oidc-ca-file: /usr/local/share/ca-certificates/dex-ca.crt
|
||||
nodes:
|
||||
- role: control-plane
|
||||
extraMounts:
|
||||
- hostPath: /tmp/kubelogin-system-test-dex-ca.crt
|
||||
containerPath: /usr/local/share/ca-certificates/dex-ca.crt
|
||||
Reference in New Issue
Block a user