From 6a05276a218c477c13c5518bc889db2222bc0769 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 31 Mar 2023 18:44:26 -0700 Subject: [PATCH] Auth bindings (#58) * basic framework. still has errors * added functionality for main endpoints + some structuring * formatting * unused code * mimicked CLI function with wait_for process * added basic auth bindings * made PR changes, added playground * cargo fmt * removed missed println * misc tests fixes * cargo fmt * added windows support * cargo fmt * all OS use dunce * restructured profile slightly; fixed mac bug * profile changes, new main.rs * fixed requested pr + canonicaliation bug * fixed regressed bug in ui * fixed regressed bugs * fixed git error * typo * ran prettier * clippy * playground clippy * ported profile loading fix * profile change for real, url println and clippy * PR changes * auth bindings + semisynch flow * fixed dropping task error * prettier, eslint, clippy * removed debugging modifications * removed unused function that eslinter missed :( * fixed settings not being released --------- Co-authored-by: Wyatt --- .vscode/launch.json | 138 +++++++++++++++++++++++ Cargo.lock | 3 + theseus/src/api/auth.rs | 38 +++++++ theseus/src/error.rs | 11 +- theseus/src/state/auth_task.rs | 74 ++++++++++++ theseus/src/state/mod.rs | 10 +- theseus_gui/src-tauri/Cargo.toml | 4 +- theseus_gui/src-tauri/src/api/auth.rs | 56 +++++++++ theseus_gui/src-tauri/src/api/mod.rs | 2 + theseus_gui/src-tauri/src/api/profile.rs | 25 ++-- theseus_gui/src-tauri/src/main.rs | 9 ++ theseus_gui/src/helpers/auth.js | 58 ++++++++++ theseus_playground/Cargo.toml | 1 + theseus_playground/src/main.rs | 64 ++++++----- 14 files changed, 447 insertions(+), 46 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 theseus/src/state/auth_task.rs create mode 100644 theseus_gui/src-tauri/src/api/auth.rs create mode 100644 theseus_gui/src/helpers/auth.js diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..3eee52764 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,138 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'theseus'", + "cargo": { + "args": [ + "test", + "--no-run", + "--lib", + "--package=theseus" + ], + "filter": { + "name": "theseus", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'theseus_cli'", + "cargo": { + "args": [ + "build", + "--bin=theseus_cli", + "--package=theseus_cli" + ], + "filter": { + "name": "theseus_cli", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'theseus_cli'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=theseus_cli", + "--package=theseus_cli" + ], + "filter": { + "name": "theseus_cli", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'theseus_playground'", + "cargo": { + "args": [ + "build", + "--bin=theseus_playground", + "--package=theseus_playground" + ], + "filter": { + "name": "theseus_playground", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'theseus_playground'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=theseus_playground", + "--package=theseus_playground" + ], + "filter": { + "name": "theseus_playground", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'theseus_gui'", + "cargo": { + "args": [ + "build", + "--bin=theseus_gui", + "--package=theseus_gui" + ], + "filter": { + "name": "theseus_gui", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'theseus_gui'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=theseus_gui", + "--package=theseus_gui" + ], + "filter": { + "name": "theseus_gui", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 6f9668071..2e055af62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3582,6 +3582,8 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "url", + "uuid 1.3.0", ] [[package]] @@ -3599,6 +3601,7 @@ dependencies = [ "tokio", "tokio-stream", "url", + "uuid 1.3.0", "webbrowser", ] diff --git a/theseus/src/api/auth.rs b/theseus/src/api/auth.rs index 5a2c3a13d..389d62c9b 100644 --- a/theseus/src/api/auth.rs +++ b/theseus/src/api/auth.rs @@ -5,6 +5,28 @@ use tokio::sync::oneshot; pub use inner::Credentials; +/// Authenticate a user with Hydra - part 1 +/// This begins the authentication flow quasi-synchronously, returning a URL +/// This can be used in conjunction with 'authenticate_await_complete_flow' +/// to call authenticate and call the flow from the frontend. +/// Visit the URL in a browser, then call and await 'authenticate_await_complete_flow'. +pub async fn authenticate_begin_flow() -> crate::Result { + let st = State::get().await?.clone(); + let url = st.auth_flow.write().await.begin_auth().await?; + Ok(url) +} + +/// Authenticate a user with Hydra - part 2 +/// This completes the authentication flow quasi-synchronously, returning the credentials +/// This can be used in conjunction with 'authenticate_begin_flow' +/// to call authenticate and call the flow from the frontend. +pub async fn authenticate_await_complete_flow() -> crate::Result { + let st = State::get().await?.clone(); + let credentials = + st.auth_flow.write().await.await_auth_completion().await?; + Ok(credentials) +} + /// Authenticate a user with Hydra /// To run this, you need to first spawn this function as a task, then /// open a browser to the given URL and finally wait on the spawned future @@ -36,6 +58,7 @@ pub async fn authenticate( } /// Refresh some credentials using Hydra, if needed +/// This is the primary desired way to get credentials, as it will also refresh them. #[tracing::instrument] pub async fn refresh( user: uuid::Uuid, @@ -98,3 +121,18 @@ pub async fn users() -> crate::Result> { let users = state.users.read().await; users.iter().collect() } + +/// Get a specific user by user ID +/// Prefer to use 'refresh' instead of this function +#[tracing::instrument] +pub async fn get_user(user: uuid::Uuid) -> crate::Result { + let state = State::get().await?; + let users = state.users.read().await; + let user = users.get(user)?.ok_or_else(|| { + crate::ErrorKind::OtherError(format!( + "Tried to get nonexistent user with ID {user}" + )) + .as_error() + })?; + Ok(user) +} diff --git a/theseus/src/error.rs b/theseus/src/error.rs index 5509b877c..12c8f5e21 100644 --- a/theseus/src/error.rs +++ b/theseus/src/error.rs @@ -1,7 +1,6 @@ //! Theseus error type -use tracing_error::InstrumentError; - use crate::profile_create; +use tracing_error::InstrumentError; #[derive(thiserror::Error, Debug)] pub enum ErrorKind { @@ -32,9 +31,12 @@ pub enum ErrorKind { #[error("Metadata error: {0}")] MetadataError(#[from] daedalus::Error), - #[error("Minecraft authentication error: {0}")] + #[error("Minecraft authentication Hydra error: {0}")] HydraError(String), + #[error("Minecraft authentication task error: {0}")] + AuthTaskError(#[from] crate::state::AuthTaskError), + #[error("I/O error: {0}")] IOError(#[from] std::io::Error), @@ -59,6 +61,9 @@ pub enum ErrorKind { #[error("Invalid input: {0}")] InputError(String), + #[error("Join handle error: {0}")] + JoinError(#[from] tokio::task::JoinError), + #[error("Recv error: {0}")] RecvError(#[from] tokio::sync::oneshot::error::RecvError), diff --git a/theseus/src/state/auth_task.rs b/theseus/src/state/auth_task.rs new file mode 100644 index 000000000..8f23e765a --- /dev/null +++ b/theseus/src/state/auth_task.rs @@ -0,0 +1,74 @@ +use crate::launcher::auth::Credentials; +use std::mem; +use tokio::task::JoinHandle; + +// Authentication task +// A wrapper over the authentication task that allows it to be called from the frontend +// without caching the task handle in the frontend + +pub struct AuthTask(Option>>); + +impl AuthTask { + pub fn new() -> AuthTask { + AuthTask(None) + } + + pub async fn begin_auth(&mut self) -> crate::Result { + // Creates a channel to receive the URL + let (tx, rx) = tokio::sync::oneshot::channel::(); + let task = tokio::spawn(crate::auth::authenticate(tx)); + + // If receiver is dropped, try to get Hydra error + let url = rx.await; + let url = match url { + Ok(url) => url, + Err(e) => { + task.await??; + return Err(e.into()); // truly a dropped receiver + } + }; + + // Flow is going, store in state and return + self.0 = Some(task); + + Ok(url) + } + + pub async fn await_auth_completion( + &mut self, + ) -> crate::Result { + // Gets the task handle from the state, replacing with None + let task = mem::replace(&mut self.0, None); + + // Waits for the task to complete, and returns the credentials + let credentials = task + .ok_or_else(|| AuthTaskError::TaskMissing)? + .await + .map_err(AuthTaskError::from)??; + + Ok(credentials) + } + + pub async fn cancel(&mut self) { + // Gets the task handle from the state, replacing with None + let task = mem::replace(&mut self.0, None); + if let Some(task) = task { + // Cancels the task + task.abort(); + } + } +} + +impl Default for AuthTask { + fn default() -> Self { + Self::new() + } +} + +#[derive(thiserror::Error, Debug)] +pub enum AuthTaskError { + #[error("Authentication task was aborted or missing")] + TaskMissing, + #[error("Join handle error")] + JoinHandleError(#[from] tokio::task::JoinError), +} diff --git a/theseus/src/state/mod.rs b/theseus/src/state/mod.rs index 21635f43e..114b659a7 100644 --- a/theseus/src/state/mod.rs +++ b/theseus/src/state/mod.rs @@ -25,6 +25,9 @@ pub use self::users::*; mod children; pub use self::children::*; +mod auth_task; +pub use self::auth_task::*; + // Global state static LAUNCHER_STATE: OnceCell> = OnceCell::const_new(); pub struct State { @@ -39,8 +42,10 @@ pub struct State { // TODO: settings API /// Launcher configuration pub settings: RwLock, - /// Reference to process children + /// Reference to minecraft process children pub children: RwLock, + /// Authentication flow + pub auth_flow: RwLock, /// Launcher profile metadata pub(crate) profiles: RwLock, /// Launcher user account info @@ -80,6 +85,8 @@ impl State { let children = Children::new(); + let auth_flow = AuthTask::new(); + Ok(Arc::new(Self { database, directories, @@ -89,6 +96,7 @@ impl State { profiles: RwLock::new(profiles), users: RwLock::new(users), children: RwLock::new(children), + auth_flow: RwLock::new(auth_flow), })) } }) diff --git a/theseus_gui/src-tauri/Cargo.toml b/theseus_gui/src-tauri/Cargo.toml index 1868c7041..c55e7fbdd 100644 --- a/theseus_gui/src-tauri/Cargo.toml +++ b/theseus_gui/src-tauri/Cargo.toml @@ -20,11 +20,13 @@ serde = { version = "1.0", features = ["derive"] } tauri = { version = "1.2", features = ["shell-open"] } tokio = { version = "1", features = ["full"] } thiserror = "1.0" - tokio-stream = { version = "0.1", features = ["fs"] } futures = "0.3" daedalus = {version = "0.1.15", features = ["bincode"] } +url = "2.2" +uuid = { version = "1.1", features = ["serde", "v4"] } + [features] # by default Tauri runs in production mode # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL diff --git a/theseus_gui/src-tauri/src/api/auth.rs b/theseus_gui/src-tauri/src/api/auth.rs new file mode 100644 index 000000000..d4d80d641 --- /dev/null +++ b/theseus_gui/src-tauri/src/api/auth.rs @@ -0,0 +1,56 @@ +use crate::api::Result; +use theseus::prelude::*; + +/// Authenticate a user with Hydra - part 1 +/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at) +#[tauri::command] +pub async fn auth_authenticate_begin_flow() -> Result { + Ok(auth::authenticate_begin_flow().await?) +} + +/// Authenticate a user with Hydra - part 2 +/// This completes the authentication flow quasi-synchronously, returning the sign-in credentials +/// (and also adding the credentials to the state) +#[tauri::command] +pub async fn auth_authenticate_await_completion() -> Result { + Ok(auth::authenticate_await_complete_flow().await?) +} + +/// Refresh some credentials using Hydra, if needed +// invoke('auth_refresh',user) +#[tauri::command] +pub async fn auth_refresh( + user: uuid::Uuid, + update_name: bool, +) -> Result { + Ok(auth::refresh(user, update_name).await?) +} + +/// Remove a user account from the database +// invoke('auth_remove_user',user) +#[tauri::command] +pub async fn auth_remove_user(user: uuid::Uuid) -> Result<()> { + Ok(auth::remove_user(user).await?) +} + +/// Check if a user exists in Theseus +// invoke('auth_has_user',user) +#[tauri::command] +pub async fn auth_has_user(user: uuid::Uuid) -> Result { + Ok(auth::has_user(user).await?) +} + +/// Get a copy of the list of all user credentials +// invoke('auth_users',user) +#[tauri::command] +pub async fn auth_users() -> Result> { + Ok(auth::users().await?) +} + +/// Get a user from the UUID +/// Prefer to use refresh instead, as it will refresh the credentials as well +// invoke('auth_users',user) +#[tauri::command] +pub async fn auth_get_user(user: uuid::Uuid) -> Result { + Ok(auth::get_user(user).await?) +} diff --git a/theseus_gui/src-tauri/src/api/mod.rs b/theseus_gui/src-tauri/src/api/mod.rs index 5c900b631..7d3880db0 100644 --- a/theseus_gui/src-tauri/src/api/mod.rs +++ b/theseus_gui/src-tauri/src/api/mod.rs @@ -2,6 +2,8 @@ use serde::ser::SerializeStruct; use serde::{Serialize, Serializer}; use thiserror::Error; +pub mod auth; + pub mod profile; pub mod profile_create; diff --git a/theseus_gui/src-tauri/src/api/profile.rs b/theseus_gui/src-tauri/src/api/profile.rs index b0f19c880..b08726305 100644 --- a/theseus_gui/src-tauri/src/api/profile.rs +++ b/theseus_gui/src-tauri/src/api/profile.rs @@ -6,27 +6,27 @@ use theseus::prelude::*; // invoke('profile_add',profile) #[tauri::command] pub async fn profile_add(profile: Profile) -> Result<()> { - let res = profile::add(profile).await?; + profile::add(profile).await?; State::sync().await?; - Ok(res) + Ok(()) } // Add a path as a profile in-memory // invoke('profile_add_path',path) #[tauri::command] pub async fn profile_add_path(path: &Path) -> Result<()> { - let res = profile::add_path(path).await?; + profile::add_path(path).await?; State::sync().await?; - Ok(res) + Ok(()) } // Remove a profile // invoke('profile_add_path',path) #[tauri::command] pub async fn profile_remove(path: &Path) -> Result<()> { - let res = profile::remove(path).await?; + profile::remove(path).await?; State::sync().await?; - Ok(res) + Ok(()) } // Get a profile by path @@ -77,15 +77,15 @@ pub async fn profile_run( ) -> Result { let proc_lock = profile::run(path, &credentials).await?; let pid = proc_lock.read().await.id().ok_or_else(|| { - theseus::Error::from(theseus::ErrorKind::LauncherError(format!( - "Process failed to stay open." - ))) + theseus::Error::from(theseus::ErrorKind::LauncherError( + "Process failed to stay open.".to_string(), + )) })?; Ok(pid) } // Run Minecraft using a profile, and wait for the result -// invoke('profile_wait_for', path, credentials) +// invoke('profile_run_wait', path, credentials) #[tauri::command] pub async fn profile_run_wait( path: &Path, @@ -101,7 +101,7 @@ pub async fn profile_run_wait( #[tauri::command] pub async fn profile_wait_for(pid: u32) -> Result<()> { let st = State::get().await?; - if let Some(proc_lock) = st.children.blocking_read().get(&pid) { + if let Some(proc_lock) = st.children.read().await.get(&pid) { let mut proc = proc_lock.write().await; return Ok(profile::wait_for(&mut proc).await?); } @@ -114,8 +114,7 @@ pub async fn profile_wait_for(pid: u32) -> Result<()> { #[tauri::command] pub async fn profile_kill(pid: u32) -> Result<()> { let st = State::get().await?; - let st = State::get().await?; - if let Some(proc_lock) = st.children.blocking_read().get(&pid) { + if let Some(proc_lock) = st.children.read().await.get(&pid) { let mut proc = proc_lock.write().await; return Ok(profile::kill(&mut proc).await?); } diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index d8482b3a7..82b2c0613 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -29,6 +29,15 @@ fn main() { api::profile::profile_list, api::profile::profile_run, api::profile::profile_run_wait, + api::profile::profile_kill, + api::profile::profile_wait_for, + api::auth::auth_authenticate_begin_flow, + api::auth::auth_authenticate_await_completion, + api::auth::auth_refresh, + api::auth::auth_remove_user, + api::auth::auth_has_user, + api::auth::auth_users, + api::auth::auth_get_user, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/theseus_gui/src/helpers/auth.js b/theseus_gui/src/helpers/auth.js new file mode 100644 index 000000000..e85720e4e --- /dev/null +++ b/theseus_gui/src/helpers/auth.js @@ -0,0 +1,58 @@ +/** + * All theseus API calls return serialized values (both return values and errors); + * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized, + * and deserialized into a usable JS object. + */ +import { invoke } from '@tauri-apps/api/tauri' + +// Example function: +// User goes to auth_url to complete flow, and when completed, authenticate_await_completion() returns the credentials +// export async function authenticate() { +// const auth_url = await authenticate_begin_flow() +// console.log(auth_url) +// await authenticate_await_completion() +// } + +/// Authenticate a user with Hydra - part 1 +/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at) +export async function authenticate_begin_flow() { + return await invoke('auth_authenticate_begin_flow') +} + +/// Authenticate a user with Hydra - part 2 +/// This completes the authentication flow quasi-synchronously, returning the sign-in credentials +/// (and also adding the credentials to the state) +export async function authenticate_await_completion() { + return await invoke('auth_authenticate_await_completion') +} + +/// Refresh some credentials using Hydra, if needed +// user is UUID +// update_name is bool +export async function refresh(user, update_name) { + return await invoke('auth_refresh', user, update_name) +} + +/// Remove a user account from the database +// user is UUID +export async function remove_user(user) { + return await invoke('auth_remove_user', user) +} + +// Add a path as a profile in-memory +// user is UUID +export async function has_user(user) { + return await invoke('auth_has_user', user) +} + +// Remove a profile +export async function users() { + return await invoke('auth_users') +} + +// Get a user by UUID +// Prefer to use refresh() instead of this because it will refresh the credentials +// user is UUID +export async function get_user(user) { + return await invoke('auth_get_user', user) +} diff --git a/theseus_playground/Cargo.toml b/theseus_playground/Cargo.toml index 4a0c6637c..eec8421dc 100644 --- a/theseus_playground/Cargo.toml +++ b/theseus_playground/Cargo.toml @@ -20,3 +20,4 @@ dunce = "1.0.3" tokio-stream = { version = "0.1", features = ["fs"] } futures = "0.3" daedalus = {version = "0.1.15", features = ["bincode"] } +uuid = { version = "1.1", features = ["serde", "v4"] } diff --git a/theseus_playground/src/main.rs b/theseus_playground/src/main.rs index 18b561e5e..6a6dc8902 100644 --- a/theseus_playground/src/main.rs +++ b/theseus_playground/src/main.rs @@ -7,30 +7,22 @@ use dunce::canonicalize; use std::path::Path; use theseus::{prelude::*, profile_create::profile_create}; use tokio::process::Child; -use tokio::sync::{oneshot, RwLockWriteGuard}; +use tokio::sync::RwLockWriteGuard; -// We use this function directly to call authentication procedure -// Note: "let url = match url" logic is handled differently, so that if there is a rate limit in the other set causing that one to end early, -// we can see the error message in this thread rather than a Recv error on 'rx' when the receiver is mysteriously droppped +// A simple Rust implementation of the authentication run +// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend) +// 2) open the URL in a browser +// 3) call the authenticate_await_complete_flow() function to get the credentials (like you would in the frontend) pub async fn authenticate_run() -> theseus::Result { - println!("Adding new user account to Theseus"); println!("A browser window will now open, follow the login flow there."); + let url = auth::authenticate_begin_flow().await?; - let (tx, rx) = oneshot::channel::(); - let flow = tokio::spawn(auth::authenticate(tx)); - - let url = rx.await; - let url = match url { - Ok(url) => url, - Err(e) => { - flow.await.unwrap()?; - return Err(e.into()); - } - }; println!("URL {}", url.as_str()); webbrowser::open(url.as_str())?; - let credentials = flow.await.unwrap()?; + + let credentials = auth::authenticate_await_complete_flow().await?; State::sync().await?; + println!("Logged in user {}.", credentials.username); Ok(credentials) } @@ -92,28 +84,44 @@ async fn main() -> theseus::Result<()> { .await?; State::sync().await?; - println!("Authenticating."); - // Attempt to create credentials and run. - let proc_lock = match authenticate_run().await { + // Attempt to get the default user, if it exists, and refresh their credentials + let default_user_uuid = { + let settings = st.settings.read().await; + settings.default_user.clone() + }; + let credentials = if let Some(uuid) = default_user_uuid { + println!("Attempting to refresh existing authentication."); + auth::refresh(uuid, false).await + } else { + println!("Freshly authenticating."); + authenticate_run().await + }; + + // Check attempt to get Credentials + // If successful, run the profile and store the RwLock to the process + let proc_lock = match credentials { Ok(credentials) => { - println!("Running."); + println!("Preparing to run Minecraft."); profile::run(&canonicalize(&profile_path)?, &credentials).await } Err(e) => { + // If Hydra could not be accessed, for testing, attempt to load credentials from disk and do the same println!("Could not authenticate: {}.\nAttempting stored authentication.",e); - // Attempt to load credentials if Hydra is down/rate limit hit - let users = auth::users().await.unwrap(); - let credentials = users.first().unwrap(); - - println!("Running."); + let users = auth::users().await.expect( + "Could not access any stored users- state was dropped.", + ); + let credentials = users + .first() + .expect("Hydra failed, and no stored users were found."); + println!("Preparing to run Minecraft."); profile::run(&canonicalize(&profile_path)?, credentials).await } }?; - println!("Started. Waiting..."); + // Spawn a thread and hold the lock to the process until it ends + println!("Started Minecraft. Waiting for process to end..."); let mut proc: RwLockWriteGuard = proc_lock.write().await; profile::wait_for(&mut proc).await?; - // Run MC Ok(()) }