Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 23d0f98179 Implement server-mediated OAuth polling flow
Replace deep link-based OAuth with server-mediated polling:

Client changes:
- AuthClient now uses PollForOAuthCompletionAsync instead of ExchangeCodeAsync
- OAuthManager no longer needs deep link handling
- Client polls CheckOAuthStatus every 2s until success/failure/timeout

Server changes:
- OAuthService now stores pending/completed OAuth sessions by state
- OAuthServiceImpl handles callback internally, stores result for polling
- AuthServiceImpl implements checkOAuthStatus gRPC method
- New OAuthHttpHandler provides HTTP endpoint for OAuth callback
- Callback shows "Login successful" or error page to user

Proto changes:
- Add CheckOAuthStatus RPC and messages
- Add OAuthStatus enum (PENDING, SUCCESS, FAILED, EXPIRED)
- Remove ExchangeCode RPC (no longer needed)

This approach works in Unity Editor and all platforms without URL scheme
registration. Client just opens browser and polls for completion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:52:53 -08:00
adminandClaude Opus 4.5 bd045579c0 Simplify OAuth to server-mediated flow with polling
Replace ExchangeCode RPC with CheckOAuthStatus for simpler OAuth flow:
- Client calls GetOAuthUrl, opens returned URL in browser
- Server handles OAuth callback and token exchange
- Client polls CheckOAuthStatus until success/failure

