Skip to content

Enable/disable sign in button #201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 24, 2025
Merged
Show file tree
Hide file tree
Changes from 5 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
39 changes: 39 additions & 0 deletions GitHubExtension.Test/DeveloperIdTests/OAuthRequestTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using GitHubExtension.DeveloperId;
using Moq;
using Octokit;

namespace GitHubExtension.Test.DeveloperIdTests;

[TestClass]
public class OAuthRequestTest
{
[TestMethod]
public async Task CompleteOAuthAsync_ShouldThrowInvalidOperationException_OnTimeout()
{
var mockGitHubClient = new Mock<IGitHubClient>();
var mockOauthClient = new Mock<IOauthClient>();

mockGitHubClient.Setup(client => client.Oauth).Returns(mockOauthClient.Object);

mockOauthClient
.Setup(oauth => oauth.CreateAccessToken(It.IsAny<OauthTokenRequest>()))
.Returns(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(10));
return new OauthToken();
});

var oAuthRequest = new OAuthRequest();

var authorizationResponse = new Uri("https://example.com/callback?code=valid_code");

await Assert.ThrowsExceptionAsync<InvalidOperationException>(async () =>
{
await oAuthRequest.CompleteOAuthAsync(authorizationResponse);
});
}
}
32 changes: 31 additions & 1 deletion GitHubExtension/Controls/Forms/SignInForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Globalization;
using GitHubExtension.DeveloperId;
using GitHubExtension.Helpers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;

namespace GitHubExtension.Controls.Forms;

Expand All @@ -21,10 +21,38 @@ public partial class SignInForm : FormContent, IGitHubForm
private readonly IDeveloperIdProvider _developerIdProvider;
private readonly IResources _resources;

private bool _isButtonEnabled = true;

private string IsButtonEnabled =>
_isButtonEnabled.ToString(CultureInfo.InvariantCulture).ToLower(CultureInfo.InvariantCulture);

public SignInForm(IDeveloperIdProvider developerIdProvider, IResources resources)
{
_resources = resources;
_developerIdProvider = developerIdProvider;
_developerIdProvider.OAuthRedirected += DeveloperIdProvider_OAuthRedirected;
}

private void DeveloperIdProvider_OAuthRedirected(object? sender, Exception? e)
{
if (e is not null)
{
SetButtonEnabled(true);
LoadingStateChanged?.Invoke(this, false);
SignInAction?.Invoke(this, new SignInStatusChangedEventArgs(false, e));
FormSubmitted?.Invoke(this, new FormSubmitEventArgs(false, e));
}
else
{
SetButtonEnabled(false);
}
}

public void SetButtonEnabled(bool isEnabled)
{
_isButtonEnabled = isEnabled;
TemplateJson = TemplateHelper.LoadTemplateJsonFromTemplateName("AuthTemplate", TemplateSubstitutions);
OnPropertyChanged(nameof(TemplateJson));
}

public Dictionary<string, string> TemplateSubstitutions => new()
Expand All @@ -33,6 +61,7 @@ public SignInForm(IDeveloperIdProvider developerIdProvider, IResources resources
{ "{{AuthButtonTitle}}", _resources.GetResource("Forms_Sign_In") },
{ "{{AuthIcon}}", $"data:image/png;base64,{GitHubIcon.GetBase64Icon("logo")}" },
{ "{{AuthButtonTooltip}}", _resources.GetResource("Forms_Sign_In_Tooltip") },
{ "{{ButtonIsEnabled}}", IsButtonEnabled },
};

public override string TemplateJson => TemplateHelper.LoadTemplateJsonFromTemplateName("AuthTemplate", TemplateSubstitutions);
Expand All @@ -52,6 +81,7 @@ public override ICommandResult SubmitForm(string inputs, string data)
catch (Exception ex)
{
LoadingStateChanged?.Invoke(this, false);
SetButtonEnabled(true);
SignInAction?.Invoke(this, new SignInStatusChangedEventArgs(false, ex));
FormSubmitted?.Invoke(this, new FormSubmitEventArgs(false, ex));
}
Expand Down
1 change: 1 addition & 0 deletions GitHubExtension/Controls/Forms/SignOutForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public SignOutForm(IDeveloperIdProvider developerIdProvider, IResources resource
{ "{{AuthButtonTitle}}", _resources.GetResource("Forms_Sign_Out_Button_Title") },
{ "{{AuthIcon}}", $"data:image/png;base64,{GitHubIcon.GetBase64Icon("logo")}" },
{ "{{AuthButtonTooltip}}", _resources.GetResource("Forms_Sign_Out_Tooltip") },
{ "{{ButtonIsEnabled}}", "true" },
};

