diff --git a/README.md b/README.md index b5062843..82299ac5 100644 --- a/README.md +++ b/README.md @@ -1014,11 +1014,34 @@ locally and can request credentials for an LLM gateway through the daemon. ![Agentdesktop controller, workstation daemon, and LLM gateway architecture](images/overview.png) -In controller-managed mode, the daemon generates its private device key on the -workstation and sends only a certificate signing request to the controller. +In controller-managed mode, the daemon starts user sign-in automatically using +read-only login configuration from the controller. It authenticates directly +with the identity provider, then generates its private device key on the +workstation and automatically enrolls with a certificate signing request and +the user's tokens. There is no separate manual enrollment step, pre-login +enrollment ticket, or pending-enrollment reservation on the controller. After enrollment, protected controller operations require the device certificate and a valid token for the user who enrolled it. +Browser sign-in retains PKCE and state/nonce checks in the daemon. Headless +device authorization retains the verification URL/code experience; the daemon +now contacts the IdP's device-authorization endpoint directly, as well as its +token endpoint. The controller validates the ID token and bearer access token +and binds enrollment to their verified user identity. Valid credentials for +the configured OIDC client authorize enrollment; a controller-started login +attempt is not required. + +Enrollment retries reuse the same CSR. The controller atomically stores a +public-certificate receipt with the device, so the same user/CSR recovers the +original result across concurrent requests or controller restarts. Receipts +are removed with their device. Losing an unsaved device key in a daemon crash +does not resume that enrollment. + +**Upgrade compatibility:** upgrade the controller and agents together. +`GetLoginConfiguration` and `EnrollDevice` replace the old two-step enrollment +RPCs; there is no legacy fallback. Existing enrolled-device connection, +refresh, and renewal behavior is unchanged. + When a supported tool requests gateway access, the controller can issue a short-lived JWT containing the user, device ID, allowed client label, audience, issuer, and expiry. The client label is asserted by the local helper; it is diff --git a/crates/agent/src/gateway_oidc.rs b/crates/agent/src/gateway_oidc.rs index 2d402886..1ae49d81 100644 --- a/crates/agent/src/gateway_oidc.rs +++ b/crates/agent/src/gateway_oidc.rs @@ -93,15 +93,15 @@ struct ProviderMetadata { /// RFC 8628 section 3.2 device authorization response. #[derive(Deserialize)] -struct DeviceAuthorizationResponse { - device_code: String, - user_code: String, - verification_uri: String, +pub(crate) struct DeviceAuthorizationResponse { + pub(crate) device_code: String, + pub(crate) user_code: String, + pub(crate) verification_uri: String, #[serde(default)] - verification_uri_complete: Option, - expires_in: u64, + pub(crate) verification_uri_complete: Option, + pub(crate) expires_in: u64, #[serde(default)] - interval: Option, + pub(crate) interval: Option, } #[derive(Deserialize, Serialize)] @@ -403,8 +403,18 @@ async fn request_device_grant( .device_authorization_endpoint .clone() .context("OIDC provider does not support device authorization")?; + let device = request_device_authorization(endpoint, client_id, scopes).await?; + Ok((metadata, device)) +} + +pub(crate) async fn request_device_authorization( + endpoint: Url, + client_id: &str, + scopes: &[String], +) -> anyhow::Result { let device = reqwest::Client::new() .post(endpoint) + .timeout(DEVICE_REQUEST_TIMEOUT) .form(&device_authorization_form(client_id, scopes)) .send() .await @@ -414,12 +424,11 @@ async fn request_device_grant( .json::() .await .context("decode OIDC device authorization response")?; - Ok((metadata, validate_device(device)?)) + validate_device(device) } /// Rejects a device-authorization response that could not be acted on, and caps its -/// timing. Mirrors the controller's `device_enrollment_response` checks: an empty code -/// or a non-URL verification address would publish an unusable `awaitingAuthentication` +/// timing. An empty code or a non-URL verification address would publish an unusable `awaitingAuthentication` /// status, and an unbounded `expires_in` overflows `Instant + Duration` in the /// background poll task, which would panic before it cleared the pending entry. fn validate_device( diff --git a/crates/agent/src/oidc.rs b/crates/agent/src/oidc.rs index 4dbf5211..5b3597f5 100644 --- a/crates/agent/src/oidc.rs +++ b/crates/agent/src/oidc.rs @@ -2,7 +2,7 @@ use std::{net::SocketAddr, sync::Arc, time::Duration}; use agentdesktop_core::config::ControllerConnectionConfig; use agentdesktop_proto::fleet::{ - BeginEnrollmentRequest, BeginEnrollmentResponse, CompleteEnrollmentRequest, + EnrollDeviceRequest, LoginConfiguration, LoginConfigurationRequest, fleet_agent_client::FleetAgentClient, }; use anyhow::{Context, bail}; @@ -26,6 +26,7 @@ use url::Url; use crate::{ enrollment::EnrollmentState, + gateway_oidc, identity::{Identity, OAuthCredentials}, remote, }; @@ -291,101 +292,130 @@ pub async fn enroll( callback_listen: Option, open_browser: bool, ) -> anyhow::Result { - if controller.device_authorization { - return enroll_with_device_authorization(controller, enrollment).await; - } - let (verifier, challenge) = pkce(); let mut client = remote::client(controller, None).await?; - let (device_key, csr) = device_csr()?; - let mut begin = client - .begin_enrollment(BeginEnrollmentRequest { - hostname: remote::hostname(), - code_challenge: challenge, - device_authorization: false, - }) + let config = client + .get_login_configuration(LoginConfigurationRequest {}) .await - .context("begin OIDC enrollment")? + .context("get controller login configuration (controller and agent must support the same enrollment protocol)")? .into_inner(); + let tokens = if controller.device_authorization { + device_authorization_login(&config, enrollment).await? + } else { + browser_login(&config, enrollment, callback_listen, open_browser).await? + }; + enrollment.set("enrolling").await; + let (device_key, csr) = device_csr()?; + complete_enrollment(&mut client, config, &device_key, &csr, tokens).await +} - let redirect_uri = Url::parse(&begin.redirect_uri).context("parse OIDC redirect URI")?; +async fn browser_login( + config: &LoginConfiguration, + enrollment: &EnrollmentState, + callback_listen: Option, + open_browser: bool, +) -> anyhow::Result { + let (verifier, challenge) = pkce(); + let state = random_secret(); + let nonce = random_secret(); + let redirect_uri = Url::parse(&config.redirect_uri).context("parse OIDC redirect URI")?; + let mut authorization_url = + Url::parse(&config.authorization_endpoint).context("parse OIDC authorization endpoint")?; + authorization_url + .query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("scope", &config.scopes.join(" ")) + .append_pair("client_id", &config.client_id) + .append_pair("redirect_uri", &config.redirect_uri) + .append_pair("state", &state) + .append_pair("nonce", &nonce) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256"); enrollment - .awaiting_authentication(begin.authorization_url.clone()) + .awaiting_authentication(authorization_url.to_string()) .await; let authorization_code = wait_for_authorization_code( - &begin.authorization_url, + authorization_url.as_str(), &redirect_uri, - std::mem::take(&mut begin.state), + state, callback_listen, open_browser, ) .await?; - enrollment.set("enrolling").await; - let tokens = exchange_authorization_code( - &begin.token_endpoint, - &begin.client_id, - &begin.redirect_uri, + &config.token_endpoint, + &config.client_id, + &config.redirect_uri, &authorization_code, &verifier, ) .await?; - complete_enrollment(&mut client, begin, &device_key, &csr, tokens).await + validate_login_nonce( + tokens + .id_token + .as_deref() + .context("OIDC token response did not contain an ID token")?, + &nonce, + )?; + Ok(tokens) } -/// Enrolls with the OAuth 2.0 Device Authorization Grant (RFC 8628). -/// -/// The controller starts the device authorization with the identity provider -/// and returns a user code. The daemon publishes the verification URL and code -/// through the enrollment status and logs, then polls the token endpoint until -/// the user approves the request from another device. -async fn enroll_with_device_authorization( - controller: &ControllerConnectionConfig, +async fn device_authorization_login( + config: &LoginConfiguration, enrollment: &EnrollmentState, -) -> anyhow::Result { - let mut client = remote::client(controller, None).await?; - let (device_key, csr) = device_csr()?; - let begin = client - .begin_enrollment(BeginEnrollmentRequest { - hostname: remote::hostname(), - code_challenge: String::new(), - device_authorization: true, - }) - .await - .context("begin OIDC device authorization enrollment")? - .into_inner(); - if begin.device_code.is_empty() || begin.user_code.is_empty() { - bail!("controller does not support device authorization enrollment"); - } - - let verification_url = if begin.verification_uri_complete.is_empty() { - begin.verification_uri.clone() - } else { - begin.verification_uri_complete.clone() - }; +) -> anyhow::Result { + let endpoint = Url::parse(&config.device_authorization_endpoint) + .context("controller's OIDC provider does not support device authorization")?; + let device = + gateway_oidc::request_device_authorization(endpoint, &config.client_id, &config.scopes) + .await?; + let verification_url = device + .verification_uri_complete + .clone() + .unwrap_or_else(|| device.verification_uri.clone()); tracing::info!( - verification_uri = %begin.verification_uri, - verification_uri_complete = %begin.verification_uri_complete, - user_code = %begin.user_code, + verification_uri = %device.verification_uri, + user_code = %device.user_code, "approve device enrollment: open the verification URL on any device and confirm the code" ); enrollment - .awaiting_device_authorization(verification_url, begin.user_code.clone()) + .awaiting_device_authorization(verification_url, device.user_code.clone()) .await; - let interval = match begin.interval_seconds { - 0 => DEFAULT_DEVICE_POLL_INTERVAL, - seconds => Duration::from_secs(seconds), - }; - let tokens = poll_device_token( - &begin.token_endpoint, - &begin.client_id, - &begin.device_code, + let interval = device + .interval + .filter(|seconds| *seconds > 0) + .map_or(DEFAULT_DEVICE_POLL_INTERVAL, Duration::from_secs); + poll_device_token( + &config.token_endpoint, + &config.client_id, + &device.device_code, interval, - Duration::from_secs(begin.expires_in_seconds), + Duration::from_secs(device.expires_in), ) - .await?; - enrollment.set("enrolling").await; - complete_enrollment(&mut client, begin, &device_key, &csr, tokens).await + .await +} + +fn validate_login_nonce(id_token: &str, expected: &str) -> anyhow::Result<()> { + // The controller verifies the signature/issuer/audience of this same token. + // Here the agent binds the token-endpoint response to its own browser login. + #[derive(Deserialize)] + struct Nonce { + nonce: String, + } + let payload = id_token + .split('.') + .nth(1) + .context("ID token has no payload")?; + let claims: Nonce = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(payload) + .context("decode ID token payload")?, + ) + .context("decode ID token nonce")?; + if claims.nonce != expected { + bail!("OIDC nonce mismatch"); + } + Ok(()) } fn device_csr() -> anyhow::Result<(KeyPair, CertificateSigningRequest)> { @@ -401,11 +431,12 @@ fn device_csr() -> anyhow::Result<(KeyPair, CertificateSigningRequest)> { async fn complete_enrollment( client: &mut FleetAgentClient, - begin: BeginEnrollmentResponse, + config: LoginConfiguration, device_key: &KeyPair, csr: &CertificateSigningRequest, tokens: TokenResponse, ) -> anyhow::Result { + let login_started = unix_time_seconds(); let id_token = tokens .id_token .context("OIDC token response did not contain an ID token")?; @@ -413,24 +444,39 @@ async fn complete_enrollment( .refresh_token .context("OIDC token response did not contain a refresh token")?; - let mut request = tonic::Request::new(CompleteEnrollmentRequest { - enrollment_id: begin.enrollment_id, - authorization_code: String::new(), - code_verifier: String::new(), + let body = EnrollDeviceRequest { + hostname: remote::hostname(), certificate_signing_request_der: csr.der().as_ref().to_vec(), id_token, - }); - request.metadata_mut().insert( - "authorization", + }; + let authorization: tonic::metadata::MetadataValue = format!("Bearer {}", tokens.access_token) .parse() - .context("encode OIDC access token")?, - ); - let response = client - .complete_enrollment(request) - .await - .context("complete OIDC enrollment")? - .into_inner(); + .context("encode OIDC access token")?; + let mut delay = Duration::from_secs(1); + let response = loop { + let mut request = tonic::Request::new(body.clone()); + request + .metadata_mut() + .insert("authorization", authorization.clone()); + request.set_timeout(Duration::from_secs(15)); + match client.enroll_device(request).await { + Ok(response) => break response.into_inner(), + Err(error) + if matches!( + error.code(), + tonic::Code::Unavailable + | tonic::Code::DeadlineExceeded + | tonic::Code::ResourceExhausted + ) && unix_time_seconds() < tokens.expires_in.saturating_add(login_started) => + { + tracing::warn!(%error, "device enrollment interrupted; retrying with the same device key"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(30)); + } + Err(error) => return Err(error).context("enroll authenticated device"), + } + }; let client_certificate_pem = String::from_utf8(response.client_certificate_pem) .context("controller returned a non-UTF-8 device certificate")?; @@ -446,10 +492,10 @@ async fn complete_enrollment( oauth: OAuthCredentials { access_token: tokens.access_token, refresh_token, - expires_at_unix_seconds: unix_time_seconds().saturating_add(tokens.expires_in), + expires_at_unix_seconds: login_started.saturating_add(tokens.expires_in), }, - oauth_token_endpoint: begin.token_endpoint, - oauth_client_id: begin.client_id, + oauth_token_endpoint: config.token_endpoint, + oauth_client_id: config.client_id, }) } @@ -963,6 +1009,295 @@ mod tests { }; use crate::identity::{Identity, OAuthCredentials}; + #[derive(Clone)] + struct EnrollmentController { + config: agentdesktop_proto::fleet::LoginConfiguration, + authenticated: std::sync::Arc, + requests: + std::sync::Arc>>, + } + + #[tonic::async_trait] + impl agentdesktop_proto::fleet::fleet_agent_server::FleetAgent for EnrollmentController { + type ConnectStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn get_login_configuration( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Ok(tonic::Response::new(self.config.clone())) + } + + async fn enroll_device( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> + { + assert!( + self.authenticated.load(std::sync::atomic::Ordering::SeqCst), + "enrollment must not be called before user authentication" + ); + assert_eq!( + request.metadata().get("authorization").unwrap(), + "Bearer access-token" + ); + let request = request.into_inner(); + rcgen::CertificateSigningRequestParams::from_der( + &request.certificate_signing_request_der.as_slice().into(), + ) + .unwrap(); + let mut requests = self.requests.lock().await; + requests.push(request); + if requests.len() == 1 { + return Err(tonic::Status::unavailable( + "simulated lost enrollment response", + )); + } + assert_eq!( + requests[0], requests[1], + "retries must reuse the CSR and user credentials" + ); + Ok(tonic::Response::new( + agentdesktop_proto::fleet::EnrollResponse { + device_id: "device-1".to_owned(), + client_certificate_pem: b"mock-certificate".to_vec(), + client_certificate_expires_at_unix_seconds: 12345, + }, + )) + } + + async fn renew_device_certificate( + &self, + _: tonic::Request, + ) -> Result< + tonic::Response, + tonic::Status, + > { + Err(tonic::Status::unimplemented("not used")) + } + async fn get_llm_gateway_credential( + &self, + _: tonic::Request, + ) -> Result< + tonic::Response, + tonic::Status, + > { + Err(tonic::Status::unimplemented("not used")) + } + async fn get_device_usage( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Err(tonic::Status::unimplemented("not used")) + } + async fn connect( + &self, + _: tonic::Request>, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("not used")) + } + } + + #[tokio::test] + async fn automatic_browser_and_headless_login_precede_enrollment_and_retries_reuse_key() { + use agentdesktop_proto::fleet::{LoginConfiguration, fleet_agent_server::FleetAgentServer}; + use axum::{Json, Router, response::IntoResponse, routing::post}; + use base64::Engine as _; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + use tokio::sync::{Mutex, Notify}; + + for (headless, approved) in [(false, true), (true, true), (false, false), (true, false)] { + let authenticated = Arc::new(AtomicBool::new(false)); + let release = Arc::new(Notify::new()); + let token_claims = Arc::new(Mutex::new(serde_json::json!({"sub": "user-1"}))); + let idp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let idp_address = idp_listener.local_addr().unwrap(); + let app = Router::new() + .route( + "/device", + post(|| async { + Json(serde_json::json!({ + "device_code": "device-code", "user_code": "CODE", + "verification_uri": "https://idp.example/activate", + "expires_in": 600, "interval": 1, + })) + }), + ) + .route( + "/token", + post({ + let authenticated = authenticated.clone(); + let release = release.clone(); + let token_claims = token_claims.clone(); + move |axum::Form(form): axum::Form< + std::collections::HashMap, + >| { + let authenticated = authenticated.clone(); + let release = release.clone(); + let token_claims = token_claims.clone(); + async move { + assert_eq!(form["client_id"], "client"); + if headless { + assert_eq!(form["device_code"], "device-code"); + } else { + assert_eq!(form["code"], "authorization-code"); + assert!(!form["code_verifier"].is_empty()); + } + release.notified().await; + if !approved { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "access_denied"})), + ) + .into_response(); + } + authenticated.store(true, Ordering::SeqCst); + let payload = super::URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&*token_claims.lock().await).unwrap(), + ); + Json(serde_json::json!({ + "access_token": "access-token", "refresh_token": "refresh-token", + "id_token": format!("header.{payload}.signature"), + "expires_in": 600, "token_type": "Bearer", + })).into_response() + } + } + }), + ); + let idp = tokio::spawn(async move { axum::serve(idp_listener, app).await.unwrap() }); + let callback_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let callback_address = callback_listener.local_addr().unwrap(); + drop(callback_listener); + let config = LoginConfiguration { + issuer: "https://idp.example".to_owned(), + authorization_endpoint: format!("http://{idp_address}/authorize"), + token_endpoint: format!("http://{idp_address}/token"), + client_id: "client".to_owned(), + redirect_uri: format!("http://{callback_address}/callback"), + device_authorization_endpoint: format!("http://{idp_address}/device"), + scopes: vec!["openid".to_owned(), "offline_access".to_owned()], + }; + let mock = EnrollmentController { + config, + authenticated: authenticated.clone(), + requests: Arc::default(), + }; + let requests = mock.requests.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(FleetAgentServer::new(mock)) + .serve_with_incoming(tonic::transport::server::TcpIncoming::from(listener)) + .await + .unwrap(); + }); + let directory = tempfile::tempdir().unwrap(); + let ca_certificate_path = directory.path().join("ca.pem"); + let ca = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); + std::fs::write(&ca_certificate_path, ca.cert.pem()).unwrap(); + let controller = agentdesktop_core::config::ControllerConnectionConfig { + address: format!("http://{address}"), + ca_certificate_path, + heartbeat_interval: Duration::from_secs(30), + device_authorization: headless, + }; + let enrollment = crate::enrollment::EnrollmentState::new(true); + let mut pending = Box::pin(super::enroll(&controller, &enrollment, None, false)); + let status = tokio::time::timeout(Duration::from_secs(5), async { + tokio::select! { + result = &mut pending => panic!("enrollment finished before login: {result:?}"), + status = async { + loop { + let status = enrollment.get().await; + if status.status == "awaitingAuthentication" { + break status; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } => status, + } + }) + .await + .expect("login must become ready"); + assert_eq!(status.status, "awaitingAuthentication"); + assert!(requests.lock().await.is_empty()); + assert!(!authenticated.load(Ordering::SeqCst)); + if headless { + assert_eq!(status.user_code.as_deref(), Some("CODE")); + assert_eq!( + status.authorization_url.as_deref(), + Some("https://idp.example/activate") + ); + } else { + let url = url::Url::parse(status.authorization_url.as_deref().unwrap()).unwrap(); + let query: std::collections::HashMap<_, _> = url.query_pairs().collect(); + assert_eq!(query["code_challenge_method"], "S256"); + assert!(!query["code_challenge"].is_empty()); + token_claims.lock().await["nonce"] = serde_json::json!(query["nonce"]); + let callback = format!( + "http://{callback_address}/callback?code=authorization-code&state={}", + query["state"], + ); + reqwest::get(callback) + .await + .unwrap() + .error_for_status() + .unwrap(); + } + release.notify_one(); + let result = tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap(); + if approved { + let identity = result.unwrap(); + assert_eq!(identity.device_id, "device-1"); + assert_eq!(identity.oauth.access_token, "access-token"); + assert_eq!(requests.lock().await.len(), 2); + assert_eq!(enrollment.get().await.status, "enrolling"); + } else { + assert!(result.is_err()); + assert!(requests.lock().await.is_empty()); + assert!(!authenticated.load(Ordering::SeqCst)); + } + server.abort(); + idp.abort(); + } + } + + #[test] + fn browser_nonce_must_match_before_device_enrollment() { + use base64::Engine as _; + let token = |value| { + format!( + "header.{}.signature", + super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&value).unwrap()), + ) + }; + assert!( + super::validate_login_nonce( + &token(serde_json::json!({"nonce": "expected"})), + "expected" + ) + .is_ok() + ); + for token in [ + "invalid".to_owned(), + "header.invalid.signature".to_owned(), + token(serde_json::json!({})), + token(serde_json::json!({"nonce": "other"})), + ] { + assert!(super::validate_login_nonce(&token, "expected").is_err()); + } + } + // The poll interval is provider-controlled and grows on every slow_down, so it can // exceed what is left of expires_in. Sleeping the whole interval kept the task, and // the pending login it stands for, alive long after the code had expired. diff --git a/crates/agent/src/remote/auth_recovery_tests.rs b/crates/agent/src/remote/auth_recovery_tests.rs index 0ca12696..df79b34c 100644 --- a/crates/agent/src/remote/auth_recovery_tests.rs +++ b/crates/agent/src/remote/auth_recovery_tests.rs @@ -26,19 +26,16 @@ struct Controller { #[tonic::async_trait] impl FleetAgent for Controller { - async fn begin_enrollment( + async fn get_login_configuration( &self, - _: Request, - ) -> Reply { + _: Request, + ) -> Reply { let _ = self.events.send(Event::EnrollmentRequested); Err(tonic::Status::unavailable( "interactive enrollment is not part of this test", )) } - async fn complete_enrollment( - &self, - _: Request, - ) -> Reply { + async fn enroll_device(&self, _: Request) -> Reply { Err(tonic::Status::unimplemented("not used")) } async fn renew_device_certificate( diff --git a/crates/controller/migrations/0005_enrollment_receipts.sql b/crates/controller/migrations/0005_enrollment_receipts.sql new file mode 100644 index 00000000..733d43d8 --- /dev/null +++ b/crates/controller/migrations/0005_enrollment_receipts.sql @@ -0,0 +1,4 @@ +CREATE TABLE enrollment_receipts ( + device_id TEXT PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE, + response_json TEXT NOT NULL +); diff --git a/crates/controller/src/database.rs b/crates/controller/src/database.rs index bbc46403..ab81d0da 100644 --- a/crates/controller/src/database.rs +++ b/crates/controller/src/database.rs @@ -322,6 +322,69 @@ impl Database { Ok(()) } + pub async fn enrollment_receipt( + &self, + device_id: &str, + ) -> anyhow::Result> { + let row: Option<(String,)> = + sqlx::query_as("SELECT response_json FROM enrollment_receipts WHERE device_id = $1") + .bind(device_id) + .fetch_optional(&self.pool) + .await + .context("load device enrollment receipt")?; + row.map(|(json,)| decode_enrollment_receipt(&json)) + .transpose() + } + + /// Commits the device and its retry receipt together. Concurrent enrollments + /// for the same principal/key return the first committed certificate. + pub async fn finish_enrollment( + &self, + completed: &crate::oidc::CompletedEnrollment, + response: &agentdesktop_proto::fleet::EnrollResponse, + ) -> anyhow::Result { + let idp_claims = + serde_json::to_string(&completed.idp_claims).context("encode verified IdP claims")?; + let receipt = serde_json::to_string(&( + &response.device_id, + &response.client_certificate_pem, + response.client_certificate_expires_at_unix_seconds, + )) + .context("encode device enrollment receipt")?; + let mut transaction = self.pool.begin().await?; + 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) + ON CONFLICT (id) DO NOTHING", + ) + .bind(&response.device_id) + .bind(&completed.hostname) + .bind(unix_time_seconds()) + .bind(&completed.issuer) + .bind(&completed.subject) + .bind(idp_claims) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO enrollment_receipts (device_id, response_json) + VALUES ($1, $2) ON CONFLICT (device_id) DO NOTHING", + ) + .bind(&response.device_id) + .bind(receipt) + .execute(&mut *transaction) + .await?; + let (receipt,): (String,) = + sqlx::query_as("SELECT response_json FROM enrollment_receipts WHERE device_id = $1") + .bind(&response.device_id) + .fetch_one(&mut *transaction) + .await?; + let response = decode_enrollment_receipt(&receipt)?; + transaction.commit().await?; + Ok(response) + } + 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 @@ -752,6 +815,19 @@ impl Database { } } +fn decode_enrollment_receipt( + json: &str, +) -> anyhow::Result { + let (device_id, client_certificate_pem, client_certificate_expires_at_unix_seconds) = + serde_json::from_str::<(String, Vec, u64)>(json) + .context("decode device enrollment receipt")?; + Ok(agentdesktop_proto::fleet::EnrollResponse { + device_id, + client_certificate_pem, + client_certificate_expires_at_unix_seconds, + }) +} + fn unix_time_seconds() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/controller/src/oidc.rs b/crates/controller/src/oidc.rs index f6c8672d..609679f0 100644 --- a/crates/controller/src/oidc.rs +++ b/crates/controller/src/oidc.rs @@ -1,31 +1,25 @@ use std::{ - collections::{BTreeMap, HashMap}, + collections::BTreeMap, future::Future, sync::{Arc, RwLock}, time::{Duration, Instant}, }; use agentdesktop_core::http::ClientExt; -use agentdesktop_proto::fleet::{BeginEnrollmentResponse, CompleteEnrollmentRequest}; +use agentdesktop_proto::fleet::LoginConfiguration; use anyhow::{Context, bail}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ DecodingKey, Validation, decode, decode_header, jwk::{Jwk, JwkSet}, }; -use rand::Rng; use reqwest::header::CACHE_CONTROL; use serde::Deserialize; use tokio::sync::Mutex; use url::Url; -const ENROLLMENT_LIFETIME: Duration = Duration::from_secs(10 * 60); -const MAX_PENDING_ENROLLMENTS: usize = 1024; const JWKS_FETCH_TIMEOUT: Duration = Duration::from_secs(10); const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(30); const ENROLLMENT_SCOPE: &str = "openid profile email offline_access"; -// RFC 8628 section 3.2: clients poll every five seconds unless told otherwise. -const DEFAULT_DEVICE_POLL_INTERVAL: Duration = Duration::from_secs(5); #[derive(Clone)] pub struct OidcProvider { @@ -42,7 +36,6 @@ struct Inner { device_authorization_endpoint: Option, jwks: JwksCache, http: reqwest::Client, - pending: Mutex>, } struct JwksCache { @@ -53,14 +46,6 @@ struct JwksCache { refresh_cooldown: Duration, } -struct PendingEnrollment { - hostname: String, - /// Absent for device authorization enrollments, which have no - /// authorization request to bind a nonce to. - nonce: Option, - expires_at: Instant, -} - pub struct CompletedEnrollment { pub hostname: String, pub issuer: String, @@ -86,19 +71,6 @@ struct DiscoveryDocument { device_authorization_endpoint: Option, } -/// RFC 8628 section 3.2 device authorization response. -#[derive(Deserialize)] -struct DeviceAuthorizationResponse { - device_code: String, - user_code: String, - verification_uri: String, - #[serde(default)] - verification_uri_complete: Option, - expires_in: u64, - #[serde(default)] - interval: Option, -} - #[derive(Deserialize)] struct IdTokenClaims { sub: String, @@ -149,192 +121,46 @@ impl OidcProvider { device_authorization_endpoint: document.device_authorization_endpoint, jwks: JwksCache::new(http.clone(), document.jwks_uri, jwks, JWKS_REFRESH_COOLDOWN), http, - pending: Mutex::new(HashMap::new()), }), }) } - pub async fn begin( - &self, - hostname: String, - code_challenge: &str, - ) -> anyhow::Result { - if hostname.trim().is_empty() { - bail!("hostname is required"); - } - if code_challenge.len() != 43 - || !code_challenge - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - bail!("invalid PKCE code challenge"); - } - - let enrollment_id = random_secret(); - let state = random_secret(); - let nonce = random_secret(); - let mut authorization_url = self.inner.authorization_endpoint.clone(); - authorization_url - .query_pairs_mut() - .append_pair("response_type", "code") - .append_pair("scope", ENROLLMENT_SCOPE) - .append_pair("client_id", &self.inner.client_id) - .append_pair("redirect_uri", &self.inner.redirect_uri) - .append_pair("state", &state) - .append_pair("nonce", &nonce) - .append_pair("code_challenge", code_challenge) - .append_pair("code_challenge_method", "S256"); - - self.insert_pending( - enrollment_id.clone(), - hostname, - Some(nonce), - ENROLLMENT_LIFETIME, - ) - .await?; - - Ok(BeginEnrollmentResponse { - enrollment_id, - authorization_url: authorization_url.into(), - state, - redirect_uri: self.inner.redirect_uri.clone(), + pub fn login_configuration(&self) -> LoginConfiguration { + LoginConfiguration { + issuer: self.inner.issuer.clone(), + authorization_endpoint: self.inner.authorization_endpoint.to_string(), token_endpoint: self.inner.token_endpoint.to_string(), client_id: self.inner.client_id.clone(), - ..Default::default() - }) - } - - /// Starts an enrollment with the OAuth 2.0 Device Authorization Grant - /// (RFC 8628), for hosts that cannot open a local browser. - /// - /// The daemon polls the token endpoint itself and completes the - /// enrollment with the resulting tokens, exactly like the browser flow. - pub async fn begin_device(&self, hostname: String) -> anyhow::Result { - if hostname.trim().is_empty() { - bail!("hostname is required"); - } - let endpoint = self - .inner - .device_authorization_endpoint - .clone() - .context("OIDC provider does not support device authorization")?; - - // Reserve a slot BEFORE contacting the IdP, not after. Reserving only once the - // IdP has already answered (the previous order) let MAX_PENDING_ENROLLMENTS - // fill up without ever bounding the outbound device-grant requests themselves: - // every caller's request still reached the IdP and only got rejected - // afterwards, on the insert. The reservation uses ENROLLMENT_LIFETIME as a - // placeholder expiry — the real, possibly shorter, device lifetime isn't known - // until the IdP responds — and is narrowed below once it is, or dropped - // entirely if the IdP call or the response fails. - let enrollment_id = random_secret(); - self.insert_pending(enrollment_id.clone(), hostname, None, ENROLLMENT_LIFETIME) - .await?; - - match self.request_device_grant(&enrollment_id, endpoint).await { - Ok((response, lifetime)) => { - self.set_pending_expiry(&enrollment_id, lifetime).await; - Ok(response) - } - Err(error) => { - self.release_pending(&enrollment_id).await; - Err(error) - } + redirect_uri: self.inner.redirect_uri.clone(), + device_authorization_endpoint: self + .inner + .device_authorization_endpoint + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), + scopes: ENROLLMENT_SCOPE + .split_whitespace() + .map(str::to_owned) + .collect(), } } - /// The IdP round-trip and response validation for [`begin_device`], split out so - /// its error paths all flow through one `?`-propagating function and the caller - /// can release the reservation on any of them uniformly. - async fn request_device_grant( - &self, - enrollment_id: &str, - endpoint: Url, - ) -> anyhow::Result<(BeginEnrollmentResponse, Duration)> { - let device = self - .inner - .http - .post(endpoint) - .form(&[ - ("client_id", self.inner.client_id.as_str()), - ("scope", ENROLLMENT_SCOPE), - ]) - .send() - .await - .context("request OIDC device authorization")? - .error_for_status() - .context("OIDC device authorization endpoint returned an error")? - .json::() - .await - .context("decode OIDC device authorization response")?; - - device_enrollment_response( - enrollment_id.to_owned(), - device, - self.inner.token_endpoint.to_string(), - self.inner.client_id.clone(), - ) - } - - async fn insert_pending( + pub async fn authenticate_enrollment( &self, - enrollment_id: String, hostname: String, - nonce: Option, - lifetime: Duration, - ) -> anyhow::Result<()> { - let mut pending = self.inner.pending.lock().await; - let now = Instant::now(); - pending.retain(|_, enrollment| enrollment.expires_at > now); - if pending.len() >= MAX_PENDING_ENROLLMENTS { - bail!("too many pending enrollments"); - } - pending.insert( - enrollment_id, - PendingEnrollment { - hostname, - nonce, - expires_at: now + lifetime, - }, - ); - Ok(()) - } - - /// Drops a reservation [`insert_pending`] made, e.g. because the IdP request or - /// response validation that was supposed to fill it in failed. Already-removed - /// (expired and pruned, or raced with a concurrent completion) is not an error. - async fn release_pending(&self, enrollment_id: &str) { - self.inner.pending.lock().await.remove(enrollment_id); - } - - /// Narrows a reservation's expiry to the real device-grant lifetime once the IdP - /// has responded, which can be shorter than the ENROLLMENT_LIFETIME placeholder - /// [`begin_device`] reserved it with. A no-op if the entry is already gone. - async fn set_pending_expiry(&self, enrollment_id: &str, lifetime: Duration) { - if let Some(entry) = self.inner.pending.lock().await.get_mut(enrollment_id) { - entry.expires_at = Instant::now() + lifetime; - } - } - - pub async fn complete( - &self, - request: CompleteEnrollmentRequest, + id_token: &str, access_token: &str, ) -> anyhow::Result { - let pending = self - .inner - .pending - .lock() - .await - .remove(&request.enrollment_id) - .context("unknown or already completed enrollment")?; - if pending.expires_at <= Instant::now() { - bail!("enrollment expired"); + if hostname.trim().is_empty() + || hostname.len() > 255 + || hostname.chars().any(char::is_control) + { + bail!("invalid enrollment hostname"); } - if request.id_token.is_empty() || access_token.is_empty() { + if id_token.is_empty() || id_token.len() > 64 * 1024 || access_token.is_empty() { bail!("ID token and access token are required"); } - let header = decode_header(&request.id_token).context("decode ID token header")?; + let header = decode_header(id_token).context("decode ID token header")?; let kid = header.kid.context("ID token has no key ID")?; let jwk = self.inner.jwks.key_for(&kid).await?; let key = DecodingKey::from_jwk(&jwk).context("construct ID token verification key")?; @@ -342,14 +168,9 @@ impl OidcProvider { validation.set_issuer(&[&self.inner.issuer]); validation.set_audience(&[&self.inner.client_id]); validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]); - let claims = decode::(&request.id_token, &key, &validation) + let claims = decode::(id_token, &key, &validation) .context("validate OIDC ID token")? .claims; - if let Some(expected) = &pending.nonce - && claims.nonce.as_ref() != Some(expected) - { - bail!("OIDC nonce mismatch"); - } let access_principal = self.authenticate_access_token(access_token).await?; if access_principal.subject != claims.sub { bail!("access token and ID token subjects do not match"); @@ -362,7 +183,7 @@ impl OidcProvider { } Ok(CompletedEnrollment { - hostname: pending.hostname, + hostname, issuer: access_principal.issuer, subject: access_principal.subject, idp_claims, @@ -380,6 +201,7 @@ impl OidcProvider { .inner .http .get(self.inner.userinfo_endpoint.clone()) + .timeout(Duration::from_secs(15)) .bearer_auth(access_token) .send() .await @@ -494,161 +316,19 @@ async fn fetch_jwks(request: reqwest::RequestBuilder) -> anyhow::Result Ok(jwks) } -/// Builds the enrollment response for a device authorization and returns how -/// long the enrollment stays pending: the IdP's device code lifetime, capped at -/// the browser enrollment lifetime. -fn device_enrollment_response( - enrollment_id: String, - device: DeviceAuthorizationResponse, - token_endpoint: String, - client_id: String, -) -> anyhow::Result<(BeginEnrollmentResponse, Duration)> { - if device.device_code.is_empty() || device.user_code.is_empty() { - bail!("OIDC device authorization response has no device or user code"); - } - Url::parse(&device.verification_uri).context("OIDC device verification URI is invalid")?; - let verification_uri_complete = device - .verification_uri_complete - .filter(|uri| Url::parse(uri).is_ok()) - .unwrap_or_default(); - let lifetime = Duration::from_secs(device.expires_in).min(ENROLLMENT_LIFETIME); - if lifetime.is_zero() { - bail!("OIDC device authorization has already expired"); - } - let interval = device - .interval - .filter(|interval| *interval > 0) - .map_or(DEFAULT_DEVICE_POLL_INTERVAL, Duration::from_secs); - - Ok(( - BeginEnrollmentResponse { - enrollment_id, - token_endpoint, - client_id, - device_code: device.device_code, - user_code: device.user_code, - verification_uri: device.verification_uri, - verification_uri_complete, - interval_seconds: interval.as_secs(), - expires_in_seconds: lifetime.as_secs(), - ..Default::default() - }, - lifetime, - )) -} - -fn random_secret() -> String { - let mut bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut bytes); - URL_SAFE_NO_PAD.encode(bytes) -} - #[cfg(test)] -mod tests { - use std::{ - collections::HashMap, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - time::{Duration, Instant}, +pub(crate) mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, }; - use axum::http::StatusCode; use jsonwebtoken::DecodingKey; use serde_json::json; - use tokio::{sync::Mutex, task::yield_now}; + use tokio::task::yield_now; use url::Url; - use super::{ - DeviceAuthorizationResponse, Inner, JWKS_FETCH_TIMEOUT, JwkSet, JwksCache, - MAX_PENDING_ENROLLMENTS, OidcProvider, PendingEnrollment, device_enrollment_response, - refresh_jwks_request, - }; - - fn device_authorization(value: serde_json::Value) -> DeviceAuthorizationResponse { - serde_json::from_value(value).unwrap() - } - - fn device_response( - value: serde_json::Value, - ) -> anyhow::Result<(agentdesktop_proto::fleet::BeginEnrollmentResponse, Duration)> { - device_enrollment_response( - "enrollment".to_owned(), - device_authorization(value), - "https://idp.example/token".to_owned(), - "client".to_owned(), - ) - } - - #[test] - fn device_enrollment_caps_lifetime_and_defaults_interval() { - let (response, lifetime) = device_response(json!({ - "device_code": "device", - "user_code": "ABCD-EFGH", - "verification_uri": "https://idp.example/activate", - "verification_uri_complete": "https://idp.example/activate?user_code=ABCD-EFGH", - "expires_in": 3600, - })) - .unwrap(); - assert_eq!(lifetime, Duration::from_secs(10 * 60)); - assert_eq!(response.expires_in_seconds, 600); - assert_eq!(response.interval_seconds, 5); - assert_eq!(response.enrollment_id, "enrollment"); - assert_eq!(response.token_endpoint, "https://idp.example/token"); - assert_eq!(response.client_id, "client"); - assert_eq!(response.device_code, "device"); - assert_eq!(response.user_code, "ABCD-EFGH"); - assert_eq!( - response.verification_uri_complete, - "https://idp.example/activate?user_code=ABCD-EFGH" - ); - assert!(response.authorization_url.is_empty()); - assert!(response.state.is_empty()); - } - - #[test] - fn device_enrollment_keeps_shorter_lifetime_and_interval() { - let (response, lifetime) = device_response(json!({ - "device_code": "device", - "user_code": "CODE", - "verification_uri": "https://idp.example/activate", - "expires_in": 120, - "interval": 10, - })) - .unwrap(); - assert_eq!(lifetime, Duration::from_secs(120)); - assert_eq!(response.interval_seconds, 10); - assert!(response.verification_uri_complete.is_empty()); - } - - #[test] - fn device_enrollment_rejects_invalid_responses() { - let valid = json!({ - "device_code": "device", - "user_code": "CODE", - "verification_uri": "https://idp.example/activate", - "expires_in": 120, - }); - for (field, value) in [ - ("device_code", json!("")), - ("user_code", json!("")), - ("verification_uri", json!("not a url")), - ("expires_in", json!(0)), - ] { - let mut invalid = valid.clone(); - invalid[field] = value; - assert!( - device_response(invalid).is_err(), - "{field} should be rejected" - ); - } - - let mut invalid_complete = valid; - invalid_complete["verification_uri_complete"] = json!("not a url"); - let (response, _) = device_response(invalid_complete).unwrap(); - assert!(response.verification_uri_complete.is_empty()); - } + use super::{Inner, JWKS_FETCH_TIMEOUT, JwkSet, JwksCache, OidcProvider, refresh_jwks_request}; const RSA_MODULUS: &str = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzsKJkZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw"; @@ -680,9 +360,6 @@ mod tests { ) } - /// Builds an `OidcProvider` without going through `discover`'s own HTTP calls, so - /// device-grant tests can point `device_authorization_endpoint` at a local mock - /// server and never touch the network for anything else. fn provider(device_authorization_endpoint: Option) -> OidcProvider { OidcProvider { inner: Arc::new(Inner { @@ -701,140 +378,162 @@ mod tests { device_authorization_endpoint, jwks: cache(jwks(&[])), http: reqwest::Client::new(), - pending: Mutex::new(HashMap::new()), }), } } - /// Starts a one-shot HTTP server that counts requests and always answers with - /// `body`, so a test can assert how many times (zero, in the capacity test) the - /// IdP was actually contacted. - async fn count_requests_server( - status: StatusCode, - body: &'static str, - ) -> (Url, Arc, tokio::task::JoinHandle<()>) { - let hits = Arc::new(AtomicUsize::new(0)); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock IdP listener"); - let addr = listener.local_addr().expect("mock IdP local addr"); - let counted = hits.clone(); + pub(crate) async fn authenticated_provider() -> ( + OidcProvider, + jsonwebtoken::EncodingKey, + tokio::task::JoinHandle<()>, + ) { + use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _}; + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let _ = jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER.install_default(); + let private = + Ed25519KeyPair::generate_pkcs8(&aws_lc_rs::rand::SystemRandom::new()).unwrap(); + let key = Ed25519KeyPair::from_pkcs8(private.as_ref()).unwrap(); + let keys = serde_json::from_value(json!({"keys": [{ + "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "kid": "login-test", + "x": URL_SAFE_NO_PAD.encode(key.public_key().as_ref()) + }]})) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); let app = axum::Router::new().route( - "/device_authorization", - axum::routing::post(move || { - let counted = counted.clone(); - async move { - counted.fetch_add(1, Ordering::SeqCst); - (status, body) + "/userinfo", + axum::routing::get(|headers: axum::http::HeaderMap| async move { + match headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + { + Some("Bearer access-token") => Ok(axum::Json(json!({"sub": "user-1"}))), + Some("Bearer other-user") => Ok(axum::Json(json!({"sub": "user-2"}))), + _ => Err(axum::http::StatusCode::UNAUTHORIZED), } }), ); - let handle = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("serve mock IdP device_authorization endpoint"); - }); - let url = format!("http://{addr}/device_authorization") - .parse() - .expect("parse mock IdP URL"); - (url, hits, handle) + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let mut provider = provider(None); + let inner = Arc::get_mut(&mut provider.inner).unwrap(); + inner.jwks = cache(keys); + inner.userinfo_endpoint = format!("http://{address}/userinfo").parse().unwrap(); + ( + provider, + jsonwebtoken::EncodingKey::from_ed_der(private.as_ref()), + server, + ) } - // Regression test for the actual bug this fix targets. A slot used to be reserved - // (MAX_PENDING_ENROLLMENTS checked) only AFTER the IdP had already answered, so - // once the pending map was full, every caller's device-grant request still - // reached the IdP — and only got rejected on the insert afterwards. The IdP - // request must never happen at all once the map is full. - #[tokio::test] - async fn begin_device_reserves_capacity_before_contacting_the_idp() { - let (endpoint, hits, server) = - count_requests_server(StatusCode::OK, "unused — must not be reached").await; - let provider = provider(Some(endpoint)); - { - let mut pending = provider.inner.pending.lock().await; - for i in 0..MAX_PENDING_ENROLLMENTS { - pending.insert( - format!("filler-{i}"), - PendingEnrollment { - hostname: "filler".to_owned(), - nonce: None, - expires_at: Instant::now() + Duration::from_secs(600), - }, - ); - } - } - - let result = provider.begin_device("new-host".to_owned()).await; - - assert!( - result.is_err(), - "a full pending map must reject begin_device" - ); - assert_eq!( - hits.load(Ordering::SeqCst), - 0, - "the IdP must not be contacted at all once MAX_PENDING_ENROLLMENTS is reached" - ); - server.abort(); + pub(crate) fn id_token(key: &jsonwebtoken::EncodingKey, claims: &serde_json::Value) -> String { + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA); + header.kid = Some("login-test".to_owned()); + jsonwebtoken::encode(&header, claims, key).unwrap() } - // The companion success path: the reservation made before the IdP call must still - // end up in `pending` with the real (possibly shorter) device-grant lifetime, not - // left at the ENROLLMENT_LIFETIME placeholder it was reserved with. - #[tokio::test] - async fn begin_device_narrows_reservation_to_the_real_device_lifetime() { - let device_body = json!({ - "device_code": "device-code", - "user_code": "ABCD-EFGH", - "verification_uri": "https://idp.example/activate", - "expires_in": 120, + pub(crate) fn user_claims() -> serde_json::Value { + json!({ + "iss": "https://idp.example", "aud": "client", "sub": "user-1", + "exp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 600, }) - .to_string(); - let (endpoint, hits, server) = - count_requests_server(StatusCode::OK, Box::leak(device_body.into_boxed_str())).await; - let provider = provider(Some(endpoint)); + } - let response = provider - .begin_device("new-host".to_owned()) + #[tokio::test] + async fn enrollment_authenticates_user_without_a_prior_login_attempt() { + let (provider, key, server) = authenticated_provider().await; + let completed = provider + .authenticate_enrollment( + "host".to_owned(), + &id_token(&key, &user_claims()), + "access-token", + ) .await - .expect("begin_device succeeds"); - - assert_eq!(hits.load(Ordering::SeqCst), 1); - assert_eq!(response.expires_in_seconds, 120); - let pending = provider.inner.pending.lock().await; - let entry = pending - .get(&response.enrollment_id) - .expect("reservation is still present under the real enrollment id"); - let remaining = entry.expires_at.saturating_duration_since(Instant::now()); - assert!( - remaining <= Duration::from_secs(120) && remaining > Duration::from_secs(100), - "reservation must be narrowed to the device grant's own expires_in (120s), \ - not left at the ENROLLMENT_LIFETIME placeholder (600s); got {remaining:?}" - ); + .unwrap(); + assert_eq!(completed.hostname, "host"); + assert_eq!(completed.issuer, "https://idp.example"); + assert_eq!(completed.subject, "user-1"); + assert_eq!(completed.idp_claims["sub"], "user-1"); server.abort(); } - // The IdP request itself must still fail closed: if it errors or returns an - // unreadable body, the reservation made for it must be released, not left behind - // consuming a MAX_PENDING_ENROLLMENTS slot forever. #[tokio::test] - async fn begin_device_releases_the_reservation_when_the_idp_call_fails() { - let (endpoint, hits, server) = - count_requests_server(StatusCode::BAD_REQUEST, "idp rejected the request").await; - let provider = provider(Some(endpoint)); - - let result = provider.begin_device("new-host".to_owned()).await; - + async fn enrollment_rejects_invalid_tokens_and_subject_mismatch() { + let (provider, key, server) = authenticated_provider().await; + let original = user_claims(); + for (field, value) in [ + ("iss", json!("https://other.example")), + ("aud", json!("another-client")), + ("sub", json!("user-2")), + ("exp", json!(1)), + ] { + let mut claims = original.clone(); + claims[field] = value; + assert!( + provider + .authenticate_enrollment( + "host".to_owned(), + &id_token(&key, &claims), + "access-token", + ) + .await + .is_err(), + "{field} must be verified" + ); + } + let token = id_token(&key, &original); + for (id_token, access_token) in [ + ("", "access-token"), + ("not-a-jwt", "access-token"), + (token.as_str(), ""), + (token.as_str(), "rejected"), + (token.as_str(), "other-user"), + ] { + assert!( + provider + .authenticate_enrollment("host".to_owned(), id_token, access_token) + .await + .is_err() + ); + } + let mut tampered = token.into_bytes(); + let index = tampered.len() - 10; + tampered[index] = if tampered[index] == b'A' { b'B' } else { b'A' }; assert!( - result.is_err(), - "a rejected IdP call must fail begin_device" + provider + .authenticate_enrollment( + "host".to_owned(), + std::str::from_utf8(&tampered).unwrap(), + "access-token", + ) + .await + .is_err() ); - assert_eq!(hits.load(Ordering::SeqCst), 1); + server.abort(); + } + + #[test] + fn login_configuration_is_read_only_and_contains_no_attempt_state() { + let configured = provider(Some("https://idp.example/device".parse().unwrap())); + for _ in 0..2048 { + let config = configured.login_configuration(); + assert_eq!(config.issuer, "https://idp.example"); + assert_eq!(config.client_id, "client"); + assert_eq!( + config.device_authorization_endpoint, + "https://idp.example/device" + ); + assert_eq!( + config.scopes, + ["openid", "profile", "email", "offline_access"] + ); + } assert!( - provider.inner.pending.lock().await.is_empty(), - "the reservation must be released, not left occupying a slot" + provider(None) + .login_configuration() + .device_authorization_endpoint + .is_empty() ); - server.abort(); } #[tokio::test] diff --git a/crates/controller/src/service.rs b/crates/controller/src/service.rs index 6fe3570b..e164cbfb 100644 --- a/crates/controller/src/service.rs +++ b/crates/controller/src/service.rs @@ -1,13 +1,16 @@ use std::pin::Pin; use agentdesktop_proto::fleet::{ - AgentMessage, BeginEnrollmentRequest, BeginEnrollmentResponse, CompleteEnrollmentRequest, - ConfigStatus, ControllerMessage, DeviceCertificateResponse, DeviceUsageRequest, - DeviceUsageResponse, EnrollResponse, LlmGatewayCredentialRequest, LlmGatewayCredentialResponse, + AgentMessage, ConfigStatus, ControllerMessage, DeviceCertificateResponse, DeviceUsageRequest, + DeviceUsageResponse, EnrollDeviceRequest, EnrollResponse, LlmGatewayCredentialRequest, + LlmGatewayCredentialResponse, LoginConfiguration, LoginConfigurationRequest, RenewDeviceCertificateRequest, agent_message, controller_message, fleet_agent_server::FleetAgent, }; use futures_core::Stream; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tokio::sync::Semaphore; use tokio::{sync::mpsc, time}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; @@ -34,6 +37,7 @@ pub struct FleetAgentService { gateway_jwt_issuer: Option, device_certificate_issuer: Option, usage_enabled: bool, + enrollments: Arc, } impl FleetAgentService { @@ -51,6 +55,7 @@ impl FleetAgentService { gateway_jwt_issuer, device_certificate_issuer, usage_enabled: false, + enrollments: Arc::new(Semaphore::new(32)), } } @@ -69,47 +74,44 @@ fn subscription_is_configured_for_client(programs: &ProgramsConfig, client_id: & impl FleetAgent for FleetAgentService { type ConnectStream = Pin> + Send>>; - async fn begin_enrollment( + async fn get_login_configuration( &self, - request: Request, - ) -> Result, Status> { + _request: Request, + ) -> Result, Status> { let oidc = self .oidc .as_ref() .ok_or_else(|| Status::failed_precondition("OIDC enrollment is disabled"))?; - let request = request.into_inner(); - let response = if request.device_authorization { - oidc.begin_device(request.hostname).await - } else { - oidc.begin(request.hostname, &request.code_challenge).await - } - .map_err(invalid_enrollment)?; - Ok(Response::new(response)) + Ok(Response::new(oidc.login_configuration())) } - async fn complete_enrollment( + async fn enroll_device( &self, - request: Request, + request: Request, ) -> Result, Status> { let oidc = self .oidc .as_ref() .ok_or_else(|| Status::failed_precondition("OIDC enrollment is disabled"))?; + let _permit = self + .enrollments + .try_acquire() + .map_err(|_| Status::resource_exhausted("too many concurrent device enrollments"))?; let access_token = bearer_credential(request.metadata())?.to_owned(); let request = request.into_inner(); + if request.certificate_signing_request_der.is_empty() + || request.certificate_signing_request_der.len() > 16 * 1024 + { + return Err(Status::invalid_argument( + "invalid certificate signing request size", + )); + } let csr_der = request.certificate_signing_request_der.clone(); let completed = oidc - .complete(request, &access_token) + .authenticate_enrollment(request.hostname, &request.id_token, &access_token) .await .map_err(invalid_enrollment)?; - self.enroll_device( - &completed.hostname, - &completed.issuer, - &completed.subject, - Some(&completed.idp_claims), - &csr_der, - ) - .await + self.issue_enrolled_device(&completed, &csr_der).await } async fn renew_device_certificate( @@ -406,45 +408,63 @@ impl FleetAgentService { Ok(device_id.to_owned()) } - async fn enroll_device( + async fn issue_enrolled_device( &self, - hostname: &str, - enrolled_by_issuer: &str, - enrolled_by_subject: &str, - idp_claims: Option<&std::collections::BTreeMap>, + completed: &crate::oidc::CompletedEnrollment, csr_der: &[u8], ) -> Result, Status> { - let device_id = Uuid::new_v4().to_string(); + rcgen::CertificateSigningRequestParams::from_der(&csr_der.into()) + .map_err(|error| invalid_csr(error.into()))?; + let device_id = enrollment_device_id(&completed.issuer, &completed.subject, csr_der); + if let Some(response) = self + .database + .enrollment_receipt(&device_id) + .await + .map_err(internal)? + { + return Ok(Response::new(response)); + } let issued_certificate = self .device_certificate_issuer .as_ref() .ok_or_else(|| Status::failed_precondition("device certificate issuance is disabled"))? .issue(&device_id, csr_der) .map_err(invalid_csr)?; - self.database - .enroll_device( - &device_id, - hostname, - enrolled_by_issuer, - enrolled_by_subject, - idp_claims, + let response = self + .database + .finish_enrollment( + completed, + &EnrollResponse { + device_id: device_id.clone(), + client_certificate_pem: issued_certificate.chain_pem, + client_certificate_expires_at_unix_seconds: issued_certificate + .expires_at_unix_seconds, + }, ) .await .map_err(internal)?; info!( %device_id, - hostname, - enrolled_by_issuer, - enrolled_by_subject, + hostname = %completed.hostname, + enrolled_by_issuer = %completed.issuer, + enrolled_by_subject = %completed.subject, "enrolled device" ); - Ok(Response::new(EnrollResponse { - device_id, - client_certificate_pem: issued_certificate.chain_pem, - client_certificate_expires_at_unix_seconds: issued_certificate.expires_at_unix_seconds, - })) + Ok(Response::new(response)) + } +} + +fn enrollment_device_id(issuer: &str, subject: &str, csr_der: &[u8]) -> String { + let mut hash = Sha256::new(); + for part in [issuer.as_bytes(), subject.as_bytes(), csr_der] { + hash.update((part.len() as u64).to_be_bytes()); + hash.update(part); } + let digest = hash.finalize(); + let mut bytes = [0; 16]; + bytes.copy_from_slice(&digest[..16]); + Uuid::from_bytes(bytes).to_string() } fn oidc_principals_match(authenticated: &OidcPrincipal, enrolled: &DevicePrincipal) -> bool { @@ -630,6 +650,203 @@ mod tests { }; use crate::{daemon_config::DaemonConfigStore, database::Database, oidc::OidcPrincipal}; + #[tokio::test] + async fn enrollment_rpc_is_user_bound_idempotent_and_durable() { + use crate::oidc::tests::{authenticated_provider, id_token, user_claims}; + use agentdesktop_proto::fleet::{ + EnrollDeviceRequest, LoginConfigurationRequest, fleet_agent_server::FleetAgent, + }; + use rcgen::{BasicConstraints, IsCa, KeyUsagePurpose}; + + let path = std::env::temp_dir().join(format!( + "agentdesktop-enrollment-{}-{}.db", + std::process::id(), + rand::random::(), + )); + let url = format!("sqlite://{}?mode=rwc", path.display()); + let database = Database::connect(&url).await.unwrap(); + let (oidc, signing_key, idp) = authenticated_provider().await; + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign]; + let ca = ca_params.self_signed(&ca_key).unwrap(); + let issuer = + crate::device_ca::DeviceCertificateIssuer::from_pem(ca.pem(), &ca_key.serialize_pem()) + .unwrap(); + let service = FleetAgentService::new( + Some(oidc.clone()), + database.clone(), + DaemonConfigStore::new(None), + None, + Some(issuer.clone()), + ); + for _ in 0..1100 { + service + .get_login_configuration(tonic::Request::new(LoginConfigurationRequest {})) + .await + .unwrap(); + } + let counts = || async { + let (devices,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM devices") + .fetch_one(database.pool()) + .await + .unwrap(); + let (receipts,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM enrollment_receipts") + .fetch_one(database.pool()) + .await + .unwrap(); + (devices, receipts) + }; + assert_eq!( + counts().await, + (0, 0), + "configuration reads must allocate no enrollment state" + ); + let device_key = KeyPair::generate().unwrap(); + let csr = CertificateParams::new(Vec::::new()) + .unwrap() + .serialize_request(&device_key) + .unwrap(); + let body = EnrollDeviceRequest { + hostname: "workstation".to_owned(), + certificate_signing_request_der: csr.der().as_ref().to_vec(), + id_token: id_token(&signing_key, &user_claims()), + }; + let request = |body: EnrollDeviceRequest| { + let mut request = tonic::Request::new(body); + request + .metadata_mut() + .insert("authorization", "Bearer access-token".parse().unwrap()); + request + }; + let permits = service.enrollments.acquire_many(32).await.unwrap(); + assert_eq!( + service + .enroll_device(request(body.clone())) + .await + .unwrap_err() + .code(), + Code::ResourceExhausted, + ); + drop(permits); + assert_eq!( + service + .enroll_device(tonic::Request::new(body.clone())) + .await + .unwrap_err() + .code(), + Code::Unauthenticated, + ); + let mut invalid = body.clone(); + invalid.id_token = "invalid".to_owned(); + assert_eq!( + service + .enroll_device(request(invalid)) + .await + .unwrap_err() + .code(), + Code::Unauthenticated + ); + let mut invalid = body.clone(); + invalid.certificate_signing_request_der = vec![1, 2, 3]; + assert_eq!( + service + .enroll_device(request(invalid)) + .await + .unwrap_err() + .code(), + Code::InvalidArgument + ); + assert_eq!(counts().await, (0, 0)); + let response = service + .enroll_device(request(body.clone())) + .await + .unwrap() + .into_inner(); + let principal = database + .device_principal(&response.device_id) + .await + .unwrap(); + assert_eq!(principal.issuer, "https://idp.example"); + assert_eq!(principal.subject, "user-1"); + assert_eq!(counts().await, (1, 1)); + let mut tasks = tokio::task::JoinSet::new(); + for _ in 0..8 { + let service = service.clone(); + let request = request(body.clone()); + tasks.spawn(async move { service.enroll_device(request).await.unwrap().into_inner() }); + } + while let Some(result) = tasks.join_next().await { + assert_eq!(result.unwrap(), response); + } + let reopened = Database::connect(&url).await.unwrap(); + let restarted = FleetAgentService::new( + Some(oidc), + reopened, + DaemonConfigStore::new(None), + None, + Some(issuer), + ); + assert_eq!( + restarted + .enroll_device(request(body.clone())) + .await + .unwrap() + .into_inner(), + response, + "retry after controller restart must recover the original certificate", + ); + let mut other_body = body; + let other_key = KeyPair::generate().unwrap(); + let other_csr = CertificateParams::new(Vec::::new()) + .unwrap() + .serialize_request(&other_key) + .unwrap(); + other_body.certificate_signing_request_der = other_csr.der().as_ref().to_vec(); + let mut tasks = tokio::task::JoinSet::new(); + for _ in 0..4 { + let service = service.clone(); + let request = request(other_body.clone()); + tasks.spawn(async move { service.enroll_device(request).await.unwrap().into_inner() }); + } + let first = tasks.join_next().await.unwrap().unwrap(); + assert_ne!(first.device_id, response.device_id); + while let Some(result) = tasks.join_next().await { + assert_eq!( + result.unwrap(), + first, + "first-time concurrent enrollment must also deduplicate" + ); + } + assert_eq!(counts().await, (2, 2)); + assert!(database.delete_device(&response.device_id).await.unwrap()); + assert!( + database + .enrollment_receipt(&response.device_id) + .await + .unwrap() + .is_none(), + "deleting a device must also remove its retry receipt", + ); + assert_eq!(counts().await, (1, 1)); + idp.abort(); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn enrollment_retry_identity_is_bound_to_principal_and_device_key() { + let id = super::enrollment_device_id("issuer", "user", b"csr"); + assert_eq!(id, super::enrollment_device_id("issuer", "user", b"csr")); + assert_ne!(id, super::enrollment_device_id("other", "user", b"csr")); + assert_ne!(id, super::enrollment_device_id("issuer", "other", b"csr")); + assert_ne!(id, super::enrollment_device_id("issuer", "user", b"other")); + assert_ne!( + super::enrollment_device_id("ab", "c", b"csr"), + super::enrollment_device_id("a", "bc", b"csr"), + ); + } + #[test] fn subscription_billing_requires_server_configured_program_auth() { let config = agentdesktop_core::config::parse_daemon( diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index e2cc18d0..d1d43a2d 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -473,7 +473,8 @@ pub struct ControllerConnectionConfig { /// 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. + /// authorization for the controller's client. The daemon must be able to + /// reach the IdP's device-authorization and token endpoints directly. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub device_authorization: bool, } diff --git a/crates/proto/proto/fleet.proto b/crates/proto/proto/fleet.proto index cad2e4b8..716a4f79 100644 --- a/crates/proto/proto/fleet.proto +++ b/crates/proto/proto/fleet.proto @@ -3,8 +3,8 @@ syntax = "proto3"; package agentdesktop.fleet.v1; service FleetAgent { - rpc BeginEnrollment(BeginEnrollmentRequest) returns (BeginEnrollmentResponse); - rpc CompleteEnrollment(CompleteEnrollmentRequest) returns (EnrollResponse); + rpc GetLoginConfiguration(LoginConfigurationRequest) returns (LoginConfiguration); + rpc EnrollDevice(EnrollDeviceRequest) returns (EnrollResponse); rpc RenewDeviceCertificate(RenewDeviceCertificateRequest) returns (DeviceCertificateResponse); rpc GetLlmGatewayCredential(LlmGatewayCredentialRequest) returns (LlmGatewayCredentialResponse); rpc GetDeviceUsage(DeviceUsageRequest) returns (DeviceUsageResponse); @@ -17,39 +17,24 @@ message EnrollResponse { uint64 client_certificate_expires_at_unix_seconds = 3; } -message BeginEnrollmentRequest { - string hostname = 1; - string code_challenge = 2; - // Use the OAuth 2.0 Device Authorization Grant (RFC 8628) instead of a - // browser redirect, for hosts without a local browser. code_challenge is - // ignored when set. - bool device_authorization = 3; -} +// Read-only metadata; creates no login or enrollment attempt. +message LoginConfigurationRequest {} -message BeginEnrollmentResponse { - string enrollment_id = 1; - string authorization_url = 2; - string state = 3; - string redirect_uri = 4; - string token_endpoint = 5; - string client_id = 6; - // Set only for device authorization enrollments. - string device_code = 7; - string user_code = 8; - string verification_uri = 9; - string verification_uri_complete = 10; - uint64 interval_seconds = 11; - uint64 expires_in_seconds = 12; +message LoginConfiguration { + string issuer = 1; + string authorization_endpoint = 2; + string token_endpoint = 3; + string client_id = 4; + string redirect_uri = 5; + string device_authorization_endpoint = 6; + repeated string scopes = 7; } -message CompleteEnrollmentRequest { - string enrollment_id = 1; - // Deprecated: the daemon exchanges the authorization code directly. - string authorization_code = 2; - // Deprecated: the PKCE verifier is sent only to the identity provider. - string code_verifier = 3; - bytes certificate_signing_request_der = 4; - string id_token = 5; +// Called only after user authentication; bearer access token is in metadata. +message EnrollDeviceRequest { + string hostname = 1; + bytes certificate_signing_request_der = 2; + string id_token = 3; } message RenewDeviceCertificateRequest { diff --git a/docs/device-identity.md b/docs/device-identity.md index 5e7920c8..e8386758 100644 --- a/docs/device-identity.md +++ b/docs/device-identity.md @@ -42,9 +42,11 @@ Key properties: - The daemon is an OIDC public client. It talks to the IdP's token endpoint directly. The controller never sees the PKCE verifier or the authorization code, and never holds the user's refresh token. -- The controller drives the parts of the protocol that bind the result to an - enrollment: it generates `state` and `nonce`, builds the authorization URL, - and validates the ID token it is given. +- The user signs in before the device is enrolled. The controller keeps no + state for a sign-in in progress: it publishes read-only login settings, and + creates a device only when it is given valid tokens for a user. The daemon + generates `state`, `nonce`, and the PKCE values, builds the authorization + URL, and checks the callback; the controller validates the tokens. ## Sequence @@ -64,12 +66,11 @@ sequenceDiagram rect rgba(128, 35, 195, 0.08) Note over User,IdP: Enrollment - Daemon->>Daemon: Generate device key pair and CSR + Daemon->>Controller: GetLoginConfiguration over server-auth TLS + Controller-->>Daemon: issuer, endpoints, client_id, redirect_uri, scopes + Note over Controller: Read-only. No enrollment state is stored. alt Browser flow (default) - Daemon->>Daemon: Generate PKCE verifier and S256 challenge - Daemon->>Controller: BeginEnrollment(hostname, code_challenge) over server-auth TLS - Controller->>Controller: Create enrollment_id, state, nonce. Store pending enrollment (10 min) - Controller-->>Daemon: enrollment_id, authorization_url, state, redirect_uri, token_endpoint, client_id + Daemon->>Daemon: Generate PKCE verifier and S256 challenge, state, nonce. Build authorization_url Daemon->>Daemon: Listen on loopback redirect_uri (default 127.0.0.1:51327) Daemon->>Daemon: Publish authorization_url on GET /v1/enrollment and wait up to 10 min alt Daemon runs in system mode (normal install) @@ -89,12 +90,10 @@ sequenceDiagram Daemon->>Daemon: Check state matches Daemon->>IdP: Token request (authorization_code, code_verifier, client_id) IdP-->>Daemon: access_token, id_token, refresh_token + Daemon->>Daemon: Check ID token nonce matches else Device authorization flow (deviceAuthorization true) - Daemon->>Controller: BeginEnrollment(hostname, device_authorization) - Controller->>IdP: Device authorization request (client_id, scope) - IdP-->>Controller: device_code, user_code, verification_uri, interval, expires_in - Controller->>Controller: Store pending enrollment (no nonce) - Controller-->>Daemon: enrollment_id, device_code, user_code, verification_uri, token_endpoint, client_id + Daemon->>IdP: Device authorization request (client_id, scope) + IdP-->>Daemon: device_code, user_code, verification_uri, interval, expires_in Daemon-->>User: Show verification URL and user code (app, CLI, logs) loop Every interval until approved or expired Daemon->>IdP: Token request (device_code grant) @@ -104,15 +103,16 @@ sequenceDiagram Daemon->>IdP: Token request (device_code grant) IdP-->>Daemon: access_token, id_token, refresh_token end - Daemon->>Controller: CompleteEnrollment(enrollment_id, CSR, id_token) with Bearer access_token - Controller->>Controller: Consume pending enrollment (single use, not expired) + Daemon->>Daemon: Generate device key pair and CSR + Daemon->>Controller: EnrollDevice(hostname, CSR, id_token) with Bearer access_token Controller->>IdP: Fetch JWKS if the ID token key ID is unknown - Controller->>Controller: Verify ID token signature, iss, aud, exp, sub, and nonce + Controller->>Controller: Verify ID token signature, iss, aud, exp, and sub Controller->>IdP: UserInfo with access_token IdP-->>Controller: sub Controller->>Controller: Require UserInfo sub equal to ID token sub - Controller->>Controller: New device_id. Sign CSR with device CA (30 days, client auth) - Controller->>Controller: Store device with hostname, issuer, subject, IdP claims + Controller->>Controller: device_id from issuer, subject, and CSR. Return the stored result if it exists + Controller->>Controller: Otherwise sign CSR with device CA (30 days, client auth) + Controller->>Controller: Store device and enrollment receipt with hostname, issuer, subject, IdP claims Controller-->>Daemon: device_id, certificate chain, expiry Daemon->>Daemon: Save certificate to identity.json. Save private key and tokens to the secret store end @@ -187,8 +187,9 @@ sequenceDiagram ## Enrollment The daemon enrolls when it starts without a stored identity, or after it has -discarded one (see [Failure handling](#failure-handling)). It starts at once, -without waiting for a user to ask: an attempt that nobody completes times out +discarded one (see [Failure handling](#failure-handling)). It starts sign-in at +once, without waiting for a user to ask, but the controller records nothing +until the user has signed in: an attempt that nobody completes times out after 10 minutes, and the daemon starts a new one after a retry delay of up to 60 seconds, with a new URL each time. While enrollment is in progress, the daemon publishes its state on the local API at @@ -202,18 +203,15 @@ device authorization, the user code. The desktop app and The default flow is the OAuth 2.0 authorization code grant with PKCE for a native app (RFC 8252): -1. The daemon generates the device key pair, a CSR for it, and a PKCE - verifier and challenge. -2. `BeginEnrollment` sends the hostname and PKCE challenge. The daemon has no - client certificate yet, so this call uses server-authenticated TLS only. - The controller generates a random `enrollment_id`, `state`, and `nonce`, - and keeps a pending enrollment (hostname and nonce) for 10 minutes. At most - 1024 enrollments can be pending at once. -3. The controller returns an authorization URL built from the IdP's discovery - document, requesting `openid profile email offline_access`, plus the - values the daemon needs to finish the exchange itself: `state`, - `redirect_uri`, `token_endpoint`, and `client_id`. -4. The daemon serves a small HTTP server on the loopback `redirect_uri` +1. `GetLoginConfiguration` returns the IdP issuer, authorization and token + endpoints, client ID, `redirect_uri`, and scopes + (`openid profile email offline_access`), taken from the IdP's discovery + document. The daemon has no client certificate yet, so this call uses + server-authenticated TLS only. The call is read-only: the controller stores + nothing. +2. The daemon generates a PKCE verifier and challenge, `state`, and `nonce`, + and builds the authorization URL. +3. The daemon serves a small HTTP server on the loopback `redirect_uri` (default `http://127.0.0.1:51327/callback`) and publishes the authorization URL on `/v1/enrollment`. How the browser gets there depends on how the daemon runs: @@ -229,12 +227,14 @@ native app (RFC 8252): IdP. `agentdesktop enrollment` prints the same URL. -5. After the user authenticates, the IdP redirects to the loopback callback. +4. After the user authenticates, the IdP redirects to the loopback callback. The daemon rejects the callback unless `state` matches, then exchanges the - code and PKCE verifier at the IdP token endpoint. The callback times out + code and PKCE verifier at the IdP token endpoint, and requires the ID + token's `nonce` to match the one it generated. The callback times out after 10 minutes. -6. `CompleteEnrollment` sends the `enrollment_id`, the CSR, and the ID token, - with the access token as a bearer credential. +5. Only now does the daemon generate the device key pair and a CSR for it. + `EnrollDevice` sends the hostname, the CSR, and the ID token, with the + access token as a bearer credential. ### Device authorization flow @@ -242,36 +242,42 @@ For hosts without a local browser, set `deviceAuthorization: true` in the daemon's controller connection. The IdP must advertise a `device_authorization_endpoint`. This follows RFC 8628: -1. `BeginEnrollment` is sent with `device_authorization` set. The controller - requests a device grant from the IdP and returns the `device_code`, - `user_code`, and verification URL. The pending enrollment lives for the - shorter of the IdP's `expires_in` and 10 minutes. +1. After `GetLoginConfiguration`, the daemon requests a device grant from the + IdP's `device_authorization_endpoint` itself and receives the + `device_code`, `user_code`, and verification URL. The daemon must be able + to reach this endpoint as well as the token endpoint. 2. The daemon logs the verification URL and user code and publishes them on `/v1/enrollment`. The user approves the request from any device. 3. The daemon polls the IdP token endpoint at the returned interval, adding 5 seconds on each `slow_down`, until it receives tokens or the code expires. -4. `CompleteEnrollment` proceeds as in the browser flow. There is no nonce to - check, because the device grant does not carry one. +4. The daemon generates the device key and CSR and calls `EnrollDevice` as + in the browser flow. There is no nonce to check, because the device grant + does not carry one. ### Controller validation -On `CompleteEnrollment` the controller: +On `EnrollDevice` the controller: -1. Removes the pending enrollment, so an `enrollment_id` can be completed - only once, and rejects it if expired. -2. Verifies the ID token signature against the IdP's JWKS. An unknown key ID +1. Verifies the ID token signature against the IdP's JWKS. An unknown key ID triggers one JWKS refresh, rate limited to once every 30 seconds. -3. Validates `iss`, `aud` (the configured client ID), `exp`, and `sub`, and - for the browser flow, that `nonce` matches the one it generated. -4. Calls the IdP's UserInfo endpoint with the access token and requires its +2. Validates `iss`, `aud` (the configured client ID), `exp`, and `sub`. Any + valid tokens for the configured client authorize enrollment; the + controller does not require a sign-in that it started. +3. Calls the IdP's UserInfo endpoint with the access token and requires its `sub` to equal the ID token's `sub`. This binds the access token, which the daemon will present on every later call, to the validated ID token. -5. Generates a new device UUID and signs the CSR with the device CA. The - certificate is valid for 30 days, allows only client authentication, and - carries the device ID in its SAN. +4. Derives the device UUID from the issuer, subject, and CSR. If a device + with that ID was already enrolled, it returns the stored result, so a + daemon that lost the response and retries with the same CSR gets the same + device and certificate, including after a controller restart. +5. Otherwise signs the CSR with the device CA. The certificate is valid for + 30 days, allows only client authentication, and carries the device ID in + its SAN. 6. Stores the device with its hostname, the IdP issuer and subject, and the - ID token's claims. The claims are later copied into gateway credentials. + ID token's claims, together with an enrollment receipt (the response, with + the public certificate only) in one transaction. The receipt is deleted + with the device. The claims are later copied into gateway credentials. ### Local storage diff --git a/schema/daemon-config.json b/schema/daemon-config.json index 093a123c..539eeec3 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -173,7 +173,7 @@ "type": "string" }, "deviceAuthorization": { - "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.", + "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. The daemon must be able to\nreach the IdP's device-authorization and token endpoints directly.", "type": "boolean" }, "heartbeatInterval": { diff --git a/schema/daemon-config.md b/schema/daemon-config.md index 24f89aa0..a9ce1192 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -5,7 +5,7 @@ |`controller`|object|Controller connection settings. Omit this field to run without fleet management.| |`controller.address`|string|HTTPS address of the controller's fleet API.| |`controller.caCertificatePath`|string|Path to the PEM-encoded CA certificate that signed the controller's fleet API
certificate. Required: the daemon trusts only this CA for the controller, not
the operating system's 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.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. The daemon must be able to
reach the IdP's device-authorization and token endpoints directly.| |`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.|