diff --git a/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs b/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs new file mode 100644 index 0000000000..b9f1e29f18 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs @@ -0,0 +1,148 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using UniGetUI.Core.Logging; + +namespace UniGetUI.Avalonia.Infrastructure; + +/// +/// Tiny loopback HTTP server that catches the GitHub OAuth redirect and extracts the +/// authorization code. Uses a raw TcpListener so no ASP.NET Core dependency is pulled into +/// the cross-platform Avalonia app. +/// +internal sealed class GHAuthApiRunner : IDisposable +{ + private const int Port = 58642; + + public event EventHandler? OnLogin; + public event EventHandler? OnCancelled; + + private TcpListener? _listener; + private CancellationTokenSource? _cts; + + public Task Start() + { + _listener = new TcpListener(IPAddress.Loopback, Port); + _listener.Start(); + _cts = new CancellationTokenSource(); + _ = AcceptLoopAsync(_cts.Token); + Logger.Info($"GitHub auth loopback server running on http://127.0.0.1:{Port}"); + return Task.CompletedTask; + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener!.AcceptTcpClientAsync(ct); } + catch (Exception) { break; } + _ = HandleClientAsync(client); + } + } + + private async Task HandleClientAsync(TcpClient client) + { + try + { + using (client) + await using (var stream = client.GetStream()) + { + var buffer = new byte[8192]; + int read = await stream.ReadAsync(buffer); + string requestLine = Encoding.ASCII.GetString(buffer, 0, read).Split("\r\n")[0]; + + string? code = ExtractParam(requestLine, "code"); + string? error = ExtractParam(requestLine, "error"); + + // GitHub redirects here with an "error" parameter when the user cancels/denies authorization. + bool isCallback = code is not null || error is not null; + string body = code is not null + ? ResultPage("Authentication successful") + : error is not null + ? ResultPage("Authentication cancelled") + : "

Authentication failed

"; + + var response = Encoding.UTF8.GetBytes( + $"HTTP/1.1 {(isCallback ? "200 OK" : "400 Bad Request")}\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n" + + "Connection: close\r\n\r\n" + + body); + await stream.WriteAsync(response); + await stream.FlushAsync(); + + if (code is not null) + { + Logger.ImportantInfo("[AUTH API] Received authentication code from GitHub"); + OnLogin?.Invoke(this, code); + } + else if (error is not null) + { + Logger.Warn($"[AUTH API] GitHub authentication was cancelled or failed (error: {error})"); + OnCancelled?.Invoke(this, error); + } + } + } + catch (Exception ex) + { + Logger.Warn(ex); + } + } + + private static string? ExtractParam(string requestLine, string key) + { + // requestLine looks like: GET /?code=XXXX&state=YYYY HTTP/1.1 + int q = requestLine.IndexOf('?'); + if (q < 0) return null; + int end = requestLine.IndexOf(' ', q); + string query = end < 0 ? requestLine[(q + 1)..] : requestLine[(q + 1)..end]; + + foreach (var pair in query.Split('&')) + { + var kv = pair.Split('=', 2); + if (kv.Length == 2 && kv[0] == key && kv[1].Length > 0) + return Uri.UnescapeDataString(kv[1]); + } + return null; + } + + private static string ResultPage(string title) => + $$""" +
+ UniGetUI authentication +

{{title}}

+

You can now close this window and return to UniGetUI

