Skip to content

Commit

Permalink
Switch WJ over to OAuth for Nexus logins (#2559)
Browse files Browse the repository at this point in the history
* Switch WJ over to OAuth for Nexus logins

* Remove debug code

* Try and fix the build error
  • Loading branch information
halgari authored May 25, 2024
1 parent f4e992f commit e41e5c2
Show file tree
Hide file tree
Showing 21 changed files with 456 additions and 133 deletions.
4 changes: 2 additions & 2 deletions Wabbajack.App.Wpf/LoginManagers/NexusLoginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ namespace Wabbajack.LoginManagers;
public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader>
{
private readonly ILogger<NexusLoginManager> _logger;
private readonly ITokenProvider<NexusApiState> _token;
private readonly ITokenProvider<NexusOAuthState> _token;
private readonly IServiceProvider _serviceProvider;

public string SiteName { get; } = "Nexus Mods";
Expand All @@ -35,7 +35,7 @@ public Type LoginFor()
[Reactive]
public bool HaveLogin { get; set; }

public NexusLoginManager(ILogger<NexusLoginManager> logger, ITokenProvider<NexusApiState> token, IServiceProvider serviceProvider)
public NexusLoginManager(ILogger<NexusLoginManager> logger, ITokenProvider<NexusOAuthState> token, IServiceProvider serviceProvider)
{
_logger = logger;
_token = token;
Expand Down
161 changes: 106 additions & 55 deletions Wabbajack.App.Wpf/UserIntervention/NexusLoginHandler.cs
Original file line number Diff line number Diff line change
@@ -1,92 +1,143 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Fizzler.Systems.HtmlAgilityPack;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Wabbajack.DTOs.Logins;
using Wabbajack.DTOs.OAuth;
using Wabbajack.Messages;
using Wabbajack.Models;
using Wabbajack.Networking.Http.Interfaces;
using Wabbajack.Services.OSIntegrated;
using Cookie = Wabbajack.DTOs.Logins.Cookie;

namespace Wabbajack.UserIntervention;

public class NexusLoginHandler : BrowserWindowViewModel
{
private readonly EncryptedJsonTokenProvider<NexusApiState> _tokenProvider;

public NexusLoginHandler(EncryptedJsonTokenProvider<NexusApiState> tokenProvider)
private static Uri OAuthUrl = new Uri("https://users.nexusmods.com/oauth");
private static string OAuthRedirectUrl = "https://127.0.0.1:1234";
private static string OAuthClientId = "wabbajack";

private readonly EncryptedJsonTokenProvider<NexusOAuthState> _tokenProvider;
private readonly ILogger<NexusLoginHandler> _logger;
private readonly HttpClient _client;

public NexusLoginHandler(ILogger<NexusLoginHandler> logger, HttpClient client, EncryptedJsonTokenProvider<NexusOAuthState> tokenProvider)
{
_logger = logger;
_client = client;
HeaderText = "Nexus Login";
_tokenProvider = tokenProvider;
}

private string Base64Id()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes);
}

protected override async Task Run(CancellationToken token)
{
token.ThrowIfCancellationRequested();

// see https://www.rfc-editor.org/rfc/rfc7636#section-4.1
var codeVerifier = Guid.NewGuid().ToString("N").ToBase64();

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

await NavigateTo(new Uri(
"https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=//www.nexusmods.com"));

Instructions = "Please log into the Nexus";

Cookie[] cookies = { };
while (true)
var state = Guid.NewGuid().ToString();

await NavigateTo(new Uri("https://nexusmods.com"));
var codeCompletionSource = new TaskCompletionSource<Dictionary<string, StringValues>>();

Browser!.Browser.CoreWebView2.NewWindowRequested += (sender, args) =>
{
cookies = await GetCookies("nexusmods.com", token);
if (cookies.Any(c => c.Name.Contains("SessionUser")))
break;

token.ThrowIfCancellationRequested();
await Task.Delay(500, token);
var uri = new Uri(args.Uri);
_logger.LogInformation("New Window Requested {Uri}", args.Uri);
if (uri.Host != "127.0.0.1") return;
codeCompletionSource.TrySetResult(QueryHelpers.ParseQuery(uri.Query));
args.Handled = true;
};

var uri = GenerateAuthorizeUrl(codeChallenge, state);
await NavigateTo(uri);

var ctx = await codeCompletionSource.Task;

if (ctx["state"].FirstOrDefault() != state)
{
throw new Exception("State mismatch");
}

var code = ctx["code"].FirstOrDefault();

Instructions = "Getting API Key...";

await NavigateTo(new Uri("https://next.nexusmods.com/settings/api-keys"));
var result = await AuthorizeToken(codeVerifier, code, token);

if (result != null)
result.ReceivedAt = DateTime.UtcNow.ToFileTimeUtc();

var key = "";

while (true)
await _tokenProvider.SetToken(new NexusOAuthState()
{
try
{
key = (await GetDom(token)).DocumentNode.QuerySelectorAll("img[alt='Wabbajack']").SelectMany(p => p.ParentNode.ParentNode.QuerySelectorAll("input[aria-label='api key']")).Select(node => node.Attributes["value"]).FirstOrDefault()?.Value;
}
catch (Exception)
{
// ignored
}

if (!string.IsNullOrEmpty(key))
break;

try
{
await EvaluateJavaScript(
"var found = document.querySelector(\"img[alt='Wabbajack']\").parentElement.parentElement.querySelector(\"button[aria-label='Request Api Key']\");" +
"found.onclick= function() {return true;};" +
"found.class = \" \"; " +
"found.click();"
);
Instructions = "Generating API Key, Please Wait...";
}
catch (Exception)
{
// ignored
}

token.ThrowIfCancellationRequested();
await Task.Delay(500, token);
OAuth = result!
});
}

private async Task<JwtTokenReply?> AuthorizeToken(string verifier, string code, CancellationToken cancel)
{
var request = new Dictionary<string, string> {
{ "grant_type", "authorization_code" },
{ "client_id", OAuthClientId },
{ "redirect_uri", OAuthRedirectUrl },
{ "code", code },
{ "code_verifier", verifier },
};

var content = new FormUrlEncodedContent(request);

var response = await _client.PostAsync($"{OAuthUrl}/token", content, cancel);
if (!response.IsSuccessStatusCode)
{
_logger.LogCritical("Failed to get token {code} - {message}", response.StatusCode,
response.ReasonPhrase);
return null;
}

Instructions = "Success, saving information...";
await _tokenProvider.SetToken(new NexusApiState
var responseString = await response.Content.ReadAsStringAsync(cancel);
return JsonSerializer.Deserialize<JwtTokenReply>(responseString);
}

internal static Uri GenerateAuthorizeUrl(string challenge, string state)
{
var request = new Dictionary<string, string>
{
Cookies = cookies,
ApiKey = key
});
{ "response_type", "code" },
{ "scope", "public openid profile" },
{ "code_challenge_method", "S256" },
{ "client_id", OAuthClientId },
{ "redirect_uri", OAuthRedirectUrl },
{ "code_challenge", challenge },
{ "state", state },
};

return new Uri(QueryHelpers.AddQueryString($"{OAuthUrl}/authorize", request));
}
}
2 changes: 2 additions & 0 deletions Wabbajack.App.Wpf/UserIntervention/OAuth2LoginHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using Wabbajack.Common;
Expand Down Expand Up @@ -103,4 +104,5 @@ await _tokenProvider.SetToken(new TLoginType
});

}

}
126 changes: 126 additions & 0 deletions Wabbajack.App.Wpf/UserIntervention/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System;
using System.Buffers;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;

