Appearance
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)| Status | Meaning |
|---|---|
pending | The record has been started; nobody has approved it yet. The Host polls. |
approved | A signed-in principal has approved the request. The Host is allowed to exchange. The cloud has created the Host Link via HostLinkStore.create(). |
namespace_required | Approval is blocked because the principal has no claimed namespace. The Device must wait for the principal to claim one (or supply a routeKey). |
exchanged | The Host has redeemed the record. The pending record is consumed; the Host Link is the durable record going forward. |
cancelled | The 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):
| Field | Use |
|---|---|
hostId | Use this hostId instead of waiting for it from the Host. Useful if the Device pre-knows its install identity. |
name / hostName | Suggest a hostName label. The user may override at approval. |
routeKey | Pre-claim a public Space-path. |
scopes | Capability 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:
| Field | Use |
|---|---|
deviceCode or userCode | Identify the pending record. |
principalId | The 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:
- Calls
HostDeviceLinkStore.exchange()to flipapproved→exchanged. - Calls
HostLinkStore.rotateHeartbeatToken()to mint the bearer token. - Calls into
@open-matrix/nats-authhelpers (scopedJwtCredentialsFromAccountSeed,rootScopedNatsUserPermissions) to mint device-scoped NATS user credentials. - 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
- Login — the user-facing wrapper around this flow.
- Pairing grant — what
exchangereturns. - Host link — what
approvecreates.
Source:
projects/matrix-3/packages/system-auth/src/host-auth.ts(HostDeviceLinkStoreat lines 990-1100,HostLinkStoreat lines 799-988);projects/matrix-3/packages/system-auth/src/index.ts(auth.device.*declarations at lines 279-309).