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 {
/// <summary>
/// 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>
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 GrpcChannel _channel;
// State parameter for CSRF protection (stored between GetOAuthUrl and ExchangeCode)
private string _pendingState;
private OAuthProvider _pendingProvider;
/// <summary>
/// Create auth client for the given server URL.
/// </summary>
@@ -43,51 +40,56 @@ namespace Auth {
/// <summary>
/// Get OAuth URL to open in system browser.
/// Returns both the URL and the state token for polling.
/// </summary>
/// <param name="provider">Discord or Google</param>
/// <returns>URL to open in browser</returns>
public async Task<string> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider, RedirectUri = RedirectUri };
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider };
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}");
return response.AuthUrl;
return (response.AuthUrl, response.State);
}
/// <summary>
/// Exchange authorization code for JWT tokens.
/// Call this after receiving the OAuth callback.
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
/// The server handles the OAuth callback and token exchange.
/// </summary>
/// <param name="code">Authorization code from callback</param>
/// <param name="state">State parameter from callback (for CSRF validation)</param>
/// <returns>Exchange response with tokens and user info</returns>
public async Task<ExchangeCodeResponse> ExchangeCodeAsync(string code, string state) {
// Validate state matches what we sent
if (state != _pendingState) {
throw new InvalidOperationException(
$"State mismatch: expected {_pendingState}, got {state}. Possible CSRF attack.");
/// <param name="state">State token from GetOAuthUrlAsync</param>
/// <returns>Response with tokens and user info on success</returns>
public async Task<CheckOAuthStatusResponse> PollForOAuthCompletionAsync(string state) {
var startTime = DateTime.UtcNow;
while (true) {
var request = new CheckOAuthStatusRequest { State = state };
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>
@@ -1,14 +1,13 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
namespace Auth {
/// <summary>
/// Manages OAuth authentication flow including:
/// - Opening system browser for OAuth consent
/// - Handling deep link callbacks (eagle0://auth/callback)
/// Manages OAuth authentication flow using server-mediated polling:
/// - Opens system browser for OAuth consent
/// - Polls server for OAuth completion (no deep links needed)
/// - Token storage and refresh
/// </summary>
public class OAuthManager : MonoBehaviour {
@@ -16,7 +15,6 @@ namespace Auth {
private AuthClient _authClient;
private string _currentServerUrl;
private TaskCompletionSource<ExchangeCodeResponse> _pendingAuth;
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
@@ -54,18 +52,6 @@ namespace Auth {
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() {
_authClient?.Dispose();
if (Instance == this) { Instance = null; }
@@ -80,23 +66,20 @@ namespace Auth {
/// <summary>
/// Start OAuth login with specified provider.
/// Opens system browser for user consent.
/// Opens system browser for user consent, then polls for completion.
/// </summary>
public async Task<ExchangeCodeResponse> LoginAsync(OAuthProvider provider) {
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
try {
// Get OAuth URL from server
var authUrl = await _authClient.GetOAuthUrlAsync(provider);
// Create completion source for callback
_pendingAuth = new TaskCompletionSource<ExchangeCodeResponse>();
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
Application.OpenURL(authUrl);
// Wait for deep link callback
var response = await _pendingAuth.Task;
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Store tokens
TokenStorage.StoreTokens(
@@ -118,44 +101,6 @@ namespace Auth {
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
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();
}
/// <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
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) {}
// Exchange authorization code for JWT tokens
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {}
// Poll for OAuth completion. Call this after opening the URL from GetOAuthUrl.
// 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)
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
@@ -42,28 +44,36 @@ enum OAuthProvider {
// Request to initiate OAuth flow
message GetOAuthUrlRequest {
OAuthProvider provider = 1;
string redirect_uri = 2; // e.g., "eagle0://auth/callback"
}
message GetOAuthUrlResponse {
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
message ExchangeCodeRequest {
OAuthProvider provider = 1;
string authorization_code = 2;
string state = 3; // Must match state from GetOAuthUrlResponse
string redirect_uri = 4; // Must match exactly what was used in GetOAuthUrlRequest
// Poll for OAuth completion status
message CheckOAuthStatusRequest {
string state = 1; // State from GetOAuthUrlResponse
}
message ExchangeCodeResponse {
string access_token = 1; // JWT for API access
string refresh_token = 2; // Long-lived token for getting new access tokens
int64 expires_at = 3; // Unix timestamp when access_token expires
UserInfo user = 4;
bool is_new_user = 5; // True if user needs to set display name
message CheckOAuthStatusResponse {
OAuthStatus status = 1;
// Only set when status is OAUTH_STATUS_SUCCESS:
string access_token = 2;
string refresh_token = 3;
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
@@ -21,6 +21,7 @@ scala_binary(
"//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: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/persistence:async_s3_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.eagle.api.auth.auth.AuthGrpc
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.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
@@ -25,6 +25,12 @@ object Main {
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 nextOption(
map: Map[Symbol, String],
@@ -43,6 +49,16 @@ object Main {
map ++ Map(gptModelNameKey -> value),
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 +: _ =>
throw new IllegalArgumentException("Unknown option " + option)
@@ -77,11 +93,24 @@ object Main {
val customBattleManager = newCustomBattleManager(shardokInterfaceAddress)
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(
gamesManager,
customBattleManager,
options.getOrElse(eagleGrpcPortKey, defaultEagleGrpcPort).toInt,
SeededRandom(random.nextLong())
SeededRandom(random.nextLong()),
oauthService
).start
SimpleTimedLogger.printLogger.logLine(
s"Eagle server is listening on port ${server.getPort}"
@@ -94,7 +123,8 @@ object Main {
gamesManager: GamesManager,
customBattleManager: CustomBattleManager,
grpcPort: Int,
functionalRandom: FunctionalRandom
functionalRandom: FunctionalRandom,
oauthService: OAuthService
): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort)
@@ -102,7 +132,6 @@ object Main {
val jwtService: Option[JwtService] = JwtServiceImpl.fromEnvironment()
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val userService = new UserServiceImpl(authPersister)
val oauthService = new OAuthServiceImpl()
// Add Eagle game service
serverBuilder.addService(
@@ -28,6 +28,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
],
deps = [
":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
@@ -24,22 +24,30 @@ case class ProviderUserInfo(
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 */
trait OAuthService {
/** Generate OAuth authorization URL */
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state)
/** Generate OAuth authorization URL. Server handles callback internally. */
def getAuthUrl(provider: OAuthProvider): (String, String) // (url, state)
/** Exchange authorization code for user info */
def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo]
/** Check status of OAuth flow for given state */
def checkStatus(state: String): OAuthResult
/** Handle OAuth callback - exchange code for user info and store result */
def handleCallback(code: String, state: 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 val json = new Json(formats)
@@ -49,25 +57,29 @@ class OAuthServiceImpl extends OAuthService {
.connectTimeout(Duration.ofSeconds(10))
.build()
// State parameter storage with timestamp (for CSRF protection)
private val stateStore = TrieMap[String, Long]()
// Pending OAuth sessions: state -> (provider, timestamp)
private val pendingOAuth = TrieMap[String, (OAuthProvider, Long)]()
// Completed OAuth results: state -> result
private val completedOAuth = TrieMap[String, OAuthResult]()
// State expiration (10 minutes)
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 state = UUID.randomUUID().toString
stateStore.put(state, System.currentTimeMillis())
pendingOAuth.put(state, (provider, System.currentTimeMillis()))
// Clean old states
val cutoff = System.currentTimeMillis() - stateExpirationMs
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
cleanupExpiredStates()
val params = Map(
"client_id" -> config.clientId,
"redirect_uri" -> redirectUri,
"redirect_uri" -> callbackUrl,
"response_type" -> "code",
"scope" -> config.scopes.mkString(" "),
"state" -> state
@@ -83,30 +95,67 @@ class OAuthServiceImpl extends OAuthService {
(url, state)
}
override def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo] = {
// Verify state
stateStore.remove(state) match {
case Some(timestamp) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
// Valid state
case _ =>
return Left("Invalid or expired state parameter")
override def checkStatus(state: String): OAuthResult =
// First check completed results
completedOAuth.get(state) match {
case Some(result) => result
case None =>
// Check if still pending
pendingOAuth.get(state) match {
case Some((_, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
OAuthPending
case Some(_) =>
// Expired
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
val tokenResult = exchangeCodeForToken(config, code, redirectUri)
tokenResult match {
case Left(error) => Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken)
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code)
tokenResult match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
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 =
@@ -118,8 +167,7 @@ class OAuthServiceImpl extends OAuthService {
private def exchangeCodeForToken(
config: OAuthProviderConfig,
code: String,
redirectUri: String
code: String
): Either[String, String] =
Try {
val formData = Map(
@@ -127,7 +175,7 @@ class OAuthServiceImpl extends OAuthService {
"client_secret" -> config.clientSecret,
"grant_type" -> "authorization_code",
"code" -> code,
"redirect_uri" -> redirectUri
"redirect_uri" -> callbackUrl
)
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.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
/** gRPC service implementation for OAuth authentication */
@@ -18,58 +26,62 @@ class AuthServiceImpl(
override def getOAuthUrl(request: GetOAuthUrlRequest): Future[GetOAuthUrlResponse] =
Future {
val (url, state) = oauthService.getAuthUrl(request.provider, request.redirectUri)
val (url, state) = oauthService.getAuthUrl(request.provider)
GetOAuthUrlResponse(authUrl = url, state = state)
}
override def exchangeCode(request: ExchangeCodeRequest): Future[ExchangeCodeResponse] =
Future {
oauthService.exchangeCode(
provider = request.provider,
code = request.authorizationCode,
state = request.state,
redirectUri = request.redirectUri
) match {
case Left(error) =>
throw new io.grpc.StatusRuntimeException(
io.grpc.Status.UNAUTHENTICATED.withDescription(error)
)
override def checkOAuthStatus(
request: CheckOAuthStatusRequest
): Future[CheckOAuthStatusResponse] = Future {
oauthService.checkStatus(request.state) match {
case OAuthPending =>
CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_PENDING)
case Right(providerInfo) =>
// Find or create user
val providerName = request.provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "discord"
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "google"
case _ => "unknown"
}
case OAuthExpired =>
CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_EXPIRED)
val user = userService.findOrCreateUser(
provider = providerName,
providerUserId = providerInfo.id,
email = providerInfo.email,
avatarUrl = providerInfo.avatarUrl
)
case OAuthFailure(error) =>
CheckOAuthStatusResponse(
status = OAuthStatus.OAUTH_STATUS_FAILED,
errorMessage = error
)
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 accessToken = jwtService.createAccessToken(
userId = user.userId,
displayName = user.displayName,
isAdmin = user.isAdmin
)
val user = userService.findOrCreateUser(
provider = providerName,
providerUserId = providerInfo.id,
email = providerInfo.email,
avatarUrl = providerInfo.avatarUrl
)
val (refreshToken, refreshInfo) = jwtService.createRefreshToken(user.userId)
val isNewUser = user.displayName.isEmpty
ExchangeCodeResponse(
accessToken = accessToken,
refreshToken = refreshToken,
expiresAt = refreshInfo.expiresAt.toEpochMilli / 1000,
user = Some(toUserInfo(user, request.provider)),
isNewUser = isNewUser
)
}
// Create JWT tokens
val accessToken = jwtService.createAccessToken(
userId = user.userId,
displayName = user.displayName,
isAdmin = user.isAdmin
)
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(
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(
name = "service",
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()
}
}
}