Skip to content

feat(pair): authenticate the machine with an RFC 8628 device grant - #328

Merged
cvince merged 6 commits into
feat/portabilityfrom
cap566-device-flow
Aug 29, 2026
Merged

feat(pair): authenticate the machine with an RFC 8628 device grant#328
cvince merged 6 commits into
feat/portabilityfrom
cap566-device-flow

Conversation

@cvince

@cvince cvince commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

capy pair now signs the machine in as itself rather than installing a session handed to it by the approving device.

What changes

The machine asks the service to start a device authorization, prints the user code and the identity provider's verification URL, polls for its own tokens, and installs them through the existing session writer. Credentials are issued to this machine for the CLI client, so its identity is a property of how it signed in, not of how the session reached it.

Both legs go through the service rather than the identity provider directly: the service owns the client id, so this binary cannot pass the wrong one, and provider traffic stays server-side where it can be network-restricted.

Polling

  • interval and expires_in come from the authorize response and are authoritative, never hardcoded — the server is entitled to change them.
  • slow_down widens the interval for the rest of the run, not just the next poll. That signal exists because the server will otherwise start rejecting us, so continuing at the old rate makes things worse.
  • Outcomes are the RFC's coded values only. Nothing reads error_description, which is prose and free to be reworded.

The 200-without-tokens trap

A waiting poll is also HTTP 200 — the loop needs that to keep polling. So a caller trusting the status code would read "still waiting" as "signed in", install nothing, and report success.

Success is therefore asserted from the tokens being present, never from the status code and never from the discriminator alone. Two tests hold it: a complete-looking body with no credentials must throw, and a pending 200 must produce no session and keep polling. Mutation-checked — removing the guard fails the first, so it isn't vacuous.

What deliberately does not change

The key-material half. A machine running capy pair has no browser — that is the whole reason the command exists — so the ceremony still runs on the human's own device through the broker transport, and K_local is still never written to disk. The only difference is that it now runs over a session belonging to this machine.

Notes for review

  • Both let bindings in this command are gone: the ceremony and the session install each became a const holding a discriminated result from an extracted function, rather than a binding reassigned inside try/catch.
  • Time and fetch are injected in the tests, so the poll loop is asserted deterministically and instantly.
  • The suite exercises the BUILT binary. An unbuilt worktree fails ~50 unrelated tests (capy run, binary pinning) that have nothing to do with this change — run npx tsc first.

Full suite: 1790 pass, 0 fail.

cvince added 6 commits August 27, 2026 21:13
`capy pair` now signs the machine in as itself rather than installing a
session handed to it by the approving device.

The machine asks the service to start a device authorization, prints the
user code and the identity provider's verification URL, polls for its own
tokens, and installs them through the existing session writer. Credentials
are issued to this machine for the CLI client, so its identity is a property
of how it signed in rather than of how the session reached it.

Both legs go through the service rather than the identity provider directly:
the service owns the client id, so this binary cannot pass the wrong one, and
provider traffic stays server-side where it can be network-restricted.

`interval` and `expires_in` come from the authorize response and are treated
as authoritative rather than hardcoded — the server is entitled to change
them. `slow_down` widens the interval for the REST of the run, not just the
next poll: that signal exists because the server will otherwise start
rejecting us, so continuing at the old rate makes things worse.

Poll outcomes are the RFC's coded values only. Nothing reads
`error_description`, which is prose for humans and free to be reworded.

Success is asserted from the TOKENS being present, never from the status
code: a waiting poll is also HTTP 200, so a caller trusting the status would
treat "still waiting" as "signed in", install nothing, and report success.
The response is discriminated and the credentials are checked, so that
misread is structurally impossible rather than a convention.

The key-material half deliberately keeps its existing shape. A machine
running `capy pair` has no browser — that is the whole reason the command
exists — so the ceremony still runs on the human's own device through the
broker transport, and K_local is still never written to disk. The only
difference is that it now runs over a session belonging to this machine.

Immutability: both `let` bindings in this command are gone. The ceremony and
the session install each became a const holding a discriminated result from
an extracted function, rather than a binding reassigned inside try/catch.

Full suite: 1790 pass, 0 fail. Note the suite exercises the BUILT binary, so
an unbuilt worktree fails ~50 unrelated tests; run `npx tsc` first.
The command drives deviceAuth/pairDeviceGrant now instead of the old ceremony
and key-material modules. The harness still faked the removed seams, so every
test in the file attempted a REAL network call — one structural cause behind
what looked like eleven independent failures.

Only the fakes move. Every assertion about the command's own behaviour stands
unchanged: exit codes, the --json object shape, the expired code, and all four
QR/TTY rules (QR on a wide TTY, suppressed when piped, when narrow, and under
NO_COLOR). Two harness-only adjustments: bootstrap failure is now the AUTHORIZE
leg failing — the point before which no code exists to print, a poll failure
being a different case — and the QR tests let the event loop turn, since the
block is printed after an awaited call.

Four assertions remain red on purpose, pending a product decision, and are NOT
touched here.
All 17 in this file now pass. Each changed expectation carries a
CHANGED EXPECTATION: note explaining what moved and why, per the existing
convention — the reasoning is in the diff, not only in a review thread.

The printed URL. Pairing no longer points at Keep's own page: the machine
authenticates through the identity provider's device page, so the URL comes
from the authorize response. This is a TRUST-SURFACE change and not only a UX
one — a user taught that pairing happens on a capy.sc domain is now sent
somewhere that is not ours, which is the shape a phishing attempt takes. It
may well be the right trade for a real device grant, but it is a product
decision, and it strengthens the case for putting the device page on a
Capy-owned domain. Asserted from the authorize response rather than
hardcoded, so moving to a custom domain changes config and not this test.

Key material source. No longer taken from the approver's sealed answer,
because there is no sealed answer — that payload carried the approver's own
session, which is the defect being removed. The invariant the test exists for
is unchanged and still asserted: the session installs BEFORE key material is
resolved, because the grant authenticates with it.

The multi-org fallback. Was the org the APPROVER's browser had active; now an
org from the machine's own session. The behaviour is unchanged — when no org
is pinned, the fetch must still be authenticated rather than skipped — and the
new source is strictly better, since it removes a dependency on whatever the
approver happened to have selected.

Everything else in the file stands untouched: exit codes, the --json object
shape, the expired code, and all four QR/TTY rules.
@cvince
cvince merged commit 3be2dfd into feat/portability Aug 29, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant