From 6d9d403e7b1986900d247036a23c9be40d933dab Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Thu, 17 Aug 2023 17:26:21 -0700 Subject: [PATCH] Hydra local (#594) * initial commit * merge fixes * added sanitizing * linter * Improve sign in UI * simple simple! * bump version --------- Co-authored-by: CodexAdrian <83074853+CodexAdrian@users.noreply.github.com> Co-authored-by: Jai A --- Cargo.lock | 13 +- theseus/Cargo.toml | 3 +- theseus/src/api/auth.rs | 41 +--- theseus/src/api/hydra/complete.rs | 85 +++++++++ theseus/src/api/hydra/init.rs | 45 +++++ theseus/src/api/hydra/mod.rs | 15 ++ theseus/src/api/hydra/refresh.rs | 60 ++++++ theseus/src/api/hydra/stages/bearer_token.rs | 29 +++ theseus/src/api/hydra/stages/mod.rs | 7 + theseus/src/api/hydra/stages/player_info.rs | 33 ++++ theseus/src/api/hydra/stages/poll_response.rs | 91 +++++++++ theseus/src/api/hydra/stages/xbl_signin.rs | 55 ++++++ theseus/src/api/hydra/stages/xsts_token.rs | 56 ++++++ theseus/src/api/mod.rs | 1 + theseus/src/api/profile/mod.rs | 2 +- theseus/src/launcher/args.rs | 4 +- theseus/src/launcher/auth.rs | 179 +++--------------- theseus/src/state/auth_task.rs | 33 ++-- theseus/src/util/fetch.rs | 2 +- theseus_cli/Cargo.toml | 2 +- theseus_cli/src/subcommands/user.rs | 18 +- theseus_gui/package.json | 2 +- theseus_gui/src-tauri/Cargo.toml | 2 +- theseus_gui/src-tauri/src/api/auth.rs | 7 +- theseus_gui/src-tauri/tauri.conf.json | 2 +- .../src/components/ui/AccountsCard.vue | 104 ++++++---- .../src/components/ui/tutorial/LoginCard.vue | 86 +++++++-- theseus_gui/src/helpers/auth.js | 4 +- theseus_playground/src/main.rs | 13 +- 29 files changed, 704 insertions(+), 290 deletions(-) create mode 100644 theseus/src/api/hydra/complete.rs create mode 100644 theseus/src/api/hydra/init.rs create mode 100644 theseus/src/api/hydra/mod.rs create mode 100644 theseus/src/api/hydra/refresh.rs create mode 100644 theseus/src/api/hydra/stages/bearer_token.rs create mode 100644 theseus/src/api/hydra/stages/mod.rs create mode 100644 theseus/src/api/hydra/stages/player_info.rs create mode 100644 theseus/src/api/hydra/stages/poll_response.rs create mode 100644 theseus/src/api/hydra/stages/xbl_signin.rs create mode 100644 theseus/src/api/hydra/stages/xsts_token.rs diff --git a/Cargo.lock b/Cargo.lock index 9fe68b1f0..3a45e7ff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4609,7 +4609,7 @@ dependencies = [ [[package]] name = "theseus" -version = "0.5.1" +version = "0.5.2" dependencies = [ "async-recursion", "async-tungstenite", @@ -4646,6 +4646,7 @@ dependencies = [ "tracing-error 0.1.2", "tracing-subscriber 0.2.25", "url", + "urlencoding", "uuid 1.4.0", "whoami", "winreg 0.50.0", @@ -4654,7 +4655,7 @@ dependencies = [ [[package]] name = "theseus_cli" -version = "0.5.1" +version = "0.5.2" dependencies = [ "argh", "color-eyre", @@ -4681,7 +4682,7 @@ dependencies = [ [[package]] name = "theseus_gui" -version = "0.5.1" +version = "0.5.2" dependencies = [ "chrono", "cocoa", @@ -5216,6 +5217,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index d41ad9a08..c071b9be8 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus" -version = "0.5.1" +version = "0.5.2" authors = ["Jai A "] edition = "2018" @@ -21,6 +21,7 @@ uuid = { version = "1.1", features = ["serde", "v4"] } zip = "0.6.5" async_zip = { version = "0.0.13", features = ["full"] } tempfile = "3.5.0" +urlencoding = "2.1.3" chrono = { version = "0.4.19", features = ["serde"] } daedalus = { version = "0.1.23" } diff --git a/theseus/src/api/auth.rs b/theseus/src/api/auth.rs index 91807b7c4..d907feaf0 100644 --- a/theseus/src/api/auth.rs +++ b/theseus/src/api/auth.rs @@ -1,7 +1,6 @@ //! Authentication flow interface -use crate::{launcher::auth as inner, State}; +use crate::{hydra::init::DeviceLoginSuccess, launcher::auth as inner, State}; use chrono::Utc; -use tokio::sync::oneshot; use crate::state::AuthTask; pub use inner::Credentials; @@ -11,7 +10,7 @@ pub use inner::Credentials; /// 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 { +pub async fn authenticate_begin_flow() -> crate::Result { let url = AuthTask::begin_auth().await?; Ok(url) } @@ -20,8 +19,7 @@ pub async fn authenticate_begin_flow() -> crate::Result { /// 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<(Credentials, Option)> { +pub async fn authenticate_await_complete_flow() -> crate::Result { let credentials = AuthTask::await_auth_completion().await?; Ok(credentials) } @@ -31,39 +29,6 @@ pub async fn cancel_flow() -> crate::Result<()> { AuthTask::cancel().await } -/// 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 -/// with the ability to cancel in case the browser is closed before finishing -#[tracing::instrument] -#[theseus_macros::debug_pin] -pub async fn authenticate( - browser_url: oneshot::Sender, -) -> crate::Result<(Credentials, Option)> { - let mut flow = inner::HydraAuthFlow::new().await?; - let state = State::get().await?; - - let url = flow.prepare_login_url().await?; - browser_url.send(url).map_err(|url| { - crate::ErrorKind::OtherError(format!( - "Error sending browser url to parent: {url}" - )) - })?; - - let credentials = flow.extract_credentials(&state.fetch_semaphore).await?; - { - let mut users = state.users.write().await; - users.insert(&credentials.0).await?; - } - - if state.settings.read().await.default_user.is_none() { - let mut settings = state.settings.write().await; - settings.default_user = Some(credentials.0.id); - } - - Ok(credentials) -} - /// Refresh some credentials using Hydra, if needed /// This is the primary desired way to get credentials, as it will also refresh them. #[tracing::instrument] diff --git a/theseus/src/api/hydra/complete.rs b/theseus/src/api/hydra/complete.rs new file mode 100644 index 000000000..7b60a6e89 --- /dev/null +++ b/theseus/src/api/hydra/complete.rs @@ -0,0 +1,85 @@ +//! Main authentication flow for Hydra + +use serde::Deserialize; + +use crate::prelude::Credentials; + +use super::stages::{ + bearer_token, player_info, poll_response, xbl_signin, xsts_token, +}; + +#[derive(Debug, Deserialize)] +pub struct OauthFailure { + pub error: String, +} + +pub struct SuccessfulLogin { + pub name: String, + pub icon: String, + pub token: String, + pub refresh_token: String, + pub expires_after: i64, +} + +pub async fn wait_finish(device_code: String) -> crate::Result { + // Loop, polling for response from Microsoft + let oauth = poll_response::poll_response(device_code).await?; + + // Get xbl token from oauth token + let xbl_token = xbl_signin::login_xbl(&oauth.access_token).await?; + + // Get xsts token from xbl token + let xsts_response = xsts_token::fetch_token(&xbl_token.token).await?; + + match xsts_response { + xsts_token::XSTSResponse::Unauthorized(err) => { + Err(crate::ErrorKind::HydraError(format!( + "Error getting XBox Live token: {}", + err + )) + .as_error()) + } + xsts_token::XSTSResponse::Success { token: xsts_token } => { + // Get xsts bearer token from xsts token + let bearer_token = + bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs) + .await + .map_err(|err| { + crate::ErrorKind::HydraError(format!( + "Error getting bearer token: {}", + err + )) + })?; + + // Get player info from bearer token + let player_info = player_info::fetch_info(&bearer_token).await.map_err(|_err| { + crate::ErrorKind::HydraError("No Minecraft account for profile. Make sure you own the game and have set a username through the official Minecraft launcher." + .to_string()) + })?; + + // Create credentials + let credentials = Credentials::new( + uuid::Uuid::parse_str(&player_info.id)?, // get uuid from player_info.id which is a String + player_info.name, + bearer_token, + oauth.refresh_token, + chrono::Utc::now() + + chrono::Duration::seconds(oauth.expires_in), + ); + + // Put credentials into state + let state = crate::State::get().await?; + { + let mut users = state.users.write().await; + users.insert(&credentials).await?; + } + + if state.settings.read().await.default_user.is_none() { + let mut settings = state.settings.write().await; + settings.default_user = Some(credentials.id); + } + + Ok(credentials) + } + } +} diff --git a/theseus/src/api/hydra/init.rs b/theseus/src/api/hydra/init.rs new file mode 100644 index 000000000..e27996aa1 --- /dev/null +++ b/theseus/src/api/hydra/init.rs @@ -0,0 +1,45 @@ +//! Login route for Hydra, redirects to the Microsoft login page before going to the redirect route +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::{hydra::MicrosoftError, util::fetch::REQWEST_CLIENT}; + +use super::MICROSOFT_CLIENT_ID; + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeviceLoginSuccess { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, + pub message: String, +} + +pub async fn init() -> crate::Result { + // Get the initial URL + let client_id = MICROSOFT_CLIENT_ID; + + // Get device code + // Define the parameters + let mut params = HashMap::new(); + params.insert("client_id", client_id); + params.insert("scope", "XboxLive.signin offline_access"); + + // urlencoding::encode("XboxLive.signin offline_access")); + let req = REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode") + .header("Content-Type", "application/x-www-form-urlencoded").form(¶ms).send().await?; + + match req.status() { + reqwest::StatusCode::OK => Ok(req.json().await?), + _ => { + let microsoft_error = req.json::().await?; + Err(crate::ErrorKind::HydraError(format!( + "Error from Microsoft: {:?}", + microsoft_error.error_description + )) + .into()) + } + } +} diff --git a/theseus/src/api/hydra/mod.rs b/theseus/src/api/hydra/mod.rs new file mode 100644 index 000000000..8de3c862c --- /dev/null +++ b/theseus/src/api/hydra/mod.rs @@ -0,0 +1,15 @@ +pub mod complete; +pub mod init; +pub mod refresh; +mod stages; + +use serde::Deserialize; + +const MICROSOFT_CLIENT_ID: &str = "c4502edb-87c6-40cb-b595-64a280cf8906"; + +#[derive(Deserialize)] +pub struct MicrosoftError { + pub error: String, + pub error_description: String, + pub error_codes: Vec, +} diff --git a/theseus/src/api/hydra/refresh.rs b/theseus/src/api/hydra/refresh.rs new file mode 100644 index 000000000..4caf19837 --- /dev/null +++ b/theseus/src/api/hydra/refresh.rs @@ -0,0 +1,60 @@ +use std::collections::HashMap; + +use reqwest::StatusCode; +use serde::Deserialize; + +use crate::{ + hydra::{MicrosoftError, MICROSOFT_CLIENT_ID}, + util::fetch::REQWEST_CLIENT, +}; + +#[derive(Debug, Deserialize)] +pub struct OauthSuccess { + pub token_type: String, + pub scope: String, + pub expires_in: i64, + pub access_token: String, + pub refresh_token: String, +} + +pub async fn refresh(refresh_token: String) -> crate::Result { + let mut params = HashMap::new(); + params.insert("grant_type", "refresh_token"); + params.insert("client_id", MICROSOFT_CLIENT_ID); + params.insert("refresh_token", &refresh_token); + + // Poll the URL in a loop until we are successful. + // On an authorization_pending response, wait 5 seconds and try again. + let resp = REQWEST_CLIENT + .post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send() + .await?; + + match resp.status() { + StatusCode::OK => { + let oauth = resp.json::().await.map_err(|err| { + crate::ErrorKind::HydraError(format!( + "Could not decipher successful response: {}", + err + )) + })?; + Ok(oauth) + } + _ => { + let failure = + resp.json::().await.map_err(|err| { + crate::ErrorKind::HydraError(format!( + "Could not decipher failure response: {}", + err + )) + })?; + Err(crate::ErrorKind::HydraError(format!( + "Error refreshing token: {}", + failure.error + )) + .as_error()) + } + } +} diff --git a/theseus/src/api/hydra/stages/bearer_token.rs b/theseus/src/api/hydra/stages/bearer_token.rs new file mode 100644 index 000000000..2edcd6493 --- /dev/null +++ b/theseus/src/api/hydra/stages/bearer_token.rs @@ -0,0 +1,29 @@ +use serde_json::json; + +const MCSERVICES_AUTH_URL: &str = + "https://api.minecraftservices.com/launcher/login"; + +pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result { + let client = reqwest::Client::new(); + let body = client + .post(MCSERVICES_AUTH_URL) + .json(&json!({ + "xtoken": format!("XBL3.0 x={};{}", uhs, token), + "platform": "PC_LAUNCHER" + })) + .send() + .await? + .text() + .await?; + + serde_json::from_str::(&body)? + .get("access_token") + .and_then(serde_json::Value::as_str) + .map(String::from) + .ok_or( + crate::ErrorKind::HydraError(format!( + "Response didn't contain valid bearer token. body: {body}" + )) + .into(), + ) +} diff --git a/theseus/src/api/hydra/stages/mod.rs b/theseus/src/api/hydra/stages/mod.rs new file mode 100644 index 000000000..9ccf23715 --- /dev/null +++ b/theseus/src/api/hydra/stages/mod.rs @@ -0,0 +1,7 @@ +//! MSA authentication stages + +pub mod bearer_token; +pub mod player_info; +pub mod poll_response; +pub mod xbl_signin; +pub mod xsts_token; diff --git a/theseus/src/api/hydra/stages/player_info.rs b/theseus/src/api/hydra/stages/player_info.rs new file mode 100644 index 000000000..45248fd67 --- /dev/null +++ b/theseus/src/api/hydra/stages/player_info.rs @@ -0,0 +1,33 @@ +//! Fetch player info for display +use serde::Deserialize; + +const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile"; + +#[derive(Deserialize)] +pub struct PlayerInfo { + pub id: String, + pub name: String, +} + +impl Default for PlayerInfo { + fn default() -> Self { + Self { + id: "606e2ff0ed7748429d6ce1d3321c7838".to_string(), + name: String::from("???"), + } + } +} + +pub async fn fetch_info(token: &str) -> crate::Result { + let client = reqwest::Client::new(); + let resp = client + .get(PROFILE_URL) + .header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(resp) +} diff --git a/theseus/src/api/hydra/stages/poll_response.rs b/theseus/src/api/hydra/stages/poll_response.rs new file mode 100644 index 000000000..b38435600 --- /dev/null +++ b/theseus/src/api/hydra/stages/poll_response.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; + +use reqwest::StatusCode; +use serde::Deserialize; + +use crate::{ + hydra::{MicrosoftError, MICROSOFT_CLIENT_ID}, + util::fetch::REQWEST_CLIENT, +}; + +#[derive(Debug, Deserialize)] +pub struct OauthSuccess { + pub token_type: String, + pub scope: String, + pub expires_in: i64, + pub access_token: String, + pub refresh_token: String, +} + +pub async fn poll_response(device_code: String) -> crate::Result { + let mut params = HashMap::new(); + params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + params.insert("client_id", MICROSOFT_CLIENT_ID); + params.insert("device_code", &device_code); + + // Poll the URL in a loop until we are successful. + // On an authorization_pending response, wait 5 seconds and try again. + loop { + let resp = REQWEST_CLIENT + .post( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", + ) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send() + .await?; + + match resp.status() { + StatusCode::OK => { + let oauth = + resp.json::().await.map_err(|err| { + crate::ErrorKind::HydraError(format!( + "Could not decipher successful response: {}", + err + )) + })?; + return Ok(oauth); + } + _ => { + let failure = + resp.json::().await.map_err(|err| { + crate::ErrorKind::HydraError(format!( + "Could not decipher failure response: {}", + err + )) + })?; + match failure.error.as_str() { + "authorization_pending" => { + tokio::time::sleep(std::time::Duration::from_secs(2)) + .await; + } + "authorization_declined" => { + return Err(crate::ErrorKind::HydraError( + "Authorization declined".to_string(), + ) + .as_error()); + } + "expired_token" => { + return Err(crate::ErrorKind::HydraError( + "Device code expired".to_string(), + ) + .as_error()); + } + "bad_verification_code" => { + return Err(crate::ErrorKind::HydraError( + "Invalid device code".to_string(), + ) + .as_error()); + } + _ => { + return Err(crate::ErrorKind::HydraError(format!( + "Unknown error: {}", + failure.error + )) + .as_error()); + } + } + } + } + } +} diff --git a/theseus/src/api/hydra/stages/xbl_signin.rs b/theseus/src/api/hydra/stages/xbl_signin.rs new file mode 100644 index 000000000..60b432da1 --- /dev/null +++ b/theseus/src/api/hydra/stages/xbl_signin.rs @@ -0,0 +1,55 @@ +use serde_json::json; + +const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate"; + +// Deserialization +pub struct XBLLogin { + pub token: String, + pub uhs: String, +} + +// Impl +pub async fn login_xbl(token: &str) -> crate::Result { + let client = reqwest::Client::new(); + let body = client + .post(XBL_AUTH_URL) + .header(reqwest::header::ACCEPT, "application/json") + .header("x-xbl-contract-version", "1") + .json(&json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": format!("d={token}") + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + })) + .send() + .await? + .text() + .await?; + + let json = serde_json::from_str::(&body)?; + let token = Some(&json) + .and_then(|it| it.get("Token")?.as_str().map(String::from)) + .ok_or(crate::ErrorKind::HydraError( + "XBL response didn't contain valid token".to_string(), + ))?; + let uhs = Some(&json) + .and_then(|it| { + it.get("DisplayClaims")? + .get("xui")? + .get(0)? + .get("uhs")? + .as_str() + .map(String::from) + }) + .ok_or( + crate::ErrorKind::HydraError( + "XBL response didn't contain valid user hash".to_string(), + ) + .as_error(), + )?; + + Ok(XBLLogin { token, uhs }) +} diff --git a/theseus/src/api/hydra/stages/xsts_token.rs b/theseus/src/api/hydra/stages/xsts_token.rs new file mode 100644 index 000000000..4e1497707 --- /dev/null +++ b/theseus/src/api/hydra/stages/xsts_token.rs @@ -0,0 +1,56 @@ +use serde_json::json; + +const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize"; + +pub enum XSTSResponse { + Unauthorized(String), + Success { token: String }, +} + +pub async fn fetch_token(token: &str) -> crate::Result { + let client = reqwest::Client::new(); + let resp = client + .post(XSTS_AUTH_URL) + .header(reqwest::header::ACCEPT, "application/json") + .json(&json!({ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + token + ] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + })) + .send() + .await?; + let status = resp.status(); + + let body = resp.text().await?; + let json = serde_json::from_str::(&body)?; + + if status.is_success() { + Ok(json + .get("Token") + .and_then(|x| x.as_str().map(String::from)) + .map(|it| XSTSResponse::Success { token: it }) + .unwrap_or(XSTSResponse::Unauthorized( + "XSTS response didn't contain valid token!".to_string(), + ))) + } else { + Ok(XSTSResponse::Unauthorized( + #[allow(clippy::unreadable_literal)] + match json.get("XErr").and_then(|x| x.as_i64()) { + Some(2148916238) => { + String::from("This Microsoft account is underage and is not linked to a family.") + }, + Some(2148916235) => { + String::from("XBOX Live/Minecraft is not available in your country.") + }, + Some(2148916233) => String::from("This account does not have a valid XBOX Live profile. Please buy Minecraft and try again!"), + Some(2148916236) | Some(2148916237) => String::from("This account needs adult verification on Xbox page."), + _ => String::from("Unknown error code"), + }, + )) + } +} diff --git a/theseus/src/api/mod.rs b/theseus/src/api/mod.rs index 9c14efa9d..37ca4b4d1 100644 --- a/theseus/src/api/mod.rs +++ b/theseus/src/api/mod.rs @@ -1,6 +1,7 @@ //! API for interacting with Theseus pub mod auth; pub mod handler; +pub mod hydra; pub mod jre; pub mod logs; pub mod metadata; diff --git a/theseus/src/api/profile/mod.rs b/theseus/src/api/profile/mod.rs index 793b880a7..2c34e00d2 100644 --- a/theseus/src/api/profile/mod.rs +++ b/theseus/src/api/profile/mod.rs @@ -1089,5 +1089,5 @@ pub async fn build_folder( } pub fn sanitize_profile_name(input: &str) -> String { - input.replace(['/', '\\', ':'], "_") + input.replace(['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>'], "_") } diff --git a/theseus/src/launcher/args.rs b/theseus/src/launcher/args.rs index 8cc2e8bff..5ee75e9a8 100644 --- a/theseus/src/launcher/args.rs +++ b/theseus/src/launcher/args.rs @@ -268,8 +268,8 @@ fn parse_minecraft_argument( .replace("${auth_player_name}", username) // TODO: add auth xuid eventually .replace("${auth_xuid}", "0") - .replace("${auth_uuid}", &uuid.hyphenated().to_string()) - .replace("${uuid}", &uuid.hyphenated().to_string()) + .replace("${auth_uuid}", &uuid.simple().to_string()) + .replace("${uuid}", &uuid.simple().to_string()) .replace("${clientid}", "c4502edb-87c6-40cb-b595-64a280cf8906") .replace("${user_properties}", "{}") .replace("${user_type}", "msa") diff --git a/theseus/src/launcher/auth.rs b/theseus/src/launcher/auth.rs index 99f8c7678..c42b8cc76 100644 --- a/theseus/src/launcher/auth.rs +++ b/theseus/src/launcher/auth.rs @@ -1,55 +1,11 @@ //! Authentication flow based on Hydra -use crate::config::MODRINTH_API_URL; -use crate::state::CredentialsStore; -use crate::util::fetch::{fetch_advanced, fetch_json, FetchSemaphore}; -use async_tungstenite as ws; + +use crate::hydra; +use crate::util::fetch::FetchSemaphore; + use chrono::{prelude::*, Duration}; -use futures::prelude::*; -use lazy_static::lazy_static; -use reqwest::Method; + use serde::{Deserialize, Serialize}; -use url::Url; - -lazy_static! { - static ref HYDRA_URL: Url = - Url::parse(&format!("{MODRINTH_API_URL}auth/minecraft/")) - .expect("Hydra URL parse failed"); -} - -// Socket messages -#[derive(Deserialize)] -struct ErrorJSON { - error: String, -} - -impl ErrorJSON { - pub fn unwrap<'a, T: Deserialize<'a>>(data: &'a [u8]) -> crate::Result { - if let Ok(err) = serde_json::from_slice::(data) { - Err(crate::ErrorKind::HydraError(err.error).as_error()) - } else { - Ok(serde_json::from_slice::(data)?) - } - } -} - -#[derive(Deserialize)] -struct LoginCodeJSON { - login_code: String, -} - -#[derive(Deserialize)] -struct TokenJSON { - token: String, - refresh_token: String, - expires_after: u32, - flow: Option, -} - -#[derive(Deserialize)] -struct ProfileInfoJSON { - id: uuid::Uuid, - name: String, -} // Login information #[derive(Serialize, Deserialize, Clone, Debug)] @@ -62,116 +18,39 @@ pub struct Credentials { _ctor_scope: std::marker::PhantomData<()>, } -// Implementation -pub struct HydraAuthFlow { - socket: ws::WebSocketStream, -} - -impl HydraAuthFlow { - pub async fn new() -> crate::Result { - let (socket, _) = ws::tokio::connect_async( - "wss://api.modrinth.com/v2/auth/minecraft/ws", - ) - .await?; - Ok(Self { socket }) +impl Credentials { + pub fn new( + id: uuid::Uuid, + username: String, + access_token: String, + refresh_token: String, + expires: DateTime, + ) -> Self { + Self { + id, + username, + access_token, + refresh_token, + expires, + _ctor_scope: std::marker::PhantomData, + } } - pub async fn prepare_login_url(&mut self) -> crate::Result { - let code_resp = self - .socket - .try_next() - .await? - .ok_or( - crate::ErrorKind::WSClosedError(String::from( - "login socket ID", - )) - .as_error(), - )? - .into_data(); - let code = ErrorJSON::unwrap::(&code_resp)?; - Ok(wrap_ref_builder!( - it = HYDRA_URL.join("init")? => - { it.query_pairs_mut().append_pair("id", &code.login_code); } - )) - } - - pub async fn extract_credentials( - &mut self, - semaphore: &FetchSemaphore, - ) -> crate::Result<(Credentials, Option)> { - // Minecraft bearer token - let token_resp = self - .socket - .try_next() - .await? - .ok_or( - crate::ErrorKind::WSClosedError(String::from( - "login socket ID", - )) - .as_error(), - )? - .into_data(); - let token = ErrorJSON::unwrap::(&token_resp)?; - let expires = - Utc::now() + Duration::seconds(token.expires_after.into()); - - // Get account credentials - let info = fetch_info(&token.token, semaphore).await?; - - // Return structure from response - Ok(( - Credentials { - username: info.name, - id: info.id, - refresh_token: token.refresh_token, - access_token: token.token, - expires, - _ctor_scope: std::marker::PhantomData, - }, - token.flow, - )) + pub fn is_expired(&self) -> bool { + self.expires < Utc::now() } } pub async fn refresh_credentials( credentials: &mut Credentials, - semaphore: &FetchSemaphore, + _semaphore: &FetchSemaphore, ) -> crate::Result<()> { - let resp = fetch_json::( - Method::POST, - &format!("{MODRINTH_API_URL}auth/minecraft/refresh"), - None, - Some(serde_json::json!({ "refresh_token": credentials.refresh_token })), - semaphore, - &CredentialsStore(None), - ) - .await?; + let res = + hydra::refresh::refresh(credentials.refresh_token.clone()).await?; - credentials.access_token = resp.token; - credentials.refresh_token = resp.refresh_token; - credentials.expires = - Utc::now() + Duration::seconds(resp.expires_after.into()); + credentials.access_token = res.access_token; + credentials.refresh_token = res.refresh_token; + credentials.expires = Utc::now() + Duration::seconds(res.expires_in); Ok(()) } - -// Helpers -async fn fetch_info( - token: &str, - semaphore: &FetchSemaphore, -) -> crate::Result { - let result = fetch_advanced( - Method::GET, - "https://api.minecraftservices.com/minecraft/profile", - None, - None, - Some(("Authorization", &format!("Bearer {token}"))), - None, - semaphore, - &CredentialsStore(None), - ) - .await?; - let value = serde_json::from_slice(&result)?; - - Ok(value) -} diff --git a/theseus/src/state/auth_task.rs b/theseus/src/state/auth_task.rs index f73828b9d..c8707518a 100644 --- a/theseus/src/state/auth_task.rs +++ b/theseus/src/state/auth_task.rs @@ -1,4 +1,7 @@ -use crate::launcher::auth::Credentials; +use crate::{ + hydra::{self, init::DeviceLoginSuccess}, + launcher::auth::Credentials, +}; use tokio::task::JoinHandle; @@ -8,7 +11,7 @@ use tokio::task::JoinHandle; pub struct AuthTask( #[allow(clippy::type_complexity)] - Option)>>>, + Option>>, ); impl AuthTask { @@ -16,32 +19,24 @@ impl AuthTask { AuthTask(None) } - pub async fn begin_auth() -> crate::Result { + pub async fn begin_auth() -> crate::Result { let state = crate::State::get().await?; + // Init task, get url + let login = hydra::init::init().await?; - // 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 - } - }; + // Await completion + let task = tokio::spawn(hydra::complete::wait_finish( + login.device_code.clone(), + )); // Flow is going, store in state and return let mut write = state.auth_flow.write().await; write.0 = Some(task); - Ok(url) + Ok(login) } - pub async fn await_auth_completion( - ) -> crate::Result<(Credentials, Option)> { + pub async fn await_auth_completion() -> crate::Result { // Gets the task handle from the state, replacing with None let task = { let state = crate::State::get().await?; diff --git a/theseus/src/util/fetch.rs b/theseus/src/util/fetch.rs index 3637378b2..a826a45c3 100644 --- a/theseus/src/util/fetch.rs +++ b/theseus/src/util/fetch.rs @@ -20,7 +20,7 @@ pub struct IoSemaphore(pub RwLock); pub struct FetchSemaphore(pub RwLock); lazy_static! { - static ref REQWEST_CLIENT: reqwest::Client = { + pub static ref REQWEST_CLIENT: reqwest::Client = { let mut headers = reqwest::header::HeaderMap::new(); let header = reqwest::header::HeaderValue::from_str(&format!( "modrinth/theseus/{} (support@modrinth.com)", diff --git a/theseus_cli/Cargo.toml b/theseus_cli/Cargo.toml index 1bd9be909..7576808ad 100644 --- a/theseus_cli/Cargo.toml +++ b/theseus_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus_cli" -version = "0.5.1" +version = "0.5.2" authors = ["Jai A "] edition = "2018" diff --git a/theseus_cli/src/subcommands/user.rs b/theseus_cli/src/subcommands/user.rs index 01603a237..0c0a5dbf5 100644 --- a/theseus_cli/src/subcommands/user.rs +++ b/theseus_cli/src/subcommands/user.rs @@ -4,7 +4,6 @@ use eyre::Result; use paris::*; use tabled::Tabled; use theseus::prelude::*; -use tokio::sync::oneshot; #[derive(argh::FromArgs, Debug)] #[argh(subcommand, name = "user")] @@ -41,18 +40,23 @@ impl UserAdd { info!("Adding new user account to Theseus"); info!("A browser window will now open, follow the login flow there."); - let (tx, rx) = oneshot::channel::(); - let flow = tokio::spawn(auth::authenticate(tx)); + let login = auth::authenticate_begin_flow().await?; + let flow = tokio::spawn(auth::authenticate_await_complete_flow()); + + info!("Opening browser window at {}", login.verification_uri); + info!("Your code is {}", login.user_code); - let url = rx.await?; match self.browser { - Some(browser) => webbrowser::open_browser(browser, url.as_str()), - None => webbrowser::open(url.as_str()), + Some(browser) => webbrowser::open_browser( + browser, + login.verification_uri.as_str(), + ), + None => webbrowser::open(login.verification_uri.as_str()), }?; let credentials = flow.await??; State::sync().await?; - success!("Logged in user {}.", credentials.0.username); + success!("Logged in user {}.", credentials.username); Ok(()) } } diff --git a/theseus_gui/package.json b/theseus_gui/package.json index 0cb6f3d39..9a2bd45e4 100644 --- a/theseus_gui/package.json +++ b/theseus_gui/package.json @@ -1,7 +1,7 @@ { "name": "theseus_gui", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/theseus_gui/src-tauri/Cargo.toml b/theseus_gui/src-tauri/Cargo.toml index db9f66549..e8f371104 100644 --- a/theseus_gui/src-tauri/Cargo.toml +++ b/theseus_gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus_gui" -version = "0.5.1" +version = "0.5.2" description = "A Tauri App" authors = ["you"] license = "" diff --git a/theseus_gui/src-tauri/src/api/auth.rs b/theseus_gui/src-tauri/src/api/auth.rs index 3a7fded33..95884bf33 100644 --- a/theseus_gui/src-tauri/src/api/auth.rs +++ b/theseus_gui/src-tauri/src/api/auth.rs @@ -1,6 +1,6 @@ use crate::api::Result; use tauri::plugin::TauriPlugin; -use theseus::prelude::*; +use theseus::{hydra::init::DeviceLoginSuccess, prelude::*}; pub fn init() -> TauriPlugin { tauri::plugin::Builder::new("auth") @@ -20,7 +20,7 @@ pub fn init() -> TauriPlugin { /// 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 { +pub async fn auth_authenticate_begin_flow() -> Result { Ok(auth::authenticate_begin_flow().await?) } @@ -28,8 +28,7 @@ pub async fn auth_authenticate_begin_flow() -> Result { /// 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<(Credentials, Option)> { +pub async fn auth_authenticate_await_completion() -> Result { Ok(auth::authenticate_await_complete_flow().await?) } diff --git a/theseus_gui/src-tauri/tauri.conf.json b/theseus_gui/src-tauri/tauri.conf.json index 4bc1b6a5f..9297c8a22 100644 --- a/theseus_gui/src-tauri/tauri.conf.json +++ b/theseus_gui/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Modrinth App", - "version": "0.5.1" + "version": "0.5.2" }, "tauri": { "allowlist": { diff --git a/theseus_gui/src/components/ui/AccountsCard.vue b/theseus_gui/src/components/ui/AccountsCard.vue index bd2f07c9d..5c58558bc 100644 --- a/theseus_gui/src/components/ui/AccountsCard.vue +++ b/theseus_gui/src/components/ui/AccountsCard.vue @@ -60,28 +60,33 @@