Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/GHAuthApiRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using UniGetUI.Core.Logging;

namespace UniGetUI.Avalonia.Infrastructure;

/// <summary>
/// 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.
/// </summary>
internal sealed class GHAuthApiRunner : IDisposable
{
private const int Port = 58642;

public event EventHandler<string>? OnLogin;
public event EventHandler<string>? 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")
: "<html><body><h1>Authentication failed</h1></body></html>";

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) =>
$$"""
<html><style>
div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-family: sans-serif;
text-align: center;
}
</style><script>
window.close();
</script><div>
<title>UniGetUI authentication</title>
<h1>{{title}}</h1>
<p>You can now close this window and return to UniGetUI</p>
</div></html>
""";

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();
}
}
131 changes: 111 additions & 20 deletions src/UniGetUI.Avalonia/Infrastructure/GitHubAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventArgs>? AuthStatusChanged;

/// <summary>
/// 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.
/// </summary>
public static event EventHandler<(string UserCode, string VerificationUri)>? DeviceFlowStarted;

public GitHubAuthService()
{
_client = new GitHubClient(new ProductHeaderValue("UniGetUI", CoreData.VersionName));
Expand All @@ -37,33 +36,125 @@ public GitHubAuthService()
};
}

private GHAuthApiRunner? _loginBackend;
private string? _codeFromApi;
private bool _loginWasCancelled;

public async Task<bool> 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<bool> 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"))
Expand All @@ -82,7 +173,7 @@ public async Task<bool> 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);
Expand Down
1 change: 1 addition & 0 deletions src/UniGetUI.Avalonia/Infrastructure/Secrets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
/* ------------------------------------------------------------------------ */
Expand Down
3 changes: 3 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/generate-secrets.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand All @@ -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`";
}
Expand Down
3 changes: 3 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/generate-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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";
}
Expand Down
1 change: 1 addition & 0 deletions src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
<Compile Include="Infrastructure\AvaloniaBootstrapper.cs" />
<Compile Include="Infrastructure\AvaloniaPackageOperationHelper.cs" />
<Compile Include="Infrastructure\GitHubAuthService.cs" />
<Compile Include="Infrastructure\GHAuthApiRunner.cs" />
<Compile Include="Infrastructure\GitHubCloudBackupService.cs" />
<Compile Include="Infrastructure\HeadlessDaemonHost.cs" />
<Compile Include="Infrastructure\HeadlessModeOptions.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading