diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4ea912df..a5416c7f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -7,7 +7,7 @@ on: branches: [ main ] env: - VERSION: 3.5.0.2 + VERSION: 3.6.0.0 jobs: build: @@ -23,7 +23,7 @@ jobs: strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v2 @@ -38,18 +38,6 @@ jobs: dotnet-version: '8.0.x' include-prerelease: true - - name: Setup .NET Core SDK 7.0.x - uses: actions/setup-dotnet@v1 - with: - dotnet-version: '7.0.x' - include-prerelease: true - - - name: Setup .NET Core SDK 6.0.x - uses: actions/setup-dotnet@v1 - with: - dotnet-version: '6.0.x' - include-prerelease: true - - name: Test run: dotnet test /p:EnableWindowsTargeting=true --filter "Category!=FlakeyNetwork" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a850d9f..b777d46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ ### Changelog +#### Version - 3.6.1.1 - TBD +* Fixed `set-nexus-api-key` CLI command + +#### Version - 3.6.1.0 - 5/26/2024 +* Fixed a race condition on renewing Nexus Mods OAuth2 tokens +* Added `set-nexus-api-key` CLI command +* Added Starfield meta data +* Added Fallout New Vegas Epic Games metadata + +#### Version - 3.6.0.0 - 5/25/2024 +* Wabbajack now uses OAuth2 for Nexus Mods logins +* Support for DirectURL use with LL files + +#### Version - 3.5.0.2 - 5/21/2024 +* *HOTFIX* - change how we log into Nexus Mods. We still need to rewrite this on + Oauth2, but this should fix the current issues we have, and get people back up and running + #### Version - 3.5.0.1 - 1/15/2024 * *HOTFIX* - change the cache file names so files will be auto-rehashed diff --git a/Wabbajack.App.Wpf/LoginManagers/NexusLoginManager.cs b/Wabbajack.App.Wpf/LoginManagers/NexusLoginManager.cs index 440b60fe..1a1aa5e4 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 class NexusLoginManager : ViewModel, ILoginFor [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 aa9e2f36..7bf069bf 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; + 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(EncryptedJsonTokenProvider tokenProvider) + 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(); + + // 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"; - 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")); - - - 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 == "member_id")) - break; + 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; + }; - token.ThrowIfCancellationRequested(); - await Task.Delay(500, token); + 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..."; + 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")); - - 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); - } - - Instructions = "Success, saving information..."; - await _tokenProvider.SetToken(new NexusApiState - { - Cookies = cookies, - ApiKey = key + 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; + } + var responseString = await response.Content.ReadAsStringAsync(cancel); + return JsonSerializer.Deserialize(responseString); + } + + internal static Uri GenerateAuthorizeUrl(string challenge, string state) + { + var request = new Dictionary + { + { "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 51f45c7b..a54ac544 100644 --- a/Wabbajack.App.Wpf/UserIntervention/OAuth2LoginHandler.cs +++ b/Wabbajack.App.Wpf/UserIntervention/OAuth2LoginHandler.cs @@ -6,6 +6,7 @@ using System.Net.Http.Json; 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 @@ public abstract class OAuth2LoginHandler : BrowserWindowViewModel }); } + } \ 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 00000000..84b55aa8 --- /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/Util/FilePickerVM.cs b/Wabbajack.App.Wpf/Util/FilePickerVM.cs index 4eb68135..6197e5eb 100644 --- a/Wabbajack.App.Wpf/Util/FilePickerVM.cs +++ b/Wabbajack.App.Wpf/Util/FilePickerVM.cs @@ -159,7 +159,6 @@ namespace Wabbajack Filters.Connect().QueryWhenChanged(), resultSelector: (target, type, checkOption, query) => { - Console.WriteLine("fff"); switch (type) { case PathTypeOptions.Either: diff --git a/Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj b/Wabbajack.App.Wpf/Wabbajack.App.Wpf.csproj index c01c5a93..5ea1888d 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.CLI/Properties/PublishProfiles/FolderProfile.pubxml b/Wabbajack.CLI/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 00000000..8345a901 --- /dev/null +++ b/Wabbajack.CLI/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + bin\Release\net8.0\publish\win-x64\ + FileSystem + <_TargetId>Folder + net8.0 + win-x64 + true + false + false + false + + \ No newline at end of file diff --git a/Wabbajack.CLI/VerbRegistration.cs b/Wabbajack.CLI/VerbRegistration.cs index 3854dce4..373b4b43 100644 --- a/Wabbajack.CLI/VerbRegistration.cs +++ b/Wabbajack.CLI/VerbRegistration.cs @@ -43,6 +43,8 @@ CommandLineBuilder.RegisterCommand(MirrorFile.Definition, c => ((Mir services.AddSingleton(); CommandLineBuilder.RegisterCommand(ModlistReport.Definition, c => ((ModlistReport)c).Run); services.AddSingleton(); +CommandLineBuilder.RegisterCommand(SetNexusApiKey.Definition, c => ((SetNexusApiKey)c).Run); +services.AddSingleton(); CommandLineBuilder.RegisterCommand(SteamDownloadFile.Definition, c => ((SteamDownloadFile)c).Run); services.AddSingleton(); CommandLineBuilder.RegisterCommand(SteamDumpAppInfo.Definition, c => ((SteamDumpAppInfo)c).Run); diff --git a/Wabbajack.CLI/Verbs/SetNexusApiKey.cs b/Wabbajack.CLI/Verbs/SetNexusApiKey.cs new file mode 100644 index 00000000..a6657644 --- /dev/null +++ b/Wabbajack.CLI/Verbs/SetNexusApiKey.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Wabbajack.CLI.Builder; +using Wabbajack.DTOs.Logins; +using Wabbajack.Services.OSIntegrated; + +namespace Wabbajack.CLI.Verbs; + +public class SetNexusApiKey +{ + private readonly EncryptedJsonTokenProvider _tokenProvider; + private readonly ILogger _logger; + + public SetNexusApiKey(EncryptedJsonTokenProvider tokenProvider, ILogger logger) + { + _tokenProvider = tokenProvider; + _logger = logger; + } + + public static VerbDefinition Definition = new("set-nexus-api-key", + "Sets the Nexus API key to the specified value", + [ + new OptionDefinition(typeof(string), "k", "key", "The Nexus API key") + ]); + + public async Task Run(string key) + { + if (string.IsNullOrEmpty(key)) + { + _logger.LogInformation("Not setting Nexus API key, that looks like an empty string to me."); + return -1; + } + else + { + await _tokenProvider.SetToken(new() { ApiKey = key }); + _logger.LogInformation("Set Nexus API Key to {key}", key); + return 0; + } + } +} \ No newline at end of file diff --git a/Wabbajack.CLI/Verbs/ValidateLists.cs b/Wabbajack.CLI/Verbs/ValidateLists.cs index 72e6285d..76e58fcb 100644 --- a/Wabbajack.CLI/Verbs/ValidateLists.cs +++ b/Wabbajack.CLI/Verbs/ValidateLists.cs @@ -611,7 +611,9 @@ public class ValidateLists 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); try diff --git a/Wabbajack.CLI/Wabbajack.CLI.csproj b/Wabbajack.CLI/Wabbajack.CLI.csproj index 7fc4af60..2f289c5f 100644 --- a/Wabbajack.CLI/Wabbajack.CLI.csproj +++ b/Wabbajack.CLI/Wabbajack.CLI.csproj @@ -8,12 +8,13 @@ GPL-3.0-or-later $(VERSION) wabbajack-cli - true + false linked CS8600 CS8601 CS8618 true + net8.0 @@ -24,7 +25,7 @@ - + diff --git a/Wabbajack.DTOs/Game/Game.cs b/Wabbajack.DTOs/Game/Game.cs index dcd6fef9..5bb36a74 100644 --- a/Wabbajack.DTOs/Game/Game.cs +++ b/Wabbajack.DTOs/Game/Game.cs @@ -54,5 +54,6 @@ public enum Game [Description("Modding Tools")] ModdingTools, [Description("Final Fantasy VII Remake")] FinalFantasy7Remake, - [Description("Baldur's Gate 3")] BaldursGate3 + [Description("Baldur's Gate 3")] BaldursGate3, + [Description("Starfield")] Starfield } diff --git a/Wabbajack.DTOs/Game/GameRegistry.cs b/Wabbajack.DTOs/Game/GameRegistry.cs index 23a18faf..500f931e 100644 --- a/Wabbajack.DTOs/Game/GameRegistry.cs +++ b/Wabbajack.DTOs/Game/GameRegistry.cs @@ -74,6 +74,7 @@ public static class GameRegistry MO2ArchiveName = "falloutnv", SteamIDs = new[] {22380, 22490}, // normal and RU version GOGIDs = new long[] {1454587428}, + EpicGameStoreIDs = new[] {"dabb52e328834da7bbe99691e374cb84"}, RequiredFiles = new[] { "FalloutNV.exe".ToRelativePath() @@ -602,6 +603,22 @@ public static class GameRegistry MainExecutable = @"bin/bg3.exe".ToRelativePath() } }, + { + Game.Starfield, new GameMetaData + { + Game = Game.Starfield, + NexusName = "starfield", + NexusGameId = 4187, + MO2Name = "Starfield", + MO2ArchiveName = "Starfield", + SteamIDs = [1716740], + RequiredFiles = new [] + { + @"Starfield.exe".ToRelativePath() + }, + MainExecutable = @"Starfield.exe".ToRelativePath() + } + }, { Game.ModdingTools, new GameMetaData { diff --git a/Wabbajack.DTOs/Logins/NexusApiState.cs b/Wabbajack.DTOs/Logins/NexusApiState.cs deleted file mode 100644 index f088062e..00000000 --- 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 00000000..21bf95b4 --- /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 00000000..5df98c7c --- /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 042a64da..74a86805 100644 --- a/Wabbajack.Downloaders.Nexus/NexusDownloader.cs +++ b/Wabbajack.Downloaders.Nexus/NexusDownloader.cs @@ -48,7 +48,7 @@ public class NexusDownloader : ADownloader, IUrlDownloader 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.Hashing.PHash.Test/Wabbajack.Hashing.PHash.Test.csproj b/Wabbajack.Hashing.PHash.Test/Wabbajack.Hashing.PHash.Test.csproj index da6b6c5c..34fd44db 100644 --- a/Wabbajack.Hashing.PHash.Test/Wabbajack.Hashing.PHash.Test.csproj +++ b/Wabbajack.Hashing.PHash.Test/Wabbajack.Hashing.PHash.Test.csproj @@ -14,7 +14,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Wabbajack.Launcher/Models/LegacyNexusApiKey.cs b/Wabbajack.Launcher/Models/LegacyNexusApiKey.cs deleted file mode 100644 index b4b12749..00000000 --- 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 04674ce9..1f5e898b 100644 --- a/Wabbajack.Launcher/Program.cs +++ b/Wabbajack.Launcher/Program.cs @@ -13,7 +13,6 @@ using Wabbajack.Downloaders.Http; 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 @@ internal class Program 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 2e8f7650..0fc9b640 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.Test/NexusApiTests.cs b/Wabbajack.Networking.NexusApi.Test/NexusApiTests.cs index d32c82a4..c30c6661 100644 --- a/Wabbajack.Networking.NexusApi.Test/NexusApiTests.cs +++ b/Wabbajack.Networking.NexusApi.Test/NexusApiTests.cs @@ -40,4 +40,4 @@ public class NexusApiTests var (links, _) = await _api.DownloadLink("skyrimspecialedition", 12604, file.FileId); Assert.True(links.Length > 0); } -} \ No newline at end of file +} diff --git a/Wabbajack.Networking.NexusApi.Test/Startup.cs b/Wabbajack.Networking.NexusApi.Test/Startup.cs index 02d96c28..b1d6bab6 100644 --- a/Wabbajack.Networking.NexusApi.Test/Startup.cs +++ b/Wabbajack.Networking.NexusApi.Test/Startup.cs @@ -17,4 +17,4 @@ public class Startup { loggerFactory.AddProvider(new XunitTestOutputLoggerProvider(accessor, delegate { return true; })); } -} \ No newline at end of file +} diff --git a/Wabbajack.Networking.NexusApi/ApiKey.cs b/Wabbajack.Networking.NexusApi/AuthInfo.cs similarity index 65% rename from Wabbajack.Networking.NexusApi/ApiKey.cs rename to Wabbajack.Networking.NexusApi/AuthInfo.cs index ea72d374..7699e1b4 100644 --- a/Wabbajack.Networking.NexusApi/ApiKey.cs +++ b/Wabbajack.Networking.NexusApi/AuthInfo.cs @@ -3,6 +3,6 @@ using Wabbajack.Networking.Http.Interfaces; namespace Wabbajack.Networking.NexusApi; -public interface ApiKey : ITokenProvider +public interface IAuthInfo : 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 00000000..b362c1a3 --- /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 3d28d176..781b1fd2 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 f78c8e94..b512d265 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 Microsoft.Extensions.Logging; 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,15 @@ 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; + private AsyncLock _authLock = new(); - 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 +51,27 @@ public class NexusApi 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 +82,8 @@ public class NexusApi return _lastValidatedInfo; } - var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); - _lastValidatedInfo = await Send(msg, token); + await Validate(token); + return _lastValidatedInfo; } @@ -166,25 +188,95 @@ public class NexusApi protected virtual async ValueTask GenerateMessage(HttpMethod method, string uri, params object?[] parameters) { + using var _ = await _authLock.WaitAsync(); var msg = new HttpRequestMessage(); msg.Method = method; var userAgent = $"{_appInfo.ApplicationSlug}/{_appInfo.Version} ({_appInfo.OSVersion}; {_appInfo.Platform})"; - if (!ApiKey.HaveToken()) - throw new Exception("Please log into the Nexus before attempting to use Wabbajack"); + await AddAuthHeaders(msg); - var token = (await ApiKey.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); + + 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 +366,7 @@ public class NexusApi 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 +384,7 @@ public class NexusApi 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 +422,7 @@ public class NexusApi 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 703dc241..965fd681 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 b2e32519..a2c3c518 100644 --- a/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs +++ b/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs @@ -166,7 +166,7 @@ public static class ServiceExtensions 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 1a8402d0..0556a4fa 100644 --- a/Wabbajack.Services.OSIntegrated/TokenProviders/NexusApiTokenProvider.cs +++ b/Wabbajack.Services.OSIntegrated/TokenProviders/NexusApiTokenProvider.cs @@ -5,10 +5,10 @@ using Wabbajack.Networking.NexusApi; namespace Wabbajack.Services.OSIntegrated.TokenProviders; -public class NexusApiTokenProvider : EncryptedJsonTokenProvider, ApiKey +public class NexusApiTokenProvider : EncryptedJsonTokenProvider, IAuthInfo { public NexusApiTokenProvider(ILogger logger, DTOSerializer dtos) : base(logger, dtos, - "nexus-login") + "nexus-oauth-info") { } } \ No newline at end of file diff --git a/Wabbajack.VFS/Wabbajack.VFS.csproj b/Wabbajack.VFS/Wabbajack.VFS.csproj index 0631cd8f..f7e75ef5 100644 --- a/Wabbajack.VFS/Wabbajack.VFS.csproj +++ b/Wabbajack.VFS/Wabbajack.VFS.csproj @@ -13,7 +13,7 @@ - +