feat(pair): authenticate the machine with an RFC 8628 device grant - #328
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
capy pairnow 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
intervalandexpires_income from the authorize response and are authoritative, never hardcoded — the server is entitled to change them.slow_downwidens 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.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 pairhas 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, andK_localis still never written to disk. The only difference is that it now runs over a session belonging to this machine.Notes for review
letbindings in this command are gone: the ceremony and the session install each became aconstholding a discriminated result from an extracted function, rather than a binding reassigned insidetry/catch.fetchare injected in the tests, so the poll loop is asserted deterministically and instantly.capy run, binary pinning) that have nothing to do with this change — runnpx tscfirst.Full suite: 1790 pass, 0 fail.