From 1de169483713ebd47409a862fa8d314043821018 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 11 Jun 2026 11:26:03 -0400 Subject: [PATCH 1/2] Fix: make Avalonia GitHub login use the loopback OAuth flow like WinUI --- .../Infrastructure/GHAuthApiRunner.cs | 136 ++++++++++++++++++ .../Infrastructure/GitHubAuthService.cs | 115 ++++++++++++--- .../Infrastructure/Secrets.cs | 1 + .../Infrastructure/generate-secrets.ps1 | 3 + .../Infrastructure/generate-secrets.sh | 3 + .../UniGetUI.Avalonia.csproj | 1 + 6 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs diff --git a/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs b/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs new file mode 100644 index 0000000000..a2e44c24f1 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs @@ -0,0 +1,136 @@ +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; + + 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 = ExtractCode(requestLine); + string body = code is null + ? "

Authentication failed

" + : SuccessPage; + + var response = Encoding.UTF8.GetBytes( + $"HTTP/1.1 {(code is null ? "400 Bad Request" : "200 OK")}\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); + } + } + } + catch (Exception ex) + { + Logger.Warn(ex); + } + } + + private static string? ExtractCode(string requestLine) + { + // 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] == "code" && kv[1].Length > 0) + return Uri.UnescapeDataString(kv[1]); + } + return null; + } + + private const string SuccessPage = + """ +
+ UniGetUI authentication +

Authentication successful

+

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..71fb5713f5 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,109 @@ public GitHubAuthService() }; } + private GHAuthApiRunner? _loginBackend; + private string? _codeFromApi; + 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; + await StopLoginBackend(); + _loginBackend = new GHAuthApiRunner(); + _loginBackend.OnLogin += BackgroundApiOnOnLogin; + await _loginBackend.Start(); + + CoreTools.Launch(oauthLoginUrl.ToString()); + + DateTime timeoutAt = DateTime.UtcNow.Add(LoginTimeout); + while (_codeFromApi is null && DateTime.UtcNow < timeoutAt) + await Task.Delay(100); + + 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 async Task StopLoginBackend() + { + if (_loginBackend is null) return; + try + { + _loginBackend.OnLogin -= BackgroundApiOnOnLogin; + 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); + } - // 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 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 +157,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 @@ + From 70a17e9287a002ecb7aea6c00fb5363e4b58bfed Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 11 Jun 2026 14:09:05 -0400 Subject: [PATCH 2/2] Fix GitHub login cancel handling and loading-ring centering --- .../Infrastructure/GHAuthApiRunner.cs | 32 +++++++--- .../Infrastructure/GitHubAuthService.cs | 18 +++++- .../SettingsPages/GeneralPages/Backup.xaml.cs | 2 +- src/UniGetUI/Services/BackgroundLoginApi.cs | 62 +++++++++++-------- src/UniGetUI/Services/GitHubAuthService.cs | 24 ++++++- src/UniGetUI/Services/UserAvatar.cs | 4 +- 6 files changed, 103 insertions(+), 39 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs b/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs index a2e44c24f1..b9f1e29f18 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs @@ -15,6 +15,7 @@ internal sealed class GHAuthApiRunner : IDisposable private const int Port = 58642; public event EventHandler? OnLogin; + public event EventHandler? OnCancelled; private TcpListener? _listener; private CancellationTokenSource? _cts; @@ -51,13 +52,19 @@ private async Task HandleClientAsync(TcpClient client) int read = await stream.ReadAsync(buffer); string requestLine = Encoding.ASCII.GetString(buffer, 0, read).Split("\r\n")[0]; - string? code = ExtractCode(requestLine); - string body = code is null - ? "

Authentication failed

" - : SuccessPage; + 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 {(code is null ? "400 Bad Request" : "200 OK")}\r\n" + + $"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" + @@ -70,6 +77,11 @@ private async Task HandleClientAsync(TcpClient client) 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) @@ -78,7 +90,7 @@ private async Task HandleClientAsync(TcpClient client) } } - private static string? ExtractCode(string requestLine) + private static string? ExtractParam(string requestLine, string key) { // requestLine looks like: GET /?code=XXXX&state=YYYY HTTP/1.1 int q = requestLine.IndexOf('?'); @@ -89,14 +101,14 @@ private async Task HandleClientAsync(TcpClient client) foreach (var pair in query.Split('&')) { var kv = pair.Split('=', 2); - if (kv.Length == 2 && kv[0] == "code" && kv[1].Length > 0) + if (kv.Length == 2 && kv[0] == key && kv[1].Length > 0) return Uri.UnescapeDataString(kv[1]); } return null; } - private const string SuccessPage = - """ + private static string ResultPage(string title) => + $$"""
- 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, }; }