This eliminates the need for custom URL schemes (eagle0://) and works
in Unity Editor, all desktop platforms, and mobile without any OS-level
URL handler registration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:32:10 -08:00
admin 6b3e3e61f4 Revert "Add post-build scripts to register eagle0:// URL scheme for OAuth"
This reverts commit 53f4195152.
2025-12-31 15:28:09 -08:00
adminandClaude Opus 4.5 53f4195152 Add post-build scripts to register eagle0:// URL scheme for OAuth
URLSchemePostBuild.cs handles macOS and iOS builds by modifying Info.plist
to register the eagle0:// custom URL scheme for OAuth callbacks.

WindowsURLScheme.cs generates a register_url_scheme.reg file that users
can run as Administrator to register the URL scheme on Windows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:21:37 -08:00
adminandClaude Opus 4.5 000eba3d0b Update OAuth redirect URI to use Cloudflare Worker relay
Discord doesn't support custom URL schemes (eagle0://) as redirect URIs.
Use the Cloudflare Worker at workers.dev to relay the callback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:13:36 -08:00
adminandClaude Opus 4.5 b8fc9cafbe Add OAuth toggle button and update Unity assets
- Add useLegacyAuthButtonText field to toggle button text
- Toggle between OAuth and Classic sign-in views
- Update button images and Unity scene

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:13:36 -08:00
adminandClaude Opus 4.5 5a195ce96f Fix AuthClient naming collision with generated gRPC client
- Add type alias for generated Auth.AuthClient to avoid namespace collision
- Use CallInvoker with JwtAuthInterceptor (matching Eagle client pattern)
- Add Unity meta files for Auth components and OAuth buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:13:35 -08:00
10 changed files with 432 additions and 230 deletions
@@ -11,18 +11,15 @@ using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
namespace Auth { namespace Auth {
/// <summary> /// <summary>
/// gRPC client for the Auth service. /// gRPC client for the Auth service.
/// Handles OAuth URL generation, code exchange, and token refresh. /// Handles OAuth URL generation, status polling, and token refresh.
/// </summary> /// </summary>
public class AuthClient : IDisposable { public class AuthClient : IDisposable {
private const string RedirectUri = "eagle0://auth/callback"; private const int PollIntervalMs = 2000; // Poll every 2 seconds
private const int PollTimeoutMs = 300000; // 5 minute timeout
private readonly GrpcAuthClient _client; private readonly GrpcAuthClient _client;
private readonly GrpcChannel _channel; private readonly GrpcChannel _channel;
// State parameter for CSRF protection (stored between GetOAuthUrl and ExchangeCode)
private string _pendingState;
private OAuthProvider _pendingProvider;
/// <summary> /// <summary>
/// Create auth client for the given server URL. /// Create auth client for the given server URL.
/// </summary> /// </summary>
@@ -43,51 +40,56 @@ namespace Auth {
/// <summary> /// <summary>
/// Get OAuth URL to open in system browser. /// Get OAuth URL to open in system browser.
/// Returns both the URL and the state token for polling.
/// </summary> /// </summary>
/// <param name="provider">Discord or Google</param> /// <param name="provider">Discord or Google</param>
/// <returns>URL to open in browser</returns> /// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<string> GetOAuthUrlAsync(OAuthProvider provider) { public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider, RedirectUri = RedirectUri }; var request = new GetOAuthUrlRequest { Provider = provider };
var response = await _client.GetOAuthUrlAsync(request); var response = await _client.GetOAuthUrlAsync(request);
// Store state for validation when code comes back
_pendingState = response.State;
_pendingProvider = provider;
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}"); Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
return response.AuthUrl; return (response.AuthUrl, response.State);
} }
/// <summary> /// <summary>
/// Exchange authorization code for JWT tokens. /// Poll for OAuth completion. Blocks until success, failure, or timeout.
/// Call this after receiving the OAuth callback. /// The server handles the OAuth callback and token exchange.
/// </summary> /// </summary>
/// <param name="code">Authorization code from callback</param> /// <param name="state">State token from GetOAuthUrlAsync</param>
/// <param name="state">State parameter from callback (for CSRF validation)</param> /// <returns>Response with tokens and user info on success</returns>
/// <returns>Exchange response with tokens and user info</returns> public async Task<CheckOAuthStatusResponse> PollForOAuthCompletionAsync(string state) {
public async Task<ExchangeCodeResponse> ExchangeCodeAsync(string code, string state) { var startTime = DateTime.UtcNow;
// Validate state matches what we sent
if (state != _pendingState) { while (true) {
throw new InvalidOperationException( var request = new CheckOAuthStatusRequest { State = state };
$"State mismatch: expected {_pendingState}, got {state}. Possible CSRF attack."); var response = await _client.CheckOAuthStatusAsync(request);
switch (response.Status) {
case OAuthStatus.Success:
Debug.Log(
$"[AuthClient] OAuth successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
return response;
case OAuthStatus.Failed:
throw new Exception($"OAuth failed: {response.ErrorMessage}");
case OAuthStatus.Expired:
throw new Exception("OAuth session expired. Please try again.");
case OAuthStatus.Pending:
// Check timeout
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
throw new TimeoutException("OAuth login timed out. Please try again.");
}
// Wait before polling again
await Task.Delay(PollIntervalMs);
break;
default: throw new Exception($"Unknown OAuth status: {response.Status}");
}
} }
var request = new ExchangeCodeRequest {
Provider = _pendingProvider,
AuthorizationCode = code,
State = state,
RedirectUri = RedirectUri
};
var response = await _client.ExchangeCodeAsync(request);
// Clear pending state
_pendingState = null;
Debug.Log(
$"[AuthClient] Code exchange successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
return response;
} }
/// <summary> /// <summary>
@@ -1,14 +1,13 @@
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Net.Eagle0.Eagle.Api.Auth; using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine; using UnityEngine;
namespace Auth { namespace Auth {
/// <summary> /// <summary>
/// Manages OAuth authentication flow including: /// Manages OAuth authentication flow using server-mediated polling:
/// - Opening system browser for OAuth consent /// - Opens system browser for OAuth consent
/// - Handling deep link callbacks (eagle0://auth/callback) /// - Polls server for OAuth completion (no deep links needed)
/// - Token storage and refresh /// - Token storage and refresh
/// </summary> /// </summary>
public class OAuthManager : MonoBehaviour { public class OAuthManager : MonoBehaviour {
@@ -16,7 +15,6 @@ namespace Auth {
private AuthClient _authClient; private AuthClient _authClient;
private string _currentServerUrl; private string _currentServerUrl;
private TaskCompletionSource<ExchangeCodeResponse> _pendingAuth;
// Events for UI updates // Events for UI updates
public event Action<UserInfo> OnLoginSuccess; public event Action<UserInfo> OnLoginSuccess;
@@ -54,18 +52,6 @@ namespace Auth {
Debug.Log($"[OAuthManager] Configured for server: {serverUrl}"); Debug.Log($"[OAuthManager] Configured for server: {serverUrl}");
} }
private void OnEnable() {
// Register for deep link callbacks
Application.deepLinkActivated += OnDeepLinkActivated;
// Check if app was launched via deep link
if (!string.IsNullOrEmpty(Application.absoluteURL)) {
OnDeepLinkActivated(Application.absoluteURL);
}
}
private void OnDisable() { Application.deepLinkActivated -= OnDeepLinkActivated; }
private void OnDestroy() { private void OnDestroy() {
_authClient?.Dispose(); _authClient?.Dispose();
if (Instance == this) { Instance = null; } if (Instance == this) { Instance = null; }
@@ -80,23 +66,20 @@ namespace Auth {
/// <summary> /// <summary>
/// Start OAuth login with specified provider. /// Start OAuth login with specified provider.
/// Opens system browser for user consent. /// Opens system browser for user consent, then polls for completion.
/// </summary> /// </summary>
public async Task<ExchangeCodeResponse> LoginAsync(OAuthProvider provider) { public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured(); EnsureConfigured();
try { try {
// Get OAuth URL from server // Get OAuth URL and state from server
var authUrl = await _authClient.GetOAuthUrlAsync(provider); var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Create completion source for callback
_pendingAuth = new TaskCompletionSource<ExchangeCodeResponse>();
// Open system browser // Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}"); Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
Application.OpenURL(authUrl); Application.OpenURL(authUrl);
// Wait for deep link callback // Poll for OAuth completion (server handles the callback)
var response = await _pendingAuth.Task; var response = await _authClient.PollForOAuthCompletionAsync(state);
// Store tokens // Store tokens
TokenStorage.StoreTokens( TokenStorage.StoreTokens(
@@ -118,44 +101,6 @@ namespace Auth {
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}"); Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message); OnLoginFailed?.Invoke(ex.Message);
throw; throw;
} finally { _pendingAuth = null; }
}
/// <summary>
/// Handle deep link callback from OAuth provider.
/// URL format: eagle0://auth/callback?code=xxx&state=yyy
/// </summary>
private async void OnDeepLinkActivated(string url) {
Debug.Log($"[OAuthManager] Deep link received: {url}");
if (!url.StartsWith("eagle0://auth/callback")) { return; }
try {
// Parse query parameters
var queryParams = ParseQueryString(url);
if (queryParams.TryGetValue("error", out var error)) {
var errorDesc =
queryParams.GetValueOrDefault("error_description", "Unknown error");
throw new Exception($"OAuth error: {error} - {errorDesc}");
}
if (!queryParams.TryGetValue("code", out var code)) {
throw new Exception("No authorization code in callback");
}
if (!queryParams.TryGetValue("state", out var state)) {
throw new Exception("No state parameter in callback");
}
// Exchange code for tokens
var response = await _authClient.ExchangeCodeAsync(code, state);
// Complete pending auth task
_pendingAuth?.TrySetResult(response);
} catch (Exception ex) {
Debug.LogError($"[OAuthManager] Callback handling failed: {ex.Message}");
_pendingAuth?.TrySetException(ex);
} }
} }
@@ -234,27 +179,5 @@ namespace Auth {
} }
OnLogout?.Invoke(); OnLogout?.Invoke();
} }
/// <summary>
/// Parse query string from URL.
/// </summary>
private static Dictionary<string, string> ParseQueryString(string url) {
var result = new Dictionary<string, string>();
var queryStart = url.IndexOf('?');
if (queryStart < 0) return result;
var query = url.Substring(queryStart + 1);
var pairs = query.Split('&');
foreach (var pair in pairs) {
var parts = pair.Split('=');
if (parts.Length == 2) {
result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]);
}
}
return result;
}
} }
} }
@@ -13,11 +13,13 @@ option objc_class_prefix = "E0A";
// Authentication service for OAuth login // Authentication service for OAuth login
service Auth { service Auth {
// Get OAuth URL to open in system browser // Get OAuth URL to open in system browser.
// The server handles the OAuth callback - client polls CheckOAuthStatus.
rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {} rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {}
// Exchange authorization code for JWT tokens // Poll for OAuth completion. Call this after opening the URL from GetOAuthUrl.
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {} // The server receives the OAuth callback and exchanges the code for tokens.
rpc CheckOAuthStatus(CheckOAuthStatusRequest) returns (CheckOAuthStatusResponse) {}
// Set or update display name (requires valid JWT) // Set or update display name (requires valid JWT)
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {} rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
@@ -42,28 +44,36 @@ enum OAuthProvider {
// Request to initiate OAuth flow // Request to initiate OAuth flow
message GetOAuthUrlRequest { message GetOAuthUrlRequest {
OAuthProvider provider = 1; OAuthProvider provider = 1;
string redirect_uri = 2; // e.g., "eagle0://auth/callback"
} }
message GetOAuthUrlResponse { message GetOAuthUrlResponse {
string auth_url = 1; // Full OAuth URL to open in system browser string auth_url = 1; // Full OAuth URL to open in system browser
string state = 2; // State parameter for CSRF protection string state = 2; // State parameter - use this to poll CheckOAuthStatus
} }
// Exchange authorization code for JWT // Poll for OAuth completion status
message ExchangeCodeRequest { message CheckOAuthStatusRequest {
OAuthProvider provider = 1; string state = 1; // State from GetOAuthUrlResponse
string authorization_code = 2;
string state = 3; // Must match state from GetOAuthUrlResponse
string redirect_uri = 4; // Must match exactly what was used in GetOAuthUrlRequest
} }
message ExchangeCodeResponse { message CheckOAuthStatusResponse {
string access_token = 1; // JWT for API access OAuthStatus status = 1;
string refresh_token = 2; // Long-lived token for getting new access tokens // Only set when status is OAUTH_STATUS_SUCCESS:
int64 expires_at = 3; // Unix timestamp when access_token expires string access_token = 2;
UserInfo user = 4; string refresh_token = 3;
bool is_new_user = 5; // True if user needs to set display name int64 expires_at = 4;
UserInfo user = 5;
bool is_new_user = 6;
// Only set when status is OAUTH_STATUS_FAILED:
string error_message = 7;
}
enum OAuthStatus {
OAUTH_STATUS_UNSPECIFIED = 0;
OAUTH_STATUS_PENDING = 1; // User hasn't completed OAuth yet
OAUTH_STATUS_SUCCESS = 2; // OAuth complete, tokens available
OAUTH_STATUS_FAILED = 3; // OAuth failed (user denied, etc.)
OAUTH_STATUS_EXPIRED = 4; // State token expired (typically 10 minutes)
} }
// User information returned from auth endpoints // User information returned from auth endpoints
@@ -21,6 +21,7 @@ scala_binary(
"//src/main/scala/net/eagle0/eagle/service:custom_battle_manager", "//src/main/scala/net/eagle0/eagle/service:custom_battle_manager",
"//src/main/scala/net/eagle0/eagle/service:exception_interceptor", "//src/main/scala/net/eagle0/eagle/service:exception_interceptor",
"//src/main/scala/net/eagle0/eagle/service:games_manager", "//src/main/scala/net/eagle0/eagle/service:games_manager",
"//src/main/scala/net/eagle0/eagle/service:oauth_http_handler",
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers", "//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
"//src/main/scala/net/eagle0/eagle/service/persistence:async_s3_persister", "//src/main/scala/net/eagle0/eagle/service/persistence:async_s3_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister", "//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
+33 -4
View File
@@ -8,7 +8,7 @@ import io.grpc.{Server, ServerBuilder}
import net.eagle0.common.{FunctionalRandom, SeededRandom, SimpleTimedLogger} import net.eagle0.common.{FunctionalRandom, SeededRandom, SimpleTimedLogger}
import net.eagle0.eagle.api.auth.auth.AuthGrpc import net.eagle0.eagle.api.auth.auth.AuthGrpc
import net.eagle0.eagle.api.eagle.EagleGrpc import net.eagle0.eagle.api.eagle.EagleGrpc
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthServiceImpl, UserServiceImpl} import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthService, OAuthServiceImpl, UserServiceImpl}
import net.eagle0.eagle.service.* import net.eagle0.eagle.service.*
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory} import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
@@ -25,6 +25,12 @@ object Main {
private val gptModelNameKey = Symbol("gptModelName") private val gptModelNameKey = Symbol("gptModelName")
private val serverBaseUrlKey = Symbol("serverBaseUrl")
private val defaultServerBaseUrl = "https://prod.eagle0.net"
private val oauthHttpPortKey = Symbol("oauthHttpPort")
private val defaultOauthHttpPort = "8080"
def parsedOptions(args: Array[String]): Map[Symbol, String] = { def parsedOptions(args: Array[String]): Map[Symbol, String] = {
def nextOption( def nextOption(
map: Map[Symbol, String], map: Map[Symbol, String],
@@ -43,6 +49,16 @@ object Main {
map ++ Map(gptModelNameKey -> value), map ++ Map(gptModelNameKey -> value),
tail tail
) )
case "--server-base-url" +: value +: tail =>
nextOption(
map ++ Map(serverBaseUrlKey -> value),
tail
)
case "--oauth-http-port" +: value +: tail =>
nextOption(
map ++ Map(oauthHttpPortKey -> value),
tail
)
case option +: _ => case option +: _ =>
throw new IllegalArgumentException("Unknown option " + option) throw new IllegalArgumentException("Unknown option " + option)
@@ -77,11 +93,24 @@ object Main {
val customBattleManager = newCustomBattleManager(shardokInterfaceAddress) val customBattleManager = newCustomBattleManager(shardokInterfaceAddress)
customBattleManager.begin() customBattleManager.begin()
val serverBaseUrl =
options.getOrElse(serverBaseUrlKey, defaultServerBaseUrl)
// Create OAuth service (shared between gRPC and HTTP)
val oauthService = new OAuthServiceImpl(serverBaseUrl)
// Start OAuth HTTP server for callbacks
val oauthHttpPort =
options.getOrElse(oauthHttpPortKey, defaultOauthHttpPort).toInt
val oauthHttpHandler = new OAuthHttpHandler(oauthService, oauthHttpPort)
oauthHttpHandler.start()
val server = buildServer( val server = buildServer(
gamesManager, gamesManager,
customBattleManager, customBattleManager,
options.getOrElse(eagleGrpcPortKey, defaultEagleGrpcPort).toInt, options.getOrElse(eagleGrpcPortKey, defaultEagleGrpcPort).toInt,
SeededRandom(random.nextLong()) SeededRandom(random.nextLong()),
oauthService
).start ).start
SimpleTimedLogger.printLogger.logLine( SimpleTimedLogger.printLogger.logLine(
s"Eagle server is listening on port ${server.getPort}" s"Eagle server is listening on port ${server.getPort}"
@@ -94,7 +123,8 @@ object Main {
gamesManager: GamesManager, gamesManager: GamesManager,
customBattleManager: CustomBattleManager, customBattleManager: CustomBattleManager,
grpcPort: Int, grpcPort: Int,
functionalRandom: FunctionalRandom functionalRandom: FunctionalRandom,
oauthService: OAuthService
): Server = { ): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort) val serverBuilder = ServerBuilder.forPort(grpcPort)
@@ -102,7 +132,6 @@ object Main {
val jwtService: Option[JwtService] = JwtServiceImpl.fromEnvironment() val jwtService: Option[JwtService] = JwtServiceImpl.fromEnvironment()
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory) val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val userService = new UserServiceImpl(authPersister) val userService = new UserServiceImpl(authPersister)
val oauthService = new OAuthServiceImpl()
// Add Eagle game service // Add Eagle game service
serverBuilder.addService( serverBuilder.addService(
@@ -28,6 +28,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__", "//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__", "//src/test/scala/net/eagle0/eagle:__subpackages__",
], ],
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
],
deps = [ deps = [
":oauth_config", ":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc", "//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
@@ -24,22 +24,30 @@ case class ProviderUserInfo(
username: String username: String
) )
/** Result of OAuth flow */
sealed trait OAuthResult
case class OAuthSuccess(providerInfo: ProviderUserInfo, provider: OAuthProvider) extends OAuthResult
case class OAuthFailure(error: String) extends OAuthResult
case object OAuthPending extends OAuthResult
case object OAuthExpired extends OAuthResult
/** Service for OAuth code exchange */ /** Service for OAuth code exchange */
trait OAuthService { trait OAuthService {
/** Generate OAuth authorization URL */ /** Generate OAuth authorization URL. Server handles callback internally. */
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state) def getAuthUrl(provider: OAuthProvider): (String, String) // (url, state)
/** Exchange authorization code for user info */ /** Check status of OAuth flow for given state */
def exchangeCode( def checkStatus(state: String): OAuthResult
provider: OAuthProvider,
code: String, /** Handle OAuth callback - exchange code for user info and store result */
state: String, def handleCallback(code: String, state: String): Either[String, ProviderUserInfo]
redirectUri: String
): Either[String, ProviderUserInfo] /** Get the server's OAuth callback URL */
def callbackUrl: String
} }
class OAuthServiceImpl extends OAuthService { class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
private implicit val formats: DefaultFormats.type = DefaultFormats private implicit val formats: DefaultFormats.type = DefaultFormats
private val json = new Json(formats) private val json = new Json(formats)
@@ -49,25 +57,29 @@ class OAuthServiceImpl extends OAuthService {
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.build() .build()
// State parameter storage with timestamp (for CSRF protection) // Pending OAuth sessions: state -> (provider, timestamp)
private val stateStore = TrieMap[String, Long]() private val pendingOAuth = TrieMap[String, (OAuthProvider, Long)]()
// Completed OAuth results: state -> result
private val completedOAuth = TrieMap[String, OAuthResult]()
// State expiration (10 minutes) // State expiration (10 minutes)
private val stateExpirationMs = 600000L private val stateExpirationMs = 600000L
override def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) = { override def callbackUrl: String = s"$serverBaseUrl/oauth/callback"
override def getAuthUrl(provider: OAuthProvider): (String, String) = {
val config = getConfig(provider) val config = getConfig(provider)
val state = UUID.randomUUID().toString val state = UUID.randomUUID().toString
stateStore.put(state, System.currentTimeMillis()) pendingOAuth.put(state, (provider, System.currentTimeMillis()))
// Clean old states // Clean old states
val cutoff = System.currentTimeMillis() - stateExpirationMs cleanupExpiredStates()
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
val params = Map( val params = Map(
"client_id" -> config.clientId, "client_id" -> config.clientId,
"redirect_uri" -> redirectUri, "redirect_uri" -> callbackUrl,
"response_type" -> "code", "response_type" -> "code",
"scope" -> config.scopes.mkString(" "), "scope" -> config.scopes.mkString(" "),
"state" -> state "state" -> state
@@ -83,30 +95,67 @@ class OAuthServiceImpl extends OAuthService {
(url, state) (url, state)
} }
override def exchangeCode( override def checkStatus(state: String): OAuthResult =
provider: OAuthProvider, // First check completed results
code: String, completedOAuth.get(state) match {
state: String, case Some(result) => result
redirectUri: String case None =>
): Either[String, ProviderUserInfo] = { // Check if still pending
// Verify state pendingOAuth.get(state) match {
stateStore.remove(state) match { case Some((_, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
case Some(timestamp) if System.currentTimeMillis() - timestamp < stateExpirationMs => OAuthPending
// Valid state case Some(_) =>
case _ => // Expired
return Left("Invalid or expired state parameter") pendingOAuth.remove(state)
OAuthExpired
case None =>
// Unknown state - might be expired and cleaned up
OAuthExpired
}
} }
val config = getConfig(provider) override def handleCallback(code: String, state: String): Either[String, ProviderUserInfo] =
// Get pending OAuth info
pendingOAuth.remove(state) match {
case Some((provider, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
val config = getConfig(provider)
// Exchange code for access token // Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code, redirectUri) val tokenResult = exchangeCodeForToken(config, code)
tokenResult match { tokenResult match {
case Left(error) => Left(error) case Left(error) =>
case Right(accessToken) => completedOAuth.put(state, OAuthFailure(error))
// Fetch user info Left(error)
fetchUserInfo(provider, config, accessToken) case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken) match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
case Right(userInfo) =>
completedOAuth.put(state, OAuthSuccess(userInfo, provider))
Right(userInfo)
}
}
case Some(_) =>
completedOAuth.put(state, OAuthExpired)
Left("OAuth session expired")
case None =>
Left("Invalid or expired state parameter")
} }
private def cleanupExpiredStates(): Unit = {
val cutoff = System.currentTimeMillis() - stateExpirationMs
pendingOAuth.filterInPlace((_, v) => v._2 > cutoff)
completedOAuth.filterInPlace((state, _) =>
pendingOAuth.contains(state) ||
completedOAuth.get(state).exists {
case OAuthSuccess(_, _) => true // Keep successes for a bit
case _ => false
}
)
} }
private def getConfig(provider: OAuthProvider): OAuthProviderConfig = private def getConfig(provider: OAuthProvider): OAuthProviderConfig =
@@ -118,8 +167,7 @@ class OAuthServiceImpl extends OAuthService {
private def exchangeCodeForToken( private def exchangeCodeForToken(
config: OAuthProviderConfig, config: OAuthProviderConfig,
code: String, code: String
redirectUri: String
): Either[String, String] = ): Either[String, String] =
Try { Try {
val formData = Map( val formData = Map(
@@ -127,7 +175,7 @@ class OAuthServiceImpl extends OAuthService {
"client_secret" -> config.clientSecret, "client_secret" -> config.clientSecret,
"grant_type" -> "authorization_code", "grant_type" -> "authorization_code",
"code" -> code, "code" -> code,
"redirect_uri" -> redirectUri "redirect_uri" -> callbackUrl
) )
val formBody = formData.map { val formBody = formData.map {
@@ -6,7 +6,15 @@ import scala.concurrent.Future
import net.eagle0.eagle.api.auth.auth.* import net.eagle0.eagle.api.auth.auth.*
import net.eagle0.eagle.api.auth.auth.AuthGrpc.Auth import net.eagle0.eagle.api.auth.auth.AuthGrpc.Auth
import net.eagle0.eagle.auth.{JwtService, OAuthService, ProviderUserInfo, UserService} import net.eagle0.eagle.auth.{
JwtService,
OAuthExpired,
OAuthFailure,
OAuthPending,
OAuthService,
OAuthSuccess,
UserService
}
import net.eagle0.eagle.internal.user.user.User import net.eagle0.eagle.internal.user.user.User
/** gRPC service implementation for OAuth authentication */ /** gRPC service implementation for OAuth authentication */
@@ -18,58 +26,62 @@ class AuthServiceImpl(
override def getOAuthUrl(request: GetOAuthUrlRequest): Future[GetOAuthUrlResponse] = override def getOAuthUrl(request: GetOAuthUrlRequest): Future[GetOAuthUrlResponse] =
Future { Future {
val (url, state) = oauthService.getAuthUrl(request.provider, request.redirectUri) val (url, state) = oauthService.getAuthUrl(request.provider)
GetOAuthUrlResponse(authUrl = url, state = state) GetOAuthUrlResponse(authUrl = url, state = state)
} }
override def exchangeCode(request: ExchangeCodeRequest): Future[ExchangeCodeResponse] = override def checkOAuthStatus(
Future { request: CheckOAuthStatusRequest
oauthService.exchangeCode( ): Future[CheckOAuthStatusResponse] = Future {
provider = request.provider, oauthService.checkStatus(request.state) match {
code = request.authorizationCode, case OAuthPending =>
state = request.state, CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_PENDING)
redirectUri = request.redirectUri
) match {
case Left(error) =>
throw new io.grpc.StatusRuntimeException(
io.grpc.Status.UNAUTHENTICATED.withDescription(error)
)
case Right(providerInfo) => case OAuthExpired =>
// Find or create user CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_EXPIRED)
val providerName = request.provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "discord"
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "google"
case _ => "unknown"
}
val user = userService.findOrCreateUser( case OAuthFailure(error) =>
provider = providerName, CheckOAuthStatusResponse(
providerUserId = providerInfo.id, status = OAuthStatus.OAUTH_STATUS_FAILED,
email = providerInfo.email, errorMessage = error
avatarUrl = providerInfo.avatarUrl )
)
val isNewUser = user.displayName.isEmpty case OAuthSuccess(providerInfo, provider) =>
// Find or create user
val providerName = provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "discord"
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "google"
case _ => "unknown"
}
// Create JWT tokens val user = userService.findOrCreateUser(
val accessToken = jwtService.createAccessToken( provider = providerName,
userId = user.userId, providerUserId = providerInfo.id,
displayName = user.displayName, email = providerInfo.email,
isAdmin = user.isAdmin avatarUrl = providerInfo.avatarUrl
) )
val (refreshToken, refreshInfo) = jwtService.createRefreshToken(user.userId) val isNewUser = user.displayName.isEmpty
ExchangeCodeResponse( // Create JWT tokens
accessToken = accessToken, val accessToken = jwtService.createAccessToken(
refreshToken = refreshToken, userId = user.userId,
expiresAt = refreshInfo.expiresAt.toEpochMilli / 1000, displayName = user.displayName,
user = Some(toUserInfo(user, request.provider)), isAdmin = user.isAdmin
isNewUser = isNewUser )
)
} val (refreshToken, refreshInfo) = jwtService.createRefreshToken(user.userId)
CheckOAuthStatusResponse(
status = OAuthStatus.OAUTH_STATUS_SUCCESS,
accessToken = accessToken,
refreshToken = refreshToken,
expiresAt = refreshInfo.expiresAt.toEpochMilli / 1000,
user = Some(toUserInfo(user, provider)),
isNewUser = isNewUser
)
} }
}
override def setDisplayName( override def setDisplayName(
request: SetDisplayNameRequest request: SetDisplayNameRequest
@@ -18,6 +18,18 @@ scala_library(
], ],
) )
scala_library(
name = "oauth_http_handler",
srcs = ["OAuthHttpHandler.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__pkg__",
],
deps = [
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle/auth:oauth_service",
],
)
scala_library( scala_library(
name = "service", name = "service",
srcs = [ srcs = [
@@ -0,0 +1,162 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.service
import java.io.{OutputStream, PrintWriter, StringWriter}
import java.net.{InetSocketAddress, URLDecoder}
import java.nio.charset.StandardCharsets
import com.sun.net.httpserver.{HttpExchange, HttpHandler, HttpServer}
import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.auth.OAuthService
/** Simple HTTP server for OAuth callback handling */
class OAuthHttpHandler(oauthService: OAuthService, httpPort: Int) {
private var server: HttpServer = _
def start(): Unit = {
server = HttpServer.create(new InetSocketAddress(httpPort), 0)
server.createContext("/oauth/callback", new CallbackHandler)
server.setExecutor(null) // Use default executor
server.start()
SimpleTimedLogger.printLogger.logLine(
s"OAuth HTTP server listening on port $httpPort"
)
}
def stop(): Unit =
if server != null then {
server.stop(0)
}
private class CallbackHandler extends HttpHandler {
override def handle(exchange: HttpExchange): Unit =
try {
val query = exchange.getRequestURI.getQuery
val params = parseQuery(query)
// Check for OAuth error
params.get("error") match {
case Some(error) =>
val errorDesc = params.getOrElse("error_description", "Unknown error")
val state = params.getOrElse("state", "unknown")
SimpleTimedLogger.printLogger.logLine(
s"OAuth error for state $state: $error - $errorDesc"
)
sendResponse(
exchange,
400,
s"""
|<!DOCTYPE html>
|<html>
|<head><title>Login Failed</title></head>
|<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|<h1>Login Failed</h1>
|<p>$error: $errorDesc</p>
|<p>You can close this tab and try again.</p>
|</body>
|</html>
|""".stripMargin
)
return
case None => // Continue
}
// Get code and state
val code = params.get("code") match {
case Some(c) => c
case None =>
sendResponse(exchange, 400, "Missing authorization code")
return
}
val state = params.get("state") match {
case Some(s) => s
case None =>
sendResponse(exchange, 400, "Missing state parameter")
return
}
// Handle the callback
oauthService.handleCallback(code, state) match {
case Left(error) =>
SimpleTimedLogger.printLogger.logLine(s"OAuth callback failed: $error")
sendResponse(
exchange,
400,
s"""
|<!DOCTYPE html>
|<html>
|<head><title>Login Failed</title></head>
|<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|<h1>Login Failed</h1>
|<p>$error</p>
|<p>You can close this tab and try again.</p>
|</body>
|</html>
|""".stripMargin
)
case Right(userInfo) =>
SimpleTimedLogger.printLogger.logLine(
s"OAuth callback successful for user ${userInfo.username}"
)
sendResponse(
exchange,
200,
s"""
|<!DOCTYPE html>
|<html>
|<head><title>Login Successful</title></head>
|<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|<h1>Login Successful!</h1>
|<p>Welcome, ${userInfo.username}!</p>
|<p>You can close this tab and return to the game.</p>
|</body>
|</html>
|""".stripMargin
)
}
} catch {
case e: Exception =>
val sw = new StringWriter()
e.printStackTrace(new PrintWriter(sw))
SimpleTimedLogger.printLogger.logLine(
s"OAuth callback error: ${e.getMessage}\n${sw.toString}"
)
sendResponse(exchange, 500, s"Internal server error: ${e.getMessage}")
}
private def parseQuery(query: String): Map[String, String] = {
if query == null || query.isEmpty then return Map.empty
query
.split("&")
.flatMap { pair =>
pair.split("=", 2) match {
case Array(key, value) =>
Some(
URLDecoder.decode(key, StandardCharsets.UTF_8) ->
URLDecoder.decode(value, StandardCharsets.UTF_8)
)
case _ => None
}
}
.toMap
}
private def sendResponse(
exchange: HttpExchange,
code: Int,
body: String
): Unit = {
val bytes = body.getBytes(StandardCharsets.UTF_8)
exchange.getResponseHeaders.add("Content-Type", "text/html; charset=utf-8")
exchange.sendResponseHeaders(code, bytes.length)
val os: OutputStream = exchange.getResponseBody
os.write(bytes)
os.close()
}
}
}