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