fix: Microsoft Account refresh token

This commit is contained in:
huanghongxun 2021-08-22 21:29:08 +08:00
parent 5890f0c782
commit 3b8a0989de
5 changed files with 133 additions and 102 deletions

View File

@ -50,12 +50,6 @@ public class MicrosoftAuthenticationServer extends NanoHTTPD implements Microsof
return String.format("http://localhost:%d/auth-response", port);
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Secret")).orElse(""));
}
@Override
public String waitFor() throws InterruptedException, ExecutionException {
return future.get();
@ -113,5 +107,18 @@ public class MicrosoftAuthenticationServer extends NanoHTTPD implements Microsof
// TODO: error!
FXUtils.openLink(url);
}
@Override
public String getClientId() {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Id")).orElse(""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Secret")).orElse(""));
}
}
}

View File

@ -24,6 +24,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@ -79,15 +80,15 @@ public class MicrosoftAccount extends Account {
if (service.validate(session.getTokenType(), session.getAccessToken())) {
authenticated = true;
} else {
MicrosoftSession acquiredSession = service.authenticate();
if (acquiredSession.getProfile() == null) {
session = service.refresh(acquiredSession);
} else {
session = acquiredSession;
MicrosoftSession acquiredSession = service.refresh(session);
if (!Objects.equals(acquiredSession.getProfile().getId(), session.getProfile().getId())) {
throw new ServerResponseMalformedException("Selected profile changed");
}
characterUUID = session.getProfile().getId();
session = acquiredSession;
authenticated = true;
invalidate();
}
}

View File

@ -48,7 +48,6 @@ import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftService {
private static final String CLIENT_ID = "6a3728d6-27a3-4180-99bb-479895b8f88e";
private static final String AUTHORIZATION_URL = "https://login.live.com/oauth20_authorize.srf";
private static final String ACCESS_TOKEN_URL = "https://login.live.com/oauth20_token.srf";
private static final String SCOPE = "XboxLive.signin offline_access";
@ -81,81 +80,21 @@ public class MicrosoftService {
// Microsoft OAuth Flow
OAuthSession session = callback.startServer();
callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL,
mapOf(pair("client_id", CLIENT_ID), pair("response_type", "code"),
mapOf(pair("client_id", callback.getClientId()), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE),
pair("prompt", "select_account"))));
String code = session.waitFor();
// Authorization Code -> Token
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf")
.form(mapOf(pair("client_id", CLIENT_ID), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", session.getClientSecret()),
String responseText = HttpRequest.POST(ACCESS_TOKEN_URL)
.form(mapOf(pair("client_id", callback.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE)))
.getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
LiveAuthorizationResponse.class);
// Authenticate with XBox Live
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest
.POST("https://user.auth.xboxlive.com/user/authenticate")
.json(mapOf(
pair("Properties",
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", "d=" + response.accessToken))),
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
// Authenticate Minecraft with XSTS
XBoxLiveAuthenticationResponse minecraftXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "rp://api.minecraftservices.com/"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String minecraftXstsUhs = (String) minecraftXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, minecraftXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
// Authenticate XBox with XSTS
XBoxLiveAuthenticationResponse xboxXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "http://xboxlive.com"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String xboxXstsUhs = (String) xboxXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, xboxXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
getXBoxProfile(uhs, xboxXstsResponse.token);
// Authenticate with Minecraft
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + minecraftXstsResponse.token)))
.accept("application/json").getJson(MinecraftLoginWithXBoxResponse.class);
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
// Checking Game Ownership
MinecraftStoreResponse storeResponse = HttpRequest
.GET("https://api.minecraftservices.com/entitlements/mcstore")
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
.getJson(MinecraftStoreResponse.class);
handleErrorResponse(storeResponse);
if (storeResponse.items.isEmpty()) {
throw new NoCharacterException();
}
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, notAfter,
new MicrosoftSession.User(minecraftResponse.username), null);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (InterruptedException e) {
@ -173,15 +112,14 @@ public class MicrosoftService {
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
try {
// Get the profile
MinecraftProfileResponse profileResponse = HttpRequest
.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", oldSession.getTokenType(), oldSession.getAccessToken()))
.getJson(MinecraftProfileResponse.class);
handleErrorResponse(profileResponse);
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(),
oldSession.getNotAfter(), oldSession.getUser(),
new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
LiveRefreshResponse response = HttpRequest.POST(ACCESS_TOKEN_URL)
.form(pair("client_id", callback.getClientId()),
pair("client_secret", callback.getClientSecret()),
pair("refresh_token", oldSession.getRefreshToken()),
pair("grant_type", "refresh_token"))
.accept("application/json").getJson(LiveRefreshResponse.class);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
@ -189,6 +127,64 @@ public class MicrosoftService {
}
}
private MicrosoftSession authenticateViaLiveAccessToken(String liveAccessToken, String liveRefreshToken) throws IOException, JsonParseException, AuthenticationException {
// Authenticate with XBox Live
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest
.POST("https://user.auth.xboxlive.com/user/authenticate")
.json(mapOf(
pair("Properties",
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", "d=" + liveAccessToken))),
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
// Authenticate Minecraft with XSTS
XBoxLiveAuthenticationResponse minecraftXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "rp://api.minecraftservices.com/"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String minecraftXstsUhs = (String) minecraftXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, minecraftXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
// Authenticate XBox with XSTS
XBoxLiveAuthenticationResponse xboxXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "http://xboxlive.com"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String xboxXstsUhs = (String) xboxXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, xboxXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
getXBoxProfile(uhs, xboxXstsResponse.token);
// Authenticate with Minecraft
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + minecraftXstsResponse.token)))
.accept("application/json").getJson(MinecraftLoginWithXBoxResponse.class);
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
// Get Minecraft Account UUID
MinecraftProfileResponse profileResponse = getMinecraftProfile(minecraftResponse.tokenType, minecraftResponse.accessToken);
handleErrorResponse(profileResponse);
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, notAfter, liveRefreshToken,
new MicrosoftSession.User(minecraftResponse.username), new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
}
public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException {
try {
return Optional.ofNullable(
@ -202,16 +198,12 @@ public class MicrosoftService {
}
public boolean validate(String tokenType, String accessToken) throws AuthenticationException {
if (true) return false;
requireNonNull(tokenType);
requireNonNull(accessToken);
try {
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", tokenType, accessToken)).filter((url, responseCode) -> {
if (responseCode / 100 == 4) {
throw new ResponseCodeException(url, responseCode);
}
}).getString();
getMinecraftProfile(tokenType, accessToken);
return true;
} catch (ResponseCodeException e) {
return false;
@ -295,6 +287,17 @@ public class MicrosoftService {
String foci;
}
private static class LiveRefreshResponse {
@SerializedName("expires_in")
int expiresIn;
@SerializedName("access_token")
String accessToken;
@SerializedName("refresh_token")
String refreshToken;
}
private static class XBoxLiveAuthenticationResponseDisplayClaims {
List<Map<Object, Object>> xui;
}
@ -421,14 +424,16 @@ public class MicrosoftService {
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
String getClientId();
String getClientSecret();
}
public interface OAuthSession {
String getRedirectURI();
String getClientSecret() throws IOException;
/**
* Wait for authentication
*

View File

@ -32,13 +32,15 @@ public class MicrosoftSession {
private final String tokenType;
private final long notAfter;
private final String accessToken;
private final String refreshToken;
private final User user;
private final GameProfile profile;
public MicrosoftSession(String tokenType, String accessToken, long notAfter, User user, GameProfile profile) {
public MicrosoftSession(String tokenType, String accessToken, long notAfter, String refreshToken, User user, GameProfile profile) {
this.tokenType = tokenType;
this.accessToken = accessToken;
this.notAfter = notAfter;
this.refreshToken = refreshToken;
this.user = user;
this.profile = profile;
}
@ -55,6 +57,10 @@ public class MicrosoftSession {
return notAfter;
}
public String getRefreshToken() {
return refreshToken;
}
public String getAuthorization() {
return String.format("%s %s", getTokenType(), getAccessToken());
}
@ -76,18 +82,25 @@ public class MicrosoftSession {
.orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class)
.orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
String refreshToken = tryCast(storage.get("refreshToken"), String.class)
.orElseThrow(() -> new IllegalArgumentException("refreshToken is missing"));
Long notAfter = tryCast(storage.get("notAfter"), Long.class).orElse(0L);
String userId = tryCast(storage.get("userid"), String.class)
.orElseThrow(() -> new IllegalArgumentException("userid is missing"));
return new MicrosoftSession(tokenType, accessToken, notAfter, new User(userId), new GameProfile(uuid, name));
return new MicrosoftSession(tokenType, accessToken, notAfter, refreshToken, new User(userId), new GameProfile(uuid, name));
}
public Map<Object, Object> toStorage() {
requireNonNull(profile);
requireNonNull(user);
return mapOf(pair("tokenType", tokenType), pair("accessToken", accessToken),
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())), pair("displayName", profile.getName()),
return mapOf(
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
pair("displayName", profile.getName()),
pair("tokenType", tokenType),
pair("accessToken", accessToken),
pair("refreshToken", refreshToken),
pair("notAfter", notAfter),
pair("userid", user.id));
}

View File

@ -96,21 +96,26 @@ public abstract class HttpRequest {
}
}
public static class HttpPostRequest extends HttpRequest {
public static final class HttpPostRequest extends HttpRequest {
private byte[] bytes;
public HttpPostRequest(URL url) {
super(url, "POST");
}
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
public HttpPostRequest json(Object payload) throws JsonParseException {
return string(payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
}
public HttpPostRequest form(Map<String, String> params) {
public final HttpPostRequest form(Map<String, String> params) {
return string(NetworkUtils.withQuery("", params), "application/x-www-form-urlencoded");
}
@SafeVarargs
public final HttpPostRequest form(Pair<String, String>... params) {
return form(mapOf(params));
}
public HttpPostRequest string(String payload, String contentType) {
bytes = payload.getBytes(UTF_8);
header("Content-Length", "" + bytes.length);