diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 96275f51bc..4916ec9e8e 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -28,7 +28,6 @@ val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" els val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3" val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: "" -val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: "" val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: "" val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: "" @@ -153,7 +152,6 @@ val hmclProperties = buildList { } add("hmcl.version.type" to versionType) add("hmcl.microsoft.auth.id" to microsoftAuthId) - add("hmcl.microsoft.auth.secret" to microsoftAuthSecret) add("hmcl.curseforge.apikey" to curseForgeApiKey) add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!) } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java index 2a32172069..726494e38f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java @@ -29,9 +29,8 @@ import org.jackhuang.hmcl.util.io.NetworkUtils; import java.io.IOException; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; +import java.security.SecureRandom; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -43,6 +42,8 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session { private final int port; private final CompletableFuture future = new CompletableFuture<>(); + private final String codeVerifier; + private final String state; public static String lastlyOpenedURL; @@ -52,6 +53,34 @@ private OAuthServer(int port) { super(port); this.port = port; + + var encoder = Base64.getUrlEncoder().withoutPadding(); + var random = new SecureRandom(); + + { + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + // https://datatracker.ietf.org/doc/html/rfc6749#section-10.12 + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + this.state = encoder.encodeToString(bytes); + } + + { + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + byte[] bytes = new byte[64]; + random.nextBytes(bytes); + this.codeVerifier = encoder.encodeToString(bytes); + } + } + + @Override + public String getCodeVerifier() { + return codeVerifier; + } + + @Override + public String getState() { + return state; } @Override @@ -93,12 +122,22 @@ public Response serve(IHTTPSession session) { String parameters = session.getQueryParameterString(); Map query = mapOf(NetworkUtils.parseQuery(parameters)); - if (query.containsKey("code")) { - idToken = query.get("id_token"); - future.complete(query.get("code")); + + String code = query.get("code"); + if (code != null) { + if (this.state.equals(query.get("state"))) { + idToken = query.get("id_token"); + future.complete(code); + } else if (query.containsKey("state")) { + LOG.warning("Failed to authenticate: invalid state in parameters"); + future.completeExceptionally(new AuthenticationException("Failed to authenticate: invalid state")); + } else { + LOG.warning("Failed to authenticate: missing state in parameters"); + future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing state")); + } } else { - LOG.warning("Error: " + parameters); - future.completeExceptionally(new AuthenticationException("failed to authenticate")); + LOG.warning("Failed to authenticate: missing authorization code in parameters"); + future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code")); } String html; @@ -168,17 +207,6 @@ public String getClientId() { return System.getProperty("hmcl.microsoft.auth.id", JarUtils.getAttribute("hmcl.microsoft.auth.id", "")); } - - @Override - public String getClientSecret() { - return System.getProperty("hmcl.microsoft.auth.secret", - JarUtils.getAttribute("hmcl.microsoft.auth.secret", "")); - } - - @Override - public boolean isPublicClient() { - return true; // We have turned on the device auth flow. - } } public static class GrantDeviceCodeEvent extends Event { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java index f5d8222031..51eec94366 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java @@ -24,12 +24,16 @@ import org.jackhuang.hmcl.util.io.NetworkUtils; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class OAuth { public static final OAuth MICROSOFT = new OAuth( @@ -77,17 +81,31 @@ public Result authenticate(GrantFlow grantFlow, Options options) throws Authenti private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException { Session session = options.callback.startServer(); + + String codeVerifier = session.getCodeVerifier(); + String state = session.getState(); + String codeChallenge = generateCodeChallenge(codeVerifier); + options.callback.openBrowser(GrantFlow.AUTHORIZATION_CODE, NetworkUtils.withQuery(authorizationURL, - mapOf(pair("client_id", options.callback.getClientId()), pair("response_type", "code"), - pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope), - pair("prompt", "select_account")))); + mapOf(pair("client_id", options.callback.getClientId()), + pair("response_type", "code"), + pair("redirect_uri", session.getRedirectURI()), + pair("scope", options.scope), + pair("prompt", "select_account"), + pair("code_challenge", codeChallenge), + pair("state", state), + pair("code_challenge_method", "S256") + ))); String code = session.waitFor(); // Authorization Code -> Token AuthorizationResponse response = HttpRequest.POST(accessTokenURL) - .form(pair("client_id", options.callback.getClientId()), pair("code", code), - pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()), - pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope)) + .form(pair("client_id", options.callback.getClientId()), + pair("code", code), + pair("grant_type", "authorization_code"), + pair("code_verifier", codeVerifier), + pair("redirect_uri", session.getRedirectURI()), + pair("scope", options.scope)) .ignoreHttpCode() .retry(5) .getJson(AuthorizationResponse.class); @@ -153,10 +171,6 @@ public Result refresh(String refreshToken, Options options) throws Authenticatio pair("grant_type", "refresh_token") ); - if (!options.callback.isPublicClient()) { - query.put("client_secret", options.callback.getClientSecret()); - } - RefreshResponse response = HttpRequest.POST(tokenURL) .form(query) .accept("application/json") @@ -174,6 +188,20 @@ public Result refresh(String refreshToken, Options options) throws Authenticatio } } + private static String generateCodeChallenge(String codeVerifier) { + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + try { + byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII); + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + messageDigest.update(bytes, 0, bytes.length); + byte[] digest = messageDigest.digest(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (Exception e) { + LOG.warning("Failed to generate code challenge", e); + throw new RuntimeException(e); + } + } + private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException { if (response.error == null || response.errorDescription == null) { return; @@ -207,6 +235,9 @@ public Options setUserAgent(String userAgent) { } public interface Session { + String getState(); + + String getCodeVerifier(); String getRedirectURI(); @@ -243,10 +274,6 @@ public interface Callback { void openBrowser(GrantFlow grantFlow, String url) throws IOException; String getClientId(); - - String getClientSecret(); - - boolean isPublicClient(); } public enum GrantFlow {