From c86c54fe1b0504e7987c24f9e935ea584d185df0 Mon Sep 17 00:00:00 2001 From: David Llewellyn-Jones Date: Fri, 19 Dec 2025 16:07:26 +0000 Subject: [PATCH] Add passive config option flag to auth command Adds a "passive" config option and flag to the auth command. When enabled the certificates are checked for validity and authentication happens only if one of the certificates has expired. --- src/auth.rs | 220 +++++++++++++++++++++++++++++++++++++++++++++++++- src/cert.rs | 24 ++++++ src/config.rs | 6 ++ src/main.rs | 195 +++++--------------------------------------- 4 files changed, 267 insertions(+), 178 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 694d6f0..1d789c8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT use anyhow::{Context, Result}; +use chrono::TimeDelta; use oauth2::{ basic::BasicClient, AuthType, AuthUrl, ClientId, DeviceAuthorizationUrl, Scope, StandardDeviceAuthorizationResponse, TokenUrl, @@ -10,8 +11,17 @@ use oauth2::{AccessToken, TokenResponse as _}; use qrcode::{render::unicode, QrCode}; use url::Url; +use crate::cache; +use crate::config; +use crate::get_cert; +use crate::print_available_aliases; +use crate::ssh_config_write; +use crate::AssociationsCache; +use crate::CaOidcResponse; +use crate::CertificateConfigCache; + /// Given an OAuth `client_id` and URL, authenticate with the device code workflow -pub fn get_access_token( +fn get_access_token( client_id: &String, issuer_url: &Url, open_webpage: bool, @@ -76,3 +86,211 @@ pub fn get_access_token( Ok(token.access_token().clone()) } + +pub fn auth( + config: config::Config, + cert_details_file_name: String, + identity: &Option, + open_browser: &Option, + show_qr: &Option, + write_config: &Option, + passive: &Option, +) -> Result<(), anyhow::Error> { + let green = anstyle::Style::new() + .fg_color(Some(anstyle::AnsiColor::Green.into())) + .bold(); + + if passive.unwrap_or(config.passive) { + if let Ok(cache_string) = cache::read_file(&cert_details_file_name) { + let cert_config_cache: CertificateConfigCache = serde_json::from_str(&cache_string)?; + let first_expiry = cert_config_cache.first_expiry(); + + if let Some(first_expiry) = first_expiry { + type Tz = chrono::offset::Utc; // TODO This is UNIX time, not UTC + let valid_before: chrono::DateTime = first_expiry.into(); + if (valid_before - Tz::now()) >= TimeDelta::zero() { + match &cert_config_cache.associations { + AssociationsCache::Projects(projects) => match projects.len() { + 0 => { + anyhow::bail!("No currently valid projects.") + } + _ => { + let project_name_list = projects + .iter() + .map(|(p_id, p)| match p.name.as_str() { + "" => format!("- {}", &p_id), + name => format!(" - {} ({})", &p_id, name), + }) + .collect::>() + .join("\n"); + println!( + "{green}Valid certificates found for {} on projects{green:#}:\n{project_name_list}", + &cert_config_cache.user + ); + } + }, + AssociationsCache::Resources(_resources) => println!( + "{green}Valid certificate found for {}.{green:#}", + &cert_config_cache.user + ), + } + + println!( + "\nCall '{} auth --passive false' to force re-authentication.", + std::env::args().next().unwrap_or("clifton".to_string()), + ); + + return Ok(()); + } + } + } + } + + let open_browser = open_browser.unwrap_or(config.open_browser); + let show_qr = show_qr.unwrap_or(config.show_qr); + let site_name = config.default_site; + + // Load the user's public key + let identity_file = std::path::absolute(shellexpand::path::tilde( + identity + .as_ref() + .or(config.identity.as_ref()) + .context("No identity file specified.")?, + )) + .context("Could not form absolute path for the identity file.")?; + let clifton_name = std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|f| f.display().to_string())) + .unwrap_or("clifton".to_string()); + if !identity_file.is_file() { + anyhow::bail!(format!( + "Identity file {} not found.\nEither specify the identity file (see `{} auth --help`) or create a new key.", + &identity_file.display(), + clifton_name, + )) + } + let identity = match ssh_key::PrivateKey::read_openssh_file(&identity_file) { + Ok(i) => i, + Err(e) => { + match e { + ssh_key::Error::Encoding(_) | ssh_key::Error::FormatEncoding => { + if identity_file.extension().is_some_and(|e| e == "pub") { + anyhow::bail!(anyhow::anyhow!(e).context("Could not decode the private key. Most likely this is caused by you passing your *public* key instead of your *private* key.")) + } else { + anyhow::bail!(anyhow::anyhow!(e).context("Could not decode the private key. Most likely this is caused by you trying to read an RSA key stored in an old format. Try generating a new key.")) + } + } + _ => anyhow::bail!(anyhow::anyhow!(e).context("Could not read SSH identity file.")), + }; + } + }; + + if !identity.is_encrypted() { + eprintln!( + "Warning, the SSH identity file `{}` is unencrypted.", + identity_file.display() + ); + } + + let site = config + .sites + .get(&site_name) + .context(format!("Could not get site details for `{site_name}`."))?; + let oidc_details: CaOidcResponse = reqwest::blocking::get(format!("{}oidc", &site.ca_url)) + .context("Could not get CA OIDC details.")? + .error_for_status() + .context("Could not get CA OIDC details.")? + .json() + .context("Could not parse CA OIDC details as URL.")?; + + println!( + "Retrieving certificate for identity `{}`.", + &identity_file.display() + ); + let cert = { + let token = get_access_token( + &oidc_details.client_id, + &oidc_details.issuer, + open_browser, + show_qr, + )?; + get_cert(&identity, &site.ca_url, token.secret()).context("Could not fetch certificate.") + }; + let cert = match cert { + Ok(cert) => cert, + Err(e) => { + cache::delete_file(cert_details_file_name).unwrap_or_default(); + anyhow::bail!(e) + } + }; + let certificate_dir = cache::cache_dir()?; + let cert_config_cache = cert.cache(identity_file.to_path_buf(), &certificate_dir)?; + match &cert_config_cache.associations { + AssociationsCache::Projects(projects) => match projects.len() { + 0 => { + anyhow::bail!("Did not authenticate with any projects.") + } + _ => { + let project_name_list = projects + .iter() + .map(|(p_id, p)| match p.name.as_str() { + "" => format!("- {}", &p_id), + name => format!(" - {} ({})", &p_id, name), + }) + .collect::>() + .join("\n"); + println!( + "\n{green}Successfully authenticated as {} and downloaded SSH certificate for projects{green:#}:\n{project_name_list}", + &cert_config_cache.user + ); + } + }, + AssociationsCache::Resources(_resources) => println!( + "\n{green}Successfully authenticated as {} and downloaded SSH certificate.{green:#}", + &cert_config_cache.user + ), + } + cache::write_file( + cert_details_file_name, + serde_json::to_string(&cert_config_cache)?, + ) + .context("Could not write certificate details cache.")?; + + // We are, in prinicple, returned many certificates. Find the one with the soonest expiry time and print it. + let first_expiry = cert_config_cache.first_expiry(); + if let Some(first_expiry) = first_expiry { + type Tz = chrono::offset::Utc; // TODO This is UNIX time, not UTC + let valid_before: chrono::DateTime = first_expiry.into(); + let valid_for = valid_before - Tz::now(); + println!( + "Certificate valid for {} hours and {} minutes.", + valid_for.num_hours(), + valid_for.num_minutes() % 60, + ); + } + + let clifton_ssh_config_path = dirs::home_dir() + .context("")? + .join(".ssh") + .join("config_clifton"); + let ssh_config = cert_config_cache.ssh_config()?; + if ssh_config != std::fs::read_to_string(&clifton_ssh_config_path).unwrap_or_default() { + if write_config.unwrap_or(config.write_config) { + ssh_config_write( + &clifton_ssh_config_path, + &cert_config_cache.ssh_config()?, + cert_config_cache, + )?; + } else { + let bold = anstyle::Style::new().bold(); + println!( + "\n{bold}SSH config appears to have changed.\nYou may now want to run `{} ssh-config write` to configure your SSH config aliases.{bold:#}", + std::env::args().next().unwrap_or("clifton".to_string()), + ); + } + } else if write_config.unwrap_or(config.write_config) { + print_available_aliases(cert_config_cache)?; + } + + Ok(()) +} diff --git a/src/cert.rs b/src/cert.rs index dd36aeb..bd3cf62 100644 --- a/src/cert.rs +++ b/src/cert.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use itertools::Itertools as _; use serde::{Deserialize, Deserializer, Serialize}; +use std::time::SystemTime; use std::{collections::HashMap, io::Write as _}; #[allow(clippy::large_enum_variant)] @@ -432,4 +433,27 @@ impl CertificateConfigCache { let config = "# CLIFTON MANAGED\n".to_string() + &config; Ok(config) } + + pub fn first_expiry(&self) -> Option { + // We are, in prinicple, returned many certificates. Find the one with the soonest expiry time and print it. + match &self.associations { + AssociationsCache::Projects(projects) => projects + .values() + .filter_map(|p| { + p.resources + .iter() + .filter_map(|(_, ra)| { + ssh_key::Certificate::read_file(ra.certificate.as_path()).ok() + }) + .map(|cert| cert.valid_before_time()) + .min() + }) + .min(), + AssociationsCache::Resources(resources) => resources + .values() + .filter_map(|ra| ssh_key::Certificate::read_file(ra.certificate.as_path()).ok()) + .map(|cert| cert.valid_before_time()) + .min(), + } + } } diff --git a/src/config.rs b/src/config.rs index 5ae9f76..d2ce565 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,9 @@ pub struct Config { /// Should the config be written out after successful auth #[serde(default = "Config::default_write_config")] pub write_config: bool, + /// Should authentication be skipped if it's not needed + #[serde(default = "Config::default_passive")] + pub passive: bool, } impl Config { @@ -71,6 +74,9 @@ impl Config { fn default_write_config() -> bool { false } + fn default_passive() -> bool { + false + } } #[derive(Debug, Deserialize, Serialize)] diff --git a/src/main.rs b/src/main.rs index 98d8254..d9985fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,13 +7,13 @@ use clap::{CommandFactory as _, Parser, Subcommand}; use itertools::Itertools; use std::io::{IsTerminal, Write as _}; -use crate::auth::get_access_token; +use crate::auth::auth; use crate::cert::CaOidcResponse; -pub mod auth; -pub mod cache; -pub mod cert; -pub mod config; +mod auth; +mod cache; +mod cert; +mod config; mod version; pub mod built_info { @@ -58,6 +58,9 @@ enum Commands { /// Should the config be written out automatically #[arg(long)] write_config: Option, + /// Should authentication be skipped if it's not needed + #[arg(long)] + passive: Option, }, /// Display the OpenSSH config SshConfig { @@ -145,8 +148,7 @@ fn main() -> Result<()> { } } - let site_name = config.default_site; - let cert_details_file_name = format!("{}.json", &site_name); + let cert_details_file_name = format!("{}.json", &config.default_site); match &args.command { Some(Commands::Auth { @@ -154,178 +156,17 @@ fn main() -> Result<()> { open_browser, show_qr, write_config, + passive, }) => { - let open_browser = open_browser.unwrap_or(config.open_browser); - let show_qr = show_qr.unwrap_or(config.show_qr); - - // Load the user's public key - let identity_file = std::path::absolute(shellexpand::path::tilde( - identity - .as_ref() - .or(config.identity.as_ref()) - .context("No identity file specified.")?, - )) - .context("Could not form absolute path for the identity file.")?; - let clifton_name = std::env::current_exe() - .ok() - .and_then(|p| p.file_name().map(|f| f.display().to_string())) - .unwrap_or("clifton".to_string()); - if !identity_file.is_file() { - anyhow::bail!(format!( - "Identity file {} not found.\nEither specify the identity file (see `{} auth --help`) or create a new key.", - &identity_file.display(), - clifton_name, - )) - } - let identity = match ssh_key::PrivateKey::read_openssh_file(&identity_file) { - Ok(i) => i, - Err(e) => { - match e { - ssh_key::Error::Encoding(_) | ssh_key::Error::FormatEncoding => { - if identity_file.extension().is_some_and(|e| e == "pub") { - anyhow::bail!(anyhow::anyhow!(e).context("Could not decode the private key. Most likely this is caused by you passing your *public* key instead of your *private* key.")) - } else { - anyhow::bail!(anyhow::anyhow!(e).context("Could not decode the private key. Most likely this is caused by you trying to read an RSA key stored in an old format. Try generating a new key.")) - } - } - _ => anyhow::bail!( - anyhow::anyhow!(e).context("Could not read SSH identity file.") - ), - }; - } - }; - - if !identity.is_encrypted() { - eprintln!( - "Warning, the SSH identity file `{}` is unencrypted.", - identity_file.display() - ); - } - - let site = config - .sites - .get(&site_name) - .context(format!("Could not get site details for `{site_name}`."))?; - let oidc_details: CaOidcResponse = - reqwest::blocking::get(format!("{}oidc", &site.ca_url)) - .context("Could not get CA OIDC details.")? - .error_for_status() - .context("Could not get CA OIDC details.")? - .json() - .context("Could not parse CA OIDC details as URL.")?; - - println!( - "Retrieving certificate for identity `{}`.", - &identity_file.display() - ); - let cert = { - let token = get_access_token( - &oidc_details.client_id, - &oidc_details.issuer, - open_browser, - show_qr, - )?; - get_cert(&identity, &site.ca_url, token.secret()) - .context("Could not fetch certificate.") - }; - let cert = match cert { - Ok(cert) => cert, - Err(e) => { - cache::delete_file(cert_details_file_name).unwrap_or_default(); - anyhow::bail!(e) - } - }; - let green = anstyle::Style::new() - .fg_color(Some(anstyle::AnsiColor::Green.into())) - .bold(); - let certificate_dir = cache::cache_dir()?; - let cert_config_cache = cert.cache(identity_file.to_path_buf(), &certificate_dir)?; - match &cert_config_cache.associations { - AssociationsCache::Projects(projects) => match projects.len() { - 0 => { - anyhow::bail!("Did not authenticate with any projects.") - } - _ => { - let project_name_list = projects - .iter() - .map(|(p_id, p)| { - match p.name.as_str() { - "" => format!("- {}", &p_id), - name => format!(" - {} ({})", &p_id, name), - } - }) - .collect::>() - .join("\n"); - println!( - "\n{green}Successfully authenticated as {} and downloaded SSH certificate for projects{green:#}:\n{project_name_list}", - &cert_config_cache.user - ); - } - }, - AssociationsCache::Resources(_resources) => println!( - "\n{green}Successfully authenticated as {} and downloaded SSH certificate.{green:#}", - &cert_config_cache.user - ), - } - cache::write_file( + let _ = auth( + config, cert_details_file_name, - serde_json::to_string(&cert_config_cache)?, - ) - .context("Could not write certificate details cache.")?; - - // We are, in prinicple, returned many certificates. Find the one with the soonest expiry time and print it. - let first_expiry = match &cert_config_cache.associations { - AssociationsCache::Projects(projects) => projects - .values() - .filter_map(|p| { - p.resources - .iter() - .filter_map(|(_, ra)| { - ssh_key::Certificate::read_file(ra.certificate.as_path()).ok() - }) - .map(|cert| cert.valid_before_time()) - .min() - }) - .min(), - AssociationsCache::Resources(resources) => resources - .values() - .filter_map(|ra| ssh_key::Certificate::read_file(ra.certificate.as_path()).ok()) - .map(|cert| cert.valid_before_time()) - .min(), - }; - if let Some(first_expiry) = first_expiry { - type Tz = chrono::offset::Utc; // TODO This is UNIX time, not UTC - let valid_before: chrono::DateTime = first_expiry.into(); - let valid_for = valid_before - Tz::now(); - println!( - "Certificate valid for {} hours and {} minutes.", - valid_for.num_hours(), - valid_for.num_minutes() % 60, - ); - } - - let clifton_ssh_config_path = dirs::home_dir() - .context("")? - .join(".ssh") - .join("config_clifton"); - let ssh_config = cert_config_cache.ssh_config()?; - if ssh_config != std::fs::read_to_string(&clifton_ssh_config_path).unwrap_or_default() { - if write_config.unwrap_or(config.write_config) { - ssh_config_write( - &clifton_ssh_config_path, - &cert_config_cache.ssh_config()?, - cert_config_cache, - )?; - } else { - let bold = anstyle::Style::new().bold(); - println!( - "\n{bold}SSH config appears to have changed.\nYou may now want to run `{} ssh-config write` to configure your SSH config aliases.{bold:#}", - std::env::args().next().unwrap_or("clifton".to_string()), - ); - } - } else if write_config.unwrap_or(config.write_config) { - print_available_aliases(cert_config_cache)?; - } + identity, + open_browser, + show_qr, + write_config, + passive, + ); } Some(Commands::SshConfig { command }) => { let f: CertificateConfigCache = serde_json::from_str(