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 <jaiagr+gpg@pm.me>
This commit is contained in:
Wyatt Verchere 2023-08-17 17:26:21 -07:00 committed by GitHub
parent 49bfb0637f
commit 6d9d403e7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 704 additions and 290 deletions

13
Cargo.lock generated
View File

@ -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"

View File

@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.5.1"
version = "0.5.2"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
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" }

View File

@ -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<url::Url> {
pub async fn authenticate_begin_flow() -> crate::Result<DeviceLoginSuccess> {
let url = AuthTask::begin_auth().await?;
Ok(url)
}
@ -20,8 +19,7 @@ pub async fn authenticate_begin_flow() -> crate::Result<url::Url> {
/// 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<String>)> {
pub async fn authenticate_await_complete_flow() -> crate::Result<Credentials> {
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<url::Url>,
) -> crate::Result<(Credentials, Option<String>)> {
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]

View File

@ -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<Credentials> {
// 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)
}
}
}

View File

@ -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<DeviceLoginSuccess> {
// 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(&params).send().await?;
match req.status() {
reqwest::StatusCode::OK => Ok(req.json().await?),
_ => {
let microsoft_error = req.json::<MicrosoftError>().await?;
Err(crate::ErrorKind::HydraError(format!(
"Error from Microsoft: {:?}",
microsoft_error.error_description
))
.into())
}
}
}

View File

@ -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<u64>,
}

View File

@ -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<OauthSuccess> {
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(&params)
.send()
.await?;
match resp.status() {
StatusCode::OK => {
let oauth = resp.json::<OauthSuccess>().await.map_err(|err| {
crate::ErrorKind::HydraError(format!(
"Could not decipher successful response: {}",
err
))
})?;
Ok(oauth)
}
_ => {
let failure =
resp.json::<MicrosoftError>().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())
}
}
}

View File

@ -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<String> {
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::<serde_json::Value>(&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(),
)
}

View File

@ -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;

View File

@ -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<PlayerInfo> {
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)
}

View File

@ -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<OauthSuccess> {
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(&params)
.send()
.await?;
match resp.status() {
StatusCode::OK => {
let oauth =
resp.json::<OauthSuccess>().await.map_err(|err| {
crate::ErrorKind::HydraError(format!(
"Could not decipher successful response: {}",
err
))
})?;
return Ok(oauth);
}
_ => {
let failure =
resp.json::<MicrosoftError>().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());
}
}
}
}
}
}

View File

@ -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<XBLLogin> {
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::<serde_json::Value>(&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 })
}

View File

@ -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<XSTSResponse> {
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::<serde_json::Value>(&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"),
},
))
}
}

View File

@ -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;

View File

@ -1089,5 +1089,5 @@ pub async fn build_folder(
}
pub fn sanitize_profile_name(input: &str) -> String {
input.replace(['/', '\\', ':'], "_")
input.replace(['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>'], "_")
}

View File

@ -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")

View File