namespace Wabbajack.UserIntervention;

public static class StringBase64Extensions
{
/// <summary>
/// Convert string to base 64 encoding
/// </summary>
public static string ToBase64(this string input)
{
return ToBase64(Encoding.UTF8.GetBytes(input));
}

/// <summary>
/// Convert byte array to base 64 encoding
/// </summary>
public static string ToBase64(this byte[] input)
{
return Convert.ToBase64String(input);
}

/// <summary>
/// Encodes <paramref name="input"/> using base64url encoding.
/// </summary>
/// <param name="input">The binary input to encode.</param>
/// <returns>The base64url-encoded form of <paramref name="input"/>.</returns>
[SkipLocalsInit]
public static string Base64UrlEncode(ReadOnlySpan<byte> input)
{
// TODO: use Microsoft.AspNetCore.WebUtilities when .NET 8 is available
// Source: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/WebEncoders/WebEncoders.cs
// The MIT License (MIT)
//
// Copyright (c) .NET Foundation and Contributors
//
// All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

const int stackAllocThreshold = 128;

if (input.IsEmpty)
{
return string.Empty;
}

var bufferSize = GetArraySizeRequiredToEncode(input.Length);

char[]? bufferToReturnToPool = null;
var buffer = bufferSize <= stackAllocThreshold
? stackalloc char[stackAllocThreshold]
: bufferToReturnToPool = ArrayPool<char>.Shared.Rent(bufferSize);

var numBase64Chars = Base64UrlEncode(input, buffer);
var base64Url = new string(buffer[..numBase64Chars]);

if (bufferToReturnToPool != null)
{
ArrayPool<char>.Shared.Return(bufferToReturnToPool);
}

return base64Url;
}


private static int Base64UrlEncode(ReadOnlySpan<byte> input, Span<char> output)
{
Debug.Assert(output.Length >= GetArraySizeRequiredToEncode(input.Length));

if (input.IsEmpty)
{
return 0;
}

// Use base64url encoding with no padding characters. See RFC 4648, Sec. 5.

Convert.TryToBase64Chars(input, output, out int charsWritten);

// Fix up '+' -> '-' and '/' -> '_'. Drop padding characters.
for (var i = 0; i < charsWritten; i++)
{
var ch = output[i];
switch (ch)
{
case '+':
output[i] = '-';
break;
case '/':
output[i] = '_';
break;
case '=':
// We've reached a padding character; truncate the remainder.
return i;
}
}

return charsWritten;
}

private static int GetArraySizeRequiredToEncode(int count)
{
var numWholeOrPartialInputBlocks = checked(count + 2) / 3;
return checked(numWholeOrPartialInputBlocks * 4);
}
}
1 change: 0 additions & 1 deletion Wabbajack.App.Wpf/Util/FilePickerVM.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ public FilePickerVM(object parentVM = null)
Filters.Connect().QueryWhenChanged(),
resultSelector: (target, type, checkOption, query) =>
{
Console.WriteLine("fff");
switch (type)
{
case PathTypeOptions.Either:
Expand Down
3 changes: 2 additions & 1 deletion Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@
<PackageReference Include="MahApps.Metro" Version="2.4.10" />
<PackageReference Include="MahApps.Metro.IconPacks" Version="4.11.0" />
<PackageReference Include="Microsoft-WindowsAPICodePack-Shell" Version="1.1.5" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2151.40" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2478.35" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.5" />
<PackageReference Include="Orc.FileAssociation" Version="5.0.0-alpha0061" />
<PackageReference Include="PInvoke.User32" Version="0.7.124" />
Expand Down
10 changes: 0 additions & 10 deletions Wabbajack.DTOs/Logins/NexusApiState.cs

This file was deleted.

Loading

0 comments on commit e41e5c2

Please sign in to comment.