Skip to content

Device flow

The device-code flow is OAuth 2.0's "Device Authorization Grant" pattern, adapted for HiveCast pairing. It exists because a Host that does not have a local browser cannot run the standard OIDC redirect flow; instead, it asks the cloud to open the door from another (already authenticated) browser.

State machine

The pending record's status field has these states (declared as HostDeviceLinkStatus in host-auth.ts:115):

pending ──► approved ──► exchanged
   │           │
   ├─► namespace_required        (approval blocked: principal needs to claim a namespace first)

   └─► cancelled                  (timeout, user cancellation, explicit cancel)
StatusMeaning
pendingThe record has been started; nobody has approved it yet. The Host polls.
approvedA signed-in principal has approved the request. The Host is allowed to exchange. The cloud has created the Host Link via HostLinkStore.create().
namespace_requiredApproval is blocked because the principal has no claimed namespace. The Device must wait for the principal to claim one (or supply a routeKey).
exchangedThe Host has redeemed the record. The pending record is consumed; the Host Link is the durable record going forward.
cancelledThe record is dead. The Host must start a new pairing.

Op-by-op trace

auth.device.* ops live on system.auth. Their declarations are in system-auth/src/index.ts:279-309. The store is HostDeviceLinkStore in host-auth.ts:990-1100.

1. auth.device.start

Inputs (all optional):

FieldUse
hostIdUse this hostId instead of waiting for it from the Host. Useful if the Device pre-knows its install identity.
name / hostNameSuggest a hostName label. The user may override at approval.
routeKeyPre-claim a public Space-path.
scopesCapability scopes to attach to the future Host Link.

Effect: HostDeviceLinkStore.start() produces a fresh row with:

ts
{
  deviceCode,                  // base64url, 32 random bytes
  userCode,                    // 'ABCD-EFGH', 32-char alphabet, collision-checked
  hostId?, hostName?, routeKey?,
  scopes,
  intervalSeconds: 2,          // poll interval hint
  createdAt, expiresAt,        // expiresAt = createdAt + 30 min
  status: 'pending',
}

The Host receives { deviceCode, userCode, expiresAt, intervalSeconds }. The user is told to enter userCode on the cloud Devices page.

2. auth.device.status

Look up a pending record by deviceCode or userCode. Read-only. Returns the current status.

3. auth.device.approve

Called from a signed-in browser session. Inputs:

FieldUse
deviceCode or userCodeIdentify the pending record.
principalIdThe approving principal (authenticated identity, NOT taken from payload — see CLAUDE.md Rule 5).

Effect: HostDeviceLinkStore.approve() flips status from pending to approved. The cloud creates a Host Link via HostLinkStore.create() — this is the moment hostLinkId and deviceSlug are minted.

If the principal has no namespace claim and the device-code did not include a routeKey, the cloud may instead transition to namespace_required via markNamespaceRequired(). The Host should display "principal must claim a Space namespace, then retry approve."

4. auth.device.poll

The Host polls every intervalSeconds (default 2 s) calling auth.device.poll. While the status is pending, the cloud responds without further action. When status is approved, the response signals "you may now exchange."

The 30-minute DEVICE_LINK_DURATION_MS is the hard timeout. After that the record is cancelled by cleanupExpired().

5. auth.device.exchange

Final redemption. Inputs: deviceCode only. The cloud:

  1. Calls HostDeviceLinkStore.exchange() to flip approvedexchanged.
  2. Calls HostLinkStore.rotateHeartbeatToken() to mint the bearer token.
  3. Calls into @open-matrix/nats-auth helpers (scopedJwtCredentialsFromAccountSeed, rootScopedNatsUserPermissions) to mint device-scoped NATS user credentials.
  4. Returns DeviceSetupExchangeResponse (see pairing-grant).

The Host writes the response into local credential files via saveHiveCastHostLink().

6. auth.device.cancel

Either side may cancel. The Host calls this on Ctrl-C. The cloud calls this on user-driven "deny" or on TTL expiry via cleanupExpired().

Polling, intervals, and slow-down

The current code uses a fixed 2-second intervalSeconds. There is no slow-down on rapid polling today; the cloud just answers idempotently. A production-hardened device flow may eventually emit slow_down per RFC 8628; that would be a target-state addition. Today: simple.

Why system.auth minted hostLinkId at approve, not at exchange

A subtle point: the Host Link is created during auth.device.approve, not during auth.device.exchange. This means the link exists (with status: 'active') before the Host has redeemed. The exchange is what produces the heartbeat token and the device-scoped NATS credentials, but the durable record is already there.

Implication: a partial approve-then-no-exchange leaves an active Host Link with no heartbeat-token-hash, no live presence, and no Device on the Devices page (because heartbeat needs the bearer token). Cancelling the device record does not retroactively revoke the link.

If you observe an "active but never used" Host Link, revoke it via auth.hostLink.revoke. See revoke-grant.

See also

Source: projects/matrix-3/packages/system-auth/src/host-auth.ts (HostDeviceLinkStore at lines 990-1100, HostLinkStore at lines 799-988); projects/matrix-3/packages/system-auth/src/index.ts (auth.device.* declarations at lines 279-309).