summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock59
-rw-r--r--Cargo.toml1
-rw-r--r--README.md11
-rw-r--r--src/bin/sso/main.rs139
4 files changed, 178 insertions, 32 deletions
diff --git a/Cargo.lock b/Cargo.lock
index dfa0983..d332dd5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 6ea9c05..0663d5e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index f0cbe24..eee3f42 100644
--- a/README.md
+++ b/README.md
@@ -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)?;