Appearance
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:
- No browser on the Host. The Host (a CLI invocation, a containerized service, an unattended VM) cannot run an OIDC redirect on its own.
- 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.
- Short-lived approval. Codes expire in 30 minutes (
DEVICE_LINK_DURATION_MS). Approval is one-shot — onceexchanged, 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
userCodeis short and randomly generated (32-char alphabet, collision-checked). - A pending record by itself does nothing — only
auth.device.approvebinds 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/exchangedtransitions (and on TTL viacleanupExpired()).
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:
| Scenario | Expected behavior |
|---|---|
| Pairing without authenticated approver | approve fails; no Host Link created |
| Approve with wrong principal session | approve uses the session's principalId, not the body's |
| Exchange before approve | returns the record unchanged, status still pending; no credentials |
| Exchange after exchange | returns the record, no new credentials |
Token replay against auth.device.poll after exchanged | status returns exchanged; no further data |
See also
- HiveCast account login — how the approver's session is established.
- docs-devices / Pairing / Device flow — the same flow from the Devices side.
- Reference / Token schema — wire shapes.
Source:
projects/matrix-3/packages/system-auth/src/host-auth.ts(HostDeviceLinkStoreat lines 990-1100); op declarations atprojects/matrix-3/packages/system-auth/src/index.ts:279-309.