diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml
index 81b101233..0beec4c69 100644
--- a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml
+++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginView.axaml
@@ -2,7 +2,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:currentUser="clr-namespace:Artemis.UI.Screens.Workshop.CurrentUser"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.CurrentUser.WorkshopLoginView"
@@ -15,22 +14,7 @@
If you do not have an account yet you can create one in the browser too. Alternatively you can log in with Google or Discord.
-
-
- When logging in please enter the the code below
-
-
-
-
-
-
-
+ Waiting for login...
+
\ No newline at end of file
diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs
index 93f86d1af..793fedc6d 100644
--- a/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs
+++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/WorkshopLoginViewModel.cs
@@ -1,11 +1,7 @@
using System;
-using System.Linq;
-using System.Reactive;
using System.Reactive.Disposables;
-using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
-using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
@@ -19,10 +15,8 @@ namespace Artemis.UI.Screens.Workshop.CurrentUser;
public class WorkshopLoginViewModel : ContentDialogViewModelBase
{
private readonly IAuthenticationService _authenticationService;
- private readonly INotificationService _notificationService;
private readonly CancellationTokenSource _cts;
- private ObservableAsPropertyHelper? _deviceCode;
- private string? _uri;
+ private readonly INotificationService _notificationService;
public WorkshopLoginViewModel(IAuthenticationService authenticationService, INotificationService notificationService)
{
@@ -30,35 +24,20 @@ public class WorkshopLoginViewModel : ContentDialogViewModelBase
_notificationService = notificationService;
_cts = new CancellationTokenSource();
- OpenBrowser = ReactiveCommand.Create(ExecuteOpenBrowser);
- CopyToClipboard = ReactiveCommand.CreateFromTask(ExecuteCopyToClipboard);
-
this.WhenActivated(d =>
{
Dispatcher.UIThread.InvokeAsync(Login);
-
- _deviceCode = _authenticationService.UserCode
- .Select(uc => uc != null ? string.Join("-", string.Join("-", uc.Chunk(3).Select(c => new string(c)))) : null)
- .ToProperty(this, vm => vm.Code)
- .DisposeWith(d);
- _authenticationService.VerificationUri.Subscribe(u => _uri = u).DisposeWith(d);
Disposable.Create(_cts, cts => cts.Cancel()).DisposeWith(d);
});
}
- public ReactiveCommand OpenBrowser { get; }
- public ReactiveCommand CopyToClipboard { get; }
-
- public string? Code => _deviceCode?.Value;
-
private async Task Login()
{
try
{
- bool result = await _authenticationService.Login(_cts.Token);
- if (result)
- _notificationService.CreateNotification().WithTitle("Login succeeded").WithSeverity(NotificationSeverity.Success).Show();
- ContentDialog?.Hide(result ? ContentDialogResult.Primary : ContentDialogResult.Secondary);
+ await _authenticationService.Login(_cts.Token);
+ _notificationService.CreateNotification().WithTitle("Login succeeded").WithSeverity(NotificationSeverity.Success).Show();
+ ContentDialog?.Hide(ContentDialogResult.Primary);
}
catch (Exception e)
{
@@ -67,19 +46,4 @@ public class WorkshopLoginViewModel : ContentDialogViewModelBase
ContentDialog?.Hide(ContentDialogResult.Secondary);
}
}
-
- private void ExecuteOpenBrowser()
- {
- if (_uri == null)
- return;
- Utilities.OpenUrl(_uri);
- }
-
- private async Task ExecuteCopyToClipboard()
- {
- if (Code == null)
- return;
- await Shared.UI.Clipboard.SetTextAsync(Code);
- _notificationService.CreateNotification().WithMessage("Code copied to clipboard.").Show();
- }
}
\ No newline at end of file
diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs
new file mode 100644
index 000000000..adec69370
--- /dev/null
+++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs
@@ -0,0 +1,233 @@
+using System.Collections.ObjectModel;
+using System.IdentityModel.Tokens.Jwt;
+using System.Net;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using Artemis.Core;
+using Artemis.WebClient.Workshop.Repositories;
+using IdentityModel;
+using IdentityModel.Client;
+
+namespace Artemis.WebClient.Workshop.Services;
+
+internal class AuthenticationService : CorePropertyChanged, IAuthenticationService
+{
+ private const string CLIENT_ID = "artemis.desktop";
+ private readonly IAuthenticationRepository _authenticationRepository;
+ private readonly SemaphoreSlim _authLock = new(1);
+ private readonly ObservableCollection _claims = new();
+
+ private readonly IDiscoveryCache _discoveryCache;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly BehaviorSubject _isLoggedInSubject = new(false);
+
+ private AuthenticationToken? _token;
+
+ public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
+ {
+ _httpClientFactory = httpClientFactory;
+ _discoveryCache = discoveryCache;
+ _authenticationRepository = authenticationRepository;
+
+ Claims = new ReadOnlyObservableCollection(_claims);
+ }
+
+ private async Task GetDiscovery()
+ {
+ DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync();
+ if (disco.IsError)
+ throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error);
+
+ return disco;
+ }
+
+ private void SetCurrentUser(TokenResponse response)
+ {
+ _token = new AuthenticationToken(response);
+ _authenticationRepository.SetRefreshToken(_token.RefreshToken);
+
+ JwtSecurityTokenHandler handler = new();
+ JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken);
+ if (token == null)
+ throw new ArtemisWebClientException("Failed to read JWT token");
+
+ _claims.Clear();
+ foreach (Claim responseClaim in token.Claims)
+ _claims.Add(responseClaim);
+
+ _isLoggedInSubject.OnNext(true);
+ }
+
+ private async Task UseRefreshToken(string refreshToken)
+ {
+ DiscoveryDocumentResponse disco = await GetDiscovery();
+ HttpClient client = _httpClientFactory.CreateClient();
+ TokenResponse response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
+ {
+ Address = disco.TokenEndpoint,
+ ClientId = CLIENT_ID,
+ RefreshToken = refreshToken
+ });
+
+ if (response.IsError)
+ {
+ if (response.Error == OidcConstants.TokenErrors.ExpiredToken)
+ return false;
+
+ throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error);
+ }
+
+ SetCurrentUser(response);
+ return false;
+ }
+
+ private static byte[] HashSha256(string inputString)
+ {
+ using SHA256 sha256 = SHA256.Create();
+ return sha256.ComputeHash(Encoding.UTF8.GetBytes(inputString));
+ }
+
+ ///
+ public IObservable IsLoggedIn => _isLoggedInSubject.AsObservable();
+
+ ///
+ public ReadOnlyObservableCollection Claims { get; }
+
+ public async Task GetBearer()
+ {
+ await _authLock.WaitAsync();
+
+ try
+ {
+ // If not logged in, attempt to auto login first
+ if (!_isLoggedInSubject.Value)
+ await AutoLogin();
+
+ if (_token == null)
+ return null;
+
+ // If the token is expiring, refresh it
+ if (_token.Expired && !await UseRefreshToken(_token.RefreshToken))
+ return null;
+
+ return _token.AccessToken;
+ }
+ finally
+ {
+ _authLock.Release();
+ }
+ }
+
+ ///
+ public async Task AutoLogin()
+ {
+ await _authLock.WaitAsync();
+
+ try
+ {
+ if (_isLoggedInSubject.Value)
+ return true;
+
+ string? refreshToken = _authenticationRepository.GetRefreshToken();
+ if (refreshToken == null)
+ return false;
+
+ return await UseRefreshToken(refreshToken);
+ }
+ finally
+ {
+ _authLock.Release();
+ }
+ }
+
+ ///
+ public async Task Login(CancellationToken cancellationToken)
+ {
+ await _authLock.WaitAsync(cancellationToken);
+
+ try
+ {
+ if (_isLoggedInSubject.Value)
+ return;
+
+ // Start a HTTP listener, this port could be in use but chances are very slim
+ string redirectUri = "http://localhost:56789";
+ using HttpListener listener = new();
+ listener.Prefixes.Add(redirectUri + "/");
+ listener.Start();
+
+ // Discover the Identity endpoint
+ DiscoveryDocumentResponse disco = await GetDiscovery();
+ if (disco.AuthorizeEndpoint == null)
+ throw new ArtemisWebClientException("Could not determine the authorize endpoint");
+
+ // Generate the PKCE code verifier and code challenge
+ string codeVerifier = CryptoRandom.CreateUniqueId();
+ string codeChallenge = Base64Url.Encode(HashSha256(codeVerifier));
+ string state = Guid.NewGuid().ToString("N");
+
+ // Open the web browser for the user to log in and authorize the app
+ RequestUrl authRequestUrl = new(disco.AuthorizeEndpoint);
+ string url = authRequestUrl.CreateAuthorizeUrl(
+ CLIENT_ID,
+ "code",
+ "openid profile email offline_access api",
+ redirectUri,
+ state,
+ codeChallenge: codeChallenge,
+ codeChallengeMethod: OidcConstants.CodeChallengeMethods.Sha256);
+ Utilities.OpenUrl(url);
+
+ // Wait for the callback with the code
+ HttpListenerContext context = await listener.GetContextAsync().WaitAsync(cancellationToken);
+ string? code = context.Request.QueryString.Get("code");
+ string? returnState = context.Request.QueryString.Get("state");
+
+ // Validate that a code was given and that our state matches, ensuring we don't respond to a request we did not initialize
+ if (code == null || returnState != state)
+ throw new ArtemisWebClientException("Did not get the expected response on the callback");
+
+ // Redirect the browser to a page hosted by Identity indicating success
+ context.Response.StatusCode = (int) HttpStatusCode.Redirect;
+ context.Response.AddHeader("Location", $"{WorkshopConstants.AUTHORITY_URL}/account/login/success");
+ context.Response.Close();
+
+ // Request auth tokens
+ HttpClient client = _httpClientFactory.CreateClient();
+ TokenResponse response = await client.RequestAuthorizationCodeTokenAsync(new AuthorizationCodeTokenRequest
+ {
+ Address = disco.TokenEndpoint,
+ ClientId = CLIENT_ID,
+ Code = code,
+ CodeVerifier = codeVerifier,
+ RedirectUri = redirectUri
+ }, cancellationToken);
+
+ if (response.IsError)
+ throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error);
+
+ // Set the current user using the new tokens
+ SetCurrentUser(response);
+
+ listener.Stop();
+ listener.Close();
+ }
+ finally
+ {
+ _authLock.Release();
+ }
+ }
+
+ ///
+ public void Logout()
+ {
+ _token = null;
+ _claims.Clear();
+ _authenticationRepository.SetRefreshToken(null);
+
+ _isLoggedInSubject.OnNext(false);
+ }
+}
\ No newline at end of file
diff --git a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs
index 2e22afbba..16b05ed5e 100644
--- a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs
+++ b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs
@@ -1,222 +1,16 @@
using System.Collections.ObjectModel;
-using System.IdentityModel.Tokens.Jwt;
-using System.Reactive.Linq;
-using System.Reactive.Subjects;
using System.Security.Claims;
-using Artemis.Core;
using Artemis.Core.Services;
-using Artemis.WebClient.Workshop.Repositories;
-using IdentityModel;
-using IdentityModel.Client;
namespace Artemis.WebClient.Workshop.Services;
public interface IAuthenticationService : IProtectedArtemisService
{
IObservable IsLoggedIn { get; }
- IObservable UserCode { get; }
- IObservable VerificationUri { get; }
ReadOnlyObservableCollection Claims { get; }
Task GetBearer();
Task AutoLogin();
- Task Login(CancellationToken cancellationToken);
+ Task Login(CancellationToken cancellationToken);
void Logout();
-}
-
-internal class AuthenticationService : CorePropertyChanged, IAuthenticationService
-{
- private const string CLIENT_ID = "artemis.desktop";
- private readonly IAuthenticationRepository _authenticationRepository;
- private readonly SemaphoreSlim _bearerLock = new(1);
- private readonly ObservableCollection _claims = new();
-
- private readonly IDiscoveryCache _discoveryCache;
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly BehaviorSubject _isLoggedInSubject = new(false);
- private readonly BehaviorSubject _userCodeSubject = new(null);
- private readonly BehaviorSubject _verificationUriSubject = new(null);
-
- private AuthenticationToken? _token;
-
- public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
- {
- _httpClientFactory = httpClientFactory;
- _discoveryCache = discoveryCache;
- _authenticationRepository = authenticationRepository;
-
- Claims = new ReadOnlyObservableCollection(_claims);
- }
-
- private async Task GetDiscovery()
- {
- DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync();
- if (disco.IsError)
- throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error);
-
- return disco;
- }
-
- private async Task AttemptRequestDeviceToken(HttpClient client, string deviceCode, CancellationToken cancellationToken)
- {
- DiscoveryDocumentResponse disco = await GetDiscovery();
- TokenResponse response = await client.RequestDeviceTokenAsync(new DeviceTokenRequest
- {
- Address = disco.TokenEndpoint,
- ClientId = CLIENT_ID,
- DeviceCode = deviceCode
- }, cancellationToken);
-
- if (response.IsError)
- {
- if (response.Error == OidcConstants.TokenErrors.AuthorizationPending || response.Error == OidcConstants.TokenErrors.SlowDown)
- return false;
-
- throw new ArtemisWebClientException("Failed to request device token: " + response.Error);
- }
-
- SetCurrentUser(response);
- return true;
- }
-
- private void SetCurrentUser(TokenResponse response)
- {
- _token = new AuthenticationToken(response);
- _authenticationRepository.SetRefreshToken(_token.RefreshToken);
-
- JwtSecurityTokenHandler handler = new();
- JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken);
- if (token == null)
- throw new ArtemisWebClientException("Failed to read JWT token");
-
- _claims.Clear();
- foreach (Claim responseClaim in token.Claims)
- _claims.Add(responseClaim);
-
- _isLoggedInSubject.OnNext(true);
- }
-
- private async Task UseRefreshToken(string refreshToken)
- {
- DiscoveryDocumentResponse disco = await GetDiscovery();
- HttpClient client = _httpClientFactory.CreateClient();
- TokenResponse response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
- {
- Address = disco.TokenEndpoint,
- ClientId = CLIENT_ID,
- RefreshToken = refreshToken
- });
-
- if (response.IsError)
- {
- if (response.Error == OidcConstants.TokenErrors.ExpiredToken)
- return false;
-
- throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error);
- }
-
- SetCurrentUser(response);
- return false;
- }
-
- ///
- public IObservable IsLoggedIn => _isLoggedInSubject.AsObservable();
-
- ///
- public IObservable UserCode => _userCodeSubject.AsObservable();
-
- ///
- public IObservable VerificationUri => _verificationUriSubject.AsObservable();
-
- ///
- public ReadOnlyObservableCollection Claims { get; }
-
- public async Task GetBearer()
- {
- await _bearerLock.WaitAsync();
-
- try
- {
- // If not logged in, attempt to auto login first
- if (!_isLoggedInSubject.Value)
- await AutoLogin();
-
- if (_token == null)
- return null;
-
- // If the token is expiring, refresh it
- if (_token.Expired && !await UseRefreshToken(_token.RefreshToken))
- return null;
-
- return _token.AccessToken;
- }
- finally
- {
- _bearerLock.Release();
- }
- }
-
- ///
- public async Task AutoLogin()
- {
- if (_isLoggedInSubject.Value)
- return true;
-
- string? refreshToken = _authenticationRepository.GetRefreshToken();
- if (refreshToken == null)
- return false;
-
- return await UseRefreshToken(refreshToken);
- }
-
- ///
- public async Task Login(CancellationToken cancellationToken)
- {
- DiscoveryDocumentResponse disco = await GetDiscovery();
- HttpClient client = _httpClientFactory.CreateClient();
- DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
- {
- Address = disco.DeviceAuthorizationEndpoint,
- ClientId = CLIENT_ID,
- Scope = "openid profile email offline_access api"
- }, cancellationToken);
- if (response.IsError)
- throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error);
- if (response.DeviceCode == null)
- throw new ArtemisWebClientException("Failed to request device authorization: Got no device code");
-
- DateTimeOffset timeout = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn ?? 1800);
- _userCodeSubject.OnNext(response.UserCode);
- _verificationUriSubject.OnNext(response.VerificationUri);
-
- await Task.Delay(TimeSpan.FromSeconds(response.Interval), cancellationToken);
- try
- {
- while (DateTimeOffset.UtcNow < timeout)
- {
- cancellationToken.ThrowIfCancellationRequested();
- bool success = await AttemptRequestDeviceToken(client, response.DeviceCode, cancellationToken);
- if (success)
- return true;
- await Task.Delay(TimeSpan.FromSeconds(response.Interval), cancellationToken);
- }
-
- return false;
- }
- finally
- {
- _userCodeSubject.OnNext(null);
- _verificationUriSubject.OnNext(null);
- }
- }
-
- ///
- public void Logout()
- {
- _token = null;
- _claims.Clear();
- _authenticationRepository.SetRefreshToken(null);
-
- _isLoggedInSubject.OnNext(false);
- }
}
\ No newline at end of file