Merge branch 'main' into starfield-meta-data

This commit is contained in:
Luca 2024-05-25 18:06:47 +02:00 committed by GitHub
commit 3ec1ddcc41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 459 additions and 134 deletions

View File

@ -19,7 +19,7 @@ namespace Wabbajack.LoginManagers;
public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader> public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader>
{ {
private readonly ILogger<NexusLoginManager> _logger; private readonly ILogger<NexusLoginManager> _logger;
private readonly ITokenProvider<NexusApiState> _token; private readonly ITokenProvider<NexusOAuthState> _token;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
public string SiteName { get; } = "Nexus Mods"; public string SiteName { get; } = "Nexus Mods";
@ -35,7 +35,7 @@ public class NexusLoginManager : ViewModel, ILoginFor<NexusDownloader>
[Reactive] [Reactive]
public bool HaveLogin { get; set; } 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; _logger = logger;
_token = token; _token = token;

View File

@ -1,92 +1,143 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; 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;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web;
using Fizzler.Systems.HtmlAgilityPack; using Fizzler.Systems.HtmlAgilityPack;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Wabbajack.DTOs.Logins; using Wabbajack.DTOs.Logins;
using Wabbajack.DTOs.OAuth;
using Wabbajack.Messages; using Wabbajack.Messages;
using Wabbajack.Models; using Wabbajack.Models;
using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.Http.Interfaces;
using Wabbajack.Services.OSIntegrated; using Wabbajack.Services.OSIntegrated;
using Cookie = Wabbajack.DTOs.Logins.Cookie;
namespace Wabbajack.UserIntervention; namespace Wabbajack.UserIntervention;
public class NexusLoginHandler : BrowserWindowViewModel public class NexusLoginHandler : BrowserWindowViewModel
{ {
private readonly 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(EncryptedJsonTokenProvider<NexusApiState> tokenProvider) public NexusLoginHandler(ILogger<NexusLoginHandler> logger, HttpClient client, EncryptedJsonTokenProvider<NexusOAuthState> tokenProvider)
{ {
_logger = logger;
_client = client;
HeaderText = "Nexus Login"; HeaderText = "Nexus Login";
_tokenProvider = tokenProvider; _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) protected override async Task Run(CancellationToken token)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
// see https://www.rfc-editor.org/rfc/rfc7636#section-4.1
var codeVerifier = Guid.NewGuid().ToString("N").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);
Instructions = "Please log into the Nexus"; Instructions = "Please log into the Nexus";
await NavigateTo(new Uri( var state = Guid.NewGuid().ToString();
"https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=//www.nexusmods.com"));
await NavigateTo(new Uri("https://nexusmods.com"));
var codeCompletionSource = new TaskCompletionSource<Dictionary<string, StringValues>>();
Cookie[] cookies = { };
while (true) Browser!.Browser.CoreWebView2.NewWindowRequested += (sender, args) =>
{ {
cookies = await GetCookies("nexusmods.com", token); var uri = new Uri(args.Uri);
if (cookies.Any(c => c.Name.Contains("SessionUser"))) _logger.LogInformation("New Window Requested {Uri}", args.Uri);
break; if (uri.Host != "127.0.0.1") return;
codeCompletionSource.TrySetResult(QueryHelpers.ParseQuery(uri.Query));
args.Handled = true;
};
token.ThrowIfCancellationRequested(); var uri = GenerateAuthorizeUrl(codeChallenge, state);
await Task.Delay(500, token); 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..."; var result = await AuthorizeToken(codeVerifier, code, token);
if (result != null)
result.ReceivedAt = DateTime.UtcNow.ToFileTimeUtc();
await NavigateTo(new Uri("https://next.nexusmods.com/settings/api-keys")); await _tokenProvider.SetToken(new NexusOAuthState()
var key = "";
while (true)
{ {
try OAuth = result!
{
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);
}
Instructions = "Success, saving information...";
await _tokenProvider.SetToken(new NexusApiState
{
Cookies = cookies,
ApiKey = key
}); });
} }
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;
}
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>
{
{ "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));
}
} }

View File

@ -6,6 +6,7 @@ using System.Net.Http.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ReactiveUI; using ReactiveUI;
using Wabbajack.Common; using Wabbajack.Common;
@ -103,4 +104,5 @@ public abstract class OAuth2LoginHandler<TLoginType> : BrowserWindowViewModel
}); });
} }
} }

