Skip to content

Commit

Permalink
Hydra local (#594)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Aug 18, 2023
1 parent 49bfb06 commit 6d9d403
Show file tree
Hide file tree
Showing 29 changed files with 702 additions and 288 deletions.
13 changes: 10 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion theseus/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.5.1"
version = "0.5.2"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"

Expand All @@ -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" }
Expand Down
41 changes: 3 additions & 38 deletions theseus/src/api/auth.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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]
Expand Down
85 changes: 85 additions & 0 deletions theseus/src/api/hydra/complete.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
45 changes: 45 additions & 0 deletions theseus/src/api/hydra/init.rs
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
15 changes: 15 additions & 0 deletions theseus/src/api/hydra/mod.rs
Original file line number Diff line number Diff line change
@@ -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>,
}
60 changes: 60 additions & 0 deletions theseus/src/api/hydra/refresh.rs
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
29 changes: 29 additions & 0 deletions theseus/src/api/hydra/stages/bearer_token.rs
Original file line number Diff line number Diff line change
@@ -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(),
)
}
7 changes: 7 additions & 0 deletions theseus/src/api/hydra/stages/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 6d9d403

Please sign in to comment.