Files
harmony/nats/integration-test-callout

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 single DEVICES account with per-device permissions in user JWTs.
  • auth_users bypass: auth and platform users skip the callout and authenticate directly with their password. Only devices with a Zitadel JWT go through the callout.
  • issuer NKey: the callout config's issuer is a plain NKey public key (generated with KeyPair::new_account()). The callout service signs user JWTs with the corresponding seed. NATS verifies the response JWT against this key.