cli: add backend device login
This commit is contained in:
parent
ba0289b820
commit
4f8a2357a5
161
crates/client/src/backend_auth.rs
Normal file
161
crates/client/src/backend_auth.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BackendAuthTarget {
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendAuthTarget {
|
||||||
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_url(&self, path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
self.base_url.trim_end_matches('/'),
|
||||||
|
path.strip_prefix('/')
|
||||||
|
.map(|path| format!("/{path}"))
|
||||||
|
.unwrap_or_else(|| path.to_string())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DeviceLoginStartResponse {
|
||||||
|
pub device_code: String,
|
||||||
|
pub user_code: String,
|
||||||
|
pub verification_uri: String,
|
||||||
|
pub verification_uri_complete: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
pub interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DeviceLoginPollResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
pub token_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BackendAuthClientError {
|
||||||
|
Http(reqwest::Error),
|
||||||
|
BackendStatus { status: u16, body: String },
|
||||||
|
MissingAccessToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BackendAuthClientError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Http(error) => write!(f, "Backend auth request failed: {error}"),
|
||||||
|
Self::BackendStatus { status, body } => {
|
||||||
|
write!(f, "Backend auth returned HTTP {status}: {body}")
|
||||||
|
}
|
||||||
|
Self::MissingAccessToken => {
|
||||||
|
f.write_str("Backend approved device login without an access token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for BackendAuthClientError {}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for BackendAuthClientError {
|
||||||
|
fn from(value: reqwest::Error) -> Self {
|
||||||
|
Self::Http(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeviceLoginStartRequest<'a> {
|
||||||
|
client_name: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeviceLoginPollRequest<'a> {
|
||||||
|
device_code: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_device_login(
|
||||||
|
target: &BackendAuthTarget,
|
||||||
|
client_name: Option<&str>,
|
||||||
|
) -> Result<DeviceLoginStartResponse, BackendAuthClientError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(target.api_url("/api/auth/device-login/start"))
|
||||||
|
.json(&DeviceLoginStartRequest { client_name })
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
parse_json_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn poll_device_login(
|
||||||
|
target: &BackendAuthTarget,
|
||||||
|
device_code: &str,
|
||||||
|
) -> Result<DeviceLoginPollResponse, BackendAuthClientError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(target.api_url("/api/auth/device-login/poll"))
|
||||||
|
.json(&DeviceLoginPollRequest { device_code })
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
parse_json_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_for_device_login(
|
||||||
|
target: &BackendAuthTarget,
|
||||||
|
device_code: &str,
|
||||||
|
interval: Duration,
|
||||||
|
expires_in: Duration,
|
||||||
|
) -> Result<String, BackendAuthClientError> {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
let response = poll_device_login(target, device_code).await?;
|
||||||
|
match response.status.as_str() {
|
||||||
|
"approved" => {
|
||||||
|
return response
|
||||||
|
.access_token
|
||||||
|
.ok_or(BackendAuthClientError::MissingAccessToken);
|
||||||
|
}
|
||||||
|
"expired" => {
|
||||||
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
|
status: 410,
|
||||||
|
body: "device login expired".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"consumed" => {
|
||||||
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
|
status: 409,
|
||||||
|
body: "device login was already consumed".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if started.elapsed() >= expires_in {
|
||||||
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
|
status: 408,
|
||||||
|
body: "timed out waiting for device login approval".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_json_response<T: for<'de> Deserialize<'de>>(
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<T, BackendAuthClientError> {
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
|
status: status.as_u16(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(response.json::<T>().await?)
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,17 @@
|
||||||
//!
|
//!
|
||||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||||
|
|
||||||
|
pub mod backend_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod ticket_role;
|
pub mod ticket_role;
|
||||||
mod worker_client;
|
mod worker_client;
|
||||||
|
|
||||||
|
pub use backend_auth::{
|
||||||
|
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||||
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
|
};
|
||||||
pub use backend_runtime::{
|
pub use backend_runtime::{
|
||||||
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
||||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,14 @@ use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
use client::{
|
||||||
|
BackendAuthTarget, BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand,
|
||||||
|
start_device_login, wait_for_device_login,
|
||||||
|
};
|
||||||
use memory_lint::{LintCliOptions, LintStatus};
|
use memory_lint::{LintCliOptions, LintStatus};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
|
|
||||||
|
|
@ -36,6 +40,10 @@ enum Mode {
|
||||||
subcommand: String,
|
subcommand: String,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
},
|
},
|
||||||
|
Login {
|
||||||
|
backend_url: String,
|
||||||
|
no_wait: bool,
|
||||||
|
},
|
||||||
WorkerRuntime(Vec<String>),
|
WorkerRuntime(Vec<String>),
|
||||||
Keys,
|
Keys,
|
||||||
SetupModel,
|
SetupModel,
|
||||||
|
|
@ -85,6 +93,16 @@ async fn main() -> ExitCode {
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
|
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
|
||||||
|
Mode::Login {
|
||||||
|
backend_url,
|
||||||
|
no_wait,
|
||||||
|
} => match run_login(&backend_url, no_wait).await {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("yoi login: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
},
|
||||||
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
||||||
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
||||||
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
Ok(LintStatus::Failed) => ExitCode::FAILURE,
|
||||||
|
|
@ -241,6 +259,9 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
"workspace" => {
|
"workspace" => {
|
||||||
return parse_workspace_args(&args[1..]);
|
return parse_workspace_args(&args[1..]);
|
||||||
}
|
}
|
||||||
|
"login" => {
|
||||||
|
return parse_login_args(&args[1..]);
|
||||||
|
}
|
||||||
"mcp" => {
|
"mcp" => {
|
||||||
let mcp_cli = parse_mcp_args(&args[1..])?;
|
let mcp_cli = parse_mcp_args(&args[1..])?;
|
||||||
return Ok(Mode::Mcp(mcp_cli));
|
return Ok(Mode::Mcp(mcp_cli));
|
||||||
|
|
@ -859,15 +880,7 @@ fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_config_path() -> Option<PathBuf> {
|
fn client_config_path() -> Option<PathBuf> {
|
||||||
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
|
yoi_config_dir().map(|dir| dir.join("client.toml"))
|
||||||
return Some(PathBuf::from(home).join("yoi").join("client.toml"));
|
|
||||||
}
|
|
||||||
std::env::var_os("HOME").map(|home| {
|
|
||||||
PathBuf::from(home)
|
|
||||||
.join(".config")
|
|
||||||
.join("yoi")
|
|
||||||
.join("client.toml")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
||||||
|
|
@ -879,6 +892,137 @@ fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_login_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
|
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
|
return Err(ParseError(
|
||||||
|
"yoi login usage: yoi login [--backend <URL>] [--no-wait]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut backend_url = None;
|
||||||
|
let mut no_wait = false;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--backend" => {
|
||||||
|
let value = args
|
||||||
|
.get(i + 1)
|
||||||
|
.ok_or_else(|| ParseError("--backend requires a URL".to_string()))?;
|
||||||
|
if value.starts_with('-') || value.is_empty() {
|
||||||
|
return Err(ParseError("--backend requires a URL".to_string()));
|
||||||
|
}
|
||||||
|
backend_url = Some(value.clone());
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
arg if arg.starts_with("--backend=") => {
|
||||||
|
let value = arg.trim_start_matches("--backend=");
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(ParseError("--backend requires a URL".to_string()));
|
||||||
|
}
|
||||||
|
backend_url = Some(value.to_string());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
"--no-wait" => {
|
||||||
|
no_wait = true;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(ParseError(format!(
|
||||||
|
"unknown yoi login argument '{other}' (try 'yoi login --help')"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let backend_url = resolve_backend_url(backend_url, None)?;
|
||||||
|
Ok(Mode::Login {
|
||||||
|
backend_url,
|
||||||
|
no_wait,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
|
||||||
|
let target = BackendAuthTarget::new(backend_url.to_string());
|
||||||
|
let start = start_device_login(&target, Some("yoi cli"))
|
||||||
|
.await
|
||||||
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
|
println!("Open this URL in your browser to approve Yoi CLI login:");
|
||||||
|
println!(" {}", start.verification_uri_complete);
|
||||||
|
println!();
|
||||||
|
println!("User code: {}", start.user_code);
|
||||||
|
println!("Expires in: {} seconds", start.expires_in);
|
||||||
|
if no_wait {
|
||||||
|
println!("Device code: {}", start.device_code);
|
||||||
|
println!(
|
||||||
|
"Run without --no-wait after approving, or poll the Backend device endpoint manually."
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let token = wait_for_device_login(
|
||||||
|
&target,
|
||||||
|
&start.device_code,
|
||||||
|
Duration::from_secs(start.interval.max(1)),
|
||||||
|
Duration::from_secs(start.expires_in.max(1)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
|
save_backend_token(backend_url, &token)?;
|
||||||
|
println!("Saved Backend API token for {backend_url}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct BackendTokenFile {
|
||||||
|
#[serde(default)]
|
||||||
|
tokens: BTreeMap<String, BackendTokenEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct BackendTokenEntry {
|
||||||
|
token_type: String,
|
||||||
|
access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> {
|
||||||
|
let path = backend_token_path().ok_or_else(|| {
|
||||||
|
ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string())
|
||||||
|
})?;
|
||||||
|
let mut file = if path.is_file() {
|
||||||
|
let contents = fs::read_to_string(&path)
|
||||||
|
.map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?;
|
||||||
|
serde_json::from_str::<BackendTokenFile>(&contents)
|
||||||
|
.map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))?
|
||||||
|
} else {
|
||||||
|
BackendTokenFile::default()
|
||||||
|
};
|
||||||
|
file.tokens.insert(
|
||||||
|
backend_url.trim_end_matches('/').to_string(),
|
||||||
|
BackendTokenEntry {
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
ParseError(format!("failed to create {}: {error}", parent.display()))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let serialized = serde_json::to_string_pretty(&file)
|
||||||
|
.map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?;
|
||||||
|
fs::write(&path, format!("{serialized}\n"))
|
||||||
|
.map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_token_path() -> Option<PathBuf> {
|
||||||
|
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yoi_config_dir() -> Option<PathBuf> {
|
||||||
|
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
return Some(PathBuf::from(home).join("yoi"));
|
||||||
|
}
|
||||||
|
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user