mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 10:55:42 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d0f98179 | ||
|
|
bd045579c0 | ||
|
|
6b3e3e61f4 | ||
|
|
53f4195152 | ||
|
|
000eba3d0b | ||
|
|
b8fc9cafbe | ||
|
|
5a195ce96f |
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user