//! Command line tool for getting access tokens //! //! Usage: sso [OPTIONS] [COMMAND] [ARGS] //! //! Options: //! --scope Request an additional scope //! --endpoint The jesterpm-sso endpoint //! //! Commands: //! login - default: get or renew an access token //! curl - pass the use chrono::{DateTime, Duration, Utc}; use clap::{Parser, Subcommand}; use gethostname::gethostname; use oauth2::basic::BasicClient; use oauth2::devicecode::StandardDeviceAuthorizationResponse; use oauth2::reqwest::http_client; use oauth2::{ AuthType, AuthUrl, ClientId, DeviceAuthorizationUrl, RefreshToken, Scope, TokenResponse, TokenUrl, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; use std::error::Error; use std::path::{Path, PathBuf}; use std::process::Command; use std::{env, fs}; use url::Url; #[derive(Parser)] #[clap(author, version, about, long_about = None)] struct Args { /// The profile to use. A profile is an endpoint and set of scopes. #[clap(short = 'P', long, default_value = "default")] profile: String, /// Request an additional scope #[clap(short, long)] scope: Vec, /// The jesterpm-sso endpoint #[clap(long)] endpoint: Option, /// Turn debugging information on #[clap(short, long, parse(from_occurrences))] verbose: usize, /// Do not attempt to open a browser window #[clap(long)] no_browser: bool, #[clap(subcommand)] command: Option, } #[derive(Subcommand, PartialEq)] enum Commands { /// Request or refresh an access token (default command). Login, /// Send a curl request with an authorization header. Curl { args: Vec }, /// Print the current bearer token. Token, } #[derive(Serialize, Deserialize, Clone)] struct Profile { endpoint: String, scopes: HashSet, access_token: Option, access_token_expiration: Option>, refresh_token: Option, #[serde(skip)] was_modified: bool, } impl Profile { /// Add a new scope to this profile. pub fn add_scope(&mut self, scope: String) { if self.scopes.insert(scope) { // Since we didn't have this scope before, our old access // and refresh tokens are useless. self.access_token = None; self.access_token_expiration = None; self.refresh_token = None; } } /// Check if the access token should be valid. pub fn valid_access_token(&self) -> bool { self.access_token.is_some() && self .access_token_expiration .map(|expiration| Utc::now() < expiration) .unwrap_or(true) } /// Check if there is a refresh token. pub fn valid_refresh_token(&self) -> bool { self.refresh_token.is_some() } pub fn authorize(&mut self, use_browser: bool) -> Result<(), Box> { let client = BasicClient::new(client_id(), None, self.auth_url(), Some(self.token_url())) .set_auth_type(AuthType::RequestBody) .set_device_authorization_url(self.device_url()); let scope = Scope::new( self.scopes .iter() .map(|s| s.to_string()) .collect::>() .join(" "), ); let details: StandardDeviceAuthorizationResponse = client .exchange_device_code()? .add_scope(scope) .request(http_client)?; let mut quiet = false; if use_browser { if let Some(uri) = details.verification_uri_complete() { if webbrowser::open(uri.secret()).is_ok() { quiet = true; } } } if !quiet { println!( "Open this URL in your browser:\n{}\nand enter the code: {}", details.verification_uri().as_str(), details.user_code().secret() ); } let token_result = 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 .expires_in() .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; Ok(()) } pub fn refresh(&mut self) -> Result<(), Box> { let client = BasicClient::new(client_id(), None, self.auth_url(), Some(self.token_url())) .set_auth_type(AuthType::RequestBody); let refresh_token = RefreshToken::new( self.refresh_token .as_deref() .map(|s| s.to_string()) .expect("Missing refresh token"), ); let token_result = client .exchange_refresh_token(&refresh_token) .request(http_client)?; self.access_token = Some(token_result.access_token().secret().to_string()); self.access_token_expiration = token_result .expires_in() .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; Ok(()) } pub fn set_endpoint(&mut self, endpoint: String) { self.endpoint = endpoint; self.was_modified = true; self.access_token = None; self.access_token_expiration = None; self.refresh_token = None; } pub fn modified(&self) -> bool { self.was_modified } fn auth_url(&self) -> AuthUrl { AuthUrl::new(format!("{}/oauth/authorize", &self.endpoint)).expect("Bad endpoint url.") } fn token_url(&self) -> TokenUrl { TokenUrl::new(format!("{}/oauth/token", &self.endpoint)).expect("Bad endpoint url.") } fn device_url(&self) -> DeviceAuthorizationUrl { DeviceAuthorizationUrl::new(format!("{}/oauth/device", &self.endpoint)) .expect("Bad endpoint url.") } } impl Default for Profile { fn default() -> Self { Profile { endpoint: "https://login.jesterpm.net".to_string(), scopes: HashSet::new(), access_token: None, access_token_expiration: None, refresh_token: None, was_modified: false, } } } fn client_id() -> ClientId { ClientId::new(format!("device:{}", gethostname().to_string_lossy())) } fn load_profile(config_dir: &Path, profile_name: &str) -> Result> { let filename = config_dir.join("profiles.json"); if filename.exists() { let file = fs::File::open(filename)?; let mut profiles: BTreeMap = serde_json::from_reader(file)?; Ok(profiles .remove(profile_name) .unwrap_or_else(Profile::default)) } else { Ok(Profile::default()) } } fn save_profile( config_dir: &Path, profile_name: &str, profile: &Profile, ) -> Result<(), Box> { let filename = config_dir.join("profiles.json"); let mut profiles: BTreeMap = if filename.exists() { let file = fs::File::open(&filename)?; serde_json::from_reader(file)? } else { BTreeMap::new() }; profiles.insert(profile_name.to_string(), profile.clone()); let file = fs::File::create(&filename)?; serde_json::to_writer(file, &profiles).map_err(|e| e.into()) } fn do_curl(profile: &Profile, mut args: Vec) -> Result<(), Box> { args.push("-H".to_string()); args.push(format!( "Authorization: Bearer {}", profile .access_token .as_deref() .expect("Must have valid access token") )); Command::new("curl") .args(args) .spawn()? .wait() .map(|_| ()) .map_err(|e| e.into()) } fn main() -> Result<(), Box> { let args: Args = Args::parse(); let command = args.command.unwrap_or(Commands::Login); // Find the config files. let home: PathBuf = env::var("HOME") .expect("No $HOME?") .parse() .expect("Bad $HOME?"); let config_dir = home.join(".config/jesterpm-sso"); if !config_dir.exists() { fs::create_dir(config_dir.as_path())?; } // Load the profile from the config. let profile_name = args.profile.as_str(); let mut profile = load_profile(config_dir.as_path(), profile_name)?; // Add any new scopes to the profile. for scope in args.scope { profile.add_scope(scope); } // Set the endpoint if let Some(endpoint) = args.endpoint { profile.set_endpoint(endpoint.to_string()); } // 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)?; } } if profile.modified() { save_profile(config_dir.as_path(), profile_name, &profile)?; } match command { Commands::Login {} => { Ok(()) /* No-op, we already took care of it above */ } Commands::Curl { args } => do_curl(&profile, args), Commands::Token => { println!( "{}", profile .access_token .as_deref() .expect("Must have valid access token") ); Ok(()) } } }