From 917d11009fe9d46a0de2af777d3d6e95594e6221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=9E=E5=BA=90?= <109708109+CiiLu@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:25:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=BE=AE=E8=BD=AF=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E4=BB=A3=E7=A0=81=E6=B5=81=20`client=5Fsecret`=20?= =?UTF-8?q?=E5=88=B0=20PKCE=20=20(#5575)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolves #5566 Co-authored-by: Glavo --- HMCL/build.gradle.kts | 2 - .../org/jackhuang/hmcl/game/OAuthServer.java | 66 +++++++++++++------ .../java/org/jackhuang/hmcl/auth/OAuth.java | 55 ++++++++++++---- 3 files changed, 88 insertions(+), 35 deletions(-) diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 3f0750f99..b9fd7c0d5 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") ?: "" @@ -154,7 +153,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 2a3217206..726494e38 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.JarUtils; 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 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; 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 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session { 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 final class OAuthServer extends NanoHTTPD implements OAuth.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 final class OAuthServer extends NanoHTTPD implements OAuth.Session { 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 f5d822203..51eec9436 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.HttpRequest; 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 class OAuth { 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 class OAuth { 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 class OAuth { } } + 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 class OAuth { } public interface Session { + String getState(); + + String getCodeVerifier(); String getRedirectURI(); @@ -243,10 +274,6 @@ public class OAuth { void openBrowser(GrantFlow grantFlow, String url) throws IOException; String getClientId(); - - String getClientSecret(); - - boolean isPublicClient(); } public enum GrantFlow {