Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 34 additions & 19 deletions crates/agent/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthCredentials>,
pub oauth_token_endpoint: String,
pub oauth_client_id: String,
}
Expand Down Expand Up @@ -109,30 +112,36 @@ pub fn load(path: &Path) -> anyhow::Result<Option<Identity>> {
fn read_secrets(
secrets: &SecretStore,
device_id: &str,
) -> anyhow::Result<(String, OAuthCredentials)> {
) -> anyhow::Result<(String, Option<OAuthCredentials>)> {
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))
}

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
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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();
}
Expand Down
1 change: 1 addition & 0 deletions crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod secure_fs;
pub mod subscription;
mod subscription_oauth;
mod tick;
mod token_enrollment;

#[cfg(windows)]
mod windows_security;
31 changes: 21 additions & 10 deletions crates/agent/src/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ pub async fn enroll(
enrollment: &EnrollmentState,
callback_listen: Option<SocketAddr>,
) -> anyhow::Result<Identity> {
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;
}
Expand Down Expand Up @@ -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::<String>::new())?;
certificate_params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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, &current.refresh_token).await?;
apply_refreshed_tokens(identity, response)
}
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(),
};
Expand All @@ -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);
Expand Down
25 changes: 18 additions & 7 deletions crates/agent/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
Expand Down Expand Up @@ -1262,7 +1266,10 @@ async fn client_with(
}

fn authenticate_request<T>(identity: &Identity, request: &mut Request<T>) -> 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}")
Expand Down Expand Up @@ -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(());
Expand Down Expand Up @@ -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,
}
}

Expand Down
80 changes: 80 additions & 0 deletions crates/agent/src/token_enrollment.rs
Original file line number Diff line number Diff line change
@@ -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<Identity> {
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<String> {
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::<u64>()
));
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();
}
}
3 changes: 3 additions & 0 deletions crates/controller/migrations/0005_enrollment_token.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading