Compare commits

...

10 Commits

Author SHA1 Message Date
Claridtimo 7a2878d246 build: depend on local-stack SNAPSHOTs (samskivert 1.12, depot 1.9)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:39:45 +12:00
Michael Bayne 225233ec7e [maven-release-plugin] prepare for next development iteration 2026-04-24 14:35:27 -07:00
Michael Bayne 0681062e4a [maven-release-plugin] prepare release ooo-user-1.6.7 2026-04-24 14:35:24 -07:00
Michael Bayne 40ca8a0dd1 Improve the API, add getCreateAccountUrl().
We don't need to expose the gameId to the caller, we can handle that all
internally.
2026-04-24 14:34:04 -07:00
Michael Bayne 6f86bc8bd9 [maven-release-plugin] prepare for next development iteration 2026-04-20 14:55:06 -07:00
Michael Bayne 54833bf70d [maven-release-plugin] prepare release ooo-user-1.6.6 2026-04-20 14:55:02 -07:00
Michael Bayne 23fc31c24e Support not saving the session token. 2026-04-20 14:54:06 -07:00
Michael Bayne 5108953fd3 [maven-release-plugin] prepare for next development iteration 2026-04-20 08:55:02 -07:00
Michael Bayne 6fe62fa67c [maven-release-plugin] prepare release ooo-user-1.6.5 2026-04-20 08:54:58 -07:00
Michael Bayne 379c6249c1 Switch to polling-based auth integration.
It turns out that having the web page talk to the running game client via HTTP
on localhost is fraught with various web-browser-dependent security limits and
complexity. This polling based system doesn't require any more of the web
browser than doing what it could do back in 1996.
2026-04-20 08:51:03 -07:00
2 changed files with 160 additions and 128 deletions
+2 -7
View File
@@ -45,12 +45,12 @@
<dependency>
<groupId>com.samskivert</groupId>
<artifactId>samskivert</artifactId>
<version>1.11.3</version>
<version>1.12-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.samskivert</groupId>
<artifactId>depot</artifactId>
<version>1.8.3</version>
<version>1.9-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.google.inject</groupId>
@@ -63,11 +63,6 @@
<version>6.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.nanohttpd</groupId>
<artifactId>nanohttpd</artifactId>
<version>2.3.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm -->
<dependency>
<groupId>de.mkammerer</groupId>
@@ -9,20 +9,16 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import java.util.prefs.Preferences;
import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.NanoHTTPD.Response.Status;
import static com.threerings.user.Log.log;
/**
* Manages game client interaction with the authosaurus authentication service. Provides methods to
* start/stop a local HTTP listener for receiving session tokens, initiate login, and query
* accounts.
* initiate login via the browser, poll for the resulting session token, and query accounts.
*/
public class AuthosaurusClient
{
@@ -39,16 +35,43 @@ public class AuthosaurusClient
/**
* Returns any currently saved session token, or null if no token has been stored.
* If token persistence is disabled, this always returns null (the token is only available
* in-memory via the session listener).
*/
public String getSessionToken ()
{
return _prefs.get(PREF_SESSION_TOKEN, null);
return _persistToken ? _prefs.get(PREF_SESSION_TOKEN, null) : null;
}
/**
* Registers a callback that will be notified when a new session token is received by the auth
* listener. The callback may be invoked on the HTTP server's request-handling thread; the
* caller is responsible for transferring control to the appropriate UI thread.
* Sets whether the session token should be persisted to Java Preferences. When set to false,
* the token is only delivered via the session listener and is not saved across client restarts.
* Defaults to true.
*
* <p>If persistence is disabled and a token is already stored in preferences, it is removed.</p>
*
* @param persist true to save tokens to preferences, false for in-memory only.
*/
public void setPersistToken (boolean persist)
{
_persistToken = persist;
if (!persist) {
_prefs.remove(PREF_SESSION_TOKEN);
}
}
/**
* Returns whether token persistence is enabled.
*/
public boolean getPersistToken ()
{
return _persistToken;
}
/**
* Registers a callback that will be notified when a new session token is received via polling.
* The callback will be invoked on the polling thread; the caller is responsible for
* transferring control to the appropriate UI thread.
*/
public void onSessionStarted (Consumer<String> callback)
{
@@ -56,64 +79,31 @@ public class AuthosaurusClient
}
/**
* Starts a local HTTP listener for receiving the session token delivered during the login
* flow. A random port in the range 4915265535 is chosen on first use and persisted via Java
* preferences so that the same port is reused for future login sessions.
* Returns the URL for the create-account page, with a newly generated game id appended as a
* query parameter. After opening this URL in a browser, call {@link #startPolling} to wait
* for the resulting session token.
*
* @return the port on which the listener is accepting connections.
* @return the create-account URL.
*/
public int startAuthListener ()
public String getCreateAccountUrl ()
{
int port = getOrCreateListenerPort();
if (_server != null) {
log.warning("Auth listener already running on port " + port);
return port;
}
try {
_server = new NanoHTTPD("127.0.0.1", port) {
@Override public Response serve (IHTTPSession session) {
if (!"/auth".equals(session.getUri())) {
return newFixedLengthResponse(Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not found");
}
return handleAuthRequest(session);
}
};
_server.start();
log.info("Auth listener started on port " + port);
} catch (IOException e) {
log.warning("Failed to start auth listener on port " + port, e);
}
return port;
}
/**
* Stops any active auth listener. If the listener is not running, logs a warning and no-ops.
*/
public void stopAuthListener ()
{
if (_server == null) {
log.warning("Auth listener is not running.");
return;
}
_server.stop();
_server = null;
log.info("Auth listener stopped.");
_gameId = UUID.randomUUID().toString();
return _authBaseUrl + "/create_account?id=" + _gameId;
}
/**
* Initiates a login session by calling the authosaurus /login endpoint with the supplied email
* address and port.
* address. Generates a new game id internally for correlating the browser login flow with
* subsequent poll requests. After calling this method, call {@link #startPolling} to wait for
* the resulting session token.
*
* @param email the user's email address.
* @param port the local listener port to which the session token should be delivered.
* @throws AuthException if the login request fails.
*/
public void startLogin (String email, int port) throws AuthException
public void startLogin (String email) throws AuthException
{
String gameId = UUID.randomUUID().toString();
_gameId = gameId;
try {
URL url = new URL(_authBaseUrl + "/login");
HttpURLConnection conn = (HttpURLConnection)url.openConnection();
@@ -122,7 +112,7 @@ public class AuthosaurusClient
conn.setDoOutput(true);
log.info("Requesting login link", "url", url);
String json = "{\"email\":" + jsonString(email) + ",\"port\":" + port + "}";
String json = "{\"email\":" + jsonString(email) + ",\"id\":" + jsonString(gameId) + "}";
try (OutputStream os = conn.getOutputStream()) {
os.write(json.getBytes(StandardCharsets.UTF_8));
}
@@ -142,6 +132,41 @@ public class AuthosaurusClient
}
}
/**
* Starts polling the authosaurus server for a session token associated with the current game
* id. Polling runs on a background daemon thread and stops automatically once a token is
* received or the login link expires.
*
* <p>When a token is received, it is stored in preferences and all registered listeners are
* notified.</p>
*
* @throws IllegalStateException if no game id has been set via {@link #startLogin} or
* {@link #getCreateAccountUrl}.
*/
public void startPolling ()
{
if (_gameId == null) {
throw new IllegalStateException(
"Call startLogin() or getCreateAccountUrl() before startPolling().");
}
stopPolling();
_polling = true;
Thread thread = new Thread(this::pollLoop, "AuthosaurusPoll");
thread.setDaemon(true);
thread.start();
log.info("Started polling for game auth token", "gameId", _gameId);
}
/**
* Stops any active polling.
*/
public void stopPolling ()
{
_polling = false;
}
/**
* Makes a synchronous HTTP request to the authosaurus /accounts endpoint to obtain the list of
* account names authorized by the supplied session token.
@@ -194,76 +219,85 @@ public class AuthosaurusClient
}
}
private NanoHTTPD.Response handleAuthRequest (NanoHTTPD.IHTTPSession session)
private void pollLoop ()
{
NanoHTTPD.Method method = session.getMethod();
// Handle CORS preflight
if (NanoHTTPD.Method.OPTIONS.equals(method)) {
NanoHTTPD.Response response = NanoHTTPD.newFixedLengthResponse(
Status.NO_CONTENT, NanoHTTPD.MIME_PLAINTEXT, "");
addCorsHeaders(response);
return response;
}
if (!NanoHTTPD.Method.POST.equals(method)) {
return NanoHTTPD.newFixedLengthResponse(
Status.METHOD_NOT_ALLOWED, NanoHTTPD.MIME_PLAINTEXT, "Method not allowed");
}
String token;
try {
// NanoHTTPD requires parsing body into files map for POST
Map<String, String> bodyMap = new java.util.HashMap<>();
session.parseBody(bodyMap);
// For plain text/non-form POSTs, the body is in "postData"
token = bodyMap.getOrDefault("postData", "").trim();
} catch (IOException | NanoHTTPD.ResponseException e) {
log.warning("Failed to read auth request body", e);
return NanoHTTPD.newFixedLengthResponse(
Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "Error reading body");
}
if (token.isEmpty()) {
return NanoHTTPD.newFixedLengthResponse(
Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Empty token");
}
// Store the session token
_prefs.put(PREF_SESSION_TOKEN, token);
log.info("Session token received and stored.");
NanoHTTPD.Response response = NanoHTTPD.newFixedLengthResponse(
Status.OK, NanoHTTPD.MIME_PLAINTEXT, "");
addCorsHeaders(response);
// Notify listeners
for (Consumer<String> listener : _listeners) {
while (_polling) {
try {
listener.accept(token);
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException e) {
break;
}
if (!_polling) break;
try {
URL url = new URL(_authBaseUrl + "/api/auth/game-poll?id=" + _gameId);
HttpURLConnection conn = (HttpURLConnection)url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(10_000);
int status = conn.getResponseCode();
String body = readResponseBody(conn, status >= 400);
if (status == 410) {
// Login expired
log.info("Game auth poll: login expired.");
_polling = false;
break;
}
if (status != 200) {
log.warning("Game auth poll: unexpected status " + status);
continue;
}
// Parse "status" and "token" from the JSON response.
// The response is simple enough to parse without a JSON library.
String pollStatus = extractJsonString(body, "status");
if ("complete".equals(pollStatus)) {
String token = extractJsonString(body, "token");
if (token != null && !token.isEmpty()) {
if (_persistToken) {
_prefs.put(PREF_SESSION_TOKEN, token);
log.info("Session token received via polling and stored.");
} else {
log.info("Session token received via polling (not persisted).");
}
_polling = false;
for (Consumer<String> listener : _listeners) {
try {
listener.accept(token);
} catch (Exception e) {
log.warning("Error in session listener", e);
}
}
break;
}
}
// status == "pending", keep polling
} catch (Exception e) {
log.warning("Error in session listener", e);
log.warning("Game auth poll error", e);
}
}
return response;
}
private static void addCorsHeaders (NanoHTTPD.Response response)
/**
* Extracts a string value for the given key from a simple JSON object.
* Only handles flat objects with string values — sufficient for our poll response.
*/
private static String extractJsonString (String json, String key)
{
response.addHeader("Access-Control-Allow-Origin", "*");
response.addHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
response.addHeader("Access-Control-Allow-Headers", "Content-Type");
}
private int getOrCreateListenerPort ()
{
int port = _prefs.getInt(PREF_LISTENER_PORT, -1);
if (port == -1) {
port = 49152 + (int)(Math.random() * (65535 - 49152 + 1));
_prefs.putInt(PREF_LISTENER_PORT, port);
}
return port;
String needle = "\"" + key + "\"";
int keyIdx = json.indexOf(needle);
if (keyIdx == -1) return null;
int colonIdx = json.indexOf(':', keyIdx + needle.length());
if (colonIdx == -1) return null;
int openQuote = json.indexOf('"', colonIdx + 1);
if (openQuote == -1) return null;
int closeQuote = json.indexOf('"', openQuote + 1);
if (closeQuote == -1) return null;
return json.substring(openQuote + 1, closeQuote);
}
private static AuthException mapErrorResponse (int status, String body)
@@ -300,8 +334,11 @@ public class AuthosaurusClient
private final String _authBaseUrl;
private final Preferences _prefs = Preferences.userNodeForPackage(AuthosaurusClient.class);
private final CopyOnWriteArrayList<Consumer<String>> _listeners = new CopyOnWriteArrayList<>();
private NanoHTTPD _server;
private static final String PREF_LISTENER_PORT = "authListenerPort";
private String _gameId;
private volatile boolean _polling;
private volatile boolean _persistToken = true;
private static final String PREF_SESSION_TOKEN = "sessionToken";
private static final long POLL_INTERVAL_MS = 3000;
}