View File

@ -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);
}
}

View File

@ -159,7 +159,6 @@ namespace Wabbajack
Filters.Connect().QueryWhenChanged(), Filters.Connect().QueryWhenChanged(),
resultSelector: (target, type, checkOption, query) => resultSelector: (target, type, checkOption, query) =>
{ {
Console.WriteLine("fff");
switch (type) switch (type)
{ {
case PathTypeOptions.Either: case PathTypeOptions.Either:

View File

@ -90,8 +90,9 @@
<PackageReference Include="MahApps.Metro" Version="2.4.10" /> <PackageReference Include="MahApps.Metro" Version="2.4.10" />
<PackageReference Include="MahApps.Metro.IconPacks" Version="4.11.0" /> <PackageReference Include="MahApps.Metro.IconPacks" Version="4.11.0" />
<PackageReference Include="Microsoft-WindowsAPICodePack-Shell" Version="1.1.5" /> <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.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="NLog.Extensions.Logging" Version="5.3.5" />
<PackageReference Include="Orc.FileAssociation" Version="5.0.0-alpha0061" /> <PackageReference Include="Orc.FileAssociation" Version="5.0.0-alpha0061" />
<PackageReference Include="PInvoke.User32" Version="0.7.124" /> <PackageReference Include="PInvoke.User32" Version="0.7.124" />

View File

@ -611,7 +611,9 @@ public class ValidateLists
return (ArchiveStatus.InValid, archive); return (ArchiveStatus.InValid, archive);
} }
if (archive.State is Http http && http.Url.Host.EndsWith("github.com")) if (archive.State is Http http && (http.Url.Host.EndsWith("github.com")
//TODO: Find a better solution for the list validation of LoversLab files.
|| http.Url.Host.EndsWith("loverslab.com")))
return (ArchiveStatus.Valid, archive); return (ArchiveStatus.Valid, archive);
try try

View File

@ -1,10 +0,0 @@
using System.Text.Json.Serialization;
namespace Wabbajack.DTOs.Logins;
public class NexusApiState
{
[JsonPropertyName("api-key")] public string ApiKey { get; set; }
[JsonPropertyName("cookies")] public Cookie[] Cookies { get; set; }
}

View File

@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using Wabbajack.DTOs.OAuth;
namespace Wabbajack.DTOs.Logins;
public class NexusOAuthState
{
[JsonPropertyName("oauth")]
public JwtTokenReply? OAuth { get; set; } = new();
[JsonPropertyName("api_key")]
public string ApiKey { get; set; } = string.Empty;
}

View File

@ -0,0 +1,52 @@
using System;
using System.Text.Json.Serialization;
namespace Wabbajack.DTOs.OAuth;
/// <summary>
/// JWT Token info as provided by the OAuth server
/// </summary>
public class JwtTokenReply
{
/// <summary>
/// the token to use for authentication
/// </summary>
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("_received_at")]
public long ReceivedAt { get; set; }
public bool IsExpired => DateTime.FromFileTimeUtc(ReceivedAt) + TimeSpan.FromSeconds(ExpiresIn) - TimeSpan.FromMinutes(5) <= DateTimeOffset.UtcNow;
/// <summary>
/// token type, e.g. "Bearer"
/// </summary>
[JsonPropertyName("token_type")]
public string? Type { get; set; }
/// <summary>
/// when the access token expires in seconds
/// </summary>
[JsonPropertyName("expires_in")]
public ulong ExpiresIn { get; set; }
/// <summary>
/// token to use to refresh once this one has expired
/// </summary>
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
/// <summary>
/// space separated list of scopes. defined by the server, currently always "public"?
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; set; }
/// <summary>
/// unix timestamp (seconds resolution) of when the token was created
/// </summary>
[JsonPropertyName("created_at")]
public long CreatedAt { get; set; }
}

View File

@ -48,7 +48,7 @@ public class NexusDownloader : ADownloader<Nexus>, IUrlDownloader
public override Task<bool> Prepare() public override Task<bool> Prepare()
{ {
return Task.FromResult(_api.ApiKey.HaveToken()); return Task.FromResult(_api.AuthInfo.HaveToken());
} }
public override bool IsAllowed(ServerAllowList allowList, IDownloadState state) public override bool IsAllowed(ServerAllowList allowList, IDownloadState state)

