Repository navigation
Conversation
…t a user Devices an MDM provisions cannot always wait for a user to sign in: some organizations do not allow enrollment to depend on a user action at all. Both existing flows (browser redirect and device authorization) need one. Add a third flow. The controller is configured with named enrollment tokens read from files (`enrollmentTokens`), the daemon with the path of the token its MDM dropped next to the configuration (`controller.enrollmentTokenPath`). The daemon sends `EnrollWithToken` with the hostname, the CSR and the token; the controller compares the token in constant time, issues the device certificate and records the token's name on the device instead of an IdP issuer and subject. Afterwards the device is authenticated by its certificate alone: the controller skips the Bearer check and the 5-minute UserInfo revalidation for devices with an enrollment token, and the daemon stores no OIDC tokens (`Identity.oauth` is now optional) and sends no Bearer token. `GetLlmGatewayCredential` is refused for these devices, since a gateway credential names the enrolled user and a leaked token must not be enough to obtain one. Deleting the device in the controller revokes it. The device list reports which token enrolled each device. Closes agentdesktop-dev#232 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
i think the requirement makes sense, i'm taking a look at the impl now. is the token basically a shared secret between the controller and every client? it sounds like there's a high likely hood it will leak. is there a better solution that works for your usecase? maybe distributed the client cert ahead of time? signed token? signed jwt as token? happy to hear more about your usecase here |
|
@yuval-k thanks for looking at this, and the concern is fair. Context first, then a revised proposal that I think addresses it better than the current PR. Use case We're a large company rolling agentdesktop out to the whole fleet — hundreds of macOS machines now, Windows next — to get visibility into agent usage and manage policy centrally. We're excited about the project and want it to be the standard here. The constraint is organizational: for security tooling, enrollment can't depend on the user. Not "mandatory, but the user clicks once" — the agent has to be fully operational with zero interaction. We also don't gate network access on enrollment (no NAC backstop), so there's nothing that eventually forces a user through the login flow; a device that doesn't enroll on its own is simply a device we never see. Both current flows (browser PKCE, device code) need a user, so an MDM-deployed daemon can't reach the controller today. What we do have is Jamf: it installs the PKG, writes config and files, and knows which device is which and who it's assigned to. We'd like enrollment to piggyback on that trust instead of the user's. On the shared secret — agreed A single static secret on every machine with no revocation story is weak; it also shows up in Jamf policy parameters and logs. Happy to drop that design. Here's what I'd propose instead, built around your suggestions: 1. Signed JWT as the enrollment credential. The controller is configured with verification keys only ( One note on lifetime: for MDM fleets I'd keep 2. Small blast radius + revocation.
3. Optional MDM attestation hook (follow-up). For operators who want the enrollment actually bound to the MDM, an 4. Pre-distributed client cert. Jamf's built-in CA can push a per-device identity into the System keychain, and the controller could trust that CA at Fitting into #236 #236 makes this cleaner: Both PRs touch Does this direction work for you? If you'd rather go straight to (4), say so and I'll plan for that instead — the one thing we need is that enrollment doesn't depend on the user. |
|
Thank you for the explanation! |
|
@yuval-k not a problem at all — if anything that model fits us better than a third enrollment mode. Separating device identity ("what") from user identity ("who") is how we'd operate anyway, and it handles shared machines cleanly too. I'm guessing the trade-off you're weighing is that if enrollment always needs a token, small/single-user setups lose the "sign in from the tray and you're done" onboarding. One way to keep both: enrollment always consumes a token, but the token can come from two issuers. (a) An operator-signed JWT (MDM fleets; the controller only holds the verification key). (b) A short-lived, single-use token the controller mints after an OIDC login — which is roughly what #236's Two things we'd want to hold onto in that shape:
On the token side the earlier points still apply: operator-minted, lifetime as an operator choice (rotation in an MDM pipeline fails silently), revocation by Happy to take work here: I can write the split up in #232, and once #236 lands, implement the token-enrollment side on top of it (issuer config, JWT verification in |
|
i think my big issue with this change is
it sounds like when going with the token flow there's no way for a user to ever get a token and do an OIDC sign in. This will also enable users that don't want/need a bootstrap token. I think what i'm thinking is similar to what you describe, and it might be easier to do once we land #236 . |
|
@yuval-k to be precise: the requirement is not that a user never signs in. It's that enrollment and normal operation (policy, telemetry, reconcile) must not depend on a user signing in. A user signing in later from the tray to get a gateway credential is fine — we'd likely want that ourselves once we put the LLM gateway in front of the fleet. The Agreed this is easier after #236. I'll wait for that and rebuild this PR as the add-on; happy to feed into the maintainers' discussion if you want anything written up. |
Closes #232.
Adds a third enrollment flow for devices an MDM provisions without any user action, as proposed in #232.
What changes
Controller
enrollmentTokens: [{ name, file }]in the controller configuration. Each file holds one token of at least 32 printable ASCII characters (likeusage.tokenFile); names must be unique.EnrollWithToken(hostname, csr, enrollment_token)→EnrollResponse. The token is compared in constant time (aws_lc_rs::constant_time); no match isUnauthenticated.0005:devices.enrollment_token TEXT(NULL for OIDC-enrolled devices). The device is stored with the token's name and empty issuer/subject/claims.enrollment_tokenis included in the device list and detail JSON.authenticate_device_identityloads the device record first. A device with an enrollment token is authenticated by its certificate alone; any other device goes through the unchanged Bearer + UserInfo + issuer/subject check. TheConnectstream skips the 5-minute UserInfo revalidation for token-enrolled devices. The enrolled principal is now loaded once per request instead of twice.GetLlmGatewayCredentialis refused (FailedPrecondition) for token-enrolled devices: a gateway JWT names the enrolled user, and a leaked enrollment token must not be enough to obtain one.Daemon
controller.enrollmentTokenPath: a file the MDM drops next toconfig.yaml. When set,oidc::enrolldispatches to the newtoken_enrollment::enroll, which sendsEnrollWithTokenand stores an identity without OIDC tokens./v1/enrollmentgoesstarting→enrolling→enrolled; the desktop app never shows a sign-in prompt.Identity.oauthis nowOption<OAuthCredentials>. Without tokens the daemon sends no Bearer metadata, refreshes nothing, and does not reconnect the stream for token expiry. The secret store simply has no OAuth item for such a device.Docs:
docs/device-identity.mdgets a "Token flow" section (configuration, sequence, security notes, revocation); README links to it; schemas regenerated withcargo xtask schema.Security notes
Unauthenticatedand the daemon re-enrolls only while its token is still configured.Tests
Unauthenticated; existing principal-matching tests reworked around the newauthenticate_device_identity.cargo fmt --check,cargo clippy --workspace --all-targets -D warnings, and the controller, core and agent unit tests pass locally (macOS). The agent's provider integration tests need a Linux-built daemon and were not run here.Not included, possible follow-ups: an optional MDM-asserted user for attribution (kept out so that unverified claims never land in
idp_claims_json), and rate limiting ofEnrollWithToken.