public override string TemplateJson => TemplateHelper.LoadTemplateJsonFromTemplateName("AuthTemplate", TemplateSubstitutions);
Expand Down
22 changes: 15 additions & 7 deletions GitHubExtension/Controls/Pages/SignInPage.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using GitHubExtension.Controls.Forms;
using GitHubExtension.Helpers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;

namespace GitHubExtension.Controls.Pages;

Expand All @@ -24,12 +25,19 @@ public SignInPage(SignInForm signInForm, StatusMessage statusMessage, string suc
_errorMessage = errorMessage;

// Wire up events using the helper
FormEventHelper.WireFormEvents(_signInForm, this, _statusMessage, _successMessage, _errorMessage);
FormEventHelper.WireFormEvents(_signInForm, this, _statusMessage, _successMessage, _errorMessage);

_signInForm.PropChanged += UpdatePage;

// Hide status message initially
ExtensionHost.HideStatus(_statusMessage);
}

}

private void UpdatePage(object sender, IPropChangedEventArgs args)
{
RaiseItemsChanged();
}

public override IContent[] GetContent()
{
ExtensionHost.HideStatus(_statusMessage);
Expand Down
7 changes: 4 additions & 3 deletions GitHubExtension/Controls/Templates/AuthTemplate.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.5",
"version": "1.6",
"body": [
{
"type": "Container",
Expand Down Expand Up @@ -37,9 +37,10 @@
"type": "ActionSet",
"actions": [
{
"type": "Action.Submit",
"title": "{{AuthButtonTitle}}",
"tooltip": "{{AuthButtonTooltip}}"
"tooltip": "{{AuthButtonTooltip}}",
"type": "Action.Submit",
"isEnabled": {{ButtonIsEnabled}}
}
]
}
Expand Down
20 changes: 16 additions & 4 deletions GitHubExtension/DeveloperId/DeveloperIdProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ private List<DeveloperId> DeveloperIds
get; set;
}

private readonly Lazy<CredentialVault> _credentialVault;
private readonly Lazy<CredentialVault> _credentialVault;

public event EventHandler<Exception?>? OAuthRedirected;

// Private constructor for Singleton class.
public DeveloperIdProvider()
Expand Down Expand Up @@ -147,7 +149,9 @@ public bool LogoutDeveloperId(IDeveloperId developerId)

public void HandleOauthRedirection(Uri authorizationResponse)
{
OAuthRequest? oAuthRequest = null;
OAuthRequest? oAuthRequest = null;

OAuthRedirected?.Invoke(this, null);

lock (_oAuthRequestsLock)
{
Expand Down Expand Up @@ -180,8 +184,16 @@ public void HandleOauthRedirection(Uri authorizationResponse)
OAuthRequests.Remove(oAuthRequest);
}
}

oAuthRequest.CompleteOAuthAsync(authorizationResponse).Wait();

try
{
oAuthRequest.CompleteOAuthAsync(authorizationResponse).Wait();
}
catch (Exception ex)
{
_log.Error(ex, $"Error while completing OAuth request: ");
OAuthRedirected?.Invoke(this, ex);
}
}

public IEnumerable<IDeveloperId> GetLoggedInDeveloperIdsInternal()
Expand Down
4 changes: 3 additions & 1 deletion GitHubExtension/DeveloperId/IDeveloperIdProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ public interface IDeveloperIdProvider

bool LogoutDeveloperId(IDeveloperId developerId);

void HandleOauthRedirection(Uri authorizationResponse);
void HandleOauthRedirection(Uri authorizationResponse);

