From 075f1fb6bc08c75ced5f17baf136fc9205ef024f Mon Sep 17 00:00:00 2001 From: "ismet.balat" Date: Sun, 8 Nov 2026 19:47:35 +0300 Subject: [PATCH] feat(enrollment): enroll MDM-provisioned devices with a token, without 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 #232 Co-Authored-By: Claude Fable 5.1 --- README.md | 5 +- crates/agent/src/identity.rs | 53 ++- crates/agent/src/lib.rs | 1 + crates/agent/src/oidc.rs | 31 +- crates/agent/src/remote.rs | 25 +- crates/agent/src/token_enrollment.rs | 80 ++++ .../migrations/0005_enrollment_token.sql | 3 + crates/controller/src/database.rs | 55 ++- crates/controller/src/main.rs | 14 +- crates/controller/src/service.rs | 344 ++++++++++++------ crates/controller/src/usage.rs | 2 + crates/core/src/config.rs | 67 ++++ crates/proto/proto/fleet.proto | 10 + docs/device-identity.md | 72 ++++ schema/controller-config.json | 26 ++ schema/controller-config.md | 3 + schema/daemon-config.json | 7 + schema/daemon-config.md | 1 + 18 files changed, 643 insertions(+), 156 deletions(-) create mode 100644 crates/agent/src/token_enrollment.rs create mode 100644 crates/controller/migrations/0005_enrollment_token.sql diff --git a/README.md b/README.md index 9b7169d3..b74a2950 100644 --- a/README.md +++ b/README.md @@ -1009,7 +1009,10 @@ locally and can request credentials for an LLM gateway through the daemon. In controller-managed mode, the daemon generates its private device key on the workstation and sends only a certificate signing request to the controller. After enrollment, protected controller operations require the device -certificate and a valid token for the user who enrolled it. +certificate and a valid token for the user who enrolled it. Devices an MDM +provisions without a user can instead enroll with an operator-issued +enrollment token and are then identified by their certificate alone; see +[Device identity and enrollment](docs/device-identity.md#token-flow). When a supported tool requests gateway access, the controller can issue a short-lived JWT containing the user, device ID, allowed client label, audience, diff --git a/crates/agent/src/identity.rs b/crates/agent/src/identity.rs index e7f5f37e..becccf8d 100644 --- a/crates/agent/src/identity.rs +++ b/crates/agent/src/identity.rs @@ -22,7 +22,10 @@ pub struct Identity { pub client_certificate_pem: String, pub client_private_key_pem: String, pub client_certificate_expires_at_unix_seconds: u64, - pub oauth: OAuthCredentials, + /// The enrolling user's OIDC tokens. `None` for a device enrolled with an + /// enrollment token, which the controller identifies by its certificate + /// alone; `oauth_token_endpoint` and `oauth_client_id` are empty then. + pub oauth: Option, pub oauth_token_endpoint: String, pub oauth_client_id: String, } @@ -109,14 +112,15 @@ pub fn load(path: &Path) -> anyhow::Result> { fn read_secrets( secrets: &SecretStore, device_id: &str, -) -> anyhow::Result<(String, OAuthCredentials)> { +) -> anyhow::Result<(String, Option)> { let client_private_key_pem = secrets .get(TLS_KEY_SERVICE, device_id) .context("read device TLS private key")?; let oauth = secrets - .get(OAUTH_SERVICE, device_id) - .context("read OAuth credentials")?; - let oauth = serde_json::from_str(&oauth).context("decode stored OAuth credentials")?; + .get_optional(OAUTH_SERVICE, device_id) + .context("read OAuth credentials")? + .map(|oauth| serde_json::from_str(&oauth).context("decode stored OAuth credentials")) + .transpose()?; Ok((client_private_key_pem, oauth)) } @@ -124,15 +128,20 @@ pub fn save(path: &Path, identity: &Identity) -> anyhow::Result<()> { let parent = path.parent().context("identity path has no parent")?; secure_fs::ensure_private_dir(parent)?; let secrets = SecretStore::new(parent)?; - let oauth = serde_json::to_string(&identity.oauth)?; - for (service, secret, operation) in [ - ( - TLS_KEY_SERVICE, - identity.client_private_key_pem.as_str(), - "store device TLS private key", - ), - (OAUTH_SERVICE, oauth.as_str(), "store OAuth credentials"), - ] { + let oauth = identity + .oauth + .as_ref() + .map(serde_json::to_string) + .transpose()?; + let mut items = vec![( + TLS_KEY_SERVICE, + identity.client_private_key_pem.as_str(), + "store device TLS private key", + )]; + if let Some(oauth) = &oauth { + items.push((OAUTH_SERVICE, oauth.as_str(), "store OAuth credentials")); + } + for (service, secret, operation) in items { // The macOS Keychain adds a new item whenever looking up the existing // one fails, including when the item is there but unreadable (after a // code-signature change), and leaves the unreadable copy behind as a @@ -317,11 +326,11 @@ mod tests { client_certificate_pem: "certificate".to_owned(), client_private_key_pem: "private-key".to_owned(), client_certificate_expires_at_unix_seconds: 123, - oauth: OAuthCredentials { + oauth: Some(OAuthCredentials { access_token: "access-token".to_owned(), refresh_token: "refresh-token".to_owned(), expires_at_unix_seconds: 456, - }, + }), oauth_token_endpoint: "https://issuer.example/token".to_owned(), oauth_client_id: "client-1".to_owned(), } @@ -356,8 +365,11 @@ mod tests { let loaded = load(&path).unwrap().unwrap(); assert_eq!(loaded.client_private_key_pem, "private-key"); - assert_eq!(loaded.oauth.access_token, "access-token"); - assert_eq!(loaded.oauth.refresh_token, "refresh-token"); + assert_eq!(loaded.oauth.as_ref().unwrap().access_token, "access-token"); + assert_eq!( + loaded.oauth.as_ref().unwrap().refresh_token, + "refresh-token" + ); delete(&path, "device-1").unwrap(); assert!(!path.exists()); @@ -437,7 +449,10 @@ mod tests { let loaded = load(&path).unwrap().unwrap(); assert_eq!(loaded.client_private_key_pem, "private-key"); - assert_eq!(loaded.oauth.refresh_token, "refresh-token"); + assert_eq!( + loaded.oauth.as_ref().unwrap().refresh_token, + "refresh-token" + ); assert_eq!(secret_count(&directory), 2); fs::remove_dir_all(directory).unwrap(); } diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index ed16e58b..61e91508 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -19,6 +19,7 @@ pub mod secure_fs; pub mod subscription; mod subscription_oauth; mod tick; +mod token_enrollment; #[cfg(windows)] mod windows_security; diff --git a/crates/agent/src/oidc.rs b/crates/agent/src/oidc.rs index 253593c8..ca25df86 100644 --- a/crates/agent/src/oidc.rs +++ b/crates/agent/src/oidc.rs @@ -290,6 +290,9 @@ pub async fn enroll( enrollment: &EnrollmentState, callback_listen: Option, ) -> anyhow::Result { + if let Some(token_path) = &controller.enrollment_token_path { + return crate::token_enrollment::enroll(controller, enrollment, token_path).await; + } if controller.device_authorization { return enroll_with_device_authorization(controller, enrollment).await; } @@ -386,7 +389,7 @@ async fn enroll_with_device_authorization( complete_enrollment(&mut client, begin, &device_key, &csr, tokens).await } -fn device_csr() -> anyhow::Result<(KeyPair, CertificateSigningRequest)> { +pub(crate) fn device_csr() -> anyhow::Result<(KeyPair, CertificateSigningRequest)> { let device_key = KeyPair::generate().context("generate device TLS private key")?; let mut certificate_params = CertificateParams::new(Vec::::new())?; certificate_params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; @@ -441,11 +444,11 @@ async fn complete_enrollment( client_private_key_pem: device_key.serialize_pem(), client_certificate_expires_at_unix_seconds: response .client_certificate_expires_at_unix_seconds, - oauth: OAuthCredentials { + oauth: Some(OAuthCredentials { access_token: tokens.access_token, refresh_token, expires_at_unix_seconds: unix_time_seconds().saturating_add(tokens.expires_in), - }, + }), oauth_token_endpoint: begin.token_endpoint, oauth_client_id: begin.client_id, }) @@ -574,7 +577,10 @@ pub(crate) async fn poll_device_token( pub async fn refresh(identity: &mut Identity) -> anyhow::Result<()> { let endpoint = &identity.oauth_token_endpoint; let client_id = &identity.oauth_client_id; - let current = &identity.oauth; + let current = identity + .oauth + .as_ref() + .context("device identity has no OIDC tokens to refresh")?; let response = refresh_access_token(endpoint, client_id, ¤t.refresh_token).await?; apply_refreshed_tokens(identity, response) } @@ -610,12 +616,17 @@ fn apply_refreshed_tokens(identity: &mut Identity, response: TokenResponse) -> a if !response.token_type.eq_ignore_ascii_case("Bearer") { bail!("OIDC token endpoint returned unsupported token type"); } - let current_refresh_token = identity.oauth.refresh_token.clone(); - identity.oauth = OAuthCredentials { + let current_refresh_token = identity + .oauth + .as_ref() + .context("device identity has no OIDC tokens to refresh")? + .refresh_token + .clone(); + identity.oauth = Some(OAuthCredentials { access_token: response.access_token, refresh_token: response.refresh_token.unwrap_or(current_refresh_token), expires_at_unix_seconds: unix_time_seconds().saturating_add(response.expires_in), - }; + }); Ok(()) } @@ -1263,11 +1274,11 @@ mod tests { client_certificate_pem: "certificate".to_owned(), client_private_key_pem: "key".to_owned(), client_certificate_expires_at_unix_seconds: u64::MAX, - oauth: OAuthCredentials { + oauth: Some(OAuthCredentials { access_token: "old-access".to_owned(), refresh_token: "old-refresh".to_owned(), expires_at_unix_seconds: 0, - }, + }), oauth_token_endpoint: "https://idp.example/token".to_owned(), oauth_client_id: "client".to_owned(), }; @@ -1283,7 +1294,7 @@ mod tests { }, ) .unwrap(); - let oauth = identity.oauth; + let oauth = identity.oauth.unwrap(); assert_eq!(oauth.access_token, "new-access"); assert_eq!(oauth.refresh_token, "new-refresh"); assert!(oauth.expires_at_unix_seconds > 0); diff --git a/crates/agent/src/remote.rs b/crates/agent/src/remote.rs index 23dfe192..65bd24e3 100644 --- a/crates/agent/src/remote.rs +++ b/crates/agent/src/remote.rs @@ -789,14 +789,18 @@ async fn connect( info!(address = %controller.address, "connected to controller"); controller_status.mark_connected().await; let mut heartbeat = time::interval(controller.heartbeat_interval); - let reconnect_at = identity.oauth.expires_at_unix_seconds.saturating_sub(60); + // Without OIDC tokens (token enrollment) there is nothing to refresh. + let reconnect_at = identity + .oauth + .as_ref() + .map(|oauth| oauth.expires_at_unix_seconds.saturating_sub(60)); let oauth_reconnect = time::sleep(Duration::from_secs( - reconnect_at.saturating_sub(unix_time_seconds()), + reconnect_at.map_or(0, |at| at.saturating_sub(unix_time_seconds())), )); tokio::pin!(oauth_reconnect); loop { tokio::select! { - _ = &mut oauth_reconnect => { + _ = &mut oauth_reconnect, if reconnect_at.is_some() => { info!("reconnecting controller stream to refresh OIDC access token"); return Ok(()); } @@ -1262,7 +1266,10 @@ async fn client_with( } fn authenticate_request(identity: &Identity, request: &mut Request) -> anyhow::Result<()> { - let access_token = &identity.oauth.access_token; + let Some(oauth) = &identity.oauth else { + return Ok(()); + }; + let access_token = &oauth.access_token; request.metadata_mut().insert( "authorization", format!("Bearer {access_token}") @@ -1314,13 +1321,16 @@ async fn refresh_oauth_if_needed( identity: &mut Identity, identity_path: &Path, ) -> anyhow::Result<()> { - let oauth = &identity.oauth; + let Some(oauth) = &identity.oauth else { + return Ok(()); + }; if oauth.expires_at_unix_seconds <= unix_time_seconds().saturating_add(120) { let _guard = OAUTH_REFRESH_MUTEX.lock().await; match identity::load(identity_path) { Ok(Some(stored)) - if stored.oauth.expires_at_unix_seconds - > unix_time_seconds().saturating_add(120) => + if stored.oauth.as_ref().is_some_and(|stored| { + stored.expires_at_unix_seconds > unix_time_seconds().saturating_add(120) + }) => { *identity = stored; return Ok(()); @@ -1952,6 +1962,7 @@ mod tests { ca_certificate_path: ca_certificate_path.map(std::path::Path::to_path_buf), heartbeat_interval: Duration::from_secs(30), device_authorization: false, + enrollment_token_path: None, } } diff --git a/crates/agent/src/token_enrollment.rs b/crates/agent/src/token_enrollment.rs new file mode 100644 index 00000000..9e265db6 --- /dev/null +++ b/crates/agent/src/token_enrollment.rs @@ -0,0 +1,80 @@ +//! Enrollment with a controller-issued token, for devices provisioned by an +//! MDM. No user signs in: the daemon reads the token the MDM dropped next to +//! its configuration and the controller issues a device certificate for it. +//! The identity has no OIDC tokens; the controller authenticates the device by +//! its certificate alone. + +use std::path::Path; + +use agentdesktop_core::config::ControllerConnectionConfig; +use agentdesktop_proto::fleet::TokenEnrollmentRequest; +use anyhow::{Context, bail}; + +use crate::{enrollment::EnrollmentState, identity::Identity, oidc, remote}; + +pub(crate) async fn enroll( + controller: &ControllerConnectionConfig, + enrollment: &EnrollmentState, + token_path: &Path, +) -> anyhow::Result { + let enrollment_token = read_token(token_path)?; + enrollment.set("enrolling").await; + let (device_key, csr) = oidc::device_csr()?; + let mut client = remote::client(controller, None).await?; + let response = client + .enroll_with_token(TokenEnrollmentRequest { + hostname: remote::hostname(), + certificate_signing_request_der: csr.der().as_ref().to_vec(), + enrollment_token, + }) + .await + .context("enroll with enrollment token")? + .into_inner(); + let client_certificate_pem = String::from_utf8(response.client_certificate_pem) + .context("controller returned a non-UTF-8 device certificate")?; + if client_certificate_pem.is_empty() { + bail!("controller returned an empty device certificate"); + } + Ok(Identity { + device_id: response.device_id, + client_certificate_pem, + client_private_key_pem: device_key.serialize_pem(), + client_certificate_expires_at_unix_seconds: response + .client_certificate_expires_at_unix_seconds, + oauth: None, + oauth_token_endpoint: String::new(), + oauth_client_id: String::new(), + }) +} + +fn read_token(path: &Path) -> anyhow::Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("read enrollment token from {}", path.display()))?; + let token = contents.trim(); + if token.is_empty() { + bail!("enrollment token file {} is empty", path.display()); + } + Ok(token.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::read_token; + + #[test] + fn reads_the_trimmed_token_and_rejects_an_empty_file() { + let directory = std::env::temp_dir().join(format!( + "agentdesktop-enrollment-token-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("token"); + std::fs::write(&path, " secret-token \n").unwrap(); + assert_eq!(read_token(&path).unwrap(), "secret-token"); + std::fs::write(&path, "\n").unwrap(); + assert!(read_token(&path).is_err()); + assert!(read_token(&directory.join("missing")).is_err()); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/controller/migrations/0005_enrollment_token.sql b/crates/controller/migrations/0005_enrollment_token.sql new file mode 100644 index 00000000..8786d888 --- /dev/null +++ b/crates/controller/migrations/0005_enrollment_token.sql @@ -0,0 +1,3 @@ +-- Name of the enrollment token a device enrolled with; NULL for devices +-- enrolled by a user through OIDC. +ALTER TABLE devices ADD COLUMN enrollment_token TEXT; diff --git a/crates/controller/src/database.rs b/crates/controller/src/database.rs index f19f455f..2819ca5c 100644 --- a/crates/controller/src/database.rs +++ b/crates/controller/src/database.rs @@ -14,10 +14,14 @@ pub struct Database { pool: AnyPool, } +#[derive(Debug)] pub struct DevicePrincipal { pub issuer: String, pub subject: String, pub idp_claims: Option>, + /// Name of the enrollment token the device enrolled with. Such a device + /// has no user: `issuer` and `subject` are empty. + pub enrollment_token: Option, } #[derive(Clone, Debug, Serialize)] @@ -35,6 +39,9 @@ pub struct DeviceSummary { /// their preferred username, else their name), when the identity provider /// supplied one. Only this derived value is returned, never the raw claims. pub enrolled_by_name: Option, + /// Name of the enrollment token the device enrolled with, for devices + /// enrolled without a user. + pub enrollment_token: Option, pub config_revision: Option, pub config_state: Option, pub config_error: Option, @@ -55,6 +62,7 @@ struct DeviceRow { enrolled_by_issuer: String, enrolled_by_subject: String, idp_claims_json: Option, + enrollment_token: Option, config_revision: Option, config_state: Option, config_error: Option, @@ -75,6 +83,7 @@ impl From for DeviceSummary { enrolled_by_issuer: row.enrolled_by_issuer, enrolled_by_subject: row.enrolled_by_subject, enrolled_by_name: row.idp_claims_json.as_deref().and_then(readable_name), + enrollment_token: row.enrollment_token, config_revision: row.config_revision, config_state: row.config_state, config_error: row.config_error, @@ -298,6 +307,7 @@ impl Database { enrolled_by_issuer: &str, enrolled_by_subject: &str, idp_claims: Option<&BTreeMap>, + enrollment_token: Option<&str>, ) -> anyhow::Result<()> { let idp_claims_json = idp_claims .map(serde_json::to_string) @@ -307,8 +317,8 @@ impl Database { sqlx::query( "INSERT INTO devices (id, hostname, created_at, last_seen_at, enrolled_by_issuer, - enrolled_by_subject, idp_claims_json) - VALUES ($1, $2, $3, $3, $4, $5, $6)", + enrolled_by_subject, idp_claims_json, enrollment_token) + VALUES ($1, $2, $3, $3, $4, $5, $6, $7)", ) .bind(device_id) .bind(hostname) @@ -316,6 +326,7 @@ impl Database { .bind(enrolled_by_issuer) .bind(enrolled_by_subject) .bind(idp_claims_json) + .bind(enrollment_token) .execute(&mut *transaction) .await?; transaction.commit().await?; @@ -323,8 +334,13 @@ impl Database { } pub async fn device_principal(&self, device_id: &str) -> anyhow::Result { - let (issuer, subject, idp_claims_json): (String, String, Option) = sqlx::query_as( - "SELECT enrolled_by_issuer, enrolled_by_subject, idp_claims_json + let (issuer, subject, idp_claims_json, enrollment_token): ( + String, + String, + Option, + Option, + ) = sqlx::query_as( + "SELECT enrolled_by_issuer, enrolled_by_subject, idp_claims_json, enrollment_token FROM devices WHERE id = $1", ) .bind(device_id) @@ -338,6 +354,7 @@ impl Database { issuer, subject, idp_claims, + enrollment_token, }) } @@ -345,7 +362,8 @@ impl Database { let rows: Vec = sqlx::query_as( "SELECT d.id, d.hostname, d.os, d.architecture, d.agent_version, d.created_at, d.last_seen_at, d.enrolled_by_issuer, - d.enrolled_by_subject, d.idp_claims_json, cs.revision AS config_revision, + d.enrolled_by_subject, d.idp_claims_json, d.enrollment_token, + cs.revision AS config_revision, cs.state AS config_state, cs.error AS config_error, cs.updated_at AS config_updated_at, COUNT(x.kind) AS discovery_count FROM devices d @@ -353,7 +371,7 @@ impl Database { LEFT JOIN discoveries x ON x.device_id = d.id GROUP BY d.id, d.hostname, d.os, d.architecture, d.agent_version, d.created_at, d.last_seen_at, d.enrolled_by_issuer, - d.enrolled_by_subject, d.idp_claims_json, cs.revision, cs.state, + d.enrolled_by_subject, d.idp_claims_json, d.enrollment_token, cs.revision, cs.state, cs.error, cs.updated_at ORDER BY d.last_seen_at DESC, d.hostname ASC", ) @@ -380,7 +398,8 @@ impl Database { let device: Option = sqlx::query_as( "SELECT d.id, d.hostname, d.os, d.architecture, d.agent_version, d.created_at, d.last_seen_at, d.enrolled_by_issuer, - d.enrolled_by_subject, d.idp_claims_json, cs.revision AS config_revision, + d.enrolled_by_subject, d.idp_claims_json, d.enrollment_token, + cs.revision AS config_revision, cs.state AS config_state, cs.error AS config_error, cs.updated_at AS config_updated_at, COUNT(x.kind) AS discovery_count FROM devices d @@ -389,7 +408,7 @@ impl Database { WHERE d.id = $1 GROUP BY d.id, d.hostname, d.os, d.architecture, d.agent_version, d.created_at, d.last_seen_at, d.enrolled_by_issuer, - d.enrolled_by_subject, d.idp_claims_json, cs.revision, cs.state, + d.enrolled_by_subject, d.idp_claims_json, d.enrollment_token, cs.revision, cs.state, cs.error, cs.updated_at", ) .bind(device_id) @@ -786,7 +805,14 @@ mod tests { ("email".to_owned(), serde_json::json!("john@example.com")), ]); database - .enroll_device("device", "host", "issuer", "subject", Some(&idp_claims)) + .enroll_device( + "device", + "host", + "issuer", + "subject", + Some(&idp_claims), + None, + ) .await .expect("enroll device"); let front_matter = BTreeMap::from([ @@ -884,7 +910,7 @@ mod tests { .await .expect("connect test database"); database - .enroll_device("device", "host", "issuer", "subject", None) + .enroll_device("device", "host", "issuer", "subject", None, None) .await .expect("enroll device"); (database, path) @@ -927,7 +953,14 @@ mod tests { ("groups".to_owned(), serde_json::json!(["admins"])), ]); database - .enroll_device("owned", "owned-host", "issuer", "subject-2", Some(&claims)) + .enroll_device( + "owned", + "owned-host", + "issuer", + "subject-2", + Some(&claims), + None, + ) .await .expect("enroll a device with claims"); diff --git a/crates/controller/src/main.rs b/crates/controller/src/main.rs index 69e7e650..e8d734ca 100644 --- a/crates/controller/src/main.rs +++ b/crates/controller/src/main.rs @@ -7,7 +7,7 @@ use agentdesktop_controller::{ device_ca::DeviceCertificateIssuer, gateway_jwt::{self, GatewayJwtIssuer}, oidc::OidcProvider, - service::FleetAgentService, + service::{EnrollmentToken, FleetAgentService}, }; use agentdesktop_core::{DEFAULT_CONTROLLER_CONFIG_PATH, config, telemetry}; use agentdesktop_proto::fleet::fleet_agent_server::FleetAgentServer; @@ -54,6 +54,15 @@ async fn main() -> anyhow::Result<()> { .await .context("initialize OIDC enrollment")?; tracing::info!("OIDC enrollment enabled"); + let enrollment_tokens = config + .enrollment_tokens + .iter() + .map(|token| EnrollmentToken::load(&token.name, &token.file)) + .collect::>>() + .context("initialize token enrollment")?; + if !enrollment_tokens.is_empty() { + tracing::info!(tokens = enrollment_tokens.len(), "token enrollment enabled"); + } let gateway_jwt_issuer = config .gateway_jwt .as_ref() @@ -116,7 +125,8 @@ async fn main() -> anyhow::Result<()> { gateway_jwt_issuer, Some(device_certificate_issuer), ) - .with_usage_collection(usage_ingest.is_some()); + .with_usage_collection(usage_ingest.is_some()) + .with_enrollment_tokens(enrollment_tokens); let certificate = std::fs::read(&tls.certificate) .with_context(|| format!("read TLS certificate from {}", tls.certificate.display()))?; diff --git a/crates/controller/src/service.rs b/crates/controller/src/service.rs index 6fe3570b..1861df97 100644 --- a/crates/controller/src/service.rs +++ b/crates/controller/src/service.rs @@ -4,9 +4,10 @@ use agentdesktop_proto::fleet::{ AgentMessage, BeginEnrollmentRequest, BeginEnrollmentResponse, CompleteEnrollmentRequest, ConfigStatus, ControllerMessage, DeviceCertificateResponse, DeviceUsageRequest, DeviceUsageResponse, EnrollResponse, LlmGatewayCredentialRequest, LlmGatewayCredentialResponse, - RenewDeviceCertificateRequest, agent_message, controller_message, + RenewDeviceCertificateRequest, TokenEnrollmentRequest, agent_message, controller_message, fleet_agent_server::FleetAgent, }; +use anyhow::Context; use futures_core::Stream; use tokio::{sync::mpsc, time}; use tokio_stream::wrappers::ReceiverStream; @@ -34,6 +35,40 @@ pub struct FleetAgentService { gateway_jwt_issuer: Option, device_certificate_issuer: Option, usage_enabled: bool, + enrollment_tokens: Vec, +} + +/// A token that enrolls operator-provisioned devices without a user. +#[derive(Clone)] +pub struct EnrollmentToken { + pub name: String, + pub token: String, +} + +impl EnrollmentToken { + /// Reads the token from `file`: at least 32 printable ASCII characters. + pub fn load(name: &str, file: &std::path::Path) -> anyhow::Result { + let contents = std::fs::read_to_string(file) + .with_context(|| format!("read enrollment token {name} from {}", file.display()))?; + let token = contents.trim(); + if token.len() < 32 || !token.bytes().all(|byte| byte.is_ascii_graphic()) { + anyhow::bail!( + "enrollment token {name} in {} must be at least 32 printable ASCII characters", + file.display() + ); + } + Ok(Self { + name: name.to_owned(), + token: token.to_owned(), + }) + } +} + +/// A device identified by its certificate, with its enrollment record. +#[derive(Debug)] +struct AuthenticatedDevice { + device_id: String, + principal: DevicePrincipal, } impl FleetAgentService { @@ -51,6 +86,7 @@ impl FleetAgentService { gateway_jwt_issuer, device_certificate_issuer, usage_enabled: false, + enrollment_tokens: Vec::new(), } } @@ -59,6 +95,27 @@ impl FleetAgentService { self.usage_enabled = enabled; self } + + /// Accepts `EnrollWithToken` with any of `tokens`. + pub fn with_enrollment_tokens(mut self, tokens: Vec) -> Self { + self.enrollment_tokens = tokens; + self + } +} + +/// The name of the configured token equal to `presented`, compared in +/// constant time. +fn enrollment_token_name<'a>(tokens: &'a [EnrollmentToken], presented: &str) -> Option<&'a str> { + tokens + .iter() + .find(|token| { + aws_lc_rs::constant_time::verify_slices_are_equal( + token.token.as_bytes(), + presented.as_bytes(), + ) + .is_ok() + }) + .map(|token| token.name.as_str()) } fn subscription_is_configured_for_client(programs: &ProgramsConfig, client_id: &str) -> bool { @@ -108,6 +165,28 @@ impl FleetAgent for FleetAgentService { &completed.subject, Some(&completed.idp_claims), &csr_der, + None, + ) + .await + } + + async fn enroll_with_token( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let token = enrollment_token_name(&self.enrollment_tokens, &request.enrollment_token) + .ok_or_else(|| { + warn!(hostname = %request.hostname, "rejected unknown enrollment token"); + Status::unauthenticated("enrollment token rejected") + })?; + self.enroll_device( + &request.hostname, + "", + "", + None, + &request.certificate_signing_request_der, + Some(token), ) .await } @@ -116,7 +195,7 @@ impl FleetAgent for FleetAgentService { &self, request: Request, ) -> Result, Status> { - let device_id = self.authenticate_device(&request).await?; + let device_id = self.authenticate_device(&request).await?.device_id; let issuer = self.device_certificate_issuer.as_ref().ok_or_else(|| { Status::failed_precondition("device certificate issuance is disabled") })?; @@ -137,7 +216,15 @@ impl FleetAgent for FleetAgentService { &self, request: Request, ) -> Result, Status> { - let device_id = self.authenticate_device(&request).await?; + let AuthenticatedDevice { + device_id, + principal, + } = self.authenticate_device(&request).await?; + if principal.enrollment_token.is_some() { + return Err(Status::failed_precondition( + "LLM gateway credentials require a device enrolled by a user", + )); + } let LlmGatewayCredentialRequest { client_id, subscription, @@ -187,11 +274,6 @@ impl FleetAgent for FleetAgentService { let issuer = self.gateway_jwt_issuer.as_ref().ok_or_else(|| { Status::failed_precondition("controller gateway JWT issuer is not configured") })?; - let principal = self - .database - .device_principal(&device_id) - .await - .map_err(internal)?; let subject = if principal.subject.is_empty() { device_id.as_str() } else { @@ -225,7 +307,7 @@ impl FleetAgent for FleetAgentService { &self, request: Request, ) -> Result, Status> { - let device_id = self.authenticate_device(&request).await?; + let device_id = self.authenticate_device(&request).await?.device_id; agentdesktop_usage::device_usage( &self.database.usage(), Some(&device_id), @@ -244,13 +326,20 @@ impl FleetAgent for FleetAgentService { &self, request: Request>, ) -> Result, Status> { - let authenticated_device_id = self.authenticate_device(&request).await?; - let oauth_revalidation = ( - self.oidc.clone().ok_or_else(|| { - Status::failed_precondition("OIDC access-token validation is disabled") - })?, - bearer_credential(request.metadata())?.to_owned(), - ); + let AuthenticatedDevice { + device_id: authenticated_device_id, + principal: enrolled_principal, + } = self.authenticate_device(&request).await?; + // A device enrolled with a token has no user token to revalidate. + let oauth_revalidation = match enrolled_principal.enrollment_token { + Some(_) => None, + None => Some(( + self.oidc.clone().ok_or_else(|| { + Status::failed_precondition("OIDC access-token validation is disabled") + })?, + bearer_credential(request.metadata())?.to_owned(), + )), + }; let mut inbound = request.into_inner(); let (sender, receiver) = mpsc::channel(8); @@ -287,20 +376,14 @@ impl FleetAgent for FleetAgentService { let database = self.database.clone(); let auth_sender = sender.clone(); tokio::spawn(async move { - let enrolled_principal = match database.device_principal(&authenticated_device_id).await - { - Ok(principal) => principal, - Err(error) => { - error!(%error, device_id = %authenticated_device_id, "failed to load stream identity"); - return; - } - }; let mut auth_interval = time::interval(std::time::Duration::from_secs(5 * 60)); auth_interval.tick().await; loop { tokio::select! { - _ = auth_interval.tick() => { - let (oidc, access_token) = &oauth_revalidation; + _ = auth_interval.tick(), if oauth_revalidation.is_some() => { + let Some((oidc, access_token)) = &oauth_revalidation else { + continue; + }; match oidc.authenticate_access_token(access_token).await { Ok(principal) if oidc_principals_match(&principal, &enrolled_principal) => {} Ok(_) => { @@ -360,36 +443,25 @@ impl FleetAgent for FleetAgentService { } impl FleetAgentService { - async fn authenticate_device(&self, request: &Request) -> Result { + async fn authenticate_device( + &self, + request: &Request, + ) -> Result { let certificate_device_id = peer_certificate_device_id(request)?; self.authenticate_device_identity(certificate_device_id.as_deref(), request.metadata()) .await } + /// Authenticates the device named in the certificate. A device enrolled + /// with a token is identified by the certificate alone; any other device + /// must also present the access token of the user who enrolled it. async fn authenticate_device_identity( &self, certificate_device_id: Option<&str>, metadata: &tonic::metadata::MetadataMap, - ) -> Result { + ) -> Result { let device_id = certificate_device_id .ok_or_else(|| Status::unauthenticated("missing device certificate"))?; - let access_token = bearer_credential(metadata)?; - let oidc = self.oidc.as_ref().ok_or_else(|| { - Status::failed_precondition("OIDC access-token validation is disabled") - })?; - let access_principal = oidc - .authenticate_access_token(access_token) - .await - .map_err(invalid_access_token)?; - self.authenticate_device_certificate_identity(device_id, &access_principal) - .await - } - - async fn authenticate_device_certificate_identity( - &self, - device_id: &str, - access_principal: &OidcPrincipal, - ) -> Result { let principal = self .database .device_principal(device_id) @@ -398,12 +470,25 @@ impl FleetAgentService { warn!(%error, device_id, "certificate references an unknown device"); Status::unauthenticated("unrecognized device certificate") })?; - if !oidc_principals_match(access_principal, &principal) { - return Err(Status::permission_denied( - "access token principal does not match enrolled device user", - )); + if principal.enrollment_token.is_none() { + let access_token = bearer_credential(metadata)?; + let oidc = self.oidc.as_ref().ok_or_else(|| { + Status::failed_precondition("OIDC access-token validation is disabled") + })?; + let access_principal = oidc + .authenticate_access_token(access_token) + .await + .map_err(invalid_access_token)?; + if !oidc_principals_match(&access_principal, &principal) { + return Err(Status::permission_denied( + "access token principal does not match enrolled device user", + )); + } } - Ok(device_id.to_owned()) + Ok(AuthenticatedDevice { + device_id: device_id.to_owned(), + principal, + }) } async fn enroll_device( @@ -413,6 +498,7 @@ impl FleetAgentService { enrolled_by_subject: &str, idp_claims: Option<&std::collections::BTreeMap>, csr_der: &[u8], + enrollment_token: Option<&str>, ) -> Result, Status> { let device_id = Uuid::new_v4().to_string(); let issued_certificate = self @@ -428,6 +514,7 @@ impl FleetAgentService { enrolled_by_issuer, enrolled_by_subject, idp_claims, + enrollment_token, ) .await .map_err(internal)?; @@ -436,6 +523,7 @@ impl FleetAgentService { hostname, enrolled_by_issuer, enrolled_by_subject, + enrollment_token, "enrolled device" ); @@ -623,10 +711,11 @@ fn invalid_access_token(err: anyhow::Error) -> Status { #[cfg(test)] mod tests { use rcgen::{CertificateParams, KeyPair}; - use tonic::Code; + use tonic::{Code, metadata::MetadataMap}; use super::{ - FleetAgentService, device_id_from_certificate, subscription_is_configured_for_client, + EnrollmentToken, FleetAgentService, device_id_from_certificate, enrollment_token_name, + oidc_principals_match, subscription_is_configured_for_client, }; use crate::{daemon_config::DaemonConfigStore, database::Database, oidc::OidcPrincipal}; @@ -662,89 +751,132 @@ mod tests { )); } - #[tokio::test] - async fn device_authentication_binds_oidc_issuer_and_subject() { - let device_id = "7ca03414-bb20-4c80-98ef-7b0538b988ba"; + async fn test_database(name: &str) -> Database { let path = std::env::temp_dir().join(format!( - "agentdesktop-mtls-{}-{}.db", + "agentdesktop-{name}-{}-{}.db", std::process::id(), rand::random::() )); - let database = Database::connect(&format!("sqlite://{}?mode=rwc", path.display())) + Database::connect(&format!("sqlite://{}?mode=rwc", path.display())) .await - .expect("connect database"); - database - .enroll_device(device_id, "host", "issuer", "subject", None) - .await - .expect("enroll device"); - let service = - FleetAgentService::new(None, database, DaemonConfigStore::new(None), None, None); - let principal = |issuer: &str, subject: &str| OidcPrincipal { + .expect("connect database") + } + + fn principal(issuer: &str, subject: &str) -> OidcPrincipal { + OidcPrincipal { issuer: issuer.to_owned(), subject: subject.to_owned(), - }; - assert_eq!( - service - .authenticate_device_certificate_identity( - device_id, - &principal("issuer", "subject"), - ) - .await - .unwrap(), - device_id - ); + } + } + #[tokio::test] + async fn device_authentication_binds_oidc_issuer_and_subject() { + let device_id = "7ca03414-bb20-4c80-98ef-7b0538b988ba"; + let database = test_database("mtls").await; + database + .enroll_device(device_id, "host", "issuer", "subject", None, None) + .await + .expect("enroll device"); + let enrolled = database.device_principal(device_id).await.unwrap(); + assert!(oidc_principals_match( + &principal("issuer", "subject"), + &enrolled + )); for mismatched in [ principal("issuer", "other-subject"), principal("other-issuer", "subject"), principal("other-issuer", "other-subject"), ] { - let status = service - .authenticate_device_certificate_identity(device_id, &mismatched) - .await - .expect_err("mismatched OIDC principal must fail"); - assert_eq!(status.code(), Code::PermissionDenied); + assert!(!oidc_principals_match(&mismatched, &enrolled)); } + let service = + FleetAgentService::new(None, database, DaemonConfigStore::new(None), None, None); let status = service - .authenticate_device_certificate_identity( - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - &principal("issuer", "subject"), + .authenticate_device_identity( + Some("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + &MetadataMap::new(), ) .await .expect_err("unknown device certificate must fail"); assert_eq!(status.code(), Code::Unauthenticated); + let status = service + .authenticate_device_identity(Some(device_id), &MetadataMap::new()) + .await + .expect_err("a user-enrolled device must present an access token"); + assert_eq!(status.code(), Code::Unauthenticated); } #[tokio::test] async fn legacy_device_without_enrolled_issuer_fails_closed() { - let device_id = "7ca03414-bb20-4c80-98ef-7b0538b988ba"; - let path = std::env::temp_dir().join(format!( - "agentdesktop-empty-issuer-{}-{}.db", - std::process::id(), - rand::random::() - )); - let database = Database::connect(&format!("sqlite://{}?mode=rwc", path.display())) - .await - .expect("connect database"); + let database = test_database("empty-issuer").await; database - .enroll_device(device_id, "host", "", "subject", None) + .enroll_device("device", "host", "", "subject", None, None) .await .expect("enroll legacy device"); + let enrolled = database.device_principal("device").await.unwrap(); + assert!(!oidc_principals_match(&principal("", "subject"), &enrolled)); + } + + #[tokio::test] + async fn a_token_enrolled_device_is_authenticated_by_its_certificate_alone() { + let device_id = "7ca03414-bb20-4c80-98ef-7b0538b988ba"; + let database = test_database("token-device").await; + database + .enroll_device(device_id, "host", "", "", None, Some("jamf")) + .await + .expect("enroll device with a token"); let service = FleetAgentService::new(None, database, DaemonConfigStore::new(None), None, None); - - let status = service - .authenticate_device_certificate_identity( - device_id, - &OidcPrincipal { - issuer: String::new(), - subject: "subject".to_owned(), - }, - ) + let device = service + .authenticate_device_identity(Some(device_id), &MetadataMap::new()) .await - .expect_err("empty enrolled issuer must fail closed"); - assert_eq!(status.code(), Code::PermissionDenied); + .expect("no access token is needed"); + assert_eq!(device.device_id, device_id); + assert_eq!(device.principal.enrollment_token.as_deref(), Some("jamf")); + } + + #[test] + fn an_enrollment_token_names_only_an_exact_match() { + let tokens = vec![ + EnrollmentToken { + name: "jamf".to_owned(), + token: "a".repeat(32), + }, + EnrollmentToken { + name: "intune".to_owned(), + token: "b".repeat(32), + }, + ]; + assert_eq!( + enrollment_token_name(&tokens, &"b".repeat(32)), + Some("intune") + ); + assert_eq!(enrollment_token_name(&tokens, &"a".repeat(31)), None); + assert_eq!(enrollment_token_name(&tokens, &"a".repeat(33)), None); + assert_eq!(enrollment_token_name(&tokens, ""), None); + assert_eq!(enrollment_token_name(&[], &"a".repeat(32)), None); + } + + #[test] + fn an_enrollment_token_file_must_hold_a_long_printable_token() { + let directory = std::env::temp_dir().join(format!( + "agentdesktop-enrollment-token-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir(&directory).unwrap(); + let file = directory.join("token"); + std::fs::write(&file, format!("{}\n", "x".repeat(40))).unwrap(); + let token = EnrollmentToken::load("jamf", &file).unwrap(); + assert_eq!(token.name, "jamf"); + assert_eq!(token.token, "x".repeat(40)); + std::fs::write(&file, "x".repeat(31)).unwrap(); + assert!(EnrollmentToken::load("jamf", &file).is_err()); + std::fs::write(&file, format!("{} {}", "x".repeat(20), "y".repeat(20))).unwrap(); + assert!(EnrollmentToken::load("jamf", &file).is_err()); + assert!(EnrollmentToken::load("jamf", &directory.join("missing")).is_err()); + let _ = std::fs::remove_dir_all(directory); } #[test] @@ -774,7 +906,7 @@ mod tests { .await .unwrap(); database - .enroll_device("device", "host", "issuer", "subject", None) + .enroll_device("device", "host", "issuer", "subject", None, None) .await .unwrap(); let accepted = ConfigStatus { diff --git a/crates/controller/src/usage.rs b/crates/controller/src/usage.rs index 96f2aa73..ba6a1ad5 100644 --- a/crates/controller/src/usage.rs +++ b/crates/controller/src/usage.rs @@ -62,6 +62,7 @@ mod tests { &serde_json::from_value(serde_json::json!({ "email": "john@example.com" })) .unwrap(), ), + None, ) .await .unwrap(); @@ -72,6 +73,7 @@ mod tests { "issuer", "user-456", None, + None, ) .await .unwrap(); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index f9ce590d..cd02b55b 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -476,6 +476,14 @@ pub struct ControllerConnectionConfig { /// authorization for the controller's client. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub device_authorization: bool, + /// Path to a file holding an enrollment token issued by the controller + /// operator (see the controller's `enrollmentTokens`). + /// + /// Set this on devices provisioned by an MDM: the daemon enrolls with the + /// token and no user signs in. The device is afterwards identified by its + /// certificate alone and gets no LLM gateway credentials. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enrollment_token_path: Option, } /// Startup configuration for the Agentdesktop fleet controller. @@ -496,6 +504,10 @@ pub struct ControllerConfig { pub database_url: String, /// OpenID Connect settings used for device enrollment and authorization. pub oidc: ControllerOidcConfig, + /// Tokens that enroll operator-provisioned devices without a user + /// sign-in. See `enrollmentTokenPath` in the daemon configuration. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enrollment_tokens: Vec, /// Daemon configuration distributed to enrolled devices. #[serde(default, skip_serializing_if = "Option::is_none")] pub daemon_config: Option, @@ -531,6 +543,20 @@ pub struct ControllerOidcConfig { pub redirect_uri: String, } +/// An enrollment token for devices provisioned by an MDM or similar. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ControllerEnrollmentTokenConfig { + /// Name recorded on devices enrolled with this token. + pub name: String, + /// Path to a file containing the token. The token must be at least 32 + /// printable ASCII characters. + /// + /// Relative paths are resolved from the controller configuration directory. + pub file: PathBuf, +} + /// Controller-owned daemon configuration file and its revision. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -1300,6 +1326,9 @@ pub fn load_controller(path: &Path) -> anyhow::Result { if let Some(usage) = &mut config.usage { resolve_relative(&mut usage.token_file, directory); } + for token in &mut config.enrollment_tokens { + resolve_relative(&mut token.file, directory); + } match &mut config.tls { ControllerTlsConfig::Directory(path) => resolve_relative(path, directory), ControllerTlsConfig::Files(tls) => { @@ -1349,6 +1378,15 @@ pub fn parse_controller(contents: &str) -> anyhow::Result { anyhow::bail!("gatewayJwt.lifetime must be greater than zero"); } } + let mut token_names = std::collections::BTreeSet::new(); + for token in &config.enrollment_tokens { + if token.name.trim().is_empty() { + anyhow::bail!("enrollmentTokens[].name cannot be empty"); + } + if !token_names.insert(token.name.as_str()) { + anyhow::bail!("enrollmentTokens[].name {:?} is used twice", token.name); + } + } Ok(config) } @@ -2258,6 +2296,35 @@ llmGateway: assert!(format!("{error:#}").contains("invalid LLM gateway client ID")); } + #[test] + fn controller_enrollment_tokens_are_named_once() { + let configuration = |tokens: &str| { + parse_controller(&format!( + "tls: /etc/agentdesktop/tls\noidc:\n issuer: https://idp.example.com\n clientId: agentdesktop\nenrollmentTokens:\n{tokens}" + )) + }; + let controller = configuration( + " - name: jamf\n file: /etc/agentdesktop/jamf\n - name: intune\n file: /etc/agentdesktop/intune\n", + ) + .expect("two named tokens"); + assert_eq!(controller.enrollment_tokens.len(), 2); + assert!(configuration(" - name: ''\n file: /etc/agentdesktop/jamf\n").is_err()); + assert!( + configuration( + " - name: jamf\n file: /etc/agentdesktop/a\n - name: jamf\n file: /etc/agentdesktop/b\n", + ) + .is_err() + ); + let daemon = parse_daemon( + "controller:\n address: https://controller.example.com\n enrollmentTokenPath: /etc/agentdesktop/enrollment-token\n", + ) + .expect("daemon enrollment token path"); + assert_eq!( + daemon.controller.unwrap().enrollment_token_path.as_deref(), + Some(std::path::Path::new("/etc/agentdesktop/enrollment-token")) + ); + } + #[test] fn controller_requires_secure_remote_transports() { assert!( diff --git a/crates/proto/proto/fleet.proto b/crates/proto/proto/fleet.proto index cad2e4b8..2f32eb85 100644 --- a/crates/proto/proto/fleet.proto +++ b/crates/proto/proto/fleet.proto @@ -5,6 +5,10 @@ package agentdesktop.fleet.v1; service FleetAgent { rpc BeginEnrollment(BeginEnrollmentRequest) returns (BeginEnrollmentResponse); rpc CompleteEnrollment(CompleteEnrollmentRequest) returns (EnrollResponse); + // Enrolls a device the operator provisioned (for example through an MDM) + // with a controller-issued enrollment token instead of a user sign-in. The + // device is afterwards authenticated by its certificate alone. + rpc EnrollWithToken(TokenEnrollmentRequest) returns (EnrollResponse); rpc RenewDeviceCertificate(RenewDeviceCertificateRequest) returns (DeviceCertificateResponse); rpc GetLlmGatewayCredential(LlmGatewayCredentialRequest) returns (LlmGatewayCredentialResponse); rpc GetDeviceUsage(DeviceUsageRequest) returns (DeviceUsageResponse); @@ -52,6 +56,12 @@ message CompleteEnrollmentRequest { string id_token = 5; } +message TokenEnrollmentRequest { + string hostname = 1; + bytes certificate_signing_request_der = 2; + string enrollment_token = 3; +} + message RenewDeviceCertificateRequest { bytes certificate_signing_request_der = 1; } diff --git a/docs/device-identity.md b/docs/device-identity.md index d1f3587d..0f1c40c5 100644 --- a/docs/device-identity.md +++ b/docs/device-identity.md @@ -35,6 +35,12 @@ matches the subject recorded for the device named in the certificate. A device certificate alone, or a valid user token presented with another user's device certificate, is rejected. +The exception is a device enrolled with an enrollment token (see +[Token flow](#token-flow)): it was provisioned by the operator, for example +through an MDM, and no user signed in on it. The controller records the +token's name instead of an IdP issuer and subject, identifies the device by its +certificate alone, and issues it no gateway credentials. + Key properties: - The device private key never leaves the workstation. The daemon sends only a @@ -253,6 +259,63 @@ daemon's controller connection. The IdP must advertise a 4. `CompleteEnrollment` proceeds as in the browser flow. There is no nonce to check, because the device grant does not carry one. +### Token flow + +For devices an MDM provisions without any user action, set +`enrollmentTokenPath` in the daemon's controller connection to a root-only +file the MDM drops next to `config.yaml`. The controller is configured with +the same token under `enrollmentTokens`, read from a file like +`usage.tokenFile`: + +```yaml +# controller +enrollmentTokens: + - name: jamf + file: /etc/agentdesktop/enrollment-token-jamf +``` + +```yaml +# daemon +controller: + address: https://controller.example.com:8443 + enrollmentTokenPath: /etc/agentdesktop/enrollment-token +``` + +```mermaid +sequenceDiagram + participant Daemon + participant Controller + Daemon->>Daemon: Read the token. Generate device key pair and CSR + Daemon->>Controller: EnrollWithToken(hostname, CSR, enrollment_token) over server-auth TLS + Controller->>Controller: Match the token in constant time. New device_id. Sign CSR with device CA (30 days) + Controller->>Controller: Store device with hostname and the token's name, no user + Controller-->>Daemon: device_id, certificate chain, expiry + Daemon->>Daemon: Save certificate to identity.json and private key to the secret store. No OIDC tokens + Daemon->>Controller: Connect stream over mTLS, no Bearer token + Controller->>Controller: Read device_id from client certificate SAN. Require a device enrolled with a token +``` + +1. The daemon reads the token and sends `EnrollWithToken` with the hostname, + the CSR, and the token, over server-authenticated TLS. The status goes + straight from `starting` to `enrolling`; there is no sign-in URL. +2. The controller compares the token with each configured token in constant + time and rejects the call as `Unauthenticated` when none matches. +3. It generates the device ID and signs the CSR as in the other flows, and + stores the device with its hostname and the token's name. The device has + no IdP issuer, subject, or claims. +4. The daemon stores the identity without OIDC tokens. Afterwards it sends no + bearer token, and the controller authenticates it by the certificate + alone. `RenewDeviceCertificate` works as usual; `GetLlmGatewayCredential` + is refused with `FailedPrecondition`, because a gateway credential names + the enrolled user and the device has none. + +The token is a shared secret of the operator, not of a user: anyone holding +it can enroll devices and receive the daemon configuration. Keep it readable +by root only, and rotate it by adding a new named token, re-provisioning the +devices, and removing the old one. The device list reports which token +enrolled each device (`enrollment_token` in `GET /api/v1/devices`), so devices +of a retired token can be found and deleted. + ### Controller validation On `CompleteEnrollment` the controller: @@ -316,6 +379,10 @@ Every other authenticated RPC (`RenewDeviceCertificate`, `GetLlmGatewayCredential`, `GetDeviceUsage`) performs the same certificate plus UserInfo check, so the IdP sees one UserInfo call per protected request. +A device enrolled with a token has no OIDC tokens: it sends no bearer token, +nothing is refreshed or revalidated, and its stream stays open until the +connection drops or the device is deleted. + ## Gateway credentials When the gateway is configured with `authentication.type: controllerJwt`, @@ -375,3 +442,8 @@ IdP. The controller rejects the next authenticated call and closes open streams within 5 minutes. A daemon whose stream is closed as `Unauthenticated`, or whose refresh token is rejected, discards its identity and must enroll again. + +A device enrolled with a token has no user to disable. Delete the device in +the controller: its next authenticated call is rejected as `Unauthenticated` +and the daemon enrolls again, which succeeds only while its token is still +configured. diff --git a/schema/controller-config.json b/schema/controller-config.json index a8feb2cb..c12d2dcd 100644 --- a/schema/controller-config.json +++ b/schema/controller-config.json @@ -30,6 +30,13 @@ "type": "string", "default": "sqlite://agentdesktop-controller.db?mode=rwc" }, + "enrollmentTokens": { + "description": "Tokens that enroll operator-provisioned devices without a user\nsign-in. See `enrollmentTokenPath` in the daemon configuration.", + "type": "array", + "items": { + "$ref": "#/$defs/ControllerEnrollmentTokenConfig" + } + }, "fleetListen": { "description": "Address on which the device-facing gRPC fleet API listens.", "type": "string", @@ -93,6 +100,25 @@ "path" ] }, + "ControllerEnrollmentTokenConfig": { + "description": "An enrollment token for devices provisioned by an MDM or similar.", + "type": "object", + "properties": { + "file": { + "description": "Path to a file containing the token. The token must be at least 32\nprintable ASCII characters.\n\nRelative paths are resolved from the controller configuration directory.", + "type": "string" + }, + "name": { + "description": "Name recorded on devices enrolled with this token.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "file" + ] + }, "ControllerGatewayJwtConfig": { "description": "Settings for issuing short-lived LLM gateway JWTs.", "type": "object", diff --git a/schema/controller-config.md b/schema/controller-config.md index c0c5b126..e0e457e2 100644 --- a/schema/controller-config.md +++ b/schema/controller-config.md @@ -8,6 +8,9 @@ |`daemonConfig.path`|string|Path to the watched YAML configuration distributed to enrolled devices.

