3.8 KiB
Manually mint a Fleet token
Use this procedure to test the Zitadel-to-NATS authentication chain. It signs a JWT-bearer assertion with a machine key, exchanges it for an OIDC ID token, and passes that ID token to the NATS CLI.
Do not use access_token from the response. Zitadel returns an opaque access
token by default; production Fleet requires the verifiable id_token.
Inputs
Read the live values rather than relying on a local Zitadel cache:
| Input | Source |
|---|---|
OIDC_ISSUER_URL |
Callout environment |
PROJECT_ID |
Callout OIDC_AUDIENCE environment value |
USER_ID |
Machine key userId |
KEY_ID |
Machine key keyId |
| RSA private key | Machine key key |
For a cached operator key:
jq -r '.machine_keys["fleet-ops"]' \
~/.local/share/harmony/zitadel/client-config.json > /tmp/fleet-ops.json
jq -r '.key' /tmp/fleet-ops.json > /tmp/fleet-ops.pem
Protect and remove these temporary files when finished.
Mint with PyJWT
Install PyJWT, not the unrelated jwt package.
import json
import time
import jwt
import requests
OIDC_ISSUER_URL = "https://sso.example.com"
PROJECT_ID = "..." # OIDC_AUDIENCE from the running callout
machine_key = json.load(open("/tmp/fleet-ops.json"))
private_key = open("/tmp/fleet-ops.pem").read()
now = int(time.time())
assertion = jwt.encode(
{
"iss": machine_key["userId"],
"sub": machine_key["userId"],
"aud": OIDC_ISSUER_URL,
"iat": now,
"exp": now + 60,
},
private_key,
algorithm="RS256",
headers={"kid": machine_key["keyId"]},
)
response = requests.post(
f"{OIDC_ISSUER_URL.rstrip('/')}/oauth/v2/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
"scope": (
"openid "
"urn:zitadel:iam:org:projects:roles "
f"urn:zitadel:iam:org:project:id:{PROJECT_ID}:aud"
),
},
timeout=10,
)
response.raise_for_status()
token = response.json()["id_token"]
print(jwt.decode(token, options={"verify_signature": False}))
print(token)
The decoded ID token must have the exact issuer configured on the callout, the
Fleet project in aud, a client_id, and the Fleet project's role claim. The
callout accepts fleet-admin or device; it strips the configured device-
prefix from client_id for device identities.
Decoding without signature verification is diagnostic only. The callout still verifies the signature against Zitadel's JWKS.
Connect to NATS
Capture the last output line from the script:
TOKEN=$(python3 mint.py | tail -1)
nats --server wss://nats.example.com --token "$TOKEN" kv ls device-info
An administrator can inspect a device entry:
nats --server wss://nats.example.com --token "$TOKEN" \
kv get device-info info.<device-id>
Do not use manual KV writes as the deployment workflow. Apply a namespaced
Fleet Deployment resource so the operator owns target planning, rollout state,
and status.
Failures
| Symptom | Check |
|---|---|
Token response has no id_token |
Include openid; confirm the API application permits an ID token |
| ID token has no project roles | Include the plural urn:zitadel:iam:org:projects:roles scope and verify the role grant |
InvalidAudience |
Use the live callout OIDC_AUDIENCE and include the project audience scope |
InvalidIssuer |
Match OIDC_ISSUER_URL exactly, including scheme, host, port, and trailing-slash behavior |
Missing client_id |
Confirm the token is an ID token minted for the Fleet API application |
| Assertion expired | Synchronize the client clock; the assertion window is 60 seconds |
AbstractJWKBase type error |
Remove the jwt package and install PyJWT |
The callout log contains the validation failure:
kubectl -n <namespace> logs deploy/fleet-callout