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.
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}
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}
So someone else forced their request upon you with a bearer token. How do you verify it?
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}
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}