View File

@ -1,40 +0,0 @@
using System.Text;
using System.Threading.Tasks;
using System.Security.Cryptography;
using System.Text.Json;
using Wabbajack.DTOs.Logins;
using Wabbajack.Networking.Http.Interfaces;
using Wabbajack.Paths;
using Wabbajack.Paths.IO;
namespace Wabbajack.Launcher.Models;
public class LegacyNexusApiKey : ITokenProvider<NexusApiState>
{
private AbsolutePath TokenPath => KnownFolders.WabbajackAppLocal.Combine("nexusapikey");
public async ValueTask<NexusApiState?> Get()
{
var data = await TokenPath.ReadAllBytesAsync();
var decoded = ProtectedData.Unprotect(data, Encoding.UTF8.GetBytes("nexusapikey"), DataProtectionScope.LocalMachine);
var apiKey = JsonSerializer.Deserialize<string>(decoded)!;
return new NexusApiState()
{
ApiKey = apiKey
};
}
public ValueTask SetToken(NexusApiState val)
{
throw new System.NotImplementedException();
}
public ValueTask<bool> Delete()
{
throw new System.NotImplementedException();
}
public bool HaveToken()
{
return TokenPath.FileExists();
}
}

View File

@ -13,7 +13,6 @@ using Wabbajack.Downloaders.Http;
using Wabbajack.DTOs; using Wabbajack.DTOs;
using Wabbajack.DTOs.JsonConverters; using Wabbajack.DTOs.JsonConverters;
using Wabbajack.DTOs.Logins; using Wabbajack.DTOs.Logins;
using Wabbajack.Launcher.Models;
using Wabbajack.Launcher.ViewModels; using Wabbajack.Launcher.ViewModels;
using Wabbajack.Networking.Http; using Wabbajack.Networking.Http;
using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.Http.Interfaces;
@ -57,7 +56,7 @@ internal class Program
services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<HttpClient>(); services.AddSingleton<HttpClient>();
services.AddSingleton<ITokenProvider<NexusApiState>, NexusApiTokenProvider>(); services.AddSingleton<ITokenProvider<NexusOAuthState>, NexusApiTokenProvider>();
services.AddSingleton<HttpDownloader>(); services.AddSingleton<HttpDownloader>();
services.AddAllSingleton<IResource, IResource<HttpClient>>(s => new Resource<HttpClient>("Web Requests", 4)); services.AddAllSingleton<IResource, IResource<HttpClient>>(s => new Resource<HttpClient>("Web Requests", 4));
services.AddAllSingleton<IHttpDownloader, SingleThreadedDownloader>(); services.AddAllSingleton<IHttpDownloader, SingleThreadedDownloader>();

View File

