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
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 19 additions & 10 deletions crates/agent/src/gateway_oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
expires_in: u64,
pub(crate) verification_uri_complete: Option<String>,
pub(crate) expires_in: u64,
#[serde(default)]
interval: Option<u64>,
pub(crate) interval: Option<u64>,
}

#[derive(Deserialize, Serialize)]
Expand Down Expand Up @@ -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<DeviceAuthorizationResponse> {
let device = reqwest::Client::new()
.post(endpoint)
.timeout(DEVICE_REQUEST_TIMEOUT)
.form(&device_authorization_form(client_id, scopes))
.send()
.await
Expand All @@ -414,12 +424,11 @@ async fn request_device_grant(
.json::<DeviceAuthorizationResponse>()
.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(
Expand Down
Loading