public event EventHandler<Exception?>? OAuthRedirected;
}
97 changes: 56 additions & 41 deletions GitHubExtension/DeveloperId/OAuthRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
// See the LICENSE file in the project root for more information.

using System.Net;
using System.Runtime.CompilerServices;
using System.Security;
using System.Security.Cryptography;
using System.Web;
using GitHubExtension.Helpers;
using Octokit;
using Serilog;
using Serilog;

[assembly: InternalsVisibleTo("GitHubExtension.Test")]

namespace GitHubExtension.DeveloperId;

Expand Down Expand Up @@ -85,46 +88,58 @@ internal void BeginOAuthRequest()
_log.Error($"Uri Launch failed");
}
});
}

}
internal async Task CompleteOAuthAsync(Uri authorizationResponse)
{
// Gets URI from navigation parameters.
var queryString = authorizationResponse.Query;

// Parse the query string variables into a NameValueCollection.
var queryStringCollection = HttpUtility.ParseQueryString(queryString);

if (!string.IsNullOrEmpty(queryStringCollection.Get("error")))
{
_log.Error($"OAuth authorization error: {queryStringCollection.Get("error")}");
throw new UriFormatException($"OAuth authorization error: {queryStringCollection.Get("error")}");
}

if (string.IsNullOrEmpty(queryStringCollection.Get("code")))
{
_log.Error($"Malformed authorization response: {queryString}");
throw new UriFormatException($"Malformed authorization response: {queryString}");
}

// Gets the Authorization code
var code = queryStringCollection.Get("code");

try
{
var request = new OauthTokenRequest(OauthConfiguration.GetClientId(), OauthConfiguration.GetClientSecret(), code);
var token = await _gitHubClient.Oauth.CreateAccessToken(request);
AccessToken = new NetworkCredential(string.Empty, token.AccessToken).SecurePassword;
_gitHubClient.Credentials = new Credentials(token.AccessToken);
}
catch (Exception ex)
{
_log.Error($"Authorization code exchange failed: {ex}");
throw new InvalidOperationException(ex.Message);
}

_log.Information($"Authorization code exchange completed");
_oAuthCompleted.Release();
{
// Gets URI from navigation parameters.
var queryString = authorizationResponse.Query;

// Parse the query string variables into a NameValueCollection.
var queryStringCollection = HttpUtility.ParseQueryString(queryString);

if (!string.IsNullOrEmpty(queryStringCollection.Get("error")))
{
_log.Error($"OAuth authorization error: {queryStringCollection.Get("error")}");
throw new UriFormatException($"OAuth authorization error: {queryStringCollection.Get("error")}");
}

if (string.IsNullOrEmpty(queryStringCollection.Get("code")))
{
_log.Error($"Malformed authorization response: {queryString}");
throw new UriFormatException($"Malformed authorization response: {queryString}");
}

// Gets the Authorization code
var code = queryStringCollection.Get("code");

try
{
var request = new OauthTokenRequest(OauthConfiguration.GetClientId(), OauthConfiguration.GetClientSecret(), code);

var tokenTask = _gitHubClient.Oauth.CreateAccessToken(request);
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));

var completedTask = await Task.WhenAny(tokenTask, timeoutTask);

if (completedTask == timeoutTask)
{
_log.Error("Authorization code exchange timed out.");
throw new InvalidOperationException("Authorization code exchange timed out.");
}

var token = await tokenTask;
AccessToken = new NetworkCredential(string.Empty, token.AccessToken).SecurePassword;
_gitHubClient.Credentials = new Credentials(token.AccessToken);
}
catch (Exception ex)
{
_log.Error($"Authorization code exchange failed: {ex}");
throw new InvalidOperationException(ex.Message);
}

_log.Information($"Authorization code exchange completed");
_oAuthCompleted.Release();
}

internal DeveloperId RetrieveDeveloperId()
Expand Down Expand Up @@ -168,4 +183,4 @@ private static string GetRandomNumber()

private readonly SemaphoreSlim _oAuthCompleted;
private readonly GitHubClient _gitHubClient;
}
}