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
220 changes: 219 additions & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<std::path::PathBuf>,
open_browser: &Option<bool>,
show_qr: &Option<bool>,
write_config: &Option<bool>,
passive: &Option<bool>,
) -> 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<Tz> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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<Tz> = 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(())
}
24 changes: 24 additions & 0 deletions src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -432,4 +433,27 @@ impl CertificateConfigCache {
let config = "# CLIFTON MANAGED\n".to_string() + &config;
Ok(config)
}

pub fn first_expiry(&self) -> Option<SystemTime> {
// 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(),
}
}
}
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -71,6 +74,9 @@ impl Config {
fn default_write_config() -> bool {
false
}
fn default_passive() -> bool {
false
}
}

#[derive(Debug, Deserialize, Serialize)]
Expand Down
Loading