Skip to content

feat(enrollment): enroll MDM-provisioned devices with a token, without a user - #233

Open
ibalat wants to merge 1 commit into
agentdesktop-dev:mainfrom
ibalat:feat/token-enrollment
Open

ibalat wants to merge 1 commit into
agentdesktop-dev:mainfrom
ibalat:feat/token-enrollment

Conversation

@ibalat

@ibalat ibalat commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

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 (like usage.tokenFile); names must be unique.
  • New RPC EnrollWithToken(hostname, csr, enrollment_token) → EnrollResponse. The token is compared in constant time (aws_lc_rs::constant_time); no match is Unauthenticated.
  • Migration 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_token is included in the device list and detail JSON.
  • Authentication: authenticate_device_identity loads 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. The Connect stream skips the 5-minute UserInfo revalidation for token-enrolled devices. The enrolled principal is now loaded once per request instead of twice.
  • GetLlmGatewayCredential is 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 to config.yaml. When set, oidc::enroll dispatches to the new token_enrollment::enroll, which sends EnrollWithToken and stores an identity without OIDC tokens. /v1/enrollment goes starting → enrolling → enrolled; the desktop app never shows a sign-in prompt.
  • Identity.oauth is now Option<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.md gets a "Token flow" section (configuration, sequence, security notes, revocation); README links to it; schemas regenerated with cargo xtask schema.

Security notes

  • The token is a shared operator secret, not a user credential: whoever has it can enroll devices and receive the daemon configuration. It should be root-only on devices and rotated by adding a new named token and removing the old one; the device list shows which token enrolled each device so devices of a retired token can be found and deleted.
  • Nothing changes for OIDC-enrolled devices: same Bearer check, same UserInfo revalidation, same principal matching (tests kept).
  • Revocation of a token-enrolled device is by deleting it in the controller; its next call is Unauthenticated and the daemon re-enrolls only while its token is still configured.

Tests

  • controller: token match is exact and constant-time, token file validation, a token-enrolled device authenticates with no metadata while a user-enrolled device without a Bearer token is Unauthenticated; existing principal-matching tests reworked around the new authenticate_device_identity.
  • core: enrollment token configuration parsing and validation.
  • agent: token file reading; identity round trip with optional OAuth (Linux secret store tests).
  • 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 of EnrollWithToken.

…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>
@yuval-k

yuval-k commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

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

@ibalat

ibalat commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

@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 (enrollment.tokenIssuers: [{name, publicKey|jwksPath}]); tokens are minted offline by the operator. Claims: iss, aud (controller), jti, iat, optional exp, and optional constraints such as hostname_pattern. The daemon reads it from controller.enrollmentTokenPath (root-only file, never in YAML, never in /v1/info).

One note on lifetime: for MDM fleets I'd keep exp optional rather than forcing short-lived tokens. The token has to be valid whenever Jamf runs the install — including a new machine onboarded six months from now, or a laptop that was in a drawer for a quarter — and a rotation pipeline between the controller and the MDM is exactly the kind of cross-team dependency that silently breaks onboarding. I'd rather make lifetime an operator knob and get the security from revocation and a small blast radius.

2. Small blast radius + revocation.

  • Token-enrolled devices get a device-only identity: they can fetch policy and send telemetry, nothing user-bound. GetLlmGatewayCredential is refused (there's no user to name), and no IdP subject/claims are recorded.
  • Each device records the issuer and jti it enrolled with. Revoking a jti (controller API / admin UI) drops every device enrolled with it; replacing the token in Jamf is the only MDM-side step, and existing devices are unaffected because they're on their own certs by then.
  • Optional requireApproval: true per issuer: token-enrolled devices start pending and can't Connect until approved (manually or via the management API). A leaked token then produces visible pending devices, not trusted ones.

3. Optional MDM attestation hook (follow-up). For operators who want the enrollment actually bound to the MDM, an enrollment.verifier webhook: before issuing a cert the controller POSTs {issuer, jti, hostname, serial} to an operator URL and expects {allow, user?}. We'd back it with a tiny Jamf API lookup ("is this serial a managed device, and who is it assigned to?"). Vendor-neutral, no Jamf code in the controller, and it solves user attribution for MDM-enrolled devices as a side effect. I'd keep this out of the first PR.

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 EnrollDevice. This is the strongest binding and I'd be glad to do it, but it needs the daemon to sign with a non-exportable keychain key (a custom rustls SigningKey over SecKey on macOS, and the equivalent on Windows), so it's a larger agent-side change. I see it as a later phase, or the path to take if you'd rather not have a bearer-style credential at all.

Fitting into #236

#236 makes this cleaner: EnrollDevice already takes a bearer in metadata after authentication. The MDM flow can reuse that RPC as-is — the bearer is either an IdP access token or an enrollment JWT, and the controller picks the verifier by issuer. No new RPC, no second enrollment path; the daemon's only change is "if enrollmentTokenPath is set, skip GetLoginConfiguration/login and call EnrollDevice with the file contents". After enrollment the device authenticates by cert only, as in this PR.

Both PRs touch oidc.rs, service.rs, fleet.proto and add a 0005_* migration, so I'd wait for #236 to land and then rebase this on top of it as the JWT variant (renumbering the migration).

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.

@yuval-k

yuval-k commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Thank you for the explanation!
I'm trying to think if it is worth separating out OIDC from enrollment entirely instead of having 2 modes. i.e. device only enroll with tokens, users enroll with OIDC. I'm still figuring out the tradeoffs here, but if we go in that direction, would that be a problem for your usecase?

@ibalat

ibalat commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

@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 EnrollDevice-after-login already does. Then there's a single EnrollDevice path, the device identity never carries user claims, and in case (b) the user association is written as a separate device↔user record at login time. Interactive users see no change; MDM deployments just use the default path.

Two things we'd want to hold onto in that shape:

  1. A device must be fully functional with no user attached. Policy, telemetry and reconcile on device identity alone; the user layer only unlocks user-bound things (gateway credentials, attribution). Most of our fleet may never have a user signed in.
  2. User association should be possible without interaction, too — not for the first cut, but a path where the operator asserts the user for a device (an MDM lookup via a verifier hook, or a claim in the enrollment token marked as operator-asserted rather than IdP-verified). Device identity should survive user sign-in/sign-out; the user is an attribute of the device session, not the device's identity.

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 jti that drops the devices enrolled with it.

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 EnrollDevice, jti recording/revocation, device-only identity) while the OIDC user layer stays with you. We also don't have to wait for the full separation — #236 plus an operator token issuer gets us running, and the user layer can follow. How would you like to split it?

@yuval-k

yuval-k commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

i think my big issue with this change is

RenewDeviceCertificate works as usual; GetLlmGatewayCredential
is refused with FailedPrecondition

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.
Is the requirement for a user to never sign in? if it is to just decouple device enrollment and OIDC, we can do that by tweaking the current state - Support token sign in as an "add-on", and if it is not there, keep current behavior.

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 .
I'll ask some more maintainers about this - it's a big decision, so I need to hear more voices.

@ibalat

ibalat commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

@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 FailedPrecondition in this PR was just the conservative choice given that a token-enrolled device had no user to name; it's not a requirement. In the add-on model you describe — token present → device enrolls with it; OIDC sign-in remains available on top and unlocks user-bound things like gateway credentials; no token → current behaviour — everything we need is covered, and it's exactly what I was describing. The one thing I'd want is that a later OIDC sign-in attaches a user to the existing device rather than re-enrolling it, so the device identity stays stable.

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.

This branch has not been deployed

No deployments
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.

Enrollment without user interaction for MDM-managed devices (enrollment token)

2 participants