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

127 lines
3.8 KiB
Markdown

# 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:
```bash
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.
```python
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:
```bash
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:
```bash
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:
```bash
kubectl -n <namespace> logs deploy/fleet-callout
```