mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 02:35:45 +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 {
|
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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user