diff options
| -rw-r--r-- | Cargo.lock | 59 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | src/bin/sso/main.rs | 139 |
4 files changed, 178 insertions, 32 deletions
@@ -18,6 +18,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] name = "android-tzdata" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -722,6 +731,7 @@ dependencies = [ "oauth2", "serde", "serde_json", + "sshcerts", "url", "webbrowser", ] @@ -1006,6 +1016,23 @@ dependencies = [ ] [[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] name = "reqwest" version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1225,6 +1252,18 @@ dependencies = [ ] [[package]] +name = "sshcerts" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea86255551f89d85d725a8aa6c795e87f582c4a152563defec247f76600416ee" +dependencies = [ + "base64 0.13.1", + "chrono", + "ring", + "zeroize", +] + +[[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1936,6 +1975,26 @@ dependencies = [ ] [[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] name = "zerovec" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -23,6 +23,7 @@ oauth2 = { version = "4" } gethostname = "0.2" url = "2.2" serde_json = "1.0" +sshcerts = { version = "=0.14.0", default-features = false } log = "0.4" webbrowser = "0.8" @@ -9,11 +9,14 @@ reasonably work with any provider that supports the OAuth device flow. Usage: sso [OPTIONS] [COMMAND] [ARGS] -Options: - --scope <SCOPE> Request an additional scope - --endpoint <URL> The jesterpm-sso endpoint - Commands: login - default: get or renew an access token curl - pass the access token to curl token - print the current bearer token + setup - configure the profile + +Setup options: + --scope <SCOPE> Request an additional scope + --endpoint <URL> The jesterpm-sso endpoint + --ssh-key <FILE> Sign the given SSH key. + diff --git a/src/bin/sso/main.rs b/src/bin/sso/main.rs index a5055ba..c76a72a 100644 --- a/src/bin/sso/main.rs +++ b/src/bin/sso/main.rs @@ -15,14 +15,18 @@ use chrono::{DateTime, Duration, Utc}; use clap::{Parser, Subcommand}; use gethostname::gethostname; -use oauth2::basic::BasicClient; +use oauth2::basic::{ + BasicClient, BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, + BasicTokenType, +}; use oauth2::devicecode::StandardDeviceAuthorizationResponse; use oauth2::reqwest::http_client; use oauth2::{ - AuthType, AuthUrl, ClientId, DeviceAuthorizationUrl, RefreshToken, Scope, TokenResponse, - TokenUrl, + AuthType, AuthUrl, Client, ClientId, DeviceAuthorizationUrl, ExtraTokenFields, RefreshToken, + Scope, StandardRevocableToken, StandardTokenResponse, TokenResponse, TokenUrl, }; use serde::{Deserialize, Serialize}; +use sshcerts::{Certificate, PrivateKey}; use std::collections::{BTreeMap, HashSet}; use std::error::Error; use std::path::{Path, PathBuf}; @@ -70,6 +74,10 @@ struct SetupOptions { /// The jesterpm-sso endpoint #[clap(long)] endpoint: Option<Url>, + + /// An SSH key to have signed. + #[clap(long)] + ssh_key: Option<PathBuf>, } #[derive(Serialize, Deserialize, Clone)] @@ -81,8 +89,16 @@ struct Profile { refresh_token: Option<String>, #[serde(skip)] was_modified: bool, + ssh_key: Option<String>, } +#[derive(Serialize, Deserialize, Debug, Clone)] +struct ExtraResponseFields { + pub ssh_certificate: Option<String>, +} + +impl ExtraTokenFields for ExtraResponseFields {} + impl Profile { /// Add a new scope to this profile. pub fn add_scope(&mut self, scope: String) { @@ -110,7 +126,14 @@ impl Profile { } pub fn authorize(&mut self, use_browser: bool) -> Result<(), Box<dyn Error>> { - let client = BasicClient::new(client_id(), None, self.auth_url(), Some(self.token_url())) + let client: Client< + BasicErrorResponse, + StandardTokenResponse<ExtraResponseFields, BasicTokenType>, + BasicTokenType, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, + > = Client::new(client_id(), None, self.auth_url(), Some(self.token_url())) .set_auth_type(AuthType::RequestBody) .set_device_authorization_url(self.device_url()); @@ -122,10 +145,15 @@ impl Profile { .join(" "), ); - let details: StandardDeviceAuthorizationResponse = client - .exchange_device_code()? - .add_scope(scope) - .request(http_client)?; + let mut device_request = client.exchange_device_code()?.add_scope(scope); + + if let Some(ref filename) = self.ssh_key { + let private_key = PrivateKey::from_path(filename)?; + let pubkey = private_key.pubkey.to_string(); + device_request = device_request.add_extra_param("ssh_pubkey", pubkey); + } + + let details: StandardDeviceAuthorizationResponse = device_request.request(http_client)?; let mut quiet = false; @@ -145,11 +173,9 @@ impl Profile { ); } - let token_result = client.exchange_device_access_token(&details).request( - http_client, - std::thread::sleep, - None, - )?; + let token_result: StandardTokenResponse<ExtraResponseFields, BasicTokenType> = client + .exchange_device_access_token(&details) + .request(http_client, std::thread::sleep, None)?; self.access_token = Some(token_result.access_token().secret().to_string()); self.access_token_expiration = token_result @@ -157,6 +183,14 @@ impl Profile { .map(|d| Utc::now() + Duration::seconds(d.as_secs() as i64)); self.refresh_token = token_result.refresh_token().map(|t| t.secret().to_string()); self.was_modified = true; + + // Save the new certificate + if let Some(ref cert) = token_result.extra_fields().ssh_certificate { + if let Some(cert_file) = self.ssh_certificate_file() { + fs::write(cert_file, cert)?; + } + } + Ok(()) } @@ -191,6 +225,11 @@ impl Profile { self.refresh_token = None; } + pub fn set_ssh_key(&mut self, ssh_key: Option<String>) { + self.ssh_key = ssh_key; + self.was_modified = true; + } + pub fn modified(&self) -> bool { self.was_modified } @@ -207,6 +246,29 @@ impl Profile { DeviceAuthorizationUrl::new(format!("{}/oauth/device", &self.endpoint)) .expect("Bad endpoint url.") } + + fn ssh_certificate_file(&self) -> Option<String> { + self.ssh_key + .as_ref() + .map(|f| { + let mut cert_file = f.to_owned(); + cert_file.push_str("-cert.pub"); + cert_file + }) + } + + fn is_ssh_certificate_valid(&self) -> Result<bool, Box<dyn Error>> { + if let Some(f) = self.ssh_certificate_file() { + let file = PathBuf::from(f); + if file.exists() { + let cert = Certificate::from_path(file)?; + let now = Utc::now().timestamp() as u64; + return Ok(now >= cert.valid_after && now <= cert.valid_before); + } + } + // No certificate + Ok(false) + } } impl Default for Profile { @@ -218,6 +280,7 @@ impl Default for Profile { access_token_expiration: None, refresh_token: None, was_modified: false, + ssh_key: None, } } } @@ -275,6 +338,31 @@ fn do_curl(profile: &Profile, mut args: Vec<String>) -> Result<(), Box<dyn Error .map_err(|e| e.into()) } +fn ensure_authorized(explicit_login: bool, profile: &mut Profile, browser: bool) -> Result<(), Box<dyn Error>> { + if !explicit_login && profile.valid_access_token() { + return Ok(()) + } + + let mut can_refresh = profile.valid_refresh_token(); + + if explicit_login && profile.ssh_key.is_some() && !profile.is_ssh_certificate_valid()? { + log::debug!("Full authorization required to refresh ssh certificate"); + can_refresh = false; + } + + if can_refresh { + log::debug!("Attempting to refresh access token"); + match profile.refresh() { + Ok(_) => return Ok(()), + Err(e) => log::info!("Failed to refresh token: {}", e), + } + } + + // Acquire credentials + log::debug!("Attempting to retrieve access token"); + profile.authorize(browser) +} + fn main() -> Result<(), Box<dyn Error>> { env_logger::init(); @@ -308,6 +396,11 @@ fn main() -> Result<(), Box<dyn Error>> { profile.set_endpoint(endpoint.to_string()); } + // Set the SSH key + if let Some(filename) = cfg.ssh_key { + profile.set_ssh_key(Some(filename.canonicalize()?.to_string_lossy().into_owned())); + } + // Print out the current configuration. println!("Profile {}", profile_name); println!("\tEndpoint: {}", profile.endpoint); @@ -316,6 +409,9 @@ fn main() -> Result<(), Box<dyn Error>> { .map(String::as_str) .collect::<Vec<_>>() .join(" ")); + if let Some(ref filename) = profile.ssh_key { + println!("\tSSH Key: {}", filename); + } if profile.modified() { save_profile(config_dir.as_path(), profile_name, &profile)?; @@ -323,21 +419,8 @@ fn main() -> Result<(), Box<dyn Error>> { return Ok(()); } - // Determine if we need a new token - if command == Commands::Login || !profile.valid_access_token() { - if profile.valid_refresh_token() { - // Try a refresh... - // Ignore any errors - if let Err(e) = profile.refresh() { - log::info!("Failed to refresh token: {}", e); - } - } - - if !profile.valid_access_token() { - // Acquire access token - profile.authorize(!args.no_browser)?; - } - } + // Everything after this point will need a token. + ensure_authorized(command == Commands::Login, &mut profile, !args.no_browser)?; if profile.modified() { save_profile(config_dir.as_path(), profile_name, &profile)?; |
