Skip to content

Instantly share code, notes, and snippets.

@reegnz
Last active April 30, 2024 00:53
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save reegnz/c687508459d0258544dc7cdda6e284bc to your computer and use it in GitHub Desktop.
Save reegnz/c687508459d0258544dc7cdda6e284bc to your computer and use it in GitHub Desktop.
Inspecting Kubernetes JWT tokens

Inspecting Kubernetes JWT tokens

Start a cluster with a dummy workload

kind create cluster
kubectl apply -f cli.yaml
kubectl apply -f discovery.yaml

Getting a service account token

If you don't opt out of it, every pod by default gets a token in a projected volume. You can check this out in the pod definition:

kubectl get pods --selector "app=cli" -o json  | jq '.items[].spec.volumes[]'
Output
{
  "name": "kube-api-access-kdlt5",
  "projected": {
    "defaultMode": 420,
    "sources": [
      {
        "serviceAccountToken": {
          "expirationSeconds": 3607,
          "path": "token"
        }
      },
      {
        "configMap": {
          "items": [
            {
              "key": "ca.crt",
              "path": "ca.crt"
            }
          ],
          "name": "kube-root-ca.crt"
        }
      },
      {
        "downwardAPI": {
          "items": [
            {
              "fieldRef": {
                "apiVersion": "v1",
                "fieldPath": "metadata.namespace"
              },
              "path": "namespace"
            }
          ]
        }
      }
    ]
  }
}

The token is mounted at /var/run/secrets/kubernetes.io/serviceaccount/token:

kubectl get pods --selector "app=cli" -o json | jq '.items[].spec.containers[].volumeMounts[]'
Output
{
  "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
  "name": "kube-api-access-kdlt5",
  "readOnly": true
}

We can then inspect the contents of the mounted token.

kubectl exec -it $(kubectl get pods --selector "app=cli" --no-headers) -- cat /var/run/secrets/kubernetes.io/serviceaccount/token > token

You can decode the token easily with jq:

jq -R '.' token  | jq 'split(".")|{header: .[0]|@base64d|fromjson, payload: .[1]|@base64d|fromjson}'
Output
{
  "header": {
    "alg": "RS256",
    "kid": "OAjVVaejWFc0Yt9ykr0_8lMMuRNs67OXTWHsN02Pkyw"
  },
  "payload": {
    "aud": [
      "https://localhost:6443"
    ],
    "exp": 1660853296,
    "iat": 1629317296,
    "iss": "https://localhost:6443",
    "kubernetes.io": {
      "namespace": "default",
      "pod": {
        "name": "cli-6fdfcfd5c8-8f7f7",
        "uid": "262a6af2-a6a8-4e0a-bdf9-a47f8f845c46"
      },
      "serviceaccount": {
        "name": "jwt-test",
        "uid": "272e2776-0648-448a-a166-848d0742abf2"
      },
      "warnafter": 1629320903
    },
    "nbf": 1629317296,
    "sub": "system:serviceaccount:default:jwt-test"
  }
}

You can see that the token has a sub claim that contains information on the service account (namespace and name).

It also contains an iss claim as well, this is an URL where you would expose the cluster issuer discovery endpoint.

It also has a kid in the header that tells us what key to look up in the discovery endpoint to use to verify the signature of the JWT.

Inspecting the discovery endpoint

Let's try and read the discovery URL of the cluster. We need to look at a well-known path of the issuer URL:

curl -s -k https://localhost:6443/.well-known/openid-configuration | jq '.'
Output
{
  "issuer": "https://localhost:6443",
  "jwks_uri": "https://localhost:6443/openid/v1/jwks",
  "response_types_supported": [
    "id_token"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ]
}

The discovery document contains a jwks_uri field. If we query that URI we get back a JSON Web Key Set (JWKS).

curl -s -k $(curl -s -k https://localhost:6443/.well-known/openid-configuration | jq -r '.jwks_uri') | jq '.'
Output
{
  "keys": [
    {
      "use": "sig",
      "kty": "RSA",
      "kid": "OAjVVaejWFc0Yt9ykr0_8lMMuRNs67OXTWHsN02Pkyw",
      "alg": "RS256",
      "n": "mAZAXERdLFYDNMywmze9xeInj4BWleUhMRVM1Dx4k8nCmgvsPVbLQiz013TfTEb00EEifILZ4Ji-hyqAkd555QaaX_LuhHYUdSVTKzIVIm8sDSglBMROkmVhFiYUF9uDK4Kl8khzLcJOchT2o9UJSnfp56Ms_q7ZIuL0P6WeCOcma-4jU-NPQtt5AhWcibnvnAg-3cemzG7BawNtAzYSVHhPUgWYVdsGJy2PqN7QUA6aIsAgmgBCv1eAcw2b3bb1kQLg_5eFxPMauwDf7CfjUcC-5NnAv9PDpJ7H10B5d1qpdtfUNJcSSZ8KnJQzLmcJeVDicOUGKx0-paK9s6zkvQ",
      "e": "AQAB"
    }
  ]
}

Notice that in the keys array you should be able to find the same the kid we had in the JWT. That key must be used to verify the signature of the JWT token.

---
apiVersion: v1
kind: ServiceAccount
metadata:
name: jwt-test
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cli
labels:
app: cli
spec:
selector:
matchLabels:
app: cli
template:
metadata:
labels:
app: cli
spec:
serviceAccountName: jwt-test
containers:
- name: cli
image: ubuntu:20.04
command:
- /bin/sh
- '-c'
- '--'
# infinite loop to keep pod around
args: ["trap : TERM INT; sleep infinity & wait"]
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
featureGates:
ServiceAccountIssuerDiscovery: true
networking:
apiServerPort: 6443
kubeadmConfigPatches:
- |
apiVersion: kubeadm.k8s.io/v1beta2
kind: ClusterConfiguration
apiServer:
extraArgs:
service-account-issuer: https://localhost:6443
service-account-jwks-uri: https://localhost:6443/openid/v1/jwks
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: unauthenticated-issuer-discovery
subjects:
- kind: Group
name: system:unauthenticated
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: system:service-account-issuer-discovery
apiGroup: rbac.authorization.k8s.io
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment