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"/>
-