SEANK.H.LIAO

k8s tokenrequest and tokenreview

K8s as an identity provider

k8s token request and review

Ever workload (Pod) that runs in Kubernetes has an attached ServiceAccount. You might think that the ServiceAccount is only good for interacting with the K8s api server, but you can actually do more. With the big clouds like AWS and GCP, cloud native roles / service accounts can be attached to K8s service accounts and their SDKs will plumb through the authentication dance.

With the K8s TokenReview API and ServiceAccountIssuerDiscovery (OIDC) support, your ServiceAccount tokens are actually ID tokens that can be verified by a third party.

get a token

default token

By default, a token attached to the service account will be present in /var/run/secrets/kubernetes.io/serviceaccount/token. It's a jwt / oidc token that can be decoded, smallstep's step-cli can do it with step-cli crypto jwt --insecure. Here we can see the audience for this token is just the kubernetes api server.

 1$ cat /var/run/secrets/kubernetes.io/serviceaccount/token
 2eyJhbGciOiJSUzI1NiIsImtpZCI6IlZsNTF6WjNLWW5PZmE5eFJLNmM0ZmM2NG1Jelc2MzdKVGRaTnhXc2ltODAifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNzIwMTE4NjY3LCJpYXQiOjE2ODg1ODI2NjcsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0IiwicG9kIjp7Im5hbWUiOiJjdXJsLTIiLCJ1aWQiOiI0YjUwZjg1Mi0xYzY3LTRkMTItODBlZi00NGZjMmFjMTY3ZjAifSwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiI0NmM1Zjg1Ni1mYzQ5LTQ2ZWMtYTY3OC1kZGE3NzVjNzQxM2QifSwid2FybmFmdGVyIjoxNjg4NTg2Mjc0fSwibmJmIjoxNjg4NTgyNjY3LCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0In0.fnEhHvf4WC1QGobRTNpauMO_nXi36L_dYouTDsaO52B4bOC_2Ea_yqJmR44x4LMI3SaiPsynl5a_k0BWso_Ufx8YXU08wgDA7uC-1At31aSJeBX2oBpHm7hwjJcgQjUZVqJUN7hEvNf1YhZ-7jKnC6QSYhNp6qhdndIpVdwPNU1jwymLKHIA7F5Tb5cVWJPwusnkl3gNBtKKC2Z-Tknw1aATjNs2glnYM4ZSDfM47P1aklwi5MOA6J3mHkMaBD2fmBrrXLisKr7VfYS5sNBxq6yo71tPBQsi4DOejBqA1jefhb6p7NWYm9HzvbOSahZxHung3KrwhcHJKEQUyr7wXQ
 3
 4$ step-cli crypto jwt inspect --insecure < /var/run/secrets/kubernetes.io/serviceaccount/token
 5{
 6  "header": {
 7    "alg": "RS256",
 8    "kid": "Vl51zZ3KYnOfa9xRK6c4fc64mIzW637JTdZNxWsim80"
 9  },
10  "payload": {
11    "aud": [
12      "https://kubernetes.default.svc.cluster.local"
13    ],
14    "exp": 1720118667,
15    "iat": 1688582667,
16    "iss": "https://kubernetes.default.svc.cluster.local",
17    "kubernetes.io": {
18      "namespace": "default",
19      "pod": {
20        "name": "curl-2",
21        "uid": "4b50f852-1c67-4d12-80ef-44fc2ac167f0"
22      },
23      "serviceaccount": {
24        "name": "default",
25        "uid": "46c5f856-fc49-46ec-a678-dda775c7413d"
26      },
27      "warnafter": 1688586274
28    },
29    "nbf": 1688582667,
30    "sub": "system:serviceaccount:default:default"
31  },
32  "signature": "fnEhHvf4WC1QGobRTNpauMO_nXi36L_dYouTDsaO52B4bOC_2Ea_yqJmR44x4LMI3SaiPsynl5a_k0BWso_Ufx8YXU08wgDA7uC-1At31aSJeBX2oBpHm7hwjJcgQjUZVqJUN7hEvNf1YhZ-7jKnC6QSYhNp6qhdndIpVdwPNU1jwymLKHIA7F5Tb5cVWJPwusnkl3gNBtKKC2Z-Tknw1aATjNs2glnYM4ZSDfM47P1aklwi5MOA6J3mHkMaBD2fmBrrXLisKr7VfYS5sNBxq6yo71tPBQsi4DOejBqA1jefhb6p7NWYm9HzvbOSahZxHung3KrwhcHJKEQUyr7wXQ"
33}
token with audience

If we're going to use the token in other places, like authenticating to a different service that isn't kubernetes, the proper way to do it is to request a token with a specific audience that the other service will check against, preventing lateral movement / misuse of existing tokens.

To get a token with a specific audience, we'll need an API call. I think you just somehow have to be aware of your own Namespace and ServiceAccount to make the call (the namespace is projected into /var/run/secrets/kubernetes.io/serviceaccount/namespace), plus have the appropriate RBAC permissions:

RBAC:

 1apiVersion: rbac.authorization.k8s.io/v1
 2kind: ClusterRole
 3metadata:
 4  name: tokenrequest
 5rules:
 6  - apiGroups: [""]
 7    resources:
 8      - "serviceaccounts/token"
 9    verbs:
10      - "create"
11---
12apiVersion: rbac.authorization.k8s.io/v1
13kind: RoleBinding
14metadata:
15  name: my-serviceaccount-tokenrequest
16roleRef:
17  apiGroup: rbac.authorization.k8s.io
18  kind: ClusterRole
19  name: tokenrequest
20subjects:
21  - kind: ServiceAccount
22    name: my-serviceaccount
23    namespace: my-namespace

