All checks were successful
Run Check Script / check (pull_request) Successful in 2m0s
NATS Auth Callout — Integration Test
Prerequisites
- Podman (running nats-server container)
- Rust toolchain
Running the Test
# From the workspace root
cargo test -p integration-test-callout -- --nocapture --test-threads=1
The --test-threads=1 flag is important because both tests use the same podman
container engine and ports must not collide.
If a previous test run left a stale container:
podman rm -f nats-callout-test-14222 nats-callout-test-14223
Architecture
┌──────────────┐ ┌─────────────────┐ ┌───────────────────┐
│ IoT Device │ │ Callout Service │ │ Mock OIDC │
│ (async-nats) │ │ (auth handler) │ │ Server │
│ │ │ │ │ │
│ 1. Connect │ │ 4. Subscribe to │ │ JWKS + │
│ with │ │ $SYS.REQ. │ │ openid- │
│ Zitadel │ │ USER.AUTH │ │ configuration │
│ JWT token │ │ │ │ │
│ │ │ 6. Decode auth │ │ │
│ │ │ request JWT │ │ │
│ │ │ │ │ │
│ │ │ 7. Validate │ │ │
│ │ │ Zitadel JWT │ │ │
│ │ │ (extract │ │ │
│ │ │ device_id) │ │ │
│ │ │ │ │ │
│ │ │ 8. Build user │ │ │
│ │ │ JWT with │ │ │
│ │ │ scoped perms │ │ │
│ │ │ │ │ │
│ │ │ 9. Send auth │ │ │
│ │ │ response JWT │ │ │
└──────┬───────┘ └────────┬─────────┘ └───────────────────┘
│ │
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ nats-server (podman) │
│ │
│ accounts { │
│ DEVICES: { jetstream: enabled, │
│ users: [{user: "auth", password: "auth"}, │
│ {user: "platform", password: "platform"}]│
│ } │
│ } │
│ │
│ authorization { │
│ auth_callout { │
│ issuer: <CALLOUT_NKEY_PUB> # signs user JWTs │
│ auth_users: [auth, platform] # bypass callout │
│ account: DEVICES # target account │
│ } │
│ } │
│ │
│ 2. Device connects → NATS sends auth request to callout │
│ 3. Callout responds with user JWT → NATS validates & admits │
│ 5. Device can only pub/sub on its scoped subjects │
└──────────────────────────────────────────────────────────────────┘
Auth Flow (Step by Step)
Device NATS Server Callout Service
│ │ │
│ CONNECT │ │
│ (auth_token= │ │
│ Zitadel JWT) │ │
│────────────────>│ │
│ │ │
│ │ $SYS.REQ.USER.AUTH │
│ │ (auth request JWT)│
│ │───────────────────>│
│ │ │
│ │ 1. Decode auth request JWT
│ │ 2. Extract Zitadel JWT from connect_opts
│ │ 3. Validate JWT, extract device_id
│ │ 4. Build user JWT:
│ │ - subject = caller's nkey
│ │ - audience = "DEVICES"
│ │ - pub_allow: device-state.{id}, _INBOX.>
│ │ - sub_allow: device-commands.{id}, _INBOX.>
│ │ 5. Wrap in auth response JWT
│ │ - audience = server_id
│ │ - signed by issuer NKey
│ │ │
│ │ auth response JWT │
│ │<───────────────────│
│ │ │
│ +OK │ │
│<────────────────│ │
│ │ │
│ SUB device-commands.sensor-01 │
│────────────────>│ (permission check: allowed)
│ │ │
│ PUB device-state.sensor-01 │
│────────────────>│ (permission check: allowed)
│ │ │
│ SUB device-commands.sensor-99 │
│────────────────>│ (permission check: DENIED)
│ -ERR Permissions Violation │
│<────────────────│ │
What the Tests Verify
device_authenticates_and_pubsub
- Device connects with a Zitadel JWT
- Callout service validates the JWT and returns a per-device user JWT
- Device subscribes to
device-commands.sensor-test-01 - Device publishes to
device-state.sensor-test-01 - Platform client (username/password) receives the device state
- Platform sends a command, device receives it
device_cannot_access_other_device_subjects
- Device A connects with JWT for
sensor-a - Device B connects with JWT for
sensor-b - Device A attempts to subscribe to
device-commands.sensor-b - NATS enforces permissions: device A gets
Permissions Violation
Key Design Decisions
- Centralized auth callout (not operator mode): no per-device NATS
accounts, no account JWTs, no
$SYS.REQ.CLAIMS.UPDATE. All devices land in a singleDEVICESaccount with per-device permissions in user JWTs. auth_usersbypass:authandplatformusers skip the callout and authenticate directly with their password. Only devices with a Zitadel JWT go through the callout.issuerNKey: the callout config'sissueris a plain NKey public key (generated withKeyPair::new_account()). The callout service signs user JWTs with the corresponding seed. NATS verifies the response JWT against this key.