Skip to main content

ADR-031: One-click linking for the Mac app; device-code stays for headless

Context​

Linking a Mac to a tenant runs the OAuth 2.0 Device Authorization Grant (RFC 8628): the agent requests a code, the console shows an approval page, a human clicks Approve, the agent polls until the decision lands. That is the right protocol for the case it was designed for — a device that cannot show a browser, being approved by a human who is somewhere else. A CI runner, a headless box, a smart TV. The code proves physical presence at the device without the device ever handling credentials.

The Mac app satisfies none of those preconditions:

The flow assumesThe Mac app
The device cannot run a browserIt is a browser — the console renders in a WebView
The approver is on a second deviceSame machine, same window, same person
No authenticated session is availableAlready signed in as the user, with a live session
The device must not see credentialsIt already holds the console session

So the approval proves nothing the session does not already prove. A customer clicks "Connect this Mac", gets navigated to a page, and clicks "Approve" — the same human, in the same window, seconds apart. It is ceremony, and it has a cost beyond the extra click: the approval page is written for a disposable browser tab, so it ended with "You can close this tab" and stranded anyone who reached it inside the app, where there is no tab and no way onward.

The endpoint one-click linking needs already exists. POST /yard/agents/enroll (SPEC-074) mints an agent credential with the tenant and user taken ambiently from the console session, never from the body. Its own description states the security property: "the only party who can cause one to exist for a tenant is somebody already signed in to that tenant." The device-code flow is a second front door onto the same destination, built for callers who have no session to spend.

The missing half is the opposite direction. /yard/agents/{agentHandle} is referenced in the spec's prose as the way to revoke a mistaken approval, but no such path is defined. A customer can link a machine and cannot unlink it.

Decision​

The Mac app links in one click, with no approval step. The console calls POST /yard/agents/enroll with its own session; the resulting credential reaches the local agent over the loopback IPC channel that already carries every other bridge call.

The device-code flow stays, unchanged, for headless callers. aucert init on a CI runner or a server is exactly what RFC 8628 is for. One backend, two front doors, chosen by whether the caller has a session to spend.

Unlinking becomes a first-class action. DELETE /yard/agents/{agentHandle} revokes the installation's credentials server-side and the agent discards its local copy. It is confirmed before it runs, because it revokes tokens and stops any scan currently using this Mac's devices.

Linking is idempotent and re-linking mints a fresh credential pair while revoking the previous one — behaviour enrollYardAgent already has. That is what makes unlink a low-regret action: a customer who disconnects and changes their mind is one click from reconnecting, and the machine keeps its agentHandle, so deviceRefs in saved scan configurations keep resolving.

Alternatives considered​

Keep the approval step for the app. Rejected: it defends nothing. Every capability the approval gates is already available to whoever holds the console session, because the approval page is authenticated by that same session. An attacker who can click Approve could equally have started the scan directly.

Keep approval, fix only the dead-end copy. Rejected as a half-measure. It would have removed the symptom while leaving a step whose only function is to make linking feel consequential. Ceremony that teaches customers to click past a security prompt is worse than no prompt.

A toggle for link/unlink. Rejected on affordance grounds. A toggle promises instant, cheap, reversible. Unlink revokes tokens and can stop a running scan, and it is confirmed — a toggle that opens a dialog misrepresents itself, and its state is ambiguous while the dialog is open. An explicit button labelled with its action ("Connect this Mac" / "Disconnect") says what will happen.

Deleting the device-code flow entirely. Rejected: it is the only mechanism that works for a machine with no browser, which is the CLI's whole deployment story.

Consequences​

The console origin becomes the sole boundary on linking. Any page rendered in the app's WebView can call window.aucertNative and cause a link. This is not new — beginEnrollment() was already reachable the same way, and the approval page was itself served from that origin — but removing the approval makes the origin load-bearing rather than merely convenient. It is stated here explicitly rather than left implicit: the WebView must never render a foreign origin in the main frame. ExternalLinkPolicy already enforces this (foreign-host clicks and new-window requests go to the system browser); that policy is now a security control, not a UX preference, and its tests should be read that way.

Linking is no longer separately audited by a human action. The enrolment record still captures which installation was credentialed, for which tenant and user, and when — the audit trail survives. What is lost is the ability to say "a human explicitly consented to this machine, at this moment", which never distinguished anything a session did not already establish.

Unlink is destructive and needs to say so. Revoking the credential drops the agent's control channel; any scan leasing a device from this Mac fails. The confirmation names that consequence rather than only mentioning tokens, and says reconnecting is one click, because a customer weighing "can I undo this" deserves the true answer.

ADR-030 is unaffected. How the credential is stored once it reaches the machine — the layered defence, the Keychain question, the Team ID dependency — is orthogonal to how it is obtained. This ADR changes the second, and the first still applies to whatever lands in ~/.aucert/credentials.json.

Reversible. If per-machine consent turns out to matter — an enterprise tenant that wants an admin in the loop, say — the device-code flow is still live and the app can be pointed back at it by changing which endpoint the bridge calls. Nothing here deletes that road.