Skip to content

Commit

Permalink
Convert nexus mods oauth login into a job
Browse files Browse the repository at this point in the history
  • Loading branch information
erri120 committed Oct 16, 2024
1 parent 307760c commit 2533e96
Show file tree
Hide file tree
Showing 14 changed files with 206 additions and 165 deletions.
18 changes: 9 additions & 9 deletions src/Abstractions/NexusMods.Abstractions.Jobs/IJobDefinition.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
using JetBrains.Annotations;

namespace NexusMods.Abstractions.Jobs;

public interface IJobDefinition
{

}
[PublicAPI]
public interface IJobDefinition;

/// <summary>
/// A typed job definition that returns a result
/// </summary>
[PublicAPI]
public interface IJobDefinition<TResultType> : IJobDefinition
where TResultType : notnull;


/// <summary>
/// A job definition that can be started with instance method
/// </summary>
/// <typeparam name="TParent"></typeparam>
/// <typeparam name="TResultType"></typeparam>
public interface IJobDefinitionWithStart<TParent, TResultType> : IJobDefinition<TResultType>
where TParent : IJobDefinition<TResultType> where TResultType : notnull
[PublicAPI]
public interface IJobDefinitionWithStart<in TParent, TResultType> : IJobDefinition<TResultType>
where TParent : IJobDefinition<TResultType>
where TResultType : notnull
{
/// <summary>
/// Starts the job
Expand Down
12 changes: 0 additions & 12 deletions src/Abstractions/NexusMods.Abstractions.NexusWebApi/Constants.cs

This file was deleted.

11 changes: 11 additions & 0 deletions src/Abstractions/NexusMods.Abstractions.NexusWebApi/IOAuthJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using NexusMods.Abstractions.Jobs;

namespace NexusMods.Abstractions.NexusWebApi;

/// <summary>
/// Represents a job for logging in using OAuth.
/// </summary>
public interface IOAuthJob : IJobDefinition, IDisposable
{
R3.Subject<Uri> LoginUriSubject { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@
<ItemGroup>
<Folder Include="Types\V2\" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\NexusMods.Abstractions.Jobs\NexusMods.Abstractions.Jobs.csproj" />
</ItemGroup>
</Project>
61 changes: 23 additions & 38 deletions src/Networking/NexusMods.Networking.NexusWebApi/Auth/OAuth.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DynamicData.Kernel;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.Jobs;
using NexusMods.Abstractions.NexusWebApi.DTOs.OAuth;
using NexusMods.Abstractions.NexusWebApi.Types;
using NexusMods.CrossPlatform.Process;
using NexusMods.Extensions.BCL;

namespace NexusMods.Networking.NexusWebApi.Auth;

Expand All @@ -23,6 +21,7 @@ public class OAuth
private const string OAuthRedirectUrl = "nxm://oauth/callback";
private const string OAuthClientId = "nma";

private readonly IJobMonitor _jobMonitor;
private readonly ILogger<OAuth> _logger;
private readonly HttpClient _http;
private readonly IOSInterop _os;
Expand All @@ -32,11 +31,14 @@ public class OAuth
/// <summary>
/// constructor
/// </summary>
public OAuth(ILogger<OAuth> logger,
public OAuth(
IJobMonitor jobMonitor,
ILogger<OAuth> logger,
HttpClient http,
IIDGenerator idGenerator,
IOSInterop os)
{
_jobMonitor = jobMonitor;
_logger = logger;
_http = http;
_os = os;
Expand All @@ -47,39 +49,18 @@ public OAuth(ILogger<OAuth> logger,
/// <summary>
/// Make an authorization request
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns>task with the jwt token once we receive one</returns>
public async Task<JwtTokenReply?> AuthorizeRequest(CancellationToken cancellationToken)
{
// see https://www.rfc-editor.org/rfc/rfc7636#section-4.1
var codeVerifier = _idGenerator.UUIDv4().ToBase64();

// see https://www.rfc-editor.org/rfc/rfc7636#section-4.2
var codeChallengeBytes = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
var codeChallenge = StringBase64Extensions.Base64UrlEncode(codeChallengeBytes);

var state = _idGenerator.UUIDv4();

var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

// Start listening first, otherwise we might miss the message
var codeTask = _nxmUrlMessages
.Where(oauth => oauth.State == state)
.Select(url => url.OAuth.Code)
.Where(code => code is not null)
.Select(code => code!)
.ToAsyncEnumerable()
.FirstAsync(cts.Token);

var url = GenerateAuthorizeUrl(codeChallenge, state);

// see https://www.rfc-editor.org/rfc/rfc7636#section-4.3
await _os.OpenUrl(url, cancellationToken: cancellationToken);

cts.CancelAfter(TimeSpan.FromMinutes(3));
var code = await codeTask;

return await AuthorizeToken(codeVerifier, code, cancellationToken);
var job = OAuthJob.Create(
jobMonitor: _jobMonitor,
idGenerator: _idGenerator,
os: _os,
httpClient: _http,
nxmUrlMessages: _nxmUrlMessages
);

var res = await job;
return res.ValueOrDefault();
}

/// <summary>
Expand Down Expand Up @@ -112,7 +93,11 @@ public void AddUrl(NXMOAuthUrl url)
return JsonSerializer.Deserialize<JwtTokenReply>(responseString);
}

private async Task<JwtTokenReply?> AuthorizeToken(string verifier, string code, CancellationToken cancel)
internal static async Task<JwtTokenReply?> AuthorizeToken(
string verifier,
string code,
HttpClient httpClient,
CancellationToken cancel)
{
var request = new Dictionary<string, string> {
{ "grant_type", "authorization_code" },
Expand All @@ -124,7 +109,7 @@ public void AddUrl(NXMOAuthUrl url)

var content = new FormUrlEncodedContent(request);

var response = await _http.PostAsync($"{OAuthUrl}/token", content, cancel);
var response = await httpClient.PostAsync($"{OAuthUrl}/token", content, cancel);
var responseString = await response.Content.ReadAsStringAsync(cancel);
return JsonSerializer.Deserialize<JwtTokenReply>(responseString);
}
Expand Down
86 changes: 86 additions & 0 deletions src/Networking/NexusMods.Networking.NexusWebApi/OAuthJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Security.Cryptography;
using System.Text;
using DynamicData.Kernel;
using NexusMods.Abstractions.Jobs;
using NexusMods.Abstractions.NexusWebApi;
using NexusMods.Abstractions.NexusWebApi.DTOs.OAuth;
using NexusMods.Abstractions.NexusWebApi.Types;
using NexusMods.CrossPlatform.Process;
using NexusMods.Extensions.BCL;
using NexusMods.Networking.NexusWebApi.Auth;

namespace NexusMods.Networking.NexusWebApi;

internal sealed class OAuthJob : IOAuthJob, IJobDefinitionWithStart<OAuthJob, Optional<JwtTokenReply>>
{
private readonly IIDGenerator _idGenerator;
private readonly IOSInterop _os;
private readonly HttpClient _httpClient;
private readonly Subject<NXMOAuthUrl> _nxmUrlMessages;

public R3.Subject<Uri> LoginUriSubject { get; } = new();

private OAuthJob(
IIDGenerator idGenerator,
IOSInterop os,
HttpClient httpClient,
Subject<NXMOAuthUrl> nxmUrlMessages)
{
_idGenerator = idGenerator;
_os = os;
_httpClient = httpClient;
_nxmUrlMessages = nxmUrlMessages;
}

public static IJobTask<OAuthJob, Optional<JwtTokenReply>> Create(
IJobMonitor jobMonitor,
IIDGenerator idGenerator,
IOSInterop os,
HttpClient httpClient,
Subject<NXMOAuthUrl> nxmUrlMessages)
{
var job = new OAuthJob(idGenerator, os, httpClient, nxmUrlMessages);
return jobMonitor.Begin<OAuthJob, Optional<JwtTokenReply>>(job);
}

public async ValueTask<Optional<JwtTokenReply>> StartAsync(IJobContext<OAuthJob> context)
{
// see https://www.rfc-editor.org/rfc/rfc7636#section-4.1
var codeVerifier = _idGenerator.UUIDv4().ToBase64();

// see https://www.rfc-editor.org/rfc/rfc7636#section-4.2
var codeChallengeBytes = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
var codeChallenge = StringBase64Extensions.Base64UrlEncode(codeChallengeBytes);

var state = _idGenerator.UUIDv4();
var uri = OAuth.GenerateAuthorizeUrl(codeChallenge, state);
LoginUriSubject.OnNext(uri);

var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);

// Start listening first, otherwise we might miss the message
var codeTask = _nxmUrlMessages
.Where(oauth => oauth.State == state)
.Select(url => url.OAuth.Code)
.Where(code => code is not null)
.Select(code => code!)
.ToAsyncEnumerable()
.FirstAsync(cts.Token);

// see https://www.rfc-editor.org/rfc/rfc7636#section-4.3
await _os.OpenUrl(uri, cancellationToken: context.CancellationToken);

cts.CancelAfter(TimeSpan.FromMinutes(3));
var code = await codeTask;

var token = await OAuth.AuthorizeToken(codeVerifier, code, _httpClient, context.CancellationToken);
return token;
}

public void Dispose()
{
LoginUriSubject.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
using System.Windows.Input;

namespace NexusMods.App.UI.Overlays.Login;
namespace NexusMods.App.UI.Overlays.Login;

public interface INexusLoginOverlayViewModel : IOverlayViewModel
{
public ICommand Cancel { get; }
public Uri Uri { get; }
public R3.ReactiveCommand Cancel { get; }
public Uri? Uri { get; }
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
using System.Windows.Input;

namespace NexusMods.App.UI.Overlays.Login;
namespace NexusMods.App.UI.Overlays.Login;

public class NexusLoginOverlayDesignerViewModel : AOverlayViewModel<INexusLoginOverlayViewModel>, INexusLoginOverlayViewModel
{
public ICommand Cancel { get; } = Initializers.ICommand;
public R3.ReactiveCommand Cancel { get; } = new();
public Uri Uri { get; } = new("https://www.nexusmods.com/some/login?name=John&key=1234567890");
}
74 changes: 39 additions & 35 deletions src/NexusMods.App.UI/Overlays/Login/NexusLoginOverlayService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,62 @@
using DynamicData;
using JetBrains.Annotations;
using Microsoft.Extensions.Hosting;
using NexusMods.Abstractions.Jobs;
using NexusMods.Abstractions.NexusWebApi;

namespace NexusMods.App.UI.Overlays.Login;


/// <summary>
/// Very simple service that connects the activity monitor to the overlay controller. Looks for OAuth login activities and
/// displays an overlay for them. We have to do it this way as the overlay controller doesn't know about the login overlays,
/// and the activity monitory and login manager are way on the backend. So this is a bit of a UI/Backend bridge.
/// </summary>
[UsedImplicitly]
public class NexusLoginOverlayService(IOverlayController overlayController) : IHostedService
public class NexusLoginOverlayService : IHostedService
{
private readonly CompositeDisposable _compositeDisposable = new();
private readonly IOverlayController _overlayController;
private readonly IJobMonitor _jobMonitor;
private readonly CompositeDisposable _compositeDisposable;

private NexusLoginOverlayViewModel? _overlayViewModel;
private IJob? _currentJob;

public NexusLoginOverlayService(IOverlayController overlayController, IJobMonitor jobMonitor)
{
_overlayController = overlayController;
_jobMonitor = jobMonitor;
_compositeDisposable = new CompositeDisposable();
}

public Task StartAsync(CancellationToken cancellationToken)
{
// TODO:
// activityMonitor.Activities
// .ToObservableChangeSet(x => x.Id)
// .Filter(x => x.Group.Value == Constants.OAuthActivityGroupName)
// .OnUI()
// .SubscribeWithErrorLogging(changeSet =>
// {
// if (changeSet.Removes > 0 && _currentLoginActivity is not null)
// {
// if (changeSet.Any(x => x.Reason == ChangeReason.Remove && x.Current == _currentLoginActivity))
// {
// _currentLoginActivity = null;
// _overlayViewModel?.Close();
// }
// }
//
// if (changeSet.Adds > 0)
// {
// if (_currentLoginActivity is not null) return;
//
// _currentLoginActivity = changeSet.First(x => x.Reason == ChangeReason.Add).Current;
// _overlayViewModel = new NexusLoginOverlayViewModel(_currentLoginActivity);
//
// overlayController.Enqueue(_overlayViewModel);
// }
// })
// .DisposeWith(_compositeDisposable);
_jobMonitor
.ObserveActiveJobs<IOAuthJob>()
.OnUI()
.SubscribeWithErrorLogging(changeSet =>
{
if (changeSet.Removes > 0 && _currentJob is not null)
{
if (changeSet.Any(x => x.Reason == ChangeReason.Remove && x.Current == _currentJob))
{
_currentJob = null;
_overlayViewModel?.Close();
}
}
if (changeSet.Adds > 0)
{
if (_currentJob is not null) return;
_currentJob = changeSet.First(x => x.Reason == ChangeReason.Add).Current;
_overlayViewModel = new NexusLoginOverlayViewModel(_currentJob);
_overlayController.Enqueue(_overlayViewModel);
}
})
.DisposeWith(_compositeDisposable);

return Task.CompletedTask;
}

public Task StopAsync(CancellationToken cancellationToken)
{
_currentJob = null;
_overlayViewModel?.Close();

_compositeDisposable.Dispose();
Expand Down
Loading

0 comments on commit 2533e96

Please sign in to comment.