Go code to request a token:

 1package main
 2
 3import (
 4    "context"
 5    "fmt"
 6
 7    authenticationv1 "k8s.io/api/authentication/v1"
 8    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 9    corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
10    "k8s.io/client-go/rest"
11)
12
13func main() {
14    k8sconf, err := rest.InClusterConfig()
15    if err != nil {
16        panic(err)
17    }
18
19    coreClient, err := corev1.NewForConfig(k8sconf)
20    if err != nil {
21        panic(err)
22    }
23
24    ctx := context.Background()
25    res, err := coreClient.ServiceAccounts("my-namespace").CreateToken(ctx, "my-serviceaccount", &authenticationv1.TokenRequest{
26        Spec: authenticationv1.TokenRequestSpec{
27            Audiences: []string{"my-audience"},
28        },
29    }, metav1.CreateOptions{})
30    if err != nil {
31        panic(err)
32    }
33    fmt.Println(res.Status.Token)
34}

verify a token

So someone else forced their request upon you with a bearer token. How do you verify it?

tokenreview

With the TokenReview API, we can hand the token to kubernetes and it will tell us some info about it. We'll also need RBAC, the builtin system:auth-delegator ClusterRole is made for services that need to check tokens, note that it needs to be granted as a ClusterRoleBinding and not just a RoleBinding.

system:auth-delegator looks like:

 1apiVersion: rbac.authorization.k8s.io/v1
 2kind: ClusterRole
 3metadata:
 4  annotations:
 5    rbac.authorization.kubernetes.io/autoupdate: "true"
 6  labels:
 7    kubernetes.io/bootstrapping: rbac-defaults
 8  name: system:auth-delegator
 9rules:
10  - apiGroups:
11      - authentication.k8s.io
12    resources:
13      - tokenreviews
14    verbs:
15      - create
16  - apiGroups:
17      - authorization.k8s.io
18    resources:
19      - subjectaccessreviews
20    verbs:
21      - create

and Go code to verify the token might look like:

 1package main
 2
 3import (
 4    "fmt"
 5    "log"
 6    "net/http"
 7    "slices"
 8    "strings"
 9
10    authentication_v1 "k8s.io/api/authentication/v1"
11    meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12    authenticationv1 "k8s.io/client-go/kubernetes/typed/authentication/v1"
13    "k8s.io/client-go/rest"
14)
15
16const audience = "my-audience"
17
18func main() {
19    k8sconf, err := rest.InClusterConfig()
20    if err != nil {
21        panic(err)
22    }
23    authClient, err := authenticationv1.NewForConfig(k8sconf)
24    if err != nil {
25        panic(err)
26    }
27
28    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
29        token := r.Header.Get("authorization")
30        token = strings.TrimPrefix(token, "Bearer ")
31        review, err := authClient.TokenReviews().Create(r.Context(), &authentication_v1.TokenReview{
32            Spec: authentication_v1.TokenReviewSpec{
33                Audiences: []string{audience}, // the audience value this service expects to see
34                Token:     token,
35            },
36        }, meta_v1.CreateOptions{})
37        fmt.Printf("%v %#v\n", err, review.Status)
38        if err != nil || !review.Status.Authenticated || !slices.Contains(review.Status.Audiences, audience) {
39            http.Error(w, "unauthorized", http.StatusUnauthorized)
40            return
41        }
42        fmt.Println(review.Status.User.Username)
43        fmt.Println(review.Status.User.Groups)
44    })
45
46    log.Fatalln(http.ListenAndServe(":8080", nil))
47}
oidc

Alternatively, we can authenticate using OIDC. Note here we need to inject a custom client to talk to the kubernetes api server, which uses its own ca certificate and requires auth. But no additional RBAC necessary for this.

 1package main
 2
 3import (
 4	"context"
 5	"crypto/tls"
 6	"crypto/x509"
 7	"log"
 8	"net/http"
 9	"os"
10	"strings"
11
12	"github.com/coreos/go-oidc/v3/oidc"
13	"golang.org/x/oauth2"
14)
15
16const audience = "my-audience"
17
18func main() {
19	caPEM, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
20	if err != nil {
21		panic(err)
22	}
23	pool := x509.NewCertPool()
24	ok := pool.AppendCertsFromPEM(caPEM)
25	if !ok {
26		panic(ok)
27	}
28
29	saToken, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
30	if err != nil {
31		panic(err)
32	}
33
34	ctx := context.Background()
35	ctx = oidc.ClientContext(ctx, &http.Client{
36		Transport: &oauth2.Transport{
37			Source: oauth2.StaticTokenSource(&oauth2.Token{
38				AccessToken: string(saToken),
39			}),
40			Base: &http.Transport{
41				TLSClientConfig: &tls.Config{
42					RootCAs: pool,
43				},
44			},
45		},
46	})
47	provider, err := oidc.NewProvider(ctx, "https://kubernetes.default.svc.cluster.local")
48	if err != nil {
49		panic(err)
50	}
51
52	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
53		token := r.Header.Get("authorization")
54		token = strings.TrimPrefix(token, "Bearer ")
55
56		id, err := provider.Verifier(&oidc.Config{
57			SkipClientIDCheck: true,
58		}).Verify(r.Context(), token)
59		log.Println(id, err)
60		if err != nil {
61			http.Error(w, "unauthorized", http.StatusUnauthorized)
62			return
63		}
64	})
65	log.Fatalln(http.ListenAndServe(":8080", nil))
66}