diff --git a/Directory.Packages.props b/Directory.Packages.props index 5b5e25d0cf..de456ced6f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + diff --git a/NexusMods.App.sln.DotSettings b/NexusMods.App.sln.DotSettings index f73699d2f0..f89265b266 100644 --- a/NexusMods.App.sln.DotSettings +++ b/NexusMods.App.sln.DotSettings @@ -4,6 +4,7 @@ DI EA GOG + JWT VM True True diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/ApiKeyMessageFactory.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/ApiKeyMessageFactory.cs index 3b19997e8d..bbc6fcc337 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/ApiKeyMessageFactory.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/ApiKeyMessageFactory.cs @@ -14,7 +14,7 @@ public class ApiKeyMessageFactory : IAuthenticatingMessageFactory /// The name of the environment variable that contains the API key. /// public const string NexusApiKeyEnvironmentVariable = "NEXUS_API_KEY"; - + private static readonly IId ApiKeyId = new IdVariableLength(EntityCategory.AuthData, "NexusMods.Networking.NexusWebApi.ApiKey"u8.ToArray()); private readonly IDataStore _store; @@ -75,7 +75,7 @@ public ValueTask SetApiKey(string apiKey) { Name = result.Data.Name, IsPremium = result.Data.IsPremium, - IsSupporter = result.Data.IsSupporter, + AvatarUrl = result.Data.ProfileUrl }; } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs index 6f1cdfc0d8..2b68575273 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs @@ -5,15 +5,9 @@ namespace NexusMods.Networking.NexusWebApi.NMA; /// -/// entity to store a JWT token -/// TODO: Right now we follow a "ask for forgiveness, not permission" approach to using the token, -/// so we use the access token until we get an error indicating it has expired, then refresh the -/// token and retry. This way we don't need to store when the token expires even though we have -/// that information. If we wanted to save one request every six hours or if the lifetime of access tokens -/// changes, we might want to refresh tokens more proactively and then we'd need to save the expire time. +/// Represents a JWT Token in our DataStore. /// [JsonName("JWTTokens")] -// ReSharper disable once InconsistentNaming public record JWTTokenEntity : Entity { /// @@ -22,17 +16,33 @@ public record JWTTokenEntity : Entity public static readonly IId StoreId = new IdVariableLength(EntityCategory.AuthData, "NexusMods.Networking.NexusWebApi.JWTTokens"u8.ToArray()); /// - public override EntityCategory Category => EntityCategory.AuthData; + public override EntityCategory Category => StoreId.Category; /// - /// the current access token + /// Gets the access token. /// + /// + /// This token expires at and needs to be refreshed using . + /// public required string AccessToken { get; init; } /// - /// token needed to generate a new access token when the current one has expired. + /// Gets the refresh token. /// public required string RefreshToken { get; init; } + + /// + /// Gets the date at which the expires. + /// + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Checks whether the token has expired. + /// + public bool HasExpired() + { + return ExpiresAt - TimeSpan.FromMinutes(5) <= DateTimeOffset.UtcNow; + } } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs index a01dae023f..e4b91c3433 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs @@ -1,4 +1,7 @@ +using System.Reactive.Concurrency; using System.Reactive.Linq; +using JetBrains.Annotations; +using NexusMods.Common; using NexusMods.Common.ProtocolRegistration; using NexusMods.DataModel.Abstractions; using NexusMods.Networking.NexusWebApi.Types; @@ -8,7 +11,8 @@ namespace NexusMods.Networking.NexusWebApi.NMA; /// /// Component for handling login and logout from the Nexus Mods /// -public class LoginManager +[PublicAPI] +public sealed class LoginManager : IDisposable { private readonly OAuth _oauth; private readonly IDataStore _dataStore; @@ -24,7 +28,7 @@ public class LoginManager /// /// True if the user is logged in /// - public IObservable IsLoggedIn => UserInfo.Select(info => info != null); + public IObservable IsLoggedIn => UserInfo.Select(info => info is not null); /// /// True if the user is logged in and is a premium member @@ -34,7 +38,7 @@ public class LoginManager /// /// The user's avatar /// - public IObservable Avatar => UserInfo.Select(info => info?.Avatar); + public IObservable Avatar => UserInfo.Select(info => info?.AvatarUrl); /// /// Nexus API client. @@ -44,25 +48,45 @@ public class LoginManager /// Used to register NXM protocol. public LoginManager(Client client, IAuthenticatingMessageFactory msgFactory, - OAuth oauth, IDataStore dataStore, IProtocolRegistration protocolRegistration) + OAuth oauth, + IDataStore dataStore, + IProtocolRegistration protocolRegistration) { _oauth = oauth; _msgFactory = msgFactory; _client = client; _dataStore = dataStore; _protocolRegistration = protocolRegistration; + UserInfo = _dataStore.IdChanges + // NOTE(err120): Since IDs don't change on startup, we can insert + // a fake change at the start of the observable chain. This will only + // run once at startup and notify the subscribers. + .Merge(Observable.Return(JWTTokenEntity.StoreId)) .Where(id => id.Equals(JWTTokenEntity.StoreId)) - .Select(_ => true) - .StartWith(true) - .SelectMany(async _ => await Verify()); + .ObserveOn(TaskPoolScheduler.Default) + .SelectMany(async _ => await Verify(CancellationToken.None)); } - private async Task Verify() + private CachedObject _cachedUserInfo = new(TimeSpan.FromHours(1)); + private readonly SemaphoreSlim _verifySemaphore = new(initialCount: 1, maxCount: 1); + + private async Task Verify(CancellationToken cancellationToken) { - if (await _msgFactory.IsAuthenticated()) - return await _msgFactory.Verify(_client, CancellationToken.None); - return null; + var cachedValue = _cachedUserInfo.Get(); + if (cachedValue is not null) return cachedValue; + + using var waiter = _verifySemaphore.CustomWait(cancellationToken); + cachedValue = _cachedUserInfo.Get(); + if (cachedValue is not null) return cachedValue; + + var isAuthenticated = await _msgFactory.IsAuthenticated(); + if (!isAuthenticated) return null; + + var userInfo = await _msgFactory.Verify(_client, cancellationToken); + _cachedUserInfo.Store(userInfo); + + return userInfo; } /// @@ -75,10 +99,15 @@ public async Task LoginAsync(CancellationToken token = default) await _protocolRegistration.RegisterSelf("nxm"); var jwtToken = await _oauth.AuthorizeRequest(token); + var createdAt = DateTimeOffset.FromUnixTimeSeconds(jwtToken.CreatedAt); + var expiresIn = TimeSpan.FromSeconds(jwtToken.ExpiresIn); + var expiresAt = createdAt + expiresIn; + _dataStore.Put(JWTTokenEntity.StoreId, new JWTTokenEntity { RefreshToken = jwtToken.RefreshToken, - AccessToken = jwtToken.AccessToken + AccessToken = jwtToken.AccessToken, + ExpiresAt = expiresAt }); } @@ -88,6 +117,13 @@ public async Task LoginAsync(CancellationToken token = default) public Task Logout() { _dataStore.Delete(JWTTokenEntity.StoreId); + _cachedUserInfo.Evict(); return Task.CompletedTask; } + + /// + public void Dispose() + { + _verifySemaphore.Dispose(); + } } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/NexusMods.Networking.NexusWebApi.NMA.csproj b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/NexusMods.Networking.NexusWebApi.NMA.csproj index b025a306f7..f4f9b74731 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/NexusMods.Networking.NexusWebApi.NMA.csproj +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/NexusMods.Networking.NexusWebApi.NMA.csproj @@ -9,8 +9,12 @@ - - + + + + + + diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs index a2fcbc57d0..716ad720a2 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs @@ -1,6 +1,7 @@ using System.Reactive.Linq; using System.Security.Cryptography; using System.Text; +using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.Extensions.Logging; using NexusMods.Common; @@ -20,10 +21,10 @@ namespace NexusMods.Networking.NexusWebApi.NMA; public class OAuth { private const string OAuthUrl = "https://users.nexusmods.com/oauth"; - // the redirect url has to explicitly be permitted by the server so we can't change - // this without consulting the backend team + // NOTE(erri120): The backend has a list of valid redirect URLs and client IDs. + // We can't change these on our own. private const string OAuthRedirectUrl = "nxm://oauth/callback"; - private const string OAuthClientId = "vortex"; + private const string OAuthClientId = "nma"; private readonly ILogger _logger; private readonly HttpClient _http; @@ -35,8 +36,11 @@ public class OAuth /// /// constructor /// - public OAuth(ILogger logger, HttpClient http, IIDGenerator idGen, - IOSInterop os, IMessageConsumer nxmUrlMessages, + public OAuth(ILogger logger, + HttpClient http, + IIDGenerator idGen, + IOSInterop os, + IMessageConsumer nxmUrlMessages, IInterprocessJobManager jobManager) { _logger = logger; @@ -48,39 +52,38 @@ public OAuth(ILogger logger, HttpClient http, IIDGenerator idGen, } /// - /// make an authorization request + /// Make an authorization request /// - /// + /// /// task with the jwt token once we receive one - public async Task AuthorizeRequest(CancellationToken cancel) + public async Task AuthorizeRequest(CancellationToken cancellationToken) { - _logger.LogInformation("Starting NexusMods OAuth2 authorization request"); - var state = _idGen.UUIDv4(); - // see https://www.rfc-editor.org/rfc/rfc7636#section-4.1 - var verifier = _idGen.UUIDv4().Replace("-", "").ToBase64(); + var codeVerifier = _idGen.UUIDv4().ToBase64(); + // see https://www.rfc-editor.org/rfc/rfc7636#section-4.2 - using var sha256 = SHA256.Create(); - var challenge = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)).ToBase64(); + var codeChallengeBytes = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier)); + var codeChallenge = StringEncodingExtension.Base64UrlEncode(codeChallengeBytes); + + var state = _idGen.UUIDv4(); // Start listening first, otherwise we might miss the message var codeTask = _nxmUrlMessages.Messages - .Where(url => url.Value.UrlType == NXMUrlType.OAuth) - .Where(url => url.Value.OAuth.State == state) - .Select(url => url.Value.OAuth.Code!) + .Where(url => url.Value.UrlType == NXMUrlType.OAuth && url.Value.OAuth.State == state) + .Select(url => url.Value.OAuth.Code) + .Where(code => code is not null) + .Select(code => code!) .ToAsyncEnumerable() - .FirstAsync(cancel); + .FirstAsync(cancellationToken); - _logger.LogInformation("Opening browser for NexusMods OAuth2 authorization request"); - var url = GenerateAuthorizeUrl(challenge, state); + var url = GenerateAuthorizeUrl(codeChallenge, state); using var job = CreateJob(url); + // see https://www.rfc-editor.org/rfc/rfc7636#section-4.3 - await _os.OpenUrl(url, cancel); + await _os.OpenUrl(url, cancellationToken); var code = await codeTask; - _logger.LogInformation("Received OAuth2 authorization code, requesting token"); - return await AuthorizeToken(verifier, code, cancel); - + return await AuthorizeToken(codeVerifier, code, cancellationToken); } private IInterprocessJob CreateJob(Uri url) @@ -127,37 +130,42 @@ private async Task AuthorizeToken(string verifier, string code, C return JsonSerializer.Deserialize(responseString); } - private string SanitizeBase64(string input) - { - return input - .Replace("+", "-") - .Replace("/", "_") - .TrimEnd('='); - } - - private Uri GenerateAuthorizeUrl(string challenge, string state) + internal static Uri GenerateAuthorizeUrl(string challenge, string state) { + // TODO: switch to Microsoft.AspNetCore.WebUtilities when .NET 8 is available var request = new Dictionary { { "response_type", "code" }, - { "scope", "public" }, + { "scope", "openid profile email" }, { "code_challenge_method", "S256" }, { "client_id", OAuthClientId }, { "redirect_uri", OAuthRedirectUrl }, - { "code_challenge", SanitizeBase64(challenge) }, + { "code_challenge", challenge }, { "state", state }, }; - return new Uri($"{OAuthUrl}/authorize?{StringifyRequest(request)}"); + + return new Uri($"{OAuthUrl}/authorize{CreateQueryString(request)}"); } - private string StringifyRequest(IDictionary input) + private static string CreateQueryString(Dictionary parameters) { - IList properties = new List(); - foreach (var kv in input) + var builder = new StringBuilder(); + var first = true; + foreach (var pair in parameters) { - properties.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}"); + var (key, value) = pair; + + builder.Append(first ? '?' : '&'); + builder.Append(UrlEncoder.Default.Encode(key)); + builder.Append('='); + if (!string.IsNullOrEmpty(value)) + { + builder.Append(UrlEncoder.Default.Encode(value)); + } + + first = false; } - return string.Join("&", properties); + return builder.ToString(); } } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs index 9fafb99adb..513ace0a4e 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs @@ -1,9 +1,7 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Text.Json; +using System.Reactive.Linq; +using Microsoft.Extensions.Logging; using NexusMods.DataModel.Abstractions; -using NexusMods.DataModel.Games; using NexusMods.Networking.NexusWebApi.DTOs.OAuth; -using NexusMods.Networking.NexusWebApi.NMA.Extensions; using NexusMods.Networking.NexusWebApi.Types; namespace NexusMods.Networking.NexusWebApi.NMA; @@ -13,116 +11,96 @@ namespace NexusMods.Networking.NexusWebApi.NMA; /// public class OAuth2MessageFactory : IAuthenticatingMessageFactory { + private readonly ILogger _logger; private readonly IDataStore _store; private readonly OAuth _auth; /// - /// constructor + /// Constructor. /// - public OAuth2MessageFactory(IDataStore store, OAuth auth) + public OAuth2MessageFactory( + IDataStore store, + OAuth auth, + ILogger logger) { _store = store; _auth = auth; + _logger = logger; + + _store.IdChanges + .Where(x => x.Equals(JWTTokenEntity.StoreId)) + .Subscribe(_ => _cachedTokenEntity = null); } - /// - public ValueTask Create(HttpMethod method, Uri uri) - {/* - if (uri.LocalPath == "/v1/users/validate.json") + private JWTTokenEntity? _cachedTokenEntity; + + private async ValueTask GetOrRefreshToken(CancellationToken cancellationToken) + { + _cachedTokenEntity ??= _store.Get(JWTTokenEntity.StoreId); + if (_cachedTokenEntity is null) return null; + if (!_cachedTokenEntity.HasExpired()) return _cachedTokenEntity.AccessToken; + + _logger.LogDebug("Refreshing expired OAuth token"); + + var newToken = await _auth.RefreshToken(_cachedTokenEntity.RefreshToken, cancellationToken); + _cachedTokenEntity = new JWTTokenEntity { - // we shouldn't have tried to call this - throw new Exception("the validate endpoint does not work when using oauth"); - }*/ + RefreshToken = newToken.RefreshToken, + AccessToken = newToken.AccessToken, + ExpiresAt = DateTimeOffset.UtcNow, + }; + + _store.Put(JWTTokenEntity.StoreId, _cachedTokenEntity); + _cachedTokenEntity.DataStoreId = JWTTokenEntity.StoreId; + + return _cachedTokenEntity.AccessToken; + } + + /// + public async ValueTask Create(HttpMethod method, Uri uri) + { + var token = await GetOrRefreshToken(CancellationToken.None); + if (token is null) throw new Exception("Unauthorized!"); + var msg = new HttpRequestMessage(method, uri); - msg.Headers.Add("Authorization", $"Bearer {Token}"); - return ValueTask.FromResult(msg); + msg.Headers.Add("Authorization", $"Bearer {token}"); + + return msg; } /// public async ValueTask IsAuthenticated() { - var token = _store.Get(JWTTokenEntity.StoreId); - return await ValueTask.FromResult(token != null); + var token = await GetOrRefreshToken(CancellationToken.None); + return token is not null; } /// public async ValueTask Verify(Client client, CancellationToken cancel) { - // TODO: there is no dedicated api endpoint we can call just to check the oauth token. - // Once graphql is implemented I recommend to fetch user info about the user the token belongs to - // so we can report more details about them. - // For now, any query will fail if the token is valid - await client.ModFilesAsync(GameDomain.From("site"), ModId.From(1), cancel); - - var tokens = _store.Get(JWTTokenEntity.StoreId); - if (tokens == null) + OAuthUserInfo oAuthUserInfo; + try { - return null; + var res = await client.GetOAuthUserInfo(cancellationToken: cancel); + oAuthUserInfo = res.Data; } - - var handler = new JwtSecurityTokenHandler(); - var tokenInfo = handler.ReadJwtToken(tokens.AccessToken); - var userClaim = tokenInfo?.Claims.FirstOrDefault(iter => iter.Type == "user"); - var userInfo = userClaim != null ? JsonSerializer.Deserialize(userClaim.Value) : null; - - if (userInfo == null) + catch (Exception e) { - // we have a token but it's invalid, should we report this or assume it's the server's fault? + _logger.LogError(e, "Exception while fetching OAuth user info"); return null; } return new UserInfo { - Name = userInfo.Name, - IsPremium = userInfo.MembershipRoles.Contains(MembershipRole.Premium), - IsSupporter = userInfo.MembershipRoles.Contains(MembershipRole.Supporter), - Avatar = new Uri($"https://forums.nexusmods.com/uploads/profile/photo-thumb-{userInfo.Id}.png") + Name = oAuthUserInfo.Name, + IsPremium = oAuthUserInfo.MembershipRoles.Contains(MembershipRole.Premium), + AvatarUrl = oAuthUserInfo.Avatar }; } - /// - /// will handle "Token has expired" errors by refreshing the JWT token and then triggering a retry of the same - /// query - /// - public async ValueTask HandleError(HttpRequestMessage original, HttpRequestException ex, CancellationToken cancel) - { - if ((ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) && (ex.Message == "Token has expired")) - { - var tokens = _store.Get(JWTTokenEntity.StoreId); - if (tokens == null) - { - // this shouldn't be possible, why would we get "Token has expired" if we don't _have_ a token? - // unless there is a race condition whereby another thread has deleted the token after the request was made - return null; - } - var newToken = await _auth.RefreshToken(tokens.RefreshToken, cancel); - - _store.Put(JWTTokenEntity.StoreId, new JWTTokenEntity - { - RefreshToken = newToken.RefreshToken, - AccessToken = newToken.AccessToken - }); - - var msg = new HttpRequestMessage(original.Method, original.RequestUri); - msg.Headers.Add("Authorization", $"Bearer {newToken.AccessToken}"); - return msg; - } - - return null; - } - - private string Token + /// + public ValueTask HandleError(HttpRequestMessage original, HttpRequestException ex, CancellationToken cancel) { - get - { - var token = _store.Get(JWTTokenEntity.StoreId); - var value = token?.AccessToken ?? null; - if (string.IsNullOrWhiteSpace(value)) - { - throw new Exception("No OAuth2 token"); - } - - return value; - } + return ValueTask.FromResult(null); } } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/Client.cs b/src/Networking/NexusMods.Networking.NexusWebApi/Client.cs index 35fe1e7cd5..11a6f09fe2 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/Client.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/Client.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using NexusMods.Networking.NexusWebApi.DTOs; using NexusMods.Networking.NexusWebApi.DTOs.Interfaces; +using NexusMods.Networking.NexusWebApi.DTOs.OAuth; using NexusMods.Networking.NexusWebApi.Types; namespace NexusMods.Networking.NexusWebApi; @@ -42,6 +43,17 @@ public async Task> Validate(CancellationToken token = def return await SendAsync(msg, token); } + private static readonly Uri OAuthUserInfoUri = new("https://users.nexusmods.com/oauth/userinfo"); + + /// + /// Retrieves information about the current user when logged in via OAuth. + /// + public async Task> GetOAuthUserInfo(CancellationToken cancellationToken = default) + { + var msg = await _factory.Create(HttpMethod.Get, OAuthUserInfoUri); + return await SendAsync(msg, cancellationToken); + } + /// /// Returns a list of games supported by Nexus. /// diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs b/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs index c815ca9f04..4c782f1442 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs @@ -36,5 +36,5 @@ public struct JwtTokenReply /// unix timestamp (seconds resolution) of when the token was created /// [JsonPropertyName("created_at")] - public ulong CreatedAt { get; set; } + public long CreatedAt { get; set; } } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/OAuthUserInfo.cs b/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/OAuthUserInfo.cs new file mode 100644 index 0000000000..d0fb949b00 --- /dev/null +++ b/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/OAuthUserInfo.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using JetBrains.Annotations; +using NexusMods.Networking.NexusWebApi.DTOs.Interfaces; + +namespace NexusMods.Networking.NexusWebApi.DTOs.OAuth; + +/// +/// Data returned by the OAuth userinfo endpoint. +/// +[PublicAPI] +public record OAuthUserInfo : IJsonSerializable +{ + /// + /// 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 MembershipRole[] MembershipRoles { get; set; } = Array.Empty(); + + /// + public static JsonTypeInfo GetTypeInfo() => OAuthUserInfoContext.Default.OAuthUserInfo; +} + +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(OAuthUserInfo))] +public partial class OAuthUserInfoContext : JsonSerializerContext { } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/IAuthenticatingMessageFactory.cs b/src/Networking/NexusMods.Networking.NexusWebApi/IAuthenticatingMessageFactory.cs index a50f9fe398..6b5808018f 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/IAuthenticatingMessageFactory.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/IAuthenticatingMessageFactory.cs @@ -10,10 +10,10 @@ namespace NexusMods.Networking.NexusWebApi; public interface IAuthenticatingMessageFactory : IHttpMessageFactory { /// - /// verify that the authentication information we have for a user is valid + /// Verify that the authentication information we have for a user is valid /// /// api client to use for making api requests - /// cancelation token + /// cancellation token /// information about the user, null if not valid public ValueTask Verify(Client client, CancellationToken token); } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/IHttpMessageFactory.cs b/src/Networking/NexusMods.Networking.NexusWebApi/IHttpMessageFactory.cs index bc83d5d9c6..d8e8d11c81 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/IHttpMessageFactory.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/IHttpMessageFactory.cs @@ -22,7 +22,7 @@ public interface IHttpMessageFactory /// /// the original request that led to this error /// the exception to handle (or not) - /// cancelation token + /// cancellation token /// a new/updated request to be sent or null if the exception should be thrown public ValueTask HandleError(HttpRequestMessage original, HttpRequestException ex, CancellationToken cancel); } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/Types/UserInfo.cs b/src/Networking/NexusMods.Networking.NexusWebApi/Types/UserInfo.cs index 6c4c137e99..42359404eb 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/Types/UserInfo.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/Types/UserInfo.cs @@ -1,27 +1,25 @@ +using JetBrains.Annotations; + namespace NexusMods.Networking.NexusWebApi.Types; /// /// Information about a logged in user /// +[PublicAPI] public record UserInfo { /// - /// user name - /// - public string Name = ""; - - /// - /// is the user premium? + /// Gets the name of the user. /// - public bool IsPremium; + public required string Name { get; init; } /// - /// is the user a supporter (e.g. formerly premium)? + /// Gets the premium status of the user. /// - public bool IsSupporter; + public required bool IsPremium { get; init; } /// - /// Uri of the user's avatar + /// Gets the avatar url of the user. /// - public Uri? Avatar { get; set; } + public required Uri? AvatarUrl { get; init; } } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/Verbs/NexusApiVerify.cs b/src/Networking/NexusMods.Networking.NexusWebApi/Verbs/NexusApiVerify.cs index b83648c750..5782430eee 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/Verbs/NexusApiVerify.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/Verbs/NexusApiVerify.cs @@ -26,14 +26,13 @@ public NexusApiVerify(Client client, IAuthenticatingMessageFactory messageFactor public async Task Run(CancellationToken token) { var userInfo = await _messageFactory.Verify(_client, token); - await Renderer.Render(new Table(new[] { "Name", "Premium", "Supporter" }, + await Renderer.Render(new Table(new[] { "Name", "Premium" }, new[] { new object[] { userInfo?.Name ?? "", userInfo?.IsPremium ?? false, - userInfo?.IsSupporter ?? false, } })); diff --git a/src/NexusMods.App.UI/Controls/TopBar/TopBarDesignViewModel.cs b/src/NexusMods.App.UI/Controls/TopBar/TopBarDesignViewModel.cs index efe98bdd90..5e531c72ff 100644 --- a/src/NexusMods.App.UI/Controls/TopBar/TopBarDesignViewModel.cs +++ b/src/NexusMods.App.UI/Controls/TopBar/TopBarDesignViewModel.cs @@ -1,4 +1,5 @@ using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using Avalonia.Media; using Avalonia.Media.Imaging; @@ -16,17 +17,13 @@ public class TopBarDesignViewModel : AViewModel, ITopBarViewMo [Reactive] public bool IsLoggedIn { get; set; } - [Reactive] - public bool IsPremium { get; set; } + [Reactive] public bool IsPremium { get; set; } = true; - [Reactive] - public IImage Avatar { get; set; } + [Reactive] public IImage Avatar { get; set; } = new Bitmap(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/cyberpunk_game.png"))); - [Reactive] - public ReactiveCommand LoginCommand { get; set; } + [Reactive] public ReactiveCommand LoginCommand { get; set; } = Initializers.EnabledReactiveCommand; - [Reactive] - public ReactiveCommand LogoutCommand { get; set; } + [Reactive] public ReactiveCommand LogoutCommand { get; set; } = Initializers.EnabledReactiveCommand; [Reactive] public ReactiveCommand MinimizeCommand { get; set; } = ReactiveCommand.Create(() => { }); @@ -38,13 +35,11 @@ public class TopBarDesignViewModel : AViewModel, ITopBarViewMo public TopBarDesignViewModel() { - Avatar = new Bitmap(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/cyberpunk_game.png"))); - IsLoggedIn = false; - IsPremium = true; - - LogoutCommand = ReactiveCommand.Create(ToggleLogin, this.WhenAnyValue(vm => vm.IsLoggedIn)); - LoginCommand = ReactiveCommand.Create(ToggleLogin, this.WhenAnyValue(vm => vm.IsLoggedIn) - .Select(x => !x)); + this.WhenActivated(disposables => + { + LogoutCommand = ReactiveCommand.Create(ToggleLogin, this.WhenAnyValue(vm => vm.IsLoggedIn)).DisposeWith(disposables); + LoginCommand = ReactiveCommand.Create(ToggleLogin, this.WhenAnyValue(vm => vm.IsLoggedIn).Select(x => !x)).DisposeWith(disposables); + }); } private void ToggleLogin() diff --git a/src/NexusMods.App.UI/Controls/TopBar/TopBarView.axaml b/src/NexusMods.App.UI/Controls/TopBar/TopBarView.axaml index 632af15331..c1a8efd19b 100644 --- a/src/NexusMods.App.UI/Controls/TopBar/TopBarView.axaml +++ b/src/NexusMods.App.UI/Controls/TopBar/TopBarView.axaml @@ -26,7 +26,7 @@ Path="avares://NexusMods.App.UI/Assets/nexus-logo-with-text.svg" VerticalAlignment="Center"/> -