@@ -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 logi n, and query
* accounts.
* initiate login via the browser, poll for the resulting session toke n, 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 49152– 65535 is chosen on first use and persisted via Java
* preferences so that the same port is reused for future log in sessions .
* Returns the URL for the create-account page, with a newly generated game id appended as a
* query parameter. After open ing this URL in a browser, call {@link #startPolling} to wait
* for the result ing 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 listene r" , e ) ;
log . warning ( " Game auth poll erro r" , 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 ;
}