1
2
Fork 0
mirror of https://github.com/mat-1/azalea.git synced 2025-08-02 14:26:04 +00:00
refreshing microsoft auth tokens isn't implemented yet, also i haven't tested it
This commit is contained in:
mat 2022-10-16 19:34:56 -05:00
parent cb001fa341
commit fe1fb2dbfd
5 changed files with 273 additions and 75 deletions

11
Cargo.lock generated
View file

@ -119,6 +119,7 @@ dependencies = [
"anyhow",
"azalea-buf",
"azalea-crypto",
"chrono",
"log",
"num-bigint",
"reqwest",
@ -429,9 +430,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.19"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
dependencies = [
"num-integer",
"num-traits",
@ -818,7 +819,7 @@ checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
dependencies = [
"cfg-if",
"libc",
"wasi 0.10.2+wasi-snapshot-preview1",
"wasi 0.10.0+wasi-snapshot-preview1",
]
[[package]]
@ -2096,9 +2097,9 @@ dependencies = [
[[package]]
name = "wasi"
version = "0.10.2+wasi-snapshot-preview1"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "wasi"

View file

@ -9,16 +9,17 @@ version = "0.1.0"
[dependencies]
anyhow = "1.0.65"
azalea-buf = { path = "../azalea-buf", version = "^0.1.0" }
azalea-buf = {path = "../azalea-buf", version = "^0.1.0"}
azalea-crypto = {path = "../azalea-crypto", version = "^0.1.0"}
chrono = {version = "0.4.22", default-features = false}
log = "0.4.17"
num-bigint = "0.4.3"
reqwest = { version = "0.11.12", features = ["json"] }
serde = { version = "1.0.145", features = ["derive"] }
reqwest = {version = "0.11.12", features = ["json"]}
serde = {version = "1.0.145", features = ["derive"]}
serde_json = "1.0.86"
thiserror = "1.0.37"
tokio = "1.21.2"
uuid = "^1.1.2"
azalea-crypto = { path = "../azalea-crypto", version = "^0.1.0" }
thiserror = "1.0.37"
[dev-dependencies]
tokio = { version = "1.21.2", features = ["full"] }
tokio = {version = "1.21.2", features = ["full"]}

View file

@ -1,10 +1,15 @@
//! Handle Minecraft (Xbox) authentication.
use crate::cache::{self, CachedAccount, ExpiringValue};
use anyhow::anyhow;
use serde::Deserialize;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{collections::HashMap, time::Instant};
use std::{
collections::HashMap,
path::PathBuf,
time::{Instant, SystemTime, UNIX_EPOCH},
};
#[derive(Default)]
pub struct AuthOpts {
@ -15,25 +20,65 @@ pub struct AuthOpts {
// /// Whether we should get the Minecraft profile data (i.e. username, uuid,
// /// skin, etc) for the player.
// pub get_profile: bool,
/// The directory to store the cache in. If this is not set, caching is not
/// done.
pub cache_file: Option<PathBuf>,
}
/// Authenticate with authenticate with Microsoft. If the data isn't cached,
/// they'll be asked to go to log into Microsoft in a web page.
pub async fn auth(opts: Option<AuthOpts>) -> anyhow::Result<AuthResult> {
///
/// The email is technically only used as a cache key, so it *could* be
/// anything. You should just have it be the actual email so it's not confusing
/// though, and in case the Microsoft API does start providing the real email.
pub async fn auth(email: &str, opts: Option<AuthOpts>) -> anyhow::Result<AuthResult> {
let opts = opts.unwrap_or_default();
let cached_account = if let Some(cache_file) = &opts.cache_file && let Some(account) = cache::get_account_in_cache(&cache_file, email).await {
Some(account)
} else { None };
// these two MUST be set by the end, since we return them in AuthResult
let profile: ProfileResponse;
let minecraft_access_token: String;
if let Some(account) = &cached_account && !account.mca.is_expired() {
// the minecraft auth data is cached and not expired, so we can just
// use that instead of doing auth all over again :)
profile = account.profile.clone();
minecraft_access_token = account.mca.data.access_token.clone();
} else {
let client = reqwest::Client::new();
let mut msa = if let Some(account) = cached_account {
account.msa
} else {
interactive_get_auth_token(&client).await?
};
if msa.is_expired() {
todo!("refresh msa token");
}
let ms_access_token = &msa.data.access_token;
println!("Got access token: {}", ms_access_token);
let auth_token_res = interactive_get_auth_token(&client).await?;
// TODO: cache this
println!("Got access token: {}", auth_token_res.access_token);
let xbl_auth = auth_with_xbox_live(&client, &ms_access_token).await?;
let xbl_auth = auth_with_xbox_live(&client, &auth_token_res.access_token).await?;
let xsts_token = obtain_xsts_for_minecraft(
&client,
&xbl_auth
.get()
.expect("Xbox Live auth token shouldn't have expired yet")
.token,
)
.await?;
let xsts_token = obtain_xsts_for_minecraft(&client, &xbl_auth).await?;
// Minecraft auth
let mca = auth_with_minecraft(&client, &xbl_auth.data.user_hash, &xsts_token).await?;
let minecraft_access_token =
auth_with_minecraft(&client, &xbl_auth.user_hash, &xsts_token).await?;
minecraft_access_token = (&mca)
.get()
.expect("Minecraft auth shouldn't have expired yet")
.access_token
.to_string();
if opts.check_ownership {
let has_game = check_ownership(&client, &minecraft_access_token).await?;
@ -45,7 +90,23 @@ pub async fn auth(opts: Option<AuthOpts>) -> anyhow::Result<AuthResult> {
}
}
let profile = get_profile(&client, &minecraft_access_token).await?;
profile = get_profile(&client, &minecraft_access_token).await?;
if let Some(cache_file) = opts.cache_file {
cache::set_account_in_cache(
&cache_file,
email,
CachedAccount {
email: email.to_string(),
mca,
msa,
xbl: xbl_auth,
profile: profile.clone(),
},
)
.await?;
}
}
Ok(AuthResult {
access_token: minecraft_access_token,
@ -69,7 +130,7 @@ pub struct DeviceCodeResponse {
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct AccessTokenResponse {
token_type: String,
expires_in: u64,
@ -90,13 +151,14 @@ pub struct XboxLiveAuthResponse {
}
/// Just the important data
#[derive(Serialize, Deserialize)]
pub struct XboxLiveAuth {
token: String,
user_hash: String,
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct MinecraftAuthResponse {
username: String,
roles: Vec<String>,
@ -120,7 +182,7 @@ pub struct GameOwnershipItem {
signature: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProfileResponse {
pub id: String,
pub name: String,
@ -131,7 +193,7 @@ pub struct ProfileResponse {
/// Asks the user to go to a webpage and log in with Microsoft.
async fn interactive_get_auth_token(
client: &reqwest::Client,
) -> anyhow::Result<AccessTokenResponse> {
) -> anyhow::Result<ExpiringValue<AccessTokenResponse>> {
// nintendo switch (real)
let client_id = "00000000441cc96b";
@ -152,15 +214,13 @@ async fn interactive_get_auth_token(
res.verification_uri, res.user_code
);
let access_token_response: AccessTokenResponse;
let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in);
let expire_time = Instant::now() + std::time::Duration::from_secs(res.expires_in);
while Instant::now() < expire_time {
while Instant::now() < login_expires_at {
tokio::time::sleep(std::time::Duration::from_secs(res.interval)).await;
println!("trying");
if let Ok(res) = client
if let Ok(access_token_response) = client
.post(format!(
"https://login.live.com/oauth20_token.srf?client_id={}",
client_id
@ -175,8 +235,15 @@ async fn interactive_get_auth_token(
.json::<AccessTokenResponse>()
.await
{
access_token_response = res;
return Ok(access_token_response);
let expires_at = SystemTime::now()
+ std::time::Duration::from_secs(access_token_response.expires_in);
return Ok(ExpiringValue {
data: access_token_response,
expires_at: expires_at
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs(),
});
}
}
@ -186,7 +253,7 @@ async fn interactive_get_auth_token(
async fn auth_with_xbox_live(
client: &reqwest::Client,
access_token: &str,
) -> anyhow::Result<XboxLiveAuth> {
) -> anyhow::Result<ExpiringValue<XboxLiveAuth>> {
let auth_json = json!({
"Properties": {
"AuthMethod": "RPS",
@ -219,15 +286,28 @@ async fn auth_with_xbox_live(
.await?;
println!("got res: {:?}", res);
Ok(XboxLiveAuth {
// not_after looks like 2020-12-21T19:52:08.4463796Z
let expires_at = DateTime::parse_from_rfc3339(&res.not_after)
.map_err(|_| {
anyhow!(
"Failed to parse not_after date from Xbox Live: {}",
res.not_after
)
})?
.with_timezone(&Utc)
.timestamp() as u64;
Ok(ExpiringValue {
data: XboxLiveAuth {
token: res.token,
user_hash: res.display_claims["xui"].get(0).unwrap()["uhs"].clone(),
},
expires_at,
})
}
async fn obtain_xsts_for_minecraft(
client: &reqwest::Client,
xbl_auth: &XboxLiveAuth,
xbl_auth_token: &str,
) -> anyhow::Result<String> {
let res = client
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
@ -235,7 +315,7 @@ async fn obtain_xsts_for_minecraft(
.json(&json!({
"Properties": {
"SandboxId": "RETAIL",
"UserTokens": [xbl_auth.token]
"UserTokens": [xbl_auth_token.to_string()]
},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
@ -253,7 +333,7 @@ async fn auth_with_minecraft(
client: &reqwest::Client,
user_hash: &str,
xsts_token: &str,
) -> anyhow::Result<String> {
) -> anyhow::Result<ExpiringValue<MinecraftAuthResponse>> {
let res = client
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
.header("Accept", "application/json")
@ -266,7 +346,12 @@ async fn auth_with_minecraft(
.await?;
println!("{:?}", res);
Ok(res.access_token)
let expires_at = SystemTime::now() + std::time::Duration::from_secs(res.expires_in);
Ok(ExpiringValue {
data: res,
// to seconds since epoch
expires_at: expires_at.duration_since(UNIX_EPOCH).unwrap().as_secs(),
})
}
async fn check_ownership(

View file

@ -1,13 +1,109 @@
//! Cache auth information
// pub fn get_auth_token() -> Option<AccessTokenResponse> {
// let mut cache = CACHE.lock().unwrap();
// if cache.auth_token.is_none() {
// return None;
// }
// let auth_token = cache.auth_token.as_ref().unwrap();
// if auth_token.expires_in < SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() {
// return None;
// }
// Some(auth_token.clone())
// }
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[derive(Debug, Error)]
pub enum CacheError {
#[error("Failed to read cache file")]
ReadError(std::io::Error),
#[error("Failed to write cache file")]
WriteError(std::io::Error),
#[error("Failed to parse cache file")]
ParseError(serde_json::Error),
}
#[derive(Deserialize, Serialize)]
pub struct CachedAccount {
pub email: String,
/// Microsoft auth
pub msa: ExpiringValue<crate::auth::AccessTokenResponse>,
/// Xbox Live auth
pub xbl: ExpiringValue<crate::auth::XboxLiveAuth>,
/// Minecraft auth
pub mca: ExpiringValue<crate::auth::MinecraftAuthResponse>,
/// The user's Minecraft profile (i.e. username, UUID, skin)
pub profile: crate::auth::ProfileResponse,
}
#[derive(Deserialize, Serialize)]
pub struct ExpiringValue<T> {
/// Seconds since the UNIX epoch
pub expires_at: u64,
pub data: T,
}
impl<T> ExpiringValue<T> {
pub fn is_expired(&self) -> bool {
self.expires_at
< SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
/// Return the data if it's not expired, otherwise return `None`
pub fn get(&self) -> Option<&T> {
if self.is_expired() {
None
} else {
Some(&self.data)
}
}
}
async fn get_entire_cache(cache_file: &Path) -> Result<Vec<CachedAccount>, CacheError> {
let mut cache: Vec<CachedAccount> = Vec::new();
if cache_file.exists() {
let cache_file = File::open(cache_file)
.await
.map_err(CacheError::ReadError)?;
// read the file into a string
let mut cache_file = tokio::io::BufReader::new(cache_file);
let mut contents = String::new();
cache_file
.read_to_string(&mut contents)
.await
.map_err(CacheError::ReadError)?;
cache = serde_json::from_str(&contents).map_err(CacheError::ParseError)?;
}
Ok(cache)
}
async fn set_entire_cache(cache_file: &Path, cache: Vec<CachedAccount>) -> Result<(), CacheError> {
let cache_file = File::create(cache_file)
.await
.map_err(CacheError::WriteError)?;
let mut cache_file = tokio::io::BufWriter::new(cache_file);
let cache = serde_json::to_string(&cache).map_err(CacheError::ParseError)?;
cache_file
.write_all(cache.as_bytes())
.await
.map_err(CacheError::WriteError)?;
Ok(())
}
/// Gets cached data for the given email.
///
/// Technically it doesn't actually have to be an email since it's only the
/// cache key. I considered using usernames or UUIDs as the cache key, but
/// usernames change and no one has their UUID memorized.
pub async fn get_account_in_cache(cache_file: &Path, email: &str) -> Option<CachedAccount> {
let cache = get_entire_cache(cache_file).await.unwrap_or_default();
cache.into_iter().find(|account| account.email == email)
}
pub async fn set_account_in_cache(
cache_file: &Path,
email: &str,
account: CachedAccount,
) -> Result<(), CacheError> {
let mut cache = get_entire_cache(cache_file).await.unwrap_or_default();
cache.retain(|account| account.email != email);
cache.push(account);
set_entire_cache(cache_file, cache).await
}

View file

@ -1,4 +1,7 @@
#![feature(let_chains)]
pub mod auth;
mod cache;
pub mod game_profile;
pub mod sessionserver;
@ -7,17 +10,6 @@ use serde::Deserialize;
use serde_json::json;
use std::{collections::HashMap, time::Instant};
#[derive(Default)]
pub struct AuthOpts {
/// Whether we should check if the user actually owns the game. This will
/// fail if the user has Xbox Game Pass! Note that this isn't really
/// necessary, since getting the user profile will check this anyways.
pub check_ownership: bool,
// /// Whether we should get the Minecraft profile data (i.e. username, uuid,
// /// skin, etc) for the player.
// pub get_profile: bool,
}
/// Ask the user to authenticate with Microsoft and return the user's Minecraft
/// access token. There's caching, so if the user has already authenticated
/// before then they won't have to log in manually again.
@ -50,6 +42,29 @@ pub async fn auth(opts: AuthOpts) -> anyhow::Result<String> {
Ok(minecraft_access_token)
}
pub struct AuthOpts {
/// Whether we should check if the user actually owns the game. This will
/// fail if the user has Xbox Game Pass! Note that this isn't really
/// necessary, since getting the user profile will check this anyways.
pub check_ownership: bool,
// /// Whether we should get the Minecraft profile data (i.e. username, uuid,
// /// skin, etc) for the player.
// pub get_profile: bool,
/// The file to store cached data in. If this is `None`, then no
/// caching will be done.
pub cache_dir: Option<std::path::PathBuf>,
}
impl Default for AuthOpts {
fn default() -> Self {
Self {
check_ownership: true,
// get_profile: true,
cache_dir: None,
}
}
}
#[derive(Debug, Deserialize)]
pub struct DeviceCodeResponse {
user_code: String,
@ -260,7 +275,7 @@ async fn auth_with_minecraft(
Ok(res.access_token)
}
async fn check_ownership(
pub async fn check_ownership(
client: &reqwest::Client,
minecraft_access_token: &str,
) -> anyhow::Result<bool> {
@ -281,7 +296,7 @@ async fn check_ownership(
Ok(!res.items.is_empty())
}
async fn get_profile(
pub async fn get_profile(
client: &reqwest::Client,
minecraft_access_token: &str,
) -> anyhow::Result<ProfileResponse> {