Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions HMCL/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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") ?: ""
Expand Down Expand Up @@ -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!!)
}
Expand Down
66 changes: 47 additions & 19 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -43,6 +42,8 @@
public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>();
private final String codeVerifier;
private final String state;

public static String lastlyOpenedURL;

Expand All @@ -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;
}
Comment on lines 45 to 79
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PKCE verifier is generated and stored, but there is still no per-request state nonce stored on the session and validated in serve() before completing the future. Adding a state field here (and exposing it via OAuth.Session, e.g. getState()) would allow the authorize URL to include state and the callback to reject mismatches.

Copilot uses AI. Check for mistakes.

@Override
public String getState() {
return state;
}

@Override
Expand Down Expand Up @@ -93,12 +122,22 @@ public Response serve(IHTTPSession session) {
String parameters = session.getQueryParameterString();

Map<String, String> 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;
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 41 additions & 14 deletions HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
)));
Comment on lines 89 to 98
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorization code flow adds PKCE, but the request still omits the OAuth state parameter. Without generating and validating state, the localhost callback can be vulnerable to CSRF/login-swapping (accepting a code not initiated by this client). Consider generating a random state per auth attempt, include it in the authorize URL, and verify it in the callback handler before accepting the code.

Copilot uses AI. Check for mistakes.
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);
Expand Down Expand Up @@ -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")
Expand All @@ -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);
}
Comment on lines +193 to +202
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateCodeChallenge catches a broad Exception, logs, and then throws a generic RuntimeException. Since this is deterministic code (SHA-256 should always be available), consider narrowing the catch to NoSuchAlgorithmException (or treating it as an AssertionError/IllegalStateException) so unexpected runtime errors aren’t accidentally swallowed and rethrown with less context.

Copilot uses AI. Check for mistakes.
}

private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException {
if (response.error == null || response.errorDescription == null) {
return;
Expand Down Expand Up @@ -207,6 +235,9 @@ public Options setUserAgent(String userAgent) {
}

public interface Session {
String getState();

String getCodeVerifier();

String getRedirectURI();

Expand Down Expand Up @@ -243,10 +274,6 @@ public interface Callback {
void openBrowser(GrantFlow grantFlow, String url) throws IOException;

String getClientId();

String getClientSecret();

boolean isPublicClient();
}

public enum GrantFlow {
Expand Down