Files
harmony/docs/guides/fleet-manual-token-mint.md
Jean-Gabriel Gill-Couture 5e3b0522e0
Some checks failed
Run Check Script / check (pull_request) Failing after 13s
docs(fleet): add operator and device guide
2026-07-27 10:34:31 -04:00

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