@ -32,9 +32,9 @@ public class MainWindowViewModel : ViewModelBase
public Uri GITHUB_REPO = new("https://api.github.com/repos/wabbajack-tools/wabbajack/releases"); public Uri GITHUB_REPO = new("https://api.github.com/repos/wabbajack-tools/wabbajack/releases");
private readonly NexusApi _nexusApi; private readonly NexusApi _nexusApi;
private readonly HttpDownloader _downloader; private readonly HttpDownloader _downloader;
private readonly ITokenProvider<NexusApiState> _tokenProvider; private readonly ITokenProvider<NexusOAuthState> _tokenProvider;
public MainWindowViewModel(NexusApi nexusApi, HttpDownloader downloader, ITokenProvider<NexusApiState> tokenProvider) public MainWindowViewModel(NexusApi nexusApi, HttpDownloader downloader, ITokenProvider<NexusOAuthState> tokenProvider)
{ {
_nexusApi = nexusApi; _nexusApi = nexusApi;
Status = "Checking for new versions"; Status = "Checking for new versions";

View File

@ -3,6 +3,6 @@ using Wabbajack.Networking.Http.Interfaces;
namespace Wabbajack.Networking.NexusApi; namespace Wabbajack.Networking.NexusApi;
public interface ApiKey : ITokenProvider<NexusApiState> public interface AuthInfo : ITokenProvider<NexusOAuthState>
{ {
} }

View File

@ -0,0 +1,33 @@
using System;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Octokit;
namespace Wabbajack.Networking.NexusApi.DTOs;
public record OAuthUserInfo
{
/// <summary>
/// Gets the User ID.
/// </summary>
[JsonPropertyName("sub")]
public string Sub { get; set; } = string.Empty;
/// <summary>
/// Gets the User Name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets the avatar url.
/// </summary>
[JsonPropertyName("avatar")]
public Uri? Avatar { get; set; }
/// <summary>
/// Gets an array of membership roles.
/// </summary>
[JsonPropertyName("membership_roles")]
public string[] MembershipRoles { get; set; } = [];
}

View File

@ -8,4 +8,6 @@ public static class Endpoints
public const string ModFile = "v1/games/{0}/mods/{1}/files/{2}.json"; public const string ModFile = "v1/games/{0}/mods/{1}/files/{2}.json";
public const string DownloadLink = "v1/games/{0}/mods/{1}/files/{2}/download_link.json"; public const string DownloadLink = "v1/games/{0}/mods/{1}/files/{2}/download_link.json";
public const string Updates = "v1/games/{0}/mods/updated.json?period={1}"; public const string Updates = "v1/games/{0}/mods/updated.json?period={1}";
public const string OAuthValidate = "https://users.nexusmods.com/oauth/userinfo";
} }

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -12,6 +13,7 @@ using Microsoft.Extensions.Logging;
using Wabbajack.Common; using Wabbajack.Common;
using Wabbajack.DTOs; using Wabbajack.DTOs;
using Wabbajack.DTOs.Logins; using Wabbajack.DTOs.Logins;
using Wabbajack.DTOs.OAuth;
using Wabbajack.Networking.Http; using Wabbajack.Networking.Http;
using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.Http.Interfaces;
using Wabbajack.Networking.NexusApi.DTOs; using Wabbajack.Networking.NexusApi.DTOs;
@ -28,14 +30,14 @@ public class NexusApi
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
private readonly IResource<HttpClient> _limiter; private readonly IResource<HttpClient> _limiter;
private readonly ILogger<NexusApi> _logger; private readonly ILogger<NexusApi> _logger;
public readonly ITokenProvider<NexusApiState> ApiKey; public readonly ITokenProvider<NexusOAuthState> AuthInfo;
private DateTime _lastValidated; private DateTime _lastValidated;
private (ValidateInfo info, ResponseMetadata header) _lastValidatedInfo; private (ValidateInfo info, ResponseMetadata header) _lastValidatedInfo;
public NexusApi(ITokenProvider<NexusApiState> apiKey, ILogger<NexusApi> logger, HttpClient client, public NexusApi(ITokenProvider<NexusOAuthState> authInfo, ILogger<NexusApi> logger, HttpClient client,
IResource<HttpClient> limiter, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions) IResource<HttpClient> limiter, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions)
{ {
ApiKey = apiKey; AuthInfo = authInfo;
_logger = logger; _logger = logger;
_client = client; _client = client;
_appInfo = appInfo; _appInfo = appInfo;
@ -48,8 +50,27 @@ public class NexusApi
public virtual async Task<(ValidateInfo info, ResponseMetadata header)> Validate( public virtual async Task<(ValidateInfo info, ResponseMetadata header)> Validate(
CancellationToken token = default) CancellationToken token = default)
{ {
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); var (isApi, code) = await GetAuthInfo();
return await Send<ValidateInfo>(msg, token);
if (isApi)
{
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate);
_lastValidatedInfo = await Send<ValidateInfo>(msg, token);
}
else
{
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.OAuthValidate);
var (data, header) = await Send<OAuthUserInfo>(msg, token);
var validateInfo = new ValidateInfo
{
IsPremium = data.MembershipRoles.Contains("premium"),
Name = data.Name,
};
_lastValidatedInfo = (validateInfo, header);
}
_lastValidated = DateTime.Now;
return _lastValidatedInfo;
} }
public async Task<(ValidateInfo info, ResponseMetadata header)> ValidateCached( public async Task<(ValidateInfo info, ResponseMetadata header)> ValidateCached(
@ -60,8 +81,8 @@ public class NexusApi
return _lastValidatedInfo; return _lastValidatedInfo;
} }
var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); await Validate(token);
_lastValidatedInfo = await Send<ValidateInfo>(msg, token);
return _lastValidatedInfo; return _lastValidatedInfo;
} }
@ -172,19 +193,92 @@ public class NexusApi
var userAgent = var userAgent =
$"{_appInfo.ApplicationSlug}/{_appInfo.Version} ({_appInfo.OSVersion}; {_appInfo.Platform})"; $"{_appInfo.ApplicationSlug}/{_appInfo.Version} ({_appInfo.OSVersion}; {_appInfo.Platform})";
if (!ApiKey.HaveToken()) if (!AuthInfo.HaveToken())
throw new Exception("Please log into the Nexus before attempting to use Wabbajack"); throw new Exception("Please log into the Nexus before attempting to use Wabbajack");
var token = (await ApiKey.Get())!; var token = (await AuthInfo.Get())!;
if (uri.StartsWith("http"))
{
msg.RequestUri = new Uri($"{string.Format(uri, parameters)}");
}
else
{
msg.RequestUri = new Uri($"https://api.nexusmods.com/{string.Format(uri, parameters)}");
}
msg.RequestUri = new Uri($"https://api.nexusmods.com/{string.Format(uri, parameters)}");
msg.Headers.Add("User-Agent", userAgent); msg.Headers.Add("User-Agent", userAgent);
msg.Headers.Add("Application-Name", _appInfo.ApplicationSlug); msg.Headers.Add("Application-Name", _appInfo.ApplicationSlug);
msg.Headers.Add("Application-Version", _appInfo.Version); msg.Headers.Add("Application-Version", _appInfo.Version);
msg.Headers.Add("apikey", token.ApiKey);
await AddAuthHeaders(msg);
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return msg; return msg;
} }
private async ValueTask AddAuthHeaders(HttpRequestMessage msg)
{
var (isApi, code) = await GetAuthInfo();
if (string.IsNullOrWhiteSpace(code))
throw new Exception("No API Key or OAuth Token found for NexusMods");
if (isApi)
msg.Headers.Add("apikey", code);
else
{
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", code);
}
}
private async ValueTask<(bool IsApiKey, string code)> GetAuthInfo()
{
if (AuthInfo.HaveToken())
{
var info = await AuthInfo.Get();
if (info!.OAuth != null)
{
if (info!.OAuth.IsExpired)
info = await RefreshToken(info, CancellationToken.None);
return (false, info.OAuth!.AccessToken!);
}
if (!string.IsNullOrWhiteSpace(info.ApiKey))
{
return (true, info.ApiKey);
}
}
else
{
if (Environment.GetEnvironmentVariable("NEXUS_API_KEY") is { } apiKey)
{
return (true, apiKey);
}
}
return default;
}
private async Task<NexusOAuthState> RefreshToken(NexusOAuthState state, CancellationToken cancel)
{
_logger.LogInformation("Refreshing OAuth Token");
var request = new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "client_id", "wabbajack" },
{ "refresh_token", state.OAuth!.RefreshToken },
};
var content = new FormUrlEncodedContent(request);
var response = await _client.PostAsync($"https://users.nexusmods.com/oauth/token", content, cancel);
var responseString = await response.Content.ReadAsStringAsync(cancel);
var newJwt = JsonSerializer.Deserialize<JwtTokenReply>(responseString);
state.OAuth = newJwt;
await AuthInfo.SetToken(state);
return state;
}
public async Task<(UpdateEntry[], ResponseMetadata headers)> GetUpdates(Game game, CancellationToken token) public async Task<(UpdateEntry[], ResponseMetadata headers)> GetUpdates(Game game, CancellationToken token)
{ {
@ -274,7 +368,7 @@ public class NexusApi
private async Task CheckAccess() private async Task CheckAccess()
{ {
var msg = new HttpRequestMessage(HttpMethod.Get, "https://www.nexusmods.com/users/myaccount"); var msg = new HttpRequestMessage(HttpMethod.Get, "https://www.nexusmods.com/users/myaccount");
msg.AddCookies((await ApiKey.Get())!.Cookies); throw new NotSupportedException("Uploading to NexusMods is currently disabled");
using var response = await _client.SendAsync(msg); using var response = await _client.SendAsync(msg);
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
@ -292,7 +386,7 @@ public class NexusApi
new Uri( new Uri(
$"https://www.nexusmods.com/{d.Game.MetaData().NexusName}/mods/edit/?id={d.ModId}&game_id={d.GameId}&step=files"); $"https://www.nexusmods.com/{d.Game.MetaData().NexusName}/mods/edit/?id={d.ModId}&game_id={d.GameId}&step=files");
msg.AddCookies((await ApiKey.Get())!.Cookies); throw new NotSupportedException("Uploading to NexusMods is currently disabled");
var form = new MultipartFormDataContent(); var form = new MultipartFormDataContent();
form.Add(new StringContent(d.GameId.ToString()), "game_id"); form.Add(new StringContent(d.GameId.ToString()), "game_id");
form.Add(new StringContent(d.Name), "name"); form.Add(new StringContent(d.Name), "name");
@ -330,6 +424,7 @@ public class NexusApi
await Task.Delay(TimeSpan.FromSeconds(5)); await Task.Delay(TimeSpan.FromSeconds(5));
} }
} }
public async Task<bool> IsPremium(CancellationToken token) public async Task<bool> IsPremium(CancellationToken token)
{ {

View File

@ -25,7 +25,7 @@ public class ProxiedNexusApi : NexusApi
Endpoints.ModFile Endpoints.ModFile
}; };
public ProxiedNexusApi(ITokenProvider<NexusApiState> apiKey, ILogger<ProxiedNexusApi> logger, HttpClient client, public ProxiedNexusApi(ITokenProvider<NexusOAuthState> apiKey, ILogger<ProxiedNexusApi> logger, HttpClient client,
IResource<HttpClient> limiter, IResource<HttpClient> limiter,
ApplicationInfo appInfo, JsonSerializerOptions jsonOptions, ITokenProvider<WabbajackApiState> apiState, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions, ITokenProvider<WabbajackApiState> apiState,
ClientConfiguration wabbajackClientConfiguration) ClientConfiguration wabbajackClientConfiguration)

