use chrono::{DateTime, TimeDelta, Utc}; use mize::out; use mize::{ item::ItemData, mize_err, mize_part, Mize, MizeError, MizePart, MizePartCreate, MizeResult, }; use openidconnect::{ ClientId, DeviceAuthorizationResponse, EmptyExtraDeviceAuthorizationFields, IssuerUrl, OAuth2TokenResponse, RefreshToken, Scope, TokenResponse, }; use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::Write; use std::{path::PathBuf, thread}; use tracing::{info, warn}; // console_log macro // that can be copied into other files for debugging purposes #[cfg(feature = "target-obsidian")] use wasm_bindgen::prelude::*; #[cfg(feature = "target-obsidian")] #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = console)] fn log(s: &str); } #[cfg(feature = "target-obsidian")] macro_rules! console_log { // Note that this is using the `log` function imported above during // `bare_bones` ($($t:tt)*) => (unsafe { log(&format_args!($($t)*).to_string())}) } #[cfg(feature = "target-os")] use clap::Command; #[derive(Default)] #[mize_part] pub struct ClientAuth { mize: Mize, secrets: Option, } #[derive(Default, Serialize, Deserialize, Clone, Debug)] pub struct ClientSecrets { id_token: String, access_token: String, refresh_token: String, expiry: DateTime, } impl MizePartCreate for ClientAuth {} impl MizePart for ClientAuth { fn name(&self) -> &'static str { "client_auth" } } #[cfg(feature = "target-js")] pub fn store_secrets_js(mize: &Mize, secrets: &ClientSecrets) -> MizeResult<()> { use web_sys::window; // Serialize struct to JSON string let serialized = serde_json::to_string(&secrets)?; // Get browser window object let window = window().ok_or_else(|| mize_err!("No global window found"))?; // Access local storage let storage = window .local_storage().map_err(|_| mize_err!("couldn't get local_storage"))? .ok_or_else(|| mize_err!("LocalStorage is not available"))?; // Store the JSON string storage.set_item("mize_client_secrets", &serialized).map_err(|_| mize_err!("couldn't set item in local_storage"))?; Ok(()) } impl ClientAuth { pub fn secrets(&self) -> Option<&ClientSecrets> { self.secrets.as_ref() } pub fn id_token(&self) -> MizeResult<&str> { if self.secrets.is_none() { return Err(mize_err!("Client not logged in")); } Ok(&self.secrets.as_ref().unwrap().id_token) } pub fn access_token(&self) -> MizeResult<&str> { if self.secrets.is_none() { return Err(mize_err!("Client not logged in")); } Ok(&self.secrets.as_ref().unwrap().access_token) } pub fn refresh_token(&self) -> MizeResult<&str> { if self.secrets.is_none() { return Err(mize_err!("Client not logged in")); } Ok(&self.secrets.as_ref().unwrap().refresh_token) } pub fn refresh(&mut self) -> MizeResult<()> { #[cfg(feature = "target-js")] { console_log!("refresh is not implemented yet on wasm"); } #[cfg(feature = "target-os")] { let secrets = self.secrets.clone().ok_or(mize_err!( "no client secrets, meaning client_auth.refresh() thread can't use the refresh token" ))?; let new_secrets = self.mize.spawn_async_blocking( "auth.refresh_flow", refresh_flow(self.mize.clone(), secrets), )?; store_secrets(&mut self.mize, &new_secrets)?; self.secrets = Some(new_secrets); } Ok(()) } pub fn login(&mut self) -> MizeResult<()> { #[cfg(feature = "target-os")] { if self.secrets().is_none() { let secrets = self .mize .spawn_async_blocking("auth.login_flow", auth_flow(self.mize.clone()))?; store_secrets(&mut self.mize, &secrets)?; self.secrets = Some(secrets); } } #[cfg(feature = "target-js")] { if self.secrets().is_none() { let mut mize_clone = self.mize.clone(); wasm_bindgen_futures::spawn_local(async move { console_log!("before auth_flow"); let secrets = match auth_flow(mize_clone.clone()).await { Ok(secrets) => secrets, Err(e) => { mize_clone.report_err(e); return (); } }; console_log!("after auth_flow"); if let Err(e) = store_secrets_js(&mut mize_clone, &secrets) { mize_clone.report_err(e); return (); } let mut auth = match mize_clone.get_part_native::("client_auth") { Ok(auth) => auth, Err(e) => { mize_clone.report_err(e); return (); } }; auth.secrets = Some(secrets); }); } } Ok(()) } pub fn start_refresh_thread(&mut self) -> MizeResult<()> { // background thread for os targets #[cfg(not(feature = "target-js"))] { let mut mize_clone = self.mize.clone(); self.mize.spawn_background("auth.refresh", move || loop { info!("hi from auth.refresh thread..."); thread::sleep(Duration::from_secs(33)); // TERRIBLE hack let auth = mize_clone.get_part_native::("client_auth")?; let expiry = auth.secrets.as_ref().unwrap().expiry.clone(); info!("auth.refresh thread: now: {}", Utc::now()); info!("auth.refresh thread: expiry: {}", expiry); let to_sleep = expiry - Utc::now() - TimeDelta::minutes(20); /* if to_sleep < TimeDelta::from_std(Duration::from_secs(0))? { if let Err(e) = auth.refresh() { e.log(); } } */ drop(auth); info!("auth.refresh: thread going to sleep for: {}", to_sleep); thread::sleep(to_sleep.to_std()?); info!("refresh_thread: sleeping done"); let mut auth = mize_clone.get_part_native::("auth")?; auth.refresh()?; drop(auth); })?; Ok(()) } // spawn an async background task doing the refresh #[cfg(feature = "target-js")] { use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; // Get the window object let window = web_sys::window().expect("no global `window` exists"); // Clone mize for the closure let mize_clone = self.mize.clone(); // Create a closure that will be called periodically let refresh_closure = Closure::wrap(Box::new(move || { let mut mize_clone = mize_clone.clone(); wasm_bindgen_futures::spawn_local(async move { let mut auth = mize_clone.get_part_native::("client_auth"); match auth { Ok(mut auth_part) => { if let Some(secrets) = auth_part.secrets() { let now = Utc::now(); let expiry = secrets.expiry; let time_until_expiry = expiry - now; // Refresh if token will expire in less than 20 minutes if time_until_expiry < TimeDelta::minutes(20) { if let Err(e) = auth_part.refresh() { console_log!("Error refreshing token: {:?}", e); } else { console_log!("Token refreshed successfully"); } } } } Err(e) => { console_log!("Error getting auth part: {:?}", e); } } }); }) as Box); // Set up interval to run every 50 minutes (300000 ms) let interval_ms = 3000000; let _ = window.set_interval_with_callback_and_timeout_and_arguments_0( refresh_closure.as_ref().unchecked_ref(), interval_ms, ); // Leak the closure to prevent it from being dropped refresh_closure.forget(); Ok(()) } } } pub fn part_client_auth(mize: &mut Mize) -> MizeResult<()> { let mize_clone = mize.clone(); let mut auth_part = ClientAuth { secrets: None, mize: mize.clone(), }; let secrets = match load_secrets(mize) { Ok(secrets) => Some(secrets), Err(err) => { tracing::warn!("err reading secrets: {:?}", err); None } }; auth_part.secrets = secrets.clone(); // do a refresh if the tokens have expired if secrets.is_some() && secrets.clone().unwrap().expiry < Utc::now() { auth_part.refresh()?; } // start refresh tread if configured if mize .get_config("auth.client.refresh_thread") .unwrap_or(ItemData::new()) .value_bool() .unwrap_or(false) { auth_part.start_refresh_thread()?; } // cli command for os #[cfg(feature = "target-os")] { let mut cli = mize.get_part_native::("cli")?; cli.set_command("ppc"); cli.subcommand(Command::new("login"), move |_, mut mize| { let mut auth = mize.get_part_native::("client_auth")?; let secrets = if auth.secrets.is_some() { match mize.spawn_async_blocking( "client_auth", refresh_flow(mize.clone(), auth.secrets.clone().unwrap()), ) { Ok(secrets) => secrets, Err(err) => { out!("err refreshing secrets: {:?}", err); mize.spawn_async_blocking("client_auth", auth_flow(mize_clone.clone()))? } } } else { mize.spawn_async_blocking("client_auth", auth_flow(mize_clone.clone()))? }; store_secrets(&mut mize, &secrets)?; auth.secrets = Some(secrets); Ok(()) })?; } // register command for obsidian #[cfg(feature = "target-obsidian")] { use crate::MartsCommand; console_log!("register obsidian command..."); let obsidian = mize.get_part_native::("obsidian")?; obsidian.add_command(MartsCommand { id: "mize-login".to_string(), name: "Mize Login".to_string(), callback: Box::new(|mut mize| { let mut auth = mize.get_part_native::("client_auth")?; let res = auth.login(); console_log!("auth.login done: {:?}", res); res }), })?; } auth_part.secrets = secrets; mize.register_part(Box::new(auth_part))?; Ok(()) } fn load_secrets(mize: &mut Mize) -> MizeResult { #[cfg(feature = "target-os")] { let mize_dir = mize.get_config("data_dir")?.value_string()?; let secrets_file_path = PathBuf::from(mize_dir).join("client_secrets.json"); let secrets_string = std::fs::read_to_string(secrets_file_path)?; let secrets: ClientSecrets = serde_json::from_str(&secrets_string)?; return Ok(secrets); } #[cfg(feature = "target-js")] { use web_sys::window; // Get browser window object let window = window().ok_or_else(|| mize_err!("No global window found"))?; // Access local storage let storage = window .local_storage().map_err(|_| mize_err!("couldn't get local_storage"))? .ok_or_else(|| mize_err!("LocalStorage is not available"))?; // Retrieve the string item return match storage.get_item("mize_client_secrets").map_err(|_| mize_err!("couldn't get item from local_storage"))? { Some(json_str) => { // Deserialize JSON string back into Client struct let secrets: ClientSecrets = serde_json::from_str(json_str.as_str())?; Ok(secrets) } None => Err(mize_err!("no secrets")), // Key does not exist } } } fn store_secrets(mize: &mut Mize, secrets: &ClientSecrets) -> MizeResult<()> { #[cfg(feature = "target-os")] { let mize_dir = mize.get_config("store")?.value_string()?; let secrets_file_path = PathBuf::from(mize_dir.clone()) .join("misc") .join("client_secrets.json"); std::fs::create_dir_all(PathBuf::from(mize_dir).join("misc"))?; let secrets_string = serde_json::to_string(secrets)?; log::info!("secrets_file_path: {}", secrets_file_path.display()); //std::fs::write(secrets_file_path, secrets_string)?; OpenOptions::new() .write(true) .create(true) .open(secrets_file_path)? .write_all(&secrets_string.as_bytes())?; // update the spacetimedb_token in XDG_CONFIG_HOME/spacetime/cli.toml config file if configured to do so if mize .get_config("auth.client.update_spacetime_token") .unwrap_or(ItemData::empty()) .as_bool() .unwrap_or(false) { update_spacetimedb_token(&secrets.id_token.as_str())?; } } Ok(()) } use openidconnect::reqwest::Client as HttpClient; use openidconnect::DeviceAuthorizationUrl; use std::time::Duration; //use wasm_bindgen::JsCast; async fn refresh_flow(mut mize: Mize, old_secrets: ClientSecrets) -> MizeResult { out!("Refresh flow..."); let issuer = mize.get_config("auth.client.issuer")?.value_string()?; let client_id = mize.get_config("auth.client.client_id")?.value_string()?; let issuer = IssuerUrl::new(issuer)?; let client_id = ClientId::new(client_id); let http_client = HttpClient::new(); // Teach openidconnect-rs about the device authorization url. #[derive(Clone, Debug, Deserialize, Serialize)] struct MyExtraProviderMetadata { device_authorization_endpoint: String, } use openidconnect::core::{ CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClient, CoreClientAuthMethod, CoreGrantType, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreResponseMode, CoreResponseType, CoreSubjectIdentifierType, }; use openidconnect::AdditionalProviderMetadata; use openidconnect::ProviderMetadata; impl AdditionalProviderMetadata for MyExtraProviderMetadata {} type MyProviderMetadata = ProviderMetadata< MyExtraProviderMetadata, CoreAuthDisplay, CoreClientAuthMethod, CoreClaimName, CoreClaimType, CoreGrantType, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreJsonWebKey, CoreResponseMode, CoreResponseType, CoreSubjectIdentifierType, >; // Discover provider metadata let provider_metadata: MyProviderMetadata = MyProviderMetadata::discover_async(issuer, &http_client).await?; let device_auth_url = provider_metadata .additional_metadata() .device_authorization_endpoint .clone(); let client = CoreClient::from_provider_metadata( provider_metadata, client_id, None, // public client ) .set_device_authorization_url(DeviceAuthorizationUrl::new(device_auth_url)?); let refresh_token = RefreshToken::new(old_secrets.refresh_token); let res = client .exchange_refresh_token(&refresh_token)? .request_async(&http_client) .await?; let expires_in = res.expires_in().ok_or(mize_err!( "no expiry information was in token response of the refresh flow" ))?; return Ok(ClientSecrets { id_token: res.id_token().unwrap().to_string(), access_token: res.access_token().secret().to_owned(), refresh_token: res.refresh_token().unwrap().secret().to_owned(), expiry: Utc::now() + expires_in, }); } async fn auth_flow(mut mize: Mize) -> MizeResult { let issuer = mize.get_config("auth.client.issuer")?.value_string()?; let client_id = mize.get_config("auth.client.client_id")?.value_string()?; let issuer = IssuerUrl::new(issuer)?; let client_id = ClientId::new(client_id); let http_client = HttpClient::new(); // Teach openidconnect-rs about the device authorization url. #[derive(Clone, Debug, Deserialize, Serialize)] struct MyExtraProviderMetadata { device_authorization_endpoint: String, } use openidconnect::core::{ CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClient, CoreClientAuthMethod, CoreGrantType, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreResponseMode, CoreResponseType, CoreSubjectIdentifierType, }; use openidconnect::AdditionalProviderMetadata; use openidconnect::ProviderMetadata; impl AdditionalProviderMetadata for MyExtraProviderMetadata {} type MyProviderMetadata = ProviderMetadata< MyExtraProviderMetadata, CoreAuthDisplay, CoreClientAuthMethod, CoreClaimName, CoreClaimType, CoreGrantType, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreJsonWebKey, CoreResponseMode, CoreResponseType, CoreSubjectIdentifierType, >; // Discover provider metadata let provider_metadata: MyProviderMetadata = MyProviderMetadata::discover_async(issuer, &http_client).await?; let device_auth_url = provider_metadata .additional_metadata() .device_authorization_endpoint .clone(); let client = CoreClient::from_provider_metadata( provider_metadata, client_id, None, // public client ) .set_device_authorization_url(DeviceAuthorizationUrl::new(device_auth_url)?); // ===== DEVICE AUTH REQUEST ===== let details: Result, _> = client .exchange_device_code() .add_scope(Scope::new("openid".into())) .add_scope(Scope::new("profile".into())) .add_scope(Scope::new("offline_access".into())) .request_async(&http_client) .await; let details = match details { Ok(val) => val, Err(err) => { out!("err: {:?}", err); out!("err: {}", err); panic!(); } }; #[cfg(feature = "target-obsidian")] { console_log!("details: {:?}", details); use std::str::FromStr; use js_sys::{Array, Function, JsString, Reflect}; use mize::error::IntoMizeResultJsValue; use wasm_bindgen::convert::TryFromJsValue; let obsidian = mize.get_part_native::("obsidian")?; let show_device_code = Reflect::get(&obsidian.plugin(), &JsValue::from_str("showDeviceCode")).mres()?; let args = Array::new(); args.push(&JsValue::from_str(details.user_code().secret().as_str())); args.push(&JsValue::from_str(details.verification_uri_complete().unwrap().secret().as_str())); Reflect::apply(&show_device_code.dyn_into::().mres()?, obsidian.plugin(), &args).mres()?; } #[cfg(feature = "target-os")] { out!( "If your browser does not open automatically, open this URL in it:\n{}\n", details.verification_uri_complete().unwrap().secret() ); out!( "Or enter this PPC login code:\n{}\n", details.user_code().secret() ); if let Some(_) = details.verification_uri_complete() { let _ = webbrowser::open(details.verification_uri_complete().unwrap().secret()); } } let token_response = client .exchange_device_access_token(&details)? .request_async( &http_client, async |dur: Duration| { #[cfg(feature = "target-os")] { tokio::time::sleep(dur).await; } #[cfg(feature = "target-js")] { console_log!("sleeping"); use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; let promise = js_sys::Promise::new(&mut |resolve, _| { web_sys::window() .unwrap() .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, dur.as_millis() as i32) .unwrap(); }); // Await the JS Promise like a Rust Future let _ = JsFuture::from(promise).await; } }, None, ) .await?; // ===== TOKENS ===== let id_token = token_response .extra_fields() .id_token() .ok_or(mize_err!("no id_token"))?; let refresh = token_response .refresh_token() .ok_or(mize_err!("no refresh token present"))?; let expires_in = token_response.expires_in().ok_or(mize_err!( "no expiry information was in token response of the refresh flow" ))?; Ok(ClientSecrets { id_token: id_token.to_string(), access_token: token_response.access_token().secret().to_string(), refresh_token: refresh.secret().to_owned(), expiry: Utc::now() + expires_in, }) } fn update_spacetimedb_token(new_token: &str) -> MizeResult<()> { // Resolve config directory let config_dir = match std::env::var("XDG_CONFIG_HOME") { Ok(val) => PathBuf::from(val), Err(_) => { let home = std::env::var("HOME")?; std::path::PathBuf::from(home).join(".config") } }; let file_path = config_dir.join("spacetime").join("cli.toml"); if !file_path.exists() { warn!("spacetime config file does not exist, not updating token there"); return Ok(()); }; // Read existing file (or create empty document if it doesn't exist) let content = std::fs::read_to_string(&file_path).unwrap_or_default(); let mut data = ItemData::from_toml(content.as_str())?; // Update the field data.set_path("spacetimedb_token", new_token)?; // Write back std::fs::write(&file_path, data.to_toml()?)?; Ok(()) }