From d411db841337a9e57239bd24879201b1f55d089b Mon Sep 17 00:00:00 2001 From: halgari Date: Sat, 25 May 2024 08:41:36 -0600 Subject: [PATCH 1/3] Switch WJ over to OAuth for Nexus logins --- .../LoginManagers/NexusLoginManager.cs | 4 +- .../UserIntervention/NexusLoginHandler.cs | 161 ++++++++++++------ .../UserIntervention/OAuth2LoginHandler.cs | 2 + .../UserIntervention/StringExtensions.cs | 126 ++++++++++++++ Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj | 3 +- Wabbajack.DTOs/Logins/NexusApiState.cs | 10 -- Wabbajack.DTOs/Logins/NexusOAuthState.cs | 13 ++ Wabbajack.DTOs/OAuth/JWTTokenReply.cs | 52 ++++++ .../NexusDownloader.cs | 2 +- .../Models/LegacyNexusApiKey.cs | 40 ----- Wabbajack.Launcher/Program.cs | 3 +- .../ViewModels/MainWindowViewModel.cs | 4 +- .../{ApiKey.cs => AuthInfo.cs} | 2 +- .../DTOs/OAuthUserInfo.cs | 33 ++++ Wabbajack.Networking.NexusApi/Endpoints.cs | 2 + Wabbajack.Networking.NexusApi/NexusApi.cs | 121 +++++++++++-- .../ProxiedNexusApi.cs | 2 +- .../ServiceExtensions.cs | 2 +- .../TokenProviders/NexusApiTokenProvider.cs | 4 +- 19 files changed, 455 insertions(+), 131 deletions(-) create mode 100644 Wabbajack.App.Wpf/UserIntervention/StringExtensions.cs delete mode 100644 Wabbajack.DTOs/Logins/NexusApiState.cs create mode 100644 Wabbajack.DTOs/Logins/NexusOAuthState.cs create mode 100644 Wabbajack.DTOs/OAuth/JWTTokenReply.cs delete mode 100644 Wabbajack.Launcher/Models/LegacyNexusApiKey.cs rename Wabbajack.Networking.NexusApi/{ApiKey.cs => AuthInfo.cs} (66%) create mode 100644 Wabbajack.Networking.NexusApi/DTOs/OAuthUserInfo.cs diff --git a/Wabbajack.App.Wpf/LoginManagers/NexusLoginManager.cs b/Wabbajack.App.Wpf/LoginManagers/NexusLoginManager.cs index 440b60fe2..1a1aa5e47 100644 --- a/Wabbajack.App.Wpf/LoginManagers/NexusLoginManager.cs +++ b/Wabbajack.App.Wpf/LoginManagers/NexusLoginManager.cs @@ -19,7 +19,7 @@ namespace Wabbajack.LoginManagers; public class NexusLoginManager : ViewModel, ILoginFor { private readonly ILogger _logger; - private readonly ITokenProvider _token; + private readonly ITokenProvider _token; private readonly IServiceProvider _serviceProvider; public string SiteName { get; } = "Nexus Mods"; @@ -35,7 +35,7 @@ public Type LoginFor() [Reactive] public bool HaveLogin { get; set; } - public NexusLoginManager(ILogger logger, ITokenProvider token, IServiceProvider serviceProvider) + public NexusLoginManager(ILogger logger, ITokenProvider token, IServiceProvider serviceProvider) { _logger = logger; _token = token; diff --git a/Wabbajack.App.Wpf/UserIntervention/NexusLoginHandler.cs b/Wabbajack.App.Wpf/UserIntervention/NexusLoginHandler.cs index aaaf50c7a..7bf069bf6 100644 --- a/Wabbajack.App.Wpf/UserIntervention/NexusLoginHandler.cs +++ b/Wabbajack.App.Wpf/UserIntervention/NexusLoginHandler.cs @@ -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 _tokenProvider; - - public NexusLoginHandler(EncryptedJsonTokenProvider 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 _tokenProvider; + private readonly ILogger _logger; + private readonly HttpClient _client; + + public NexusLoginHandler(ILogger logger, HttpClient client, EncryptedJsonTokenProvider 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>(); + + 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 AuthorizeToken(string verifier, string code, CancellationToken cancel) + { + var request = new Dictionary { + { "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(responseString); + } + + internal static Uri GenerateAuthorizeUrl(string challenge, string state) + { + var request = new Dictionary { - 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)); } } \ No newline at end of file diff --git a/Wabbajack.App.Wpf/UserIntervention/OAuth2LoginHandler.cs b/Wabbajack.App.Wpf/UserIntervention/OAuth2LoginHandler.cs index 51f45c7b8..a54ac5449 100644 --- a/Wabbajack.App.Wpf/UserIntervention/OAuth2LoginHandler.cs +++ b/Wabbajack.App.Wpf/UserIntervention/OAuth2LoginHandler.cs @@ -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; @@ -103,4 +104,5 @@ await _tokenProvider.SetToken(new TLoginType }); } + } \ No newline at end of file diff --git a/Wabbajack.App.Wpf/UserIntervention/StringExtensions.cs b/Wabbajack.App.Wpf/UserIntervention/StringExtensions.cs new file mode 100644 index 000000000..84b55aa80 --- /dev/null +++ b/Wabbajack.App.Wpf/UserIntervention/StringExtensions.cs @@ -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 +{ + /// + /// Convert string to base 64 encoding + /// + public static string ToBase64(this string input) + { + return ToBase64(Encoding.UTF8.GetBytes(input)); + } + + /// + /// Convert byte array to base 64 encoding + /// + public static string ToBase64(this byte[] input) + { + return Convert.ToBase64String(input); + } + + /// + /// Encodes using base64url encoding. + /// + /// The binary input to encode. + /// The base64url-encoded form of . + [SkipLocalsInit] + public static string Base64UrlEncode(ReadOnlySpan 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.Shared.Rent(bufferSize); + + var numBase64Chars = Base64UrlEncode(input, buffer); + var base64Url = new string(buffer[..numBase64Chars]); + + if (bufferToReturnToPool != null) + { + ArrayPool.Shared.Return(bufferToReturnToPool); + } + + return base64Url; + } + + + private static int Base64UrlEncode(ReadOnlySpan input, Span 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); + } +} diff --git a/Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj b/Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj index a31fc4617..267086911 100644 --- a/Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj +++ b/Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj @@ -90,8 +90,9 @@ + - + diff --git a/Wabbajack.DTOs/Logins/NexusApiState.cs b/Wabbajack.DTOs/Logins/NexusApiState.cs deleted file mode 100644 index f088062ee..000000000 --- a/Wabbajack.DTOs/Logins/NexusApiState.cs +++ /dev/null @@ -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; } -} \ No newline at end of file diff --git a/Wabbajack.DTOs/Logins/NexusOAuthState.cs b/Wabbajack.DTOs/Logins/NexusOAuthState.cs new file mode 100644 index 000000000..21bf95b4f --- /dev/null +++ b/Wabbajack.DTOs/Logins/NexusOAuthState.cs @@ -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; +} \ No newline at end of file diff --git a/Wabbajack.DTOs/OAuth/JWTTokenReply.cs b/Wabbajack.DTOs/OAuth/JWTTokenReply.cs new file mode 100644 index 000000000..5df98c7cb --- /dev/null +++ b/Wabbajack.DTOs/OAuth/JWTTokenReply.cs @@ -0,0 +1,52 @@ +using System; +using System.Text.Json.Serialization; + +namespace Wabbajack.DTOs.OAuth; + +/// +/// JWT Token info as provided by the OAuth server +/// +public class JwtTokenReply +{ + /// + /// the token to use for authentication + /// + [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; + + /// + /// token type, e.g. "Bearer" + /// + [JsonPropertyName("token_type")] + public string? Type { get; set; } + + /// + /// when the access token expires in seconds + /// + [JsonPropertyName("expires_in")] + public ulong ExpiresIn { get; set; } + + /// + /// token to use to refresh once this one has expired + /// + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + /// + /// space separated list of scopes. defined by the server, currently always "public"? + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + /// + /// unix timestamp (seconds resolution) of when the token was created + /// + [JsonPropertyName("created_at")] + public long CreatedAt { get; set; } +} diff --git a/Wabbajack.Downloaders.Nexus/NexusDownloader.cs b/Wabbajack.Downloaders.Nexus/NexusDownloader.cs index 042a64daa..74a86805c 100644 --- a/Wabbajack.Downloaders.Nexus/NexusDownloader.cs +++ b/Wabbajack.Downloaders.Nexus/NexusDownloader.cs @@ -48,7 +48,7 @@ public NexusDownloader(ILogger logger, HttpClient client, IHttp public override Task Prepare() { - return Task.FromResult(_api.ApiKey.HaveToken()); + return Task.FromResult(_api.AuthInfo.HaveToken()); } public override bool IsAllowed(ServerAllowList allowList, IDownloadState state) diff --git a/Wabbajack.Launcher/Models/LegacyNexusApiKey.cs b/Wabbajack.Launcher/Models/LegacyNexusApiKey.cs deleted file mode 100644 index b4b127491..000000000 --- a/Wabbajack.Launcher/Models/LegacyNexusApiKey.cs +++ /dev/null @@ -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 -{ - private AbsolutePath TokenPath => KnownFolders.WabbajackAppLocal.Combine("nexusapikey"); - public async ValueTask Get() - { - var data = await TokenPath.ReadAllBytesAsync(); - var decoded = ProtectedData.Unprotect(data, Encoding.UTF8.GetBytes("nexusapikey"), DataProtectionScope.LocalMachine); - var apiKey = JsonSerializer.Deserialize(decoded)!; - return new NexusApiState() - { - ApiKey = apiKey - }; - } - - public ValueTask SetToken(NexusApiState val) - { - throw new System.NotImplementedException(); - } - - public ValueTask Delete() - { - throw new System.NotImplementedException(); - } - - public bool HaveToken() - { - return TokenPath.FileExists(); - } -} \ No newline at end of file diff --git a/Wabbajack.Launcher/Program.cs b/Wabbajack.Launcher/Program.cs index 04674ce91..1f5e898b2 100644 --- a/Wabbajack.Launcher/Program.cs +++ b/Wabbajack.Launcher/Program.cs @@ -13,7 +13,6 @@ using Wabbajack.DTOs; using Wabbajack.DTOs.JsonConverters; using Wabbajack.DTOs.Logins; -using Wabbajack.Launcher.Models; using Wabbajack.Launcher.ViewModels; using Wabbajack.Networking.Http; using Wabbajack.Networking.Http.Interfaces; @@ -57,7 +56,7 @@ public static void Main(string[] args) services.AddSingleton(); services.AddSingleton(); - services.AddSingleton, NexusApiTokenProvider>(); + services.AddSingleton, NexusApiTokenProvider>(); services.AddSingleton(); services.AddAllSingleton>(s => new Resource("Web Requests", 4)); services.AddAllSingleton(); diff --git a/Wabbajack.Launcher/ViewModels/MainWindowViewModel.cs b/Wabbajack.Launcher/ViewModels/MainWindowViewModel.cs index 2e8f76504..0fc9b640c 100644 --- a/Wabbajack.Launcher/ViewModels/MainWindowViewModel.cs +++ b/Wabbajack.Launcher/ViewModels/MainWindowViewModel.cs @@ -32,9 +32,9 @@ public class MainWindowViewModel : ViewModelBase public Uri GITHUB_REPO = new("https://api.github.com/repos/wabbajack-tools/wabbajack/releases"); private readonly NexusApi _nexusApi; private readonly HttpDownloader _downloader; - private readonly ITokenProvider _tokenProvider; + private readonly ITokenProvider _tokenProvider; - public MainWindowViewModel(NexusApi nexusApi, HttpDownloader downloader, ITokenProvider tokenProvider) + public MainWindowViewModel(NexusApi nexusApi, HttpDownloader downloader, ITokenProvider tokenProvider) { _nexusApi = nexusApi; Status = "Checking for new versions"; diff --git a/Wabbajack.Networking.NexusApi/ApiKey.cs b/Wabbajack.Networking.NexusApi/AuthInfo.cs similarity index 66% rename from Wabbajack.Networking.NexusApi/ApiKey.cs rename to Wabbajack.Networking.NexusApi/AuthInfo.cs index ea72d374b..fa25d3f89 100644 --- a/Wabbajack.Networking.NexusApi/ApiKey.cs +++ b/Wabbajack.Networking.NexusApi/AuthInfo.cs @@ -3,6 +3,6 @@ namespace Wabbajack.Networking.NexusApi; -public interface ApiKey : ITokenProvider +public interface AuthInfo : ITokenProvider { } \ No newline at end of file diff --git a/Wabbajack.Networking.NexusApi/DTOs/OAuthUserInfo.cs b/Wabbajack.Networking.NexusApi/DTOs/OAuthUserInfo.cs new file mode 100644 index 000000000..b362c1a39 --- /dev/null +++ b/Wabbajack.Networking.NexusApi/DTOs/OAuthUserInfo.cs @@ -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 +{ + /// + /// Gets the User ID. + /// + [JsonPropertyName("sub")] + public string Sub { get; set; } = string.Empty; + + /// + /// Gets the User Name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets the avatar url. + /// + [JsonPropertyName("avatar")] + public Uri? Avatar { get; set; } + + /// + /// Gets an array of membership roles. + /// + [JsonPropertyName("membership_roles")] + public string[] MembershipRoles { get; set; } = []; +} \ No newline at end of file diff --git a/Wabbajack.Networking.NexusApi/Endpoints.cs b/Wabbajack.Networking.NexusApi/Endpoints.cs index 3d28d1766..781b1fd2e 100644 --- a/Wabbajack.Networking.NexusApi/Endpoints.cs +++ b/Wabbajack.Networking.NexusApi/Endpoints.cs @@ -8,4 +8,6 @@ public static class Endpoints 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 Updates = "v1/games/{0}/mods/updated.json?period={1}"; + + public const string OAuthValidate = "https://users.nexusmods.com/oauth/userinfo"; } \ No newline at end of file diff --git a/Wabbajack.Networking.NexusApi/NexusApi.cs b/Wabbajack.Networking.NexusApi/NexusApi.cs index f78c8e947..31c7c01ff 100644 --- a/Wabbajack.Networking.NexusApi/NexusApi.cs +++ b/Wabbajack.Networking.NexusApi/NexusApi.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; @@ -12,6 +13,7 @@ using Wabbajack.Common; using Wabbajack.DTOs; using Wabbajack.DTOs.Logins; +using Wabbajack.DTOs.OAuth; using Wabbajack.Networking.Http; using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.NexusApi.DTOs; @@ -28,14 +30,14 @@ public class NexusApi private readonly JsonSerializerOptions _jsonOptions; private readonly IResource _limiter; private readonly ILogger _logger; - public readonly ITokenProvider ApiKey; + public readonly ITokenProvider AuthInfo; private DateTime _lastValidated; private (ValidateInfo info, ResponseMetadata header) _lastValidatedInfo; - public NexusApi(ITokenProvider apiKey, ILogger logger, HttpClient client, + public NexusApi(ITokenProvider authInfo, ILogger logger, HttpClient client, IResource limiter, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions) { - ApiKey = apiKey; + AuthInfo = authInfo; _logger = logger; _client = client; _appInfo = appInfo; @@ -48,8 +50,27 @@ public NexusApi(ITokenProvider apiKey, ILogger logger, public virtual async Task<(ValidateInfo info, ResponseMetadata header)> Validate( CancellationToken token = default) { - var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); - return await Send(msg, token); + var (isApi, code) = await GetAuthInfo(); + + if (isApi) + { + var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); + _lastValidatedInfo = await Send(msg, token); + } + else + { + var msg = await GenerateMessage(HttpMethod.Get, Endpoints.OAuthValidate); + var (data, header) = await Send(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( @@ -60,8 +81,8 @@ public NexusApi(ITokenProvider apiKey, ILogger logger, return _lastValidatedInfo; } - var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); - _lastValidatedInfo = await Send(msg, token); + await Validate(token); + return _lastValidatedInfo; } @@ -172,19 +193,92 @@ protected virtual async ValueTask GenerateMessage(HttpMethod var userAgent = $"{_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"); - 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("Application-Name", _appInfo.ApplicationSlug); msg.Headers.Add("Application-Version", _appInfo.Version); - msg.Headers.Add("apikey", token.ApiKey); + + await AddAuthHeaders(msg); + msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 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 RefreshToken(NexusOAuthState state, CancellationToken cancel) + { + _logger.LogInformation("Refreshing OAuth Token"); + var request = new Dictionary + { + { "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(responseString); + + state.OAuth = newJwt; + await AuthInfo.SetToken(state); + return state; + } public async Task<(UpdateEntry[], ResponseMetadata headers)> GetUpdates(Game game, CancellationToken token) { @@ -274,7 +368,7 @@ public async Task UploadFile(UploadDefinition d) private async Task CheckAccess() { 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); var body = await response.Content.ReadAsStringAsync(); @@ -292,7 +386,7 @@ private async Task AddFile(UploadDefinition d, ChunkStatusResult status) new Uri( $"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(); form.Add(new StringContent(d.GameId.ToString()), "game_id"); form.Add(new StringContent(d.Name), "name"); @@ -330,6 +424,7 @@ private async Task WaitForFileStatus(ChunkStatusResult chunkSt await Task.Delay(TimeSpan.FromSeconds(5)); } } + public async Task IsPremium(CancellationToken token) { diff --git a/Wabbajack.Networking.NexusApi/ProxiedNexusApi.cs b/Wabbajack.Networking.NexusApi/ProxiedNexusApi.cs index 703dc2419..965fd681e 100644 --- a/Wabbajack.Networking.NexusApi/ProxiedNexusApi.cs +++ b/Wabbajack.Networking.NexusApi/ProxiedNexusApi.cs @@ -25,7 +25,7 @@ public class ProxiedNexusApi : NexusApi Endpoints.ModFile }; - public ProxiedNexusApi(ITokenProvider apiKey, ILogger logger, HttpClient client, + public ProxiedNexusApi(ITokenProvider apiKey, ILogger logger, HttpClient client, IResource limiter, ApplicationInfo appInfo, JsonSerializerOptions jsonOptions, ITokenProvider apiState, ClientConfiguration wabbajackClientConfiguration) diff --git a/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs b/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs index b2e325192..a2c3c5181 100644 --- a/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs +++ b/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs @@ -166,7 +166,7 @@ public static IServiceCollection AddOSIntegrated(this IServiceCollection service service.AddBethesdaNet(); // Token Providers - service.AddAllSingleton, EncryptedJsonTokenProvider, NexusApiTokenProvider>(); + service.AddAllSingleton, EncryptedJsonTokenProvider, NexusApiTokenProvider>(); service.AddAllSingleton, EncryptedJsonTokenProvider, MegaTokenProvider>(); service.AddAllSingleton, EncryptedJsonTokenProvider, BethesdaNetTokenProvider>(); diff --git a/Wabbajack.Services.OSIntegrated/TokenProviders/NexusApiTokenProvider.cs b/Wabbajack.Services.OSIntegrated/TokenProviders/NexusApiTokenProvider.cs index 1a8402d0c..033025853 100644 --- a/Wabbajack.Services.OSIntegrated/TokenProviders/NexusApiTokenProvider.cs +++ b/Wabbajack.Services.OSIntegrated/TokenProviders/NexusApiTokenProvider.cs @@ -5,10 +5,10 @@ namespace Wabbajack.Services.OSIntegrated.TokenProviders; -public class NexusApiTokenProvider : EncryptedJsonTokenProvider, ApiKey +public class NexusApiTokenProvider : EncryptedJsonTokenProvider, AuthInfo { public NexusApiTokenProvider(ILogger logger, DTOSerializer dtos) : base(logger, dtos, - "nexus-login") + "nexus-oauth-info") { } } \ No newline at end of file From 53ac8de3e43fe4e7c3ae817f7f024a85c5806081 Mon Sep 17 00:00:00 2001 From: halgari Date: Sat, 25 May 2024 08:42:06 -0600 Subject: [PATCH 2/3] Remove debug code --- Wabbajack.App.Wpf/Util/FilePickerVM.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Wabbajack.App.Wpf/Util/FilePickerVM.cs b/Wabbajack.App.Wpf/Util/FilePickerVM.cs index 4eb68135a..6197e5eb2 100644 --- a/Wabbajack.App.Wpf/Util/FilePickerVM.cs +++ b/Wabbajack.App.Wpf/Util/FilePickerVM.cs @@ -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: From 082bb2e8c1410febd52d3065f807642f8fedc9c0 Mon Sep 17 00:00:00 2001 From: halgari Date: Sat, 25 May 2024 09:04:23 -0600 Subject: [PATCH 3/3] Try and fix the build error --- Wabbajack.VFS/Wabbajack.VFS.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Wabbajack.VFS/Wabbajack.VFS.csproj b/Wabbajack.VFS/Wabbajack.VFS.csproj index 0631cd8f1..f7e75ef5e 100644 --- a/Wabbajack.VFS/Wabbajack.VFS.csproj +++ b/Wabbajack.VFS/Wabbajack.VFS.csproj @@ -13,7 +13,7 @@ - +