Skip to content

Device flow

The device flow lets a Host that has no local browser pair to a HiveCast account by getting an already-authenticated browser to approve. It is a direct application of OAuth 2.0's "Device Authorization Grant" pattern (RFC 8628), adapted for Host pairing.

Cross-reference: see docs-devices Pairing / Device flow for the same flow described from the Devices side. This page emphasizes the auth properties — the security argument for why the design is safe.

State machine

pending ──► approved ──► exchanged
   │           │
   ├─► namespace_required        (cannot approve until principal claims a namespace)

   └─► cancelled                  (timeout, user cancellation, explicit cancel)

HostDeviceLinkStatus is declared at host-auth.ts:115. The state transitions are enforced by HostDeviceLinkStore methods at host-auth.ts:990-1100.

Why the device flow is appropriate here

Three properties make it the right fit for Host pairing:

  1. No browser on the Host. The Host (a CLI invocation, a containerized service, an unattended VM) cannot run an OIDC redirect on its own.
  2. Decoupled identity verification. The principal who approves is on a separate device, in a separate browser session. Their identity is verified via the cloud's normal HiveCast sign-in, NOT through the Host.
  3. Short-lived approval. Codes expire in 30 minutes (DEVICE_LINK_DURATION_MS). Approval is one-shot — once exchanged, the code is consumed.

Auth properties

Principal authentication is checked at approve, not at start

auth.device.start does not require an authenticated caller. Anyone can start a pairing with their own userCode. This matches RFC 8628 and is safe because:

  • The userCode is short and randomly generated (32-char alphabet, collision-checked).
  • A pending record by itself does nothing — only auth.device.approve binds a principal to it.
  • Approval requires a signed-in browser session (per CLAUDE.md Rule 5, the principalId is derived from transport metadata, not the body).

Approval cannot be replayed

HostDeviceLinkStore.approve() flips the status from pending to approved. Approving an already-approved record is idempotent (no new effect). Approving a cancelled or exchanged record returns null — the state machine refuses transitions out of terminal states.

Exchange is one-shot

HostDeviceLinkStore.exchange() requires status === 'approved'. On success, status flips to exchanged. A second exchange call returns the record unchanged (still exchanged); the cloud does NOT re-mint new credentials on a re-exchange. The Host gets one chance.

This means: if the Host crashes between approve and writing credentials to disk, the user must re-pair (start a new device-code). The lost credential cannot be re-issued through the same deviceCode.

Code grain

deviceCode  -- base64url, 32 random bytes (256 bits)
userCode    -- 8-char from a 32-char alphabet (40 bits effective)

The userCode has small entropy because users type it. Defense:

  • _createUniqueUserCode() retries on collision against existing pending records.
  • The 30-minute TTL keeps the active code population small at any moment.
  • Pending records are deleted on cancelled / exchanged transitions (and on TTL via cleanupExpired()).

A brute-force attack would need to enumerate the active userCode space against an unauthenticated auth.device.status — but auth.device.status is read-only and reveals only the status, not any secret. The actual approval still requires the principal to act.

namespace_required does not leak namespace info

When a principal tries to approve without a claimed namespace, the status moves to namespace_required and error: 'namespace_required'. The pending record is NOT given the principal's existing namespaces — the only signal is the error string. The Host then prompts the user to claim a namespace and retry.

Polling pattern

The Host polls auth.device.poll every intervalSeconds (default 2 s). The poll returns the current status:

ts
{ ok: true, status: 'pending' }
{ ok: true, status: 'approved', hostLinkId, ... }
{ ok: true, status: 'namespace_required', error: 'namespace_required' }
{ ok: true, status: 'cancelled' }
{ ok: false, error: '<not found / expired>' }

The Host polls until approved, then calls auth.device.exchange to collect the credentials.

There is no slow-down today on rapid polling (RFC 8628's slow_down response is target state). Excessively rapid polling will not be rate-limited at the cloud auth actor; the gateway HTTP layer may apply its own rate limits.

Adversarial scenarios

P1.17 (device-heartbeat-auth-proof) covers post-pairing heartbeat adversarial cases. For the device flow itself, the equivalent properties to verify:

ScenarioExpected behavior
Pairing without authenticated approverapprove fails; no Host Link created
Approve with wrong principal sessionapprove uses the session's principalId, not the body's
Exchange before approvereturns the record unchanged, status still pending; no credentials
Exchange after exchangereturns the record, no new credentials
Token replay against auth.device.poll after exchangedstatus returns exchanged; no further data

See also

Source: projects/matrix-3/packages/system-auth/src/host-auth.ts (HostDeviceLinkStore at lines 990-1100); op declarations at projects/matrix-3/packages/system-auth/src/index.ts:279-309.