Ionite
1 year ago
13 changed files with 320 additions and 82 deletions
@ -0,0 +1,7 @@
|
||||
namespace StabilityMatrix.Core.Api; |
||||
|
||||
public interface ITokenProvider |
||||
{ |
||||
Task<string> GetAccessTokenAsync(); |
||||
Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync(); |
||||
} |
@ -0,0 +1,52 @@
|
||||
using StabilityMatrix.Core.Attributes; |
||||
using StabilityMatrix.Core.Models.Api.Lykos; |
||||
using StabilityMatrix.Core.Services; |
||||
|
||||
namespace StabilityMatrix.Core.Api; |
||||
|
||||
[Singleton] |
||||
public class LykosAuthTokenProvider : ITokenProvider |
||||
{ |
||||
private readonly ISecretsManager secretsManager; |
||||
private readonly Lazy<ILykosAuthApi> lazyLykosAuthApi; |
||||
|
||||
public LykosAuthTokenProvider( |
||||
Lazy<ILykosAuthApi> lazyLykosAuthApi, |
||||
ISecretsManager secretsManager |
||||
) |
||||
{ |
||||
// Lazy as instantiating requires the current class to be instantiated. |
||||
this.lazyLykosAuthApi = lazyLykosAuthApi; |
||||
this.secretsManager = secretsManager; |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public async Task<string> GetAccessTokenAsync() |
||||
{ |
||||
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false); |
||||
|
||||
return secrets.LykosAccount?.AccessToken ?? ""; |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public async Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync() |
||||
{ |
||||
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false); |
||||
|
||||
if (string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken)) |
||||
{ |
||||
throw new InvalidOperationException("No refresh token found"); |
||||
} |
||||
|
||||
var lykosAuthApi = lazyLykosAuthApi.Value; |
||||
var newTokens = await lykosAuthApi |
||||
.PostLoginRefresh(new PostLoginRefreshRequest(secrets.LykosAccount.RefreshToken)) |
||||
.ConfigureAwait(false); |
||||
|
||||
secrets = secrets with { LykosAccount = newTokens }; |
||||
|
||||
await secretsManager.SaveAsync(secrets).ConfigureAwait(false); |
||||
|
||||
return (newTokens.AccessToken, newTokens.RefreshToken); |
||||
} |
||||
} |
@ -0,0 +1,70 @@
|
||||
using System.Net; |
||||
using System.Net.Http.Headers; |
||||
using NLog; |
||||
using Polly; |
||||
using Polly.Retry; |
||||
using StabilityMatrix.Core.Helper; |
||||
|
||||
namespace StabilityMatrix.Core.Api; |
||||
|
||||
public class TokenAuthHeaderHandler : DelegatingHandler |
||||
{ |
||||
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||
|
||||
private readonly AsyncRetryPolicy<HttpResponseMessage> policy; |
||||
private readonly ITokenProvider tokenProvider; |
||||
|
||||
public TokenAuthHeaderHandler(ITokenProvider tokenProvider) |
||||
{ |
||||
this.tokenProvider = tokenProvider; |
||||
|
||||
policy = Policy |
||||
.HandleResult<HttpResponseMessage>( |
||||
r => |
||||
r.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden |
||||
&& r.RequestMessage?.Headers.Authorization |
||||
is { Parameter: "Bearer", Scheme: not null } |
||||
) |
||||
.RetryAsync( |
||||
async (result, _) => |
||||
{ |
||||
var oldToken = ObjectHash.GetStringSignature( |
||||
await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false) |
||||
); |
||||
Logger.Info( |
||||
"Refreshing access token for status ({StatusCode}) {Message}", |
||||
result.Result.StatusCode, |
||||
result.Exception.Message |
||||
); |
||||
var (newToken, _) = await tokenProvider |
||||
.RefreshTokensAsync() |
||||
.ConfigureAwait(false); |
||||
|
||||
Logger.Info( |
||||
"Access token refreshed: {OldToken} -> {NewToken}", |
||||
ObjectHash.GetStringSignature(oldToken), |
||||
ObjectHash.GetStringSignature(newToken) |
||||
); |
||||
} |
||||
); |
||||
|
||||
// InnerHandler must be left as null when using DI, but must be assigned a value when |
||||
// using RestService.For<IMyApi> |
||||
// InnerHandler = new HttpClientHandler(); |
||||
} |
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync( |
||||
HttpRequestMessage request, |
||||
CancellationToken cancellationToken |
||||
) |
||||
{ |
||||
return policy.ExecuteAsync(async () => |
||||
{ |
||||
var accessToken = await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false); |
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); |
||||
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,3 @@
|
||||
namespace StabilityMatrix.Core.Models.Api.Lykos; |
||||
|
||||
public record PostLoginRefreshRequest(string RefreshToken); |
@ -0,0 +1,8 @@
|
||||
using StabilityMatrix.Core.Models.Api.Lykos; |
||||
|
||||
namespace StabilityMatrix.Core.Models; |
||||
|
||||
public readonly record struct Secrets |
||||
{ |
||||
public LykosAccountTokens? LykosAccount { get; init; } |
||||
} |
@ -0,0 +1,21 @@
|
||||
using StabilityMatrix.Core.Models; |
||||
|
||||
namespace StabilityMatrix.Core.Services; |
||||
|
||||
/// <summary> |
||||
/// Interface for managing secure settings and tokens. |
||||
/// </summary> |
||||
public interface ISecretsManager |
||||
{ |
||||
/// <summary> |
||||
/// Load and return the secrets. |
||||
/// </summary> |
||||
Task<Secrets> LoadAsync(); |
||||
|
||||
/// <summary> |
||||
/// Load and return the secrets, or save and return a new instance on error. |
||||
/// </summary> |
||||
Task<Secrets> SafeLoadAsync(); |
||||
|
||||
Task SaveAsync(Secrets secrets); |
||||
} |
@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.Logging; |
||||
using StabilityMatrix.Core.Attributes; |
||||
using StabilityMatrix.Core.Models; |
||||
using StabilityMatrix.Core.Models.FileInterfaces; |
||||
|
||||
namespace StabilityMatrix.Core.Services; |
||||
|
||||
/// <summary> |
||||
/// Default implementation of <see cref="ISecretsManager"/>. |
||||
/// Data is encrypted at rest in %APPDATA%\StabilityMatrix\user-secrets.data |
||||
/// </summary> |
||||
[Singleton(typeof(ISecretsManager))] |
||||
public class SecretsManager : ISecretsManager |
||||
{ |
||||
private readonly ILogger<SecretsManager> logger; |
||||
|
||||
private static FilePath GlobalFile => GlobalConfig.HomeDir.JoinFile("user-secrets.data"); |
||||
|
||||
public SecretsManager(ILogger<SecretsManager> logger) |
||||
{ |
||||
this.logger = logger; |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public async Task<Secrets> LoadAsync() |
||||
{ |
||||
if (!GlobalFile.Exists) |
||||
{ |
||||
return new Secrets(); |
||||
} |
||||
|
||||
var fileBytes = await GlobalFile.ReadAllBytesAsync().ConfigureAwait(false); |
||||
return GlobalEncryptedSerializer.Deserialize<Secrets>(fileBytes); |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public async Task<Secrets> SafeLoadAsync() |
||||
{ |
||||
try |
||||
{ |
||||
return await LoadAsync().ConfigureAwait(false); |
||||
} |
||||
catch (Exception e) |
||||
{ |
||||
logger.LogWarning( |
||||
e, |
||||
"Failed to load secrets ({ExcType}), saving new instance", |
||||
e.GetType().Name |
||||
); |
||||
|
||||
var secrets = new Secrets(); |
||||
await SaveAsync(secrets).ConfigureAwait(false); |
||||
|
||||
return secrets; |
||||
} |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public Task SaveAsync(Secrets secrets) |
||||
{ |
||||
var fileBytes = GlobalEncryptedSerializer.Serialize(secrets); |
||||
return GlobalFile.WriteAllBytesAsync(fileBytes); |
||||
} |
||||
} |
Loading…
Reference in new issue