View File

@ -166,7 +166,7 @@ public static class ServiceExtensions
service.AddBethesdaNet(); service.AddBethesdaNet();
// Token Providers // Token Providers
service.AddAllSingleton<ITokenProvider<NexusApiState>, EncryptedJsonTokenProvider<NexusApiState>, NexusApiTokenProvider>(); service.AddAllSingleton<ITokenProvider<NexusOAuthState>, EncryptedJsonTokenProvider<NexusOAuthState>, NexusApiTokenProvider>();
service.AddAllSingleton<ITokenProvider<MegaToken>, EncryptedJsonTokenProvider<MegaToken>, MegaTokenProvider>(); service.AddAllSingleton<ITokenProvider<MegaToken>, EncryptedJsonTokenProvider<MegaToken>, MegaTokenProvider>();
service.AddAllSingleton<ITokenProvider<BethesdaNetLoginState>, EncryptedJsonTokenProvider<BethesdaNetLoginState>, BethesdaNetTokenProvider>(); service.AddAllSingleton<ITokenProvider<BethesdaNetLoginState>, EncryptedJsonTokenProvider<BethesdaNetLoginState>, BethesdaNetTokenProvider>();

View File

@ -5,10 +5,10 @@ using Wabbajack.Networking.NexusApi;
namespace Wabbajack.Services.OSIntegrated.TokenProviders; namespace Wabbajack.Services.OSIntegrated.TokenProviders;
public class NexusApiTokenProvider : EncryptedJsonTokenProvider<NexusApiState>, ApiKey public class NexusApiTokenProvider : EncryptedJsonTokenProvider<NexusOAuthState>, AuthInfo
{ {
public NexusApiTokenProvider(ILogger<NexusApiTokenProvider> logger, DTOSerializer dtos) : base(logger, dtos, public NexusApiTokenProvider(ILogger<NexusApiTokenProvider> logger, DTOSerializer dtos) : base(logger, dtos,
"nexus-login") "nexus-oauth-info")
{ {
} }
} }

View File

@ -13,7 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.118" /> <PackageReference Include="System.Data.SQLite.Core" Version="1.0.118" PrivateAssets="None" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>