Relative paths are resolved from the controller configuration directory.
Valid file changes are published to connected devices automatically.| |`daemonConfig.revision`|integer|Monotonically increasing revision assigned to the daemon configuration.| |`databaseUrl`|string|SQLite or PostgreSQL URL used for controller state.| +|`enrollmentTokens`|[]object|Tokens that enroll operator-provisioned devices without a user
sign-in. See `enrollmentTokenPath` in the daemon configuration.| +|`enrollmentTokens[].file`|string|Path to a file containing the token. The token must be at least 32
printable ASCII characters.

Relative paths are resolved from the controller configuration directory.| +|`enrollmentTokens[].name`|string|Name recorded on devices enrolled with this token.| |`fleetListen`|string|Address on which the device-facing gRPC fleet API listens.| |`gatewayJwt`|object|LLM gateway JWT signing settings.| |`gatewayJwt.issuer`|string|Issuer claim placed in generated JWTs.| diff --git a/schema/daemon-config.json b/schema/daemon-config.json index a028c9d2..21ae778a 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -179,6 +179,13 @@ "description": "Enroll with the OAuth 2.0 Device Authorization Grant (RFC 8628) instead\nof a browser redirect to a local callback.\n\nUse this on hosts without a local browser, such as servers reached over\nSSH. The daemon logs a verification URL and user code to approve from\nany other device. The identity provider must support device\nauthorization for the controller's client.", "type": "boolean" }, + "enrollmentTokenPath": { + "description": "Path to a file holding an enrollment token issued by the controller\noperator (see the controller's `enrollmentTokens`).\n\nSet this on devices provisioned by an MDM: the daemon enrolls with the\ntoken and no user signs in. The device is afterwards identified by its\ncertificate alone and gets no LLM gateway credentials.", + "type": [ + "string", + "null" + ] + }, "heartbeatInterval": { "description": "Interval between device heartbeats. Defaults to `30s`.", "type": "string", diff --git a/schema/daemon-config.md b/schema/daemon-config.md index 77f28259..a6941c8a 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -6,6 +6,7 @@ |`controller.address`|string|HTTPS address of the controller's fleet API.| |`controller.caCertificatePath`|string|Path to a PEM-encoded CA certificate used to verify the controller.

Omit this field to use the operating system's trusted certificate roots.| |`controller.deviceAuthorization`|boolean|Enroll with the OAuth 2.0 Device Authorization Grant (RFC 8628) instead
of a browser redirect to a local callback.

Use this on hosts without a local browser, such as servers reached over
SSH. The daemon logs a verification URL and user code to approve from
any other device. The identity provider must support device
authorization for the controller's client.| +|`controller.enrollmentTokenPath`|string|Path to a file holding an enrollment token issued by the controller
operator (see the controller's `enrollmentTokens`).

Set this on devices provisioned by an MDM: the daemon enrolls with the
token and no user signs in. The device is afterwards identified by its
certificate alone and gets no LLM gateway credentials.| |`controller.heartbeatInterval`|string|Interval between device heartbeats. Defaults to `30s`.| |`daemon`|object|Local daemon startup settings. Not accepted in controller-delivered policy.| |`daemon.claudeCode`|object|Claude Code paths.|