@ -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<T> {
if let Ok(err) = serde_json::from_slice::<Self>(data) {
Err(crate::ErrorKind::HydraError(err.error).as_error())
} else {
Ok(serde_json::from_slice::<T>(data)?)
}
}
}
#[derive(Deserialize)]
struct LoginCodeJSON {
login_code: String,
}
#[derive(Deserialize)]
struct TokenJSON {
token: String,
refresh_token: String,
expires_after: u32,
flow: Option<String>,
}
#[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<S: AsyncRead + AsyncWrite + Unpin> {
socket: ws::WebSocketStream<S>,
}
impl HydraAuthFlow<ws::tokio::ConnectStream> {
pub async fn new() -> crate::Result<Self> {
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<Utc>,
) -> Self {
Self {
id,
username,
access_token,
refresh_token,
expires,
_ctor_scope: std::marker::PhantomData,
}
}
pub async fn prepare_login_url(&mut self) -> crate::Result<Url> {
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::<LoginCodeJSON>(&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<String>)> {
// 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::<TokenJSON>(&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::<TokenJSON>(
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<ProfileInfoJSON> {
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)
}

View File

@ -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<JoinHandle<crate::Result<(Credentials, Option<String>)>>>,
Option<JoinHandle<crate::Result<Credentials>>>,
);
impl AuthTask {
@ -16,32 +19,24 @@ impl AuthTask {
AuthTask(None)
}
pub async fn begin_auth() -> crate::Result<url::Url> {
pub async fn begin_auth() -> crate::Result<DeviceLoginSuccess> {
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::<url::Url>();
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<String>)> {
pub async fn await_auth_completion() -> crate::Result<Credentials> {
// Gets the task handle from the state, replacing with None
let task = {
let state = crate::State::get().await?;

View File

@ -20,7 +20,7 @@ pub struct IoSemaphore(pub RwLock<Semaphore>);
pub struct FetchSemaphore(pub RwLock<Semaphore>);
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)",

View File

@ -1,6 +1,6 @@
[package]
name = "theseus_cli"
version = "0.5.1"
version = "0.5.2"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"

View File

@ -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::<url::Url>();
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(())
}
}

View File

@ -1,7 +1,7 @@
{
"name": "theseus_gui",
"private": true,
"version": "0.5.1",
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.5.1"
version = "0.5.2"
description = "A Tauri App"
authors = ["you"]
license = ""

View File

@ -1,6 +1,6 @@
use crate::api::Result;
use tauri::plugin::TauriPlugin;
use theseus::prelude::*;
use theseus::{hydra::init::DeviceLoginSuccess, prelude::*};
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("auth")
@ -20,7 +20,7 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
/// 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<url::Url> {
pub async fn auth_authenticate_begin_flow() -> Result<DeviceLoginSuccess> {
Ok(auth::authenticate_begin_flow().await?)
}
@ -28,8 +28,7 @@ pub async fn auth_authenticate_begin_flow() -> Result<url::Url> {
/// 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<String>)> {
pub async fn auth_authenticate_await_completion() -> Result<Credentials> {
Ok(auth::authenticate_await_complete_flow().await?)
}

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "Modrinth App",
"version": "0.5.1"
"version": "0.5.2"
},
"tauri": {
"allowlist": {

View File

@ -60,28 +60,33 @@
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
<p>
Sign into Microsoft with your browser. If your browser didn't open, you can copy and open
the link below, or scan the QR code with your device.
</p>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<div class="label">Copy this code</div>
<div class="code-text">
<div class="code">
{{ loginCode }}
</div>
<Button
v-tooltip="'Copy link'"
v-tooltip="'Copy code'"
icon-only
large
color="raised"
@click="() => navigator.clipboard.writeText(loginUrl)"
@click="() => clipboardWrite(loginCode)"
>
<ClipboardCopyIcon />
</Button>
</div>
<div class="button-row">
<Button @click="openUrl">
<div>And enter it on Microsoft's website to sign in.</div>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<Button
v-tooltip="'Open link'"
icon-only
color="raised"
@click="() => clipboardWrite(loginUrl)"
>
<GlobeIcon />
Open link
</Button>
<Button class="transparent" @click="loginModal.hide"> Cancel </Button>
</div>
</div>
</div>
@ -109,7 +114,6 @@ import {
} from '@/helpers/auth'
import { get, set } from '@/helpers/settings'
import { handleError } from '@/store/state.js'
import { get as getCreds, login_minecraft } from '@/helpers/mr_auth'
import { mixpanel_track } from '@/helpers/mixpanel'
import QrcodeVue from 'qrcode.vue'
@ -123,6 +127,8 @@ defineProps({
const emit = defineEmits(['change'])
const loginCode = ref(null)
const settings = ref({})
const accounts = ref([])
const loginUrl = ref('')
@ -151,34 +157,30 @@ async function setAccount(account) {
emit('change')
}
async function login() {
const url = await authenticate_begin_flow().catch(handleError)
loginUrl.value = url
const clipboardWrite = async (a) => {
navigator.clipboard.writeText(a)
}
async function login() {
const loginSuccess = await authenticate_begin_flow().catch(handleError)
loginModal.value.show()
loginCode.value = loginSuccess.user_code
loginUrl.value = loginSuccess.verification_uri
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
path: loginSuccess.verification_uri,
},
})
loginModal.value.show()
const loggedIn = await authenticate_await_completion().catch(handleError)
loginModal.value.hide()
if (loggedIn && loggedIn[0]) {
await setAccount(loggedIn[0])
if (loggedIn) {
await setAccount(loggedIn)
await refreshValues()
const creds = await getCreds().catch(handleError)
if (!creds) {
try {
await login_minecraft(loggedIn[1])
} catch (err) {
/* empty */
}
}
}
loginModal.value.hide()
@ -382,17 +384,42 @@ onBeforeUnmount(() => {
flex-direction: row;
gap: var(--gap-lg);
align-items: center;
padding: var(--gap-lg);
padding: var(--gap-xl);
.modal-text {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
h2,
p {
margin: 0;
}
.code-text {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
align-items: center;
.code {
background-color: var(--color-bg);
border-radius: var(--radius-md);
border: solid 1px var(--color-button-bg);
font-family: var(--mono-font);
letter-spacing: var(--gap-md);
color: var(--color-contrast);
font-size: 2rem;
font-weight: bold;
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
}
.btn {
width: 2.5rem;
height: 2.5rem;
}
}
}
}
@ -404,4 +431,17 @@ onBeforeUnmount(() => {
.modal {
position: absolute;
}
.code {
color: var(--color-brand);
padding: 0.05rem 0.1rem;
// row not column
display: flex;
.card {
background: var(--color-base);
color: var(--color-contrast);
padding: 0.5rem 1rem;
}
}
</style>

View File

@ -9,6 +9,8 @@ import QrcodeVue from 'qrcode.vue'
const loginUrl = ref(null)
const loginModal = ref()
const loginCode = ref(null)
const finalizedLogin = ref(false)
const props = defineProps({
nextPage: {
@ -22,25 +24,28 @@ const props = defineProps({
})
async function login() {
const url = await authenticate_begin_flow().catch(handleError)
loginUrl.value = url
const loginSuccess = await authenticate_begin_flow().catch(handleError)
loginUrl.value = loginSuccess.verification_uri
loginCode.value = loginSuccess.user_code
loginModal.value.show()
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
path: loginSuccess.verification_uri,
},
})
const loggedIn = await authenticate_await_completion().catch(handleError)
loginModal.value.hide()
props.nextPage(loggedIn[1])
const settings = await get().catch(handleError)
settings.default_user = loggedIn[0].id
settings.default_user = loggedIn.id
await set(settings).catch(handleError)
finalizedLogin.value = true
await mixpanel.track('AccountLogIn')
props.nextPage()
}
const openUrl = async () => {
@ -52,6 +57,10 @@ const openUrl = async () => {
},
})
}
const clipboardWrite = async (a) => {
navigator.clipboard.writeText(a)
}
</script>
<template>
@ -80,9 +89,6 @@ const openUrl = async () => {
<LogInIcon v-if="!finalizedLogin" />
{{ finalizedLogin ? 'Next' : 'Sign in' }}
</Button>
<Button v-if="loginUrl" class="transparent" @click="loginModal.show()">
Browser didn't open?
</Button>
</div>
<Button class="transparent" large @click="nextPage()"> Next </Button>
</div>
@ -92,28 +98,28 @@ const openUrl = async () => {
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
<p>
Sign into Microsoft with your browser. If your browser didn't open, you can copy and open
the link below, or scan the QR code with your device.
</p>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<div class="label">Copy this code</div>
<div class="code-text">
<div class="code">
{{ loginCode }}
</div>
<Button
v-tooltip="'Copy link'"
v-tooltip="'Copy code'"
icon-only
large
color="raised"
@click="() => navigator.clipboard.writeText(loginUrl)"
@click="() => clipboardWrite(loginCode)"
>
<ClipboardCopyIcon />
</Button>
</div>
<div class="button-row">
<Button @click="openUrl">
<div>And enter it on Microsoft's website to sign in.</div>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<Button v-tooltip="'Open link'" icon-only color="raised" @click="openUrl">
<GlobeIcon />
Open link
</Button>
<Button class="transparent" @click="loginModal.hide"> Cancel </Button>
</div>
</div>
</div>
@ -196,6 +202,7 @@ const openUrl = async () => {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
h2,
p {
@ -204,6 +211,30 @@ const openUrl = async () => {
}
}
.code-text {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
align-items: center;
.code {
background-color: var(--color-bg);
border-radius: var(--radius-md);
border: solid 1px var(--color-button-bg);
font-family: var(--mono-font);
letter-spacing: var(--gap-md);
color: var(--color-contrast);
font-size: 2rem;
font-weight: bold;
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
}
.btn {
width: 2.5rem;
height: 2.5rem;
}
}
.sticker {
width: 100%;
max-width: 25rem;
@ -217,4 +248,17 @@ const openUrl = async () => {
gap: var(--gap-sm);
align-items: center;
}
.code {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
.card {
background: var(--color-base);
color: var(--color-contrast);
padding: 0.5rem 1rem;
margin-top: 0.5rem;
}
}
</style>

View File

@ -15,7 +15,9 @@ import { invoke } from '@tauri-apps/api/tauri'
/// Authenticate a user with Hydra - part 1
/// This begins the authentication flow quasi-synchronously
/// This returns a URL to be opened in a browser
/// This returns a DeviceLoginSuccess object, with two relevant fields:
/// - verification_uri: the URL to go to to complete the flow
/// - user_code: the code to enter on the verification_uri page
export async function authenticate_begin_flow() {
return await invoke('plugin:auth|auth_authenticate_begin_flow')
}

View File

@ -15,17 +15,18 @@ use tokio::time::{sleep, Duration};
// 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<Credentials> {
println!("A browser window will now open, follow the login flow there.");
let url = auth::authenticate_begin_flow().await?;
let login = auth::authenticate_begin_flow().await?;
println!("URL {}", url.as_str());
webbrowser::open(url.as_str())
.map_err(|e| IOError::with_path(e, url.as_str()))?;
println!("URL {}", login.verification_uri.as_str());
println!("Code {}", login.user_code.as_str());
webbrowser::open(login.verification_uri.as_str())
.map_err(|e| IOError::with_path(e, login.verification_uri.as_str()))?;
let credentials = auth::authenticate_await_complete_flow().await?;
State::sync().await?;
println!("Logged in user {}.", credentials.0.username);
Ok(credentials.0)
println!("Logged in user {}.", credentials.username);
Ok(credentials)
}
#[tokio::main]