Skip to content

Commit bd296af

Browse files
devmittal02claude
andauthored
feat(llm-gateway): sign in with the OAuth device authorization grant (#145)
* feat(llm-gateway): sign in with the OAuth device authorization grant Headless daemons have no browser to complete the loopback redirect used for LLM gateway OIDC sign-in. Add an opt-in `deviceAuthorization` flag to `llmGateway.authentication` (type `oidc`). When set, the daemon signs in with RFC 8628: it logs a verification URL and user code, polls the token endpoint in the background, and stores the tokens once the user approves from any device. - `agentdesktop-headless login` / `POST /v1/llm-gateway/login` start or resume a sign-in and print the URL and code. - The credential endpoint never blocks in device mode: it returns 401 with the login command and any pending code when no valid or refreshable token exists. Refresh works as before. - Subscription auth requires a browser and is rejected in combination. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(llm-gateway): address device authorization review feedback - Start the device sign-in from managed policy. The startup path only sees the local config file, so a `deviceAuthorization: true` delivered by the controller never started a grant and credentials returned a 401 with no pending URL until someone ran `login`. The cached controller configuration at startup and every controller update now call `start_device_login_if_configured`, which is idempotent because `device_login` returns the pending grant. - Stop holding the global login lock across the identity provider round-trip. A per-account lock serialises starting a grant instead, so concurrent `login` calls still share one grant, and the provider request is bounded by a timeout so a stalled provider cannot wedge every `login` behind it. - Validate the device-authorization response (codes, verification URLs, nonzero lifetime) and cap the lifetime and poll interval, so a malformed response cannot publish an unusable status or overflow `Instant + Duration` in the poll task. - Never sleep past the expiry deadline while polling. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7171618 commit bd296af

11 files changed

Lines changed: 872 additions & 40 deletions

File tree

‎crates/agent/src/api.rs‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use tokio::sync::{mpsc, oneshot, watch};
1212
use agentdesktop_core::{
1313
config::{DaemonConfig, LlmGatewayAuthentication, ProgramAuthentication, valid_client_id},
1414
model::{
15-
DaemonInfo, Discovery, EnrollmentStatus, Health, LlmGatewayCredential, TelemetryEvent,
16-
TelemetryEventKind,
15+
DaemonInfo, Discovery, EnrollmentStatus, Health, LlmGatewayCredential,
16+
LlmGatewayLoginStatus, TelemetryEvent, TelemetryEventKind,
1717
},
1818
};
1919

@@ -49,6 +49,7 @@ pub fn router(state: AppState) -> Router {
4949
.route("/v1/logout", post(logout))
5050
.route("/v1/telemetry", post(telemetry))
5151
.route("/v1/llm-gateway/credential", get(llm_gateway_credential))
52+
.route("/v1/llm-gateway/login", post(llm_gateway_login))
5253
.with_state(state)
5354
}
5455

@@ -273,6 +274,7 @@ pub(crate) async fn gateway_credential(
273274
redirect_uri,
274275
scopes,
275276
allow_insecure,
277+
device_authorization,
276278
}) => gateway_oidc::credential(
277279
issuer,
278280
client_id,
@@ -297,6 +299,7 @@ pub(crate) async fn gateway_credential(
297299
.flatten()
298300
})
299301
},
302+
device_authorization: *device_authorization,
300303
},
301304
)
302305
.await
@@ -310,7 +313,14 @@ pub(crate) async fn gateway_credential(
310313
"LLM gateway has no authentication configured"
311314
)),
312315
}
313-
.map_err(|error| (StatusCode::BAD_GATEWAY, format!("{error:#}")))?;
316+
.map_err(|error| {
317+
let status = if error.is::<gateway_oidc::SignInRequired>() {
318+
StatusCode::UNAUTHORIZED
319+
} else {
320+
StatusCode::BAD_GATEWAY
321+
};
322+
(status, format!("{error:#}"))
323+
})?;
314324
if uses_subscription {
315325
subscription::compose(
316326
identity,
@@ -325,6 +335,38 @@ pub(crate) async fn gateway_credential(
325335
.map_err(|error| (StatusCode::BAD_GATEWAY, format!("{error:#}")))
326336
}
327337

338+
async fn llm_gateway_login(
339+
State(state): State<AppState>,
340+
) -> Result<Json<LlmGatewayLoginStatus>, (StatusCode, String)> {
341+
let effective = load_effective_config(&state.config, &state.state_dir).map_err(|error| {
342+
(
343+
StatusCode::INTERNAL_SERVER_ERROR,
344+
format!("read applied configuration: {error:#}"),
345+
)
346+
})?;
347+
let Some(LlmGatewayAuthentication::Oidc {
348+
issuer,
349+
client_id,
350+
scopes,
351+
allow_insecure,
352+
device_authorization: true,
353+
..
354+
}) = effective
355+
.llm_gateway
356+
.as_ref()
357+
.and_then(|gateway| gateway.authentication.as_ref())
358+
else {
359+
return Err((
360+
StatusCode::FAILED_DEPENDENCY,
361+
"LLM gateway sign-in with device authorization is not configured".to_owned(),
362+
));
363+
};
364+
gateway_oidc::device_login(issuer, client_id, scopes, *allow_insecure, &state.state_dir)
365+
.await
366+
.map(Json)
367+
.map_err(|error| (StatusCode::BAD_GATEWAY, format!("{error:#}")))
368+
}
369+
328370
fn program_uses_subscription(config: &DaemonConfig, client_id: &str) -> bool {
329371
match client_id {
330372
"claude-code" => config

‎crates/agent/src/cli.rs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ use std::{io::Read, path::PathBuf};
33
use agentdesktop_client as client;
44
use agentdesktop_core::{
55
config::DaemonConfig,
6-
model::{Discovery, EnrollmentStatus, Health, LlmGatewayCredential, TelemetryEventKind},
6+
model::{
7+
Discovery, EnrollmentStatus, Health, LlmGatewayCredential, LlmGatewayLoginStatus,
8+
TelemetryEventKind,
9+
},
710
};
811
use clap::Subcommand;
912
use serde::Deserialize;
@@ -26,6 +29,9 @@ pub enum ClientCommand {
2629
#[arg(long, default_value = "agentdesktop")]
2730
client_id: String,
2831
},
32+
/// Sign in to the LLM gateway with OIDC device authorization and print
33+
/// the verification URL and code to approve from any device.
34+
Login,
2935
/// Handle an event emitted by a managed developer-tool hook.
3036
Hook {
3137
#[command(subcommand)]
@@ -75,6 +81,17 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()>
7581
println!("Code: {code}");
7682
}
7783
}
84+
ClientCommand::Login => {
85+
let login: LlmGatewayLoginStatus =
86+
client::post(&socket, "/v1/llm-gateway/login").await?;
87+
println!("{}", login.status);
88+
if let Some(url) = login.verification_url {
89+
println!("Sign in at: {url}");
90+
}
91+
if let Some(code) = login.user_code {
92+
println!("Code: {code}");
93+
}
94+
}
7895
ClientCommand::Discover => {
7996
let discovery: Discovery = client::get(&socket, "/v1/discovery").await?;
8097
if discovery.agents.is_empty() && discovery.model_runtimes.is_empty() {

‎crates/agent/src/daemon.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,12 @@ where
436436
reconciler
437437
.apply(&initial_config)
438438
.context("apply initial daemon configuration")?;
439+
// With a controller, the cached controller configuration is what is in effect
440+
// from here, and start_gateway_authentication above only saw the local file.
441+
// Without one the two are the same configuration and it already ran.
442+
if config.controller.is_some() {
443+
gateway_oidc::start_device_login_if_configured(&initial_config, &args.state_dir);
444+
}
439445
} else {
440446
tracing::info!(
441447
"preserving managed files until the controller provides daemon configuration"
@@ -755,8 +761,23 @@ fn start_gateway_authentication(
755761
redirect_uri,
756762
scopes,
757763
allow_insecure,
764+
device_authorization,
758765
}) = authentication
759766
{
767+
if device_authorization {
768+
// Logs the verification URL and code, then signs in in the
769+
// background once the user approves from any device.
770+
tracing::info!(%issuer, "starting LLM gateway OIDC device authorization");
771+
gateway_oidc::device_login(
772+
&issuer,
773+
&client_id,
774+
&scopes,
775+
allow_insecure,
776+
&state_dir,
777+
)
778+
.await?;
779+
return Ok(());
780+
}
760781
tracing::info!(%issuer, "starting LLM gateway OIDC authentication");
761782
let acquired = gateway_oidc::credential(
762783
&issuer,
@@ -777,6 +798,7 @@ fn start_gateway_authentication(
777798
.then(|| github.client_id.clone())
778799
.flatten()
779800
}),
801+
device_authorization: false,
780802
},
781803
)
782804
.await?;

0 commit comments

Comments
 (0)