mod cache;
use std::fmt;
use std::time::{Duration, Instant, SystemTime};
use reqwest::{Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use uuid::Uuid;
use crate::SecretString;
pub use cache::{CacheError, CachedAccount, TokenCache};
const MAX_BODY: usize = 1 << 20;
const MAX_ERROR_BODY: usize = 512;
const MIN_REMAINING: Duration = Duration::from_secs(5 * 60);
const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
const LIVE_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
const AZURE_SCOPE: &str = "XboxLive.signin offline_access";
const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OAuthFlavor {
Live,
AzureAd,
}
impl OAuthFlavor {
fn scope(self) -> &'static str {
match self {
Self::Live => LIVE_SCOPE,
Self::AzureAd => AZURE_SCOPE,
}
}
fn rps_ticket(self, access_token: &str) -> String {
match self {
Self::Live => access_token.to_owned(),
Self::AzureAd => format!("d={access_token}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoints {
pub live_device_code: String,
pub live_token: String,
pub azure_device_code: String,
pub azure_token: String,
pub xbox_user_auth: String,
pub xsts_authorize: String,
pub minecraft_login: String,
pub minecraft_profile: String,
pub session_join: String,
}
impl Default for Endpoints {
fn default() -> Self {
Self {
live_device_code: "https://login.live.com/oauth20_connect.srf".into(),
live_token: "https://login.live.com/oauth20_token.srf".into(),
azure_device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
.into(),
azure_token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".into(),
xbox_user_auth: "https://user.auth.xboxlive.com/user/authenticate".into(),
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".into(),
minecraft_login: "https://api.minecraftservices.com/authentication/login_with_xbox"
.into(),
minecraft_profile: "https://api.minecraftservices.com/minecraft/profile".into(),
session_join: "https://sessionserver.mojang.com/session/minecraft/join".into(),
}
}
}
impl Endpoints {
pub fn with_base_url(base: &str) -> Self {
let base = base.trim_end_matches('/');
Self {
live_device_code: format!("{base}/live/oauth20_connect.srf"),
live_token: format!("{base}/live/oauth20_token.srf"),
azure_device_code: format!("{base}/azure/devicecode"),
azure_token: format!("{base}/azure/token"),
xbox_user_auth: format!("{base}/user/authenticate"),
xsts_authorize: format!("{base}/xsts/authorize"),
minecraft_login: format!("{base}/authentication/login_with_xbox"),
minecraft_profile: format!("{base}/minecraft/profile"),
session_join: format!("{base}/session/minecraft/join"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MicrosoftConfig {
pub client_id: String,
pub flavor: OAuthFlavor,
pub endpoints: Endpoints,
}
impl MicrosoftConfig {
pub fn azure_ad(client_id: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
flavor: OAuthFlavor::AzureAd,
endpoints: Endpoints::default(),
}
}
pub fn live(client_id: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
flavor: OAuthFlavor::Live,
endpoints: Endpoints::default(),
}
}
pub fn with_endpoints(mut self, endpoints: Endpoints) -> Self {
self.endpoints = endpoints;
self
}
fn device_code_url(&self) -> &str {
match self.flavor {
OAuthFlavor::Live => &self.endpoints.live_device_code,
OAuthFlavor::AzureAd => &self.endpoints.azure_device_code,
}
}
fn token_url(&self) -> &str {
match self.flavor {
OAuthFlavor::Live => &self.endpoints.live_token,
OAuthFlavor::AzureAd => &self.endpoints.azure_token,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
DeviceCode,
MicrosoftToken,
XboxUser,
Xsts,
MinecraftLogin,
Profile,
Join,
}
impl fmt::Display for Stage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::DeviceCode => "device code request",
Self::MicrosoftToken => "Microsoft token request",
Self::XboxUser => "Xbox Live authentication",
Self::Xsts => "XSTS authorization",
Self::MinecraftLogin => "Minecraft login",
Self::Profile => "Minecraft profile request",
Self::Join => "session server join",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum XboxError {
NoXboxAccount,
CountryUnavailable,
AdultVerificationRequired,
ChildAccount,
Other(u64),
}
impl XboxError {
pub fn from_code(code: u64) -> Self {
match code {
2_148_916_233 => Self::NoXboxAccount,
2_148_916_235 => Self::CountryUnavailable,
2_148_916_236 | 2_148_916_237 => Self::AdultVerificationRequired,
2_148_916_238 => Self::ChildAccount,
other => Self::Other(other),
}
}
}
impl fmt::Display for XboxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoXboxAccount => f.write_str("the Microsoft account has no Xbox profile"),
Self::CountryUnavailable => f.write_str("Xbox Live is not available in this country"),
Self::AdultVerificationRequired => f.write_str("the account needs adult verification"),
Self::ChildAccount => {
f.write_str("child account: an adult must add it to a Microsoft family")
}
Self::Other(code) => write!(f, "XSTS error {code}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JoinRejection {
InvalidToken,
MultiplayerDisabled,
Banned,
Other(String),
}
#[derive(Debug, Error)]
pub enum MicrosoftError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("{stage}: rate limited (retry after {retry_after:?})")]
RateLimited {
stage: Stage,
retry_after: Option<Duration>,
},
#[error("{stage}: HTTP {status}: {body}")]
Status {
stage: Stage,
status: u16,
body: String,
},
#[error("{stage}: invalid response: {reason}")]
InvalidResponse {
stage: Stage,
reason: String,
},
#[error("OAuth error {error}: {}", description.as_deref().unwrap_or(""))]
OAuth {
error: String,
description: Option<String>,
},
#[error("the device code expired before the user signed in")]
DeviceCodeExpired,
#[error("the user declined the sign-in")]
AuthorizationDeclined,
#[error("the refresh token was rejected; sign in again")]
RefreshTokenRejected,
#[error("XSTS refused the account: {0}")]
Xbox(XboxError),
#[error("Minecraft services do not accept this OAuth client id (app not approved)")]
AppNotPermitted,
#[error("this account does not own Minecraft Java Edition")]
NoMinecraftLicense,
#[error("the Minecraft access token was rejected")]
MinecraftTokenRejected,
#[error("session server refused the join: {kind:?}: {message}")]
JoinRejected {
kind: JoinRejection,
message: String,
},
#[error("token cache: {0}")]
Cache(#[from] CacheError),
}
#[derive(Clone)]
pub struct DeviceCode {
pub user_code: String,
pub verification_uri: String,
pub message: Option<String>,
pub expires_in: Duration,
pub interval: Duration,
device_code: SecretString,
deadline: Instant,
}
impl DeviceCode {
pub fn deadline(&self) -> Instant {
self.deadline
}
}
impl fmt::Debug for DeviceCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceCode")
.field("user_code", &self.user_code)
.field("verification_uri", &self.verification_uri)
.field("expires_in", &self.expires_in)
.field("interval", &self.interval)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub enum DevicePoll {
Pending,
SlowDown,
Complete(MicrosoftToken),
}
#[derive(Debug, Clone)]
pub struct MicrosoftToken {
pub access_token: SecretString,
pub refresh_token: Option<SecretString>,
pub expires_at: SystemTime,
}
#[derive(Debug, Clone)]
pub struct XboxToken {
pub token: SecretString,
pub user_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MinecraftProfile {
pub id: Uuid,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MinecraftSession {
pub access_token: SecretString,
#[serde(with = "unix_seconds")]
pub expires_at: SystemTime,
pub profile: MinecraftProfile,
}
impl MinecraftSession {
pub fn is_valid_for(&self, margin: Duration) -> bool {
SystemTime::now()
.checked_add(margin)
.is_some_and(|t| t < self.expires_at)
}
}
mod unix_seconds {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
let secs = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
s.serialize_u64(secs)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
let secs = u64::deserialize(d)?;
Ok(UNIX_EPOCH
.checked_add(Duration::from_secs(secs))
.unwrap_or(UNIX_EPOCH))
}
}
#[derive(Debug, Clone)]
pub struct MicrosoftAuth {
http: reqwest::Client,
config: MicrosoftConfig,
}
#[derive(Deserialize)]
struct DeviceCodeResponse {
user_code: String,
device_code: String,
#[serde(alias = "verification_url")]
verification_uri: String,
expires_in: u64,
#[serde(default)]
interval: Option<u64>,
#[serde(default)]
message: Option<String>,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
#[derive(Deserialize)]
struct OAuthErrorResponse {
error: String,
#[serde(default)]
error_description: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct XboxResponse {
token: String,
display_claims: DisplayClaims,
}
#[derive(Deserialize)]
struct DisplayClaims {
xui: Vec<Xui>,
}
#[derive(Deserialize)]
struct Xui {
uhs: String,
}
#[derive(Deserialize)]
struct XErrResponse {
#[serde(rename = "XErr")]
xerr: u64,
}
#[derive(Deserialize)]
struct MinecraftLoginResponse {
access_token: String,
expires_in: u64,
}
#[derive(Deserialize)]
struct ProfileResponse {
id: String,
name: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MojangError {
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_message: Option<String>,
}
impl MicrosoftAuth {
pub fn new(config: MicrosoftConfig) -> Result<Self, MicrosoftError> {
let http = reqwest::Client::builder()
.user_agent(concat!("ghast-auth/", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()?;
Ok(Self { http, config })
}
pub fn with_http_client(config: MicrosoftConfig, http: reqwest::Client) -> Self {
Self { http, config }
}
pub fn config(&self) -> &MicrosoftConfig {
&self.config
}
pub async fn authenticate(
&self,
cache: Option<&TokenCache>,
on_code: impl FnOnce(&DeviceCode),
) -> Result<MinecraftSession, MicrosoftError> {
let cached = match cache {
Some(cache) => match cache.load().await {
Ok(account) => account,
Err(CacheError::Format(_)) => None,
Err(e) => return Err(e.into()),
},
None => None,
}
.filter(|account| account.client_id == self.config.client_id);
if let Some(account) = &cached {
if let Some(session) = &account.minecraft
&& session.is_valid_for(MIN_REMAINING)
{
return Ok(session.clone());
}
if let Some(refresh) = &account.microsoft_refresh_token {
match self.refresh(refresh).await {
Ok(token) => return self.finish(cache, token).await,
Err(MicrosoftError::RefreshTokenRejected) => {}
Err(e) => return Err(e),
}
}
}
let code = self.request_device_code().await?;
on_code(&code);
let token = self.poll_device_code(&code).await?;
self.finish(cache, token).await
}
async fn finish(
&self,
cache: Option<&TokenCache>,
token: MicrosoftToken,
) -> Result<MinecraftSession, MicrosoftError> {
let session = self.login_with_microsoft_token(&token.access_token).await?;
if let Some(cache) = cache {
let account = CachedAccount {
version: CachedAccount::VERSION,
client_id: self.config.client_id.clone(),
microsoft_refresh_token: token.refresh_token,
minecraft: Some(session.clone()),
};
cache.save(&account).await?;
}
Ok(session)
}
pub async fn login_with_microsoft_token(
&self,
access_token: &SecretString,
) -> Result<MinecraftSession, MicrosoftError> {
let user = self.xbox_user_token(access_token).await?;
let xsts = self.xsts_token(&user.token).await?;
let (mc_token, expires_at) = self.minecraft_login(&xsts).await?;
let profile = self.profile(&mc_token).await?;
Ok(MinecraftSession {
access_token: mc_token,
expires_at,
profile,
})
}
pub async fn request_device_code(&self) -> Result<DeviceCode, MicrosoftError> {
let stage = Stage::DeviceCode;
let mut form = vec![
("client_id", self.config.client_id.as_str()),
("scope", self.config.flavor.scope()),
];
if self.config.flavor == OAuthFlavor::Live {
form.push(("response_type", "device_code"));
}
let resp = self
.http
.post(self.config.device_code_url())
.form(&form)
.send()
.await?;
let resp = check_status(resp, stage).await?;
let body: DeviceCodeResponse = read_json(resp, stage).await?;
let expires_in = Duration::from_secs(body.expires_in);
Ok(DeviceCode {
user_code: body.user_code,
verification_uri: body.verification_uri,
message: body.message,
expires_in,
interval: Duration::from_secs(body.interval.unwrap_or(5)),
device_code: SecretString::new(body.device_code),
deadline: Instant::now()
.checked_add(expires_in)
.unwrap_or_else(Instant::now),
})
}
pub async fn poll_device_code(
&self,
code: &DeviceCode,
) -> Result<MicrosoftToken, MicrosoftError> {
let mut interval = code.interval;
loop {
if Instant::now() >= code.deadline {
return Err(MicrosoftError::DeviceCodeExpired);
}
tokio::time::sleep(interval).await;
match self.poll_device_code_once(code).await? {
DevicePoll::Pending => {}
DevicePoll::SlowDown => interval += SLOW_DOWN_STEP,
DevicePoll::Complete(token) => return Ok(token),
}
}
}
pub async fn poll_device_code_once(
&self,
code: &DeviceCode,
) -> Result<DevicePoll, MicrosoftError> {
let form = [
("client_id", self.config.client_id.as_str()),
("device_code", code.device_code.expose()),
("grant_type", DEVICE_CODE_GRANT),
];
let resp = self
.http
.post(self.config.token_url())
.form(&form)
.send()
.await?;
match self.token_response(resp).await {
Ok(token) => Ok(DevicePoll::Complete(token)),
Err(MicrosoftError::OAuth { error, description }) => match error.as_str() {
"authorization_pending" => Ok(DevicePoll::Pending),
"slow_down" => Ok(DevicePoll::SlowDown),
"expired_token" | "code_expired" => Err(MicrosoftError::DeviceCodeExpired),
"authorization_declined" | "access_denied" => {
Err(MicrosoftError::AuthorizationDeclined)
}
_ => Err(MicrosoftError::OAuth { error, description }),
},
Err(e) => Err(e),
}
}
pub async fn refresh(
&self,
refresh_token: &SecretString,
) -> Result<MicrosoftToken, MicrosoftError> {
let form = [
("client_id", self.config.client_id.as_str()),
("scope", self.config.flavor.scope()),
("grant_type", "refresh_token"),
("refresh_token", refresh_token.expose()),
];
let resp = self
.http
.post(self.config.token_url())
.form(&form)
.send()
.await?;
match self.token_response(resp).await {
Err(MicrosoftError::OAuth { error, .. }) if error == "invalid_grant" => {
Err(MicrosoftError::RefreshTokenRejected)
}
other => other,
}
}
async fn token_response(&self, resp: Response) -> Result<MicrosoftToken, MicrosoftError> {
let stage = Stage::MicrosoftToken;
let status = resp.status();
if status == StatusCode::TOO_MANY_REQUESTS {
return Err(rate_limited(&resp, stage));
}
let body = read_body(resp, stage).await?;
if !status.is_success() {
if let Ok(err) = serde_json::from_slice::<OAuthErrorResponse>(&body) {
return Err(MicrosoftError::OAuth {
error: err.error,
description: err.error_description,
});
}
return Err(status_error(stage, status, &body));
}
let token: TokenResponse = parse_json(&body, stage)?;
Ok(MicrosoftToken {
access_token: SecretString::new(token.access_token),
refresh_token: token.refresh_token.map(SecretString::new),
expires_at: expires_at(token.expires_in.unwrap_or(3600)),
})
}
pub async fn xbox_user_token(
&self,
microsoft_access_token: &SecretString,
) -> Result<XboxToken, MicrosoftError> {
let body = json!({
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": self.config.flavor.rps_ticket(microsoft_access_token.expose()),
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT",
});
self.xbox_request(
&self.config.endpoints.xbox_user_auth,
&body,
Stage::XboxUser,
)
.await
}
pub async fn xsts_token(&self, user_token: &SecretString) -> Result<XboxToken, MicrosoftError> {
let body = json!({
"Properties": {
"SandboxId": "RETAIL",
"UserTokens": [user_token.expose()],
},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT",
});
self.xbox_request(&self.config.endpoints.xsts_authorize, &body, Stage::Xsts)
.await
}
async fn xbox_request(
&self,
url: &str,
body: &serde_json::Value,
stage: Stage,
) -> Result<XboxToken, MicrosoftError> {
let resp = self
.http
.post(url)
.header("x-xbl-contract-version", "1")
.header(reqwest::header::ACCEPT, "application/json")
.json(body)
.send()
.await?;
let status = resp.status();
if status == StatusCode::TOO_MANY_REQUESTS {
return Err(rate_limited(&resp, stage));
}
let bytes = read_body(resp, stage).await?;
if status == StatusCode::UNAUTHORIZED
&& let Ok(err) = serde_json::from_slice::<XErrResponse>(&bytes)
{
return Err(MicrosoftError::Xbox(XboxError::from_code(err.xerr)));
}
if !status.is_success() {
return Err(status_error(stage, status, &bytes));
}
let parsed: XboxResponse = parse_json(&bytes, stage)?;
let user_hash = parsed
.display_claims
.xui
.into_iter()
.next()
.map(|x| x.uhs)
.ok_or_else(|| MicrosoftError::InvalidResponse {
stage,
reason: "no user hash in DisplayClaims.xui".into(),
})?;
Ok(XboxToken {
token: SecretString::new(parsed.token),
user_hash,
})
}
pub async fn minecraft_login(
&self,
xsts: &XboxToken,
) -> Result<(SecretString, SystemTime), MicrosoftError> {
let stage = Stage::MinecraftLogin;
let body = json!({
"identityToken": format!("XBL3.0 x={};{}", xsts.user_hash, xsts.token.expose()),
});
let resp = self
.http
.post(&self.config.endpoints.minecraft_login)
.json(&body)
.send()
.await?;
let status = resp.status();
if status == StatusCode::TOO_MANY_REQUESTS {
return Err(rate_limited(&resp, stage));
}
let bytes = read_body(resp, stage).await?;
if status == StatusCode::FORBIDDEN
&& String::from_utf8_lossy(&bytes).contains("Invalid app registration")
{
return Err(MicrosoftError::AppNotPermitted);
}
if !status.is_success() {
return Err(status_error(stage, status, &bytes));
}
let parsed: MinecraftLoginResponse = parse_json(&bytes, stage)?;
Ok((
SecretString::new(parsed.access_token),
expires_at(parsed.expires_in),
))
}
pub async fn profile(
&self,
minecraft_access_token: &SecretString,
) -> Result<MinecraftProfile, MicrosoftError> {
let stage = Stage::Profile;
let resp = self
.http
.get(&self.config.endpoints.minecraft_profile)
.bearer_auth(minecraft_access_token.expose())
.send()
.await?;
match resp.status() {
StatusCode::NOT_FOUND => return Err(MicrosoftError::NoMinecraftLicense),
StatusCode::UNAUTHORIZED => return Err(MicrosoftError::MinecraftTokenRejected),
_ => {}
}
let resp = check_status(resp, stage).await?;
let parsed: ProfileResponse = read_json(resp, stage).await?;
let id = Uuid::parse_str(&parsed.id).map_err(|e| MicrosoftError::InvalidResponse {
stage,
reason: format!("bad profile id {:?}: {e}", parsed.id),
})?;
Ok(MinecraftProfile {
id,
name: parsed.name,
})
}
pub async fn join_server(
&self,
minecraft_access_token: &SecretString,
profile_id: Uuid,
server_hash: &str,
) -> Result<(), MicrosoftError> {
let stage = Stage::Join;
let body = json!({
"accessToken": minecraft_access_token.expose(),
"selectedProfile": profile_id.simple().to_string(),
"serverId": server_hash,
});
let resp = self
.http
.post(&self.config.endpoints.session_join)
.json(&body)
.send()
.await?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
if status == StatusCode::TOO_MANY_REQUESTS {
return Err(rate_limited(&resp, stage));
}
let bytes = read_body(resp, stage).await?;
if let Ok(MojangError {
error: Some(error),
error_message,
}) = serde_json::from_slice::<MojangError>(&bytes)
{
let kind = match error.as_str() {
"ForbiddenOperationException" => JoinRejection::InvalidToken,
"InsufficientPrivilegesException" => JoinRejection::MultiplayerDisabled,
"UserBannedException" => JoinRejection::Banned,
_ => JoinRejection::Other(error),
};
return Err(MicrosoftError::JoinRejected {
kind,
message: error_message.unwrap_or_default(),
});
}
Err(status_error(stage, status, &bytes))
}
}
fn expires_at(expires_in_secs: u64) -> SystemTime {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
SystemTime::UNIX_EPOCH
.checked_add(Duration::from_secs(
now_secs.saturating_add(expires_in_secs),
))
.unwrap_or_else(SystemTime::now)
}
fn rate_limited(resp: &Response, stage: Stage) -> MicrosoftError {
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs);
MicrosoftError::RateLimited { stage, retry_after }
}
fn status_error(stage: Stage, status: StatusCode, body: &[u8]) -> MicrosoftError {
let text = String::from_utf8_lossy(body);
let mut end = text.len().min(MAX_ERROR_BODY);
while !text.is_char_boundary(end) {
end -= 1;
}
MicrosoftError::Status {
stage,
status: status.as_u16(),
body: text[..end].to_owned(),
}
}
async fn check_status(resp: Response, stage: Stage) -> Result<Response, MicrosoftError> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
if status == StatusCode::TOO_MANY_REQUESTS {
return Err(rate_limited(&resp, stage));
}
let body = read_body(resp, stage).await?;
Err(status_error(stage, status, &body))
}
async fn read_body(mut resp: Response, stage: Stage) -> Result<Vec<u8>, MicrosoftError> {
let too_large = || MicrosoftError::InvalidResponse {
stage,
reason: format!("body larger than {MAX_BODY} bytes"),
};
if resp
.content_length()
.is_some_and(|len| len > MAX_BODY as u64)
{
return Err(too_large());
}
let mut body = Vec::new();
while let Some(chunk) = resp.chunk().await? {
if body.len() + chunk.len() > MAX_BODY {
return Err(too_large());
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
fn parse_json<T: DeserializeOwned>(body: &[u8], stage: Stage) -> Result<T, MicrosoftError> {
serde_json::from_slice(body).map_err(|e| MicrosoftError::InvalidResponse {
stage,
reason: e.to_string(),
})
}
async fn read_json<T: DeserializeOwned>(resp: Response, stage: Stage) -> Result<T, MicrosoftError> {
let body = read_body(resp, stage).await?;
parse_json(&body, stage)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xbox_error_codes() {
assert_eq!(XboxError::from_code(2148916233), XboxError::NoXboxAccount);
assert_eq!(
XboxError::from_code(2148916235),
XboxError::CountryUnavailable
);
assert_eq!(
XboxError::from_code(2148916236),
XboxError::AdultVerificationRequired
);
assert_eq!(
XboxError::from_code(2148916237),
XboxError::AdultVerificationRequired
);
assert_eq!(XboxError::from_code(2148916238), XboxError::ChildAccount);
assert_eq!(XboxError::from_code(1), XboxError::Other(1));
}
#[test]
fn rps_ticket_by_flavor() {
assert_eq!(OAuthFlavor::Live.rps_ticket("abc"), "abc");
assert_eq!(OAuthFlavor::AzureAd.rps_ticket("abc"), "d=abc");
}
#[test]
fn error_body_is_truncated_on_char_boundary() {
let body = "\u{e9}".repeat(MAX_ERROR_BODY);
let MicrosoftError::Status { body, .. } =
status_error(Stage::Profile, StatusCode::BAD_GATEWAY, body.as_bytes())
else {
panic!("wrong variant");
};
assert!(body.len() <= MAX_ERROR_BODY);
assert!(body.chars().all(|c| c == '\u{e9}'));
}
#[test]
fn session_serde_round_trip() {
let session = MinecraftSession {
access_token: "tok".into(),
expires_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000),
profile: MinecraftProfile {
id: Uuid::nil(),
name: "Steve".into(),
},
};
let json = serde_json::to_string(&session).unwrap();
assert!(json.contains("1700000000"), "{json}");
let back: MinecraftSession = serde_json::from_str(&json).unwrap();
assert_eq!(back, session);
assert!(!session.is_valid_for(Duration::ZERO));
}
}