+
+ """; + + public async Task Stop() + { + try + { + if (_cts is not null) await _cts.CancelAsync(); + _listener?.Stop(); + } + catch (Exception ex) + { + Logger.Error(ex); + } + } + + public void Dispose() + { + _cts?.Dispose(); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/GitHubAuthService.cs b/src/UniGetUI.Avalonia/Infrastructure/GitHubAuthService.cs index c2d12462b3..a99ca1392b 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/GitHubAuthService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/GitHubAuthService.cs @@ -9,17 +9,16 @@ namespace UniGetUI.Avalonia.Infrastructure; internal sealed class GitHubAuthService { + private const string MissingClientId = "CLIENT_ID_UNSET"; + private const string MissingClientSecret = "CLIENT_SECRET_UNSET"; + private static readonly TimeSpan LoginTimeout = TimeSpan.FromMinutes(2); private readonly string _gitHubClientId = Secrets.GetGitHubClientId(); + private readonly string _gitHubClientSecret = Secrets.GetGitHubClientSecret(); + private const string RedirectUri = "http://127.0.0.1:58642/"; private readonly GitHubClient _client; public static event EventHandler? AuthStatusChanged; - /// - /// Fired when the device flow has started. Provides the user code and verification URI - /// that must be shown to the user so they can authorize the app at GitHub. - /// - public static event EventHandler<(string UserCode, string VerificationUri)>? DeviceFlowStarted; - public GitHubAuthService() { _client = new GitHubClient(new ProductHeaderValue("UniGetUI", CoreData.VersionName)); @@ -37,33 +36,125 @@ public GitHubAuthService() }; } + private GHAuthApiRunner? _loginBackend; + private string? _codeFromApi; + private bool _loginWasCancelled; + public async Task SignInAsync() { try { - Logger.Info("Initiating GitHub sign-in using device flow..."); + if (!HasConfiguredOAuthClient()) + { + Logger.Error("GitHub sign-in is not configured for this build. Missing OAuth client ID or client secret."); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + + Logger.Info("Initiating GitHub sign-in process using loopback redirect..."); + + var request = new OauthLoginRequest(_gitHubClientId) + { + Scopes = { "read:user", "gist" }, + RedirectUri = new Uri(RedirectUri), + }; + + var oauthLoginUrl = _client.Oauth.GetGitHubLoginUrl(request); + + _codeFromApi = null; + _loginWasCancelled = false; + await StopLoginBackend(); + _loginBackend = new GHAuthApiRunner(); + _loginBackend.OnLogin += BackgroundApiOnOnLogin; + _loginBackend.OnCancelled += BackgroundApiOnCancelled; + await _loginBackend.Start(); + + CoreTools.Launch(oauthLoginUrl.ToString()); + + DateTime timeoutAt = DateTime.UtcNow.Add(LoginTimeout); + while (_codeFromApi is null && !_loginWasCancelled && DateTime.UtcNow < timeoutAt) + await Task.Delay(100); + + if (_loginWasCancelled) + { + Logger.Warn("GitHub sign-in was cancelled by the user."); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + + if (string.IsNullOrEmpty(_codeFromApi)) + { + Logger.Error("GitHub sign-in timed out before the loopback callback was received."); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + + return await CompleteSignInAsync(_codeFromApi); + } + catch (Exception ex) + { + Logger.Error("Exception during GitHub sign-in process:"); + Logger.Error(ex); + ClearAuthenticatedUserData(); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + finally + { + await StopLoginBackend(); + } + } - var deviceFlow = await _client.Oauth.InitiateDeviceFlow( - new OauthDeviceFlowRequest(_gitHubClientId) - { - Scopes = { "read:user", "gist" }, - }, CancellationToken.None); + private void BackgroundApiOnOnLogin(object? sender, string code) + { + _codeFromApi = code; + } - // Open the verification page and notify the UI layer so it can show the user code. - CoreTools.Launch(deviceFlow.VerificationUri); - DeviceFlowStarted?.Invoke(this, (deviceFlow.UserCode, deviceFlow.VerificationUri)); + private void BackgroundApiOnCancelled(object? sender, string error) + { + _loginWasCancelled = true; + } - // Octokit handles polling with the correct interval until the user authorises or the code expires. - var token = await _client.Oauth.CreateAccessTokenForDeviceFlow(_gitHubClientId, deviceFlow, CancellationToken.None); + private async Task StopLoginBackend() + { + if (_loginBackend is null) return; + try + { + _loginBackend.OnLogin -= BackgroundApiOnOnLogin; + _loginBackend.OnCancelled -= BackgroundApiOnCancelled; + await _loginBackend.Stop(); + _loginBackend.Dispose(); + } + catch (Exception ex) { Logger.Warn(ex); } + finally { _loginBackend = null; } + } + + private bool HasConfiguredOAuthClient() + { + return !string.IsNullOrWhiteSpace(_gitHubClientId) + && !string.IsNullOrWhiteSpace(_gitHubClientSecret) + && !string.Equals(_gitHubClientId, MissingClientId, StringComparison.Ordinal) + && !string.Equals(_gitHubClientSecret, MissingClientSecret, StringComparison.Ordinal); + } + + private async Task CompleteSignInAsync(string code) + { + try + { + var tokenRequest = new OauthTokenRequest(_gitHubClientId, _gitHubClientSecret, code) + { + RedirectUri = new Uri(RedirectUri), // The same redirect_uri must be sent + }; + var token = await _client.Oauth.CreateAccessToken(tokenRequest); if (string.IsNullOrEmpty(token.AccessToken)) { - Logger.Error("Failed to obtain GitHub access token via device flow."); + Logger.Error("Failed to obtain GitHub access token."); AuthStatusChanged?.Invoke(this, EventArgs.Empty); return false; } - Logger.Info("GitHub device flow login successful. Storing access token."); + Logger.Info("GitHub login successful. Storing access token."); SecureGHTokenManager.StoreToken(token.AccessToken); var userClient = new GitHubClient(new ProductHeaderValue("UniGetUI")) @@ -82,7 +173,7 @@ public async Task SignInAsync() } catch (Exception ex) { - Logger.Error("Exception during GitHub device flow sign-in:"); + Logger.Error("Exception during GitHub token exchange:"); Logger.Error(ex); ClearAuthenticatedUserData(); AuthStatusChanged?.Invoke(this, EventArgs.Empty); diff --git a/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs b/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs index a050b5ca05..76633560be 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/Secrets.cs @@ -8,6 +8,7 @@ internal static partial class Secrets * Seeing errors? Build the project (maybe twice) */ public static partial string GetGitHubClientId(); + public static partial string GetGitHubClientSecret(); public static partial string GetOpenSearchUsername(); public static partial string GetOpenSearchPassword(); /* ------------------------------------------------------------------------ */ diff --git a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 index 53fc0d0d3b..18d6410b79 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 +++ b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1 @@ -12,10 +12,12 @@ if (-not (Test-Path -Path $generatedDir)) { } $clientId = $env:UNIGETUI_GITHUB_CLIENT_ID +$clientSecret = $env:UNIGETUI_GITHUB_CLIENT_SECRET $openSearchUsername = $env:UNIGETUI_OPENSEARCH_USERNAME $openSearchPassword = $env:UNIGETUI_OPENSEARCH_PASSWORD if (-not $clientId) { $clientId = "CLIENT_ID_UNSET" } +if (-not $clientSecret) { $clientSecret = "CLIENT_SECRET_UNSET" } if (-not $openSearchUsername) { $openSearchUsername = "OPENSEARCH_USERNAME_UNSET" } if (-not $openSearchPassword) { $openSearchPassword = "OPENSEARCH_PASSWORD_UNSET" } @@ -26,6 +28,7 @@ namespace UniGetUI.Avalonia.Infrastructure internal static partial class Secrets { public static partial string GetGitHubClientId() => `"$clientId`"; + public static partial string GetGitHubClientSecret() => `"$clientSecret`"; public static partial string GetOpenSearchUsername() => `"$openSearchUsername`"; public static partial string GetOpenSearchPassword() => `"$openSearchPassword`"; } diff --git a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh index 59583f81bb..76bf51b3f2 100755 --- a/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh +++ b/src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh @@ -5,10 +5,12 @@ if [ ! -d "Generated Files" ]; then mkdir -p "Generated Files"; fi if [ ! -d "${OUTPUT_PATH}Generated Files" ]; then mkdir -p "${OUTPUT_PATH}Generated Files"; fi CLIENT_ID="${UNIGETUI_GITHUB_CLIENT_ID}" +CLIENT_SECRET="${UNIGETUI_GITHUB_CLIENT_SECRET}" OPENSEARCH_USERNAME="${UNIGETUI_OPENSEARCH_USERNAME}" OPENSEARCH_PASSWORD="${UNIGETUI_OPENSEARCH_PASSWORD}" if [ -z "$CLIENT_ID" ]; then CLIENT_ID="CLIENT_ID_UNSET"; fi +if [ -z "$CLIENT_SECRET" ]; then CLIENT_SECRET="CLIENT_SECRET_UNSET"; fi if [ -z "$OPENSEARCH_USERNAME" ]; then OPENSEARCH_USERNAME="OPENSEARCH_USERNAME_UNSET"; fi if [ -z "$OPENSEARCH_PASSWORD" ]; then OPENSEARCH_PASSWORD="OPENSEARCH_PASSWORD_UNSET"; fi @@ -19,6 +21,7 @@ namespace UniGetUI.Avalonia.Infrastructure internal static partial class Secrets { public static partial string GetGitHubClientId() => "$CLIENT_ID"; + public static partial string GetGitHubClientSecret() => "$CLIENT_SECRET"; public static partial string GetOpenSearchUsername() => "$OPENSEARCH_USERNAME"; public static partial string GetOpenSearchPassword() => "$OPENSEARCH_PASSWORD"; } diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 4bcda3fbe4..0967b8a541 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -207,6 +207,7 @@ + diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs index 2060bfdac4..8a03146a10 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs @@ -223,7 +223,7 @@ private async Task _loginWithGitHubButton_Click() UpdateCloudControlsEnabled(); bool success = await _authService.SignInAsync(); - if (!success) + if (!success && !_authService.LoginWasCancelled) { DialogHelper.ShowDismissableBalloon( CoreTools.Translate("Failed"), diff --git a/src/UniGetUI/Services/BackgroundLoginApi.cs b/src/UniGetUI/Services/BackgroundLoginApi.cs index 590500b240..669c582ad1 100644 --- a/src/UniGetUI/Services/BackgroundLoginApi.cs +++ b/src/UniGetUI/Services/BackgroundLoginApi.cs @@ -9,6 +9,7 @@ namespace UniGetUI.Services; public class GHAuthApiRunner : IDisposable { public event EventHandler? OnLogin; + public event EventHandler? OnCancelled; private IHost? _host; public GHAuthApiRunner() { } @@ -38,38 +39,49 @@ public async Task Start() private async Task LOGIN_CollectGitHubToken(HttpContext context) { var code = context.Request.Query["code"]; - if (string.IsNullOrEmpty(code)) + if (!string.IsNullOrEmpty(code)) { - context.Response.StatusCode = 400; + await context.Response.WriteAsync(ResultPage("Authentication successful", "You can now close this window and return to UniGetUI")); + Logger.ImportantInfo($"[AUTH API] Received authentication token {code} from GitHub"); + OnLogin?.Invoke(this, code.ToString()); return; } - await context.Response.WriteAsync( - """ -
- UniGetUI authentication -

Authentication successful

-

You can now close this window and return to UniGetUI

-
- """ - ); + var error = context.Request.Query["error"]; + if (!string.IsNullOrEmpty(error)) + { + // GitHub redirects here with an "error" parameter when the user cancels/denies the authorization. + await context.Response.WriteAsync(ResultPage("Authentication cancelled", "You can now close this window and return to UniGetUI")); + Logger.Warn($"[AUTH API] GitHub authentication was cancelled or failed (error: {error})"); + OnCancelled?.Invoke(this, error.ToString()); + return; + } - Logger.ImportantInfo($"[AUTH API] Received authentication token {code} from GitHub"); - OnLogin?.Invoke(this, code.ToString()); + // Not an OAuth callback (e.g. a favicon request or a probe): ignore it. + context.Response.StatusCode = 400; } + private static string ResultPage(string title, string message) => + $$""" +
+ UniGetUI authentication +

{{title}}

+

{{message}}

+
+ """; + public async Task Stop() { try diff --git a/src/UniGetUI/Services/GitHubAuthService.cs b/src/UniGetUI/Services/GitHubAuthService.cs index 8a75078381..e67d1a3dd4 100644 --- a/src/UniGetUI/Services/GitHubAuthService.cs +++ b/src/UniGetUI/Services/GitHubAuthService.cs @@ -70,6 +70,7 @@ public async Task SignInAsync() var oauthLoginUrl = _client.Oauth.GetGitHubLoginUrl(request); codeFromAPI = null; + LoginWasCancelled = false; if (loginBackend is not null) { try @@ -85,6 +86,7 @@ public async Task SignInAsync() } loginBackend = new GHAuthApiRunner(); loginBackend.OnLogin += BackgroundApiOnOnLogin; + loginBackend.OnCancelled += BackgroundApiOnCancelled; await loginBackend.Start(); bool launchSucceeded = await Launcher.LaunchUriAsync(oauthLoginUrl); @@ -96,9 +98,16 @@ public async Task SignInAsync() } DateTime timeoutAt = DateTime.UtcNow.Add(LoginTimeout); - while (codeFromAPI is null && DateTime.UtcNow < timeoutAt) + while (codeFromAPI is null && !LoginWasCancelled && DateTime.UtcNow < timeoutAt) await Task.Delay(100); + if (LoginWasCancelled) + { + Logger.Warn("GitHub sign-in was cancelled by the user."); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + if (string.IsNullOrEmpty(codeFromAPI)) { Logger.Error("GitHub sign-in timed out before the loopback callback was received."); @@ -123,6 +132,7 @@ public async Task SignInAsync() try { loginBackend.OnLogin -= BackgroundApiOnOnLogin; + loginBackend.OnCancelled -= BackgroundApiOnCancelled; await loginBackend.Stop(); loginBackend.Dispose(); } @@ -140,11 +150,23 @@ public async Task SignInAsync() private string? codeFromAPI; + /// + /// True when the most recent ended because the user cancelled the + /// authorization on GitHub (as opposed to an error). Callers use this to avoid showing an + /// error message for a deliberate cancellation. + /// + public bool LoginWasCancelled { get; private set; } + private void BackgroundApiOnOnLogin(object? sender, string c) { codeFromAPI = c; } + private void BackgroundApiOnCancelled(object? sender, string error) + { + LoginWasCancelled = true; + } + private bool HasConfiguredOAuthClient() { return !string.IsNullOrWhiteSpace(GitHubClientId) diff --git a/src/UniGetUI/Services/UserAvatar.cs b/src/UniGetUI/Services/UserAvatar.cs index a00dd77037..b7127a6115 100644 --- a/src/UniGetUI/Services/UserAvatar.cs +++ b/src/UniGetUI/Services/UserAvatar.cs @@ -71,7 +71,7 @@ private async Task _loginButton_Click() } bool success = await client.SignInAsync(); - if (!success) + if (!success && !client.LoginWasCancelled) { DialogHelper.ShowDismissableBalloon( CoreTools.Translate("Failed"), @@ -115,6 +115,8 @@ private void SetLoading() IsIndeterminate = true, Width = 24, Height = 24, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, }; }