diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs
index fa177f3..84a43d1 100644
--- a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs
+++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs
@@ -6,15 +6,18 @@
namespace AStar.Dev.CloudSyncFunctional.Auth;
///
-public sealed partial class AuthService(IPublicClientApplication app, ILogger logger) : IAuthService
+public sealed partial class AuthService(IPublicClientApplication app, ILogger logger, ITokenCacheService tokenCacheService) : IAuthService
{
private static readonly string[] Scopes = ["Files.ReadWrite", "offline_access", "User.Read"];
+ private bool _cacheRegistered;
///
public async Task> SignInInteractiveAsync(CancellationToken cancellationToken = default)
{
try
{
+ await EnsureCacheRegisteredAsync(cancellationToken).ConfigureAwait(false);
+
var msalResult = await app
.AcquireTokenInteractive(Scopes)
.WithPrompt(Prompt.SelectAccount)
@@ -35,11 +38,13 @@ public async Task> SignInInteractiveAsync(Cancella
catch (MsalException ex)
{
LogAuthFailed(logger, ex.Message);
+
return AuthErrorFactory.Failed(ex.Message);
}
catch (Exception ex)
{
LogAuthFailed(logger, ex.Message);
+
return AuthErrorFactory.Failed(ex.Message);
}
}
@@ -49,6 +54,8 @@ public async Task> AcquireTokenSilentAsync(string
{
try
{
+ await EnsureCacheRegisteredAsync(cancellationToken).ConfigureAwait(false);
+
var accounts = await app.GetAccountsAsync().ConfigureAwait(false);
var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId);
if (account is null)
@@ -60,11 +67,14 @@ public async Task> AcquireTokenSilentAsync(string
}
catch (MsalUiRequiredException)
{
+ LogAuthFailed(logger, "Re-authentication required.");
+
return AuthErrorFactory.Failed("Re-authentication required.");
}
catch (Exception ex)
{
LogAuthFailed(logger, ex.Message);
+
return AuthErrorFactory.Failed(ex.Message);
}
}
@@ -86,6 +96,13 @@ public async Task> GetCachedAccountIdsAsync()
return [.. accounts.Where(a => a.HomeAccountId is not null).Select(a => a.HomeAccountId!.Identifier)];
}
+ private async Task EnsureCacheRegisteredAsync(CancellationToken cancellationToken)
+ {
+ if (_cacheRegistered) return;
+ await tokenCacheService.RegisterAsync(app, cancellationToken).ConfigureAwait(false);
+ _cacheRegistered = true;
+ }
+
private static AuthResult BuildAuthResult(AuthenticationResult result)
{
var displayName = result.ClaimsPrincipal?.FindFirst("name")?.Value ?? result.Account.Username;
diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs
index d2d61bc..60002e3 100644
--- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs
+++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Auth/GivenAnAuthService.cs
@@ -2,22 +2,155 @@
using AStar.Dev.FunctionalParadigm;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
+using MELogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Auth;
public sealed class GivenAnAuthService
{
- private static AuthService CreateSut(IPublicClientApplication? app = null) =>
- new(app ?? Substitute.For(), Substitute.For>());
+ private static AuthService CreateSut(IPublicClientApplication? app = null, ITokenCacheService? tokenCacheService = null) =>
+ new(app ?? Substitute.For(), Substitute.For>(), tokenCacheService ?? Substitute.For());
private static IAccount CreateAccount(string identifier)
{
var account = Substitute.For();
var homeAccountId = new Microsoft.Identity.Client.AccountId(identifier, identifier, "tenant");
account.HomeAccountId.Returns(homeAccountId);
+
return account;
}
+ [Fact]
+ public async Task when_sign_in_is_called_then_cache_register_is_called()
+ {
+ var app = Substitute.For();
+ app.When(a => a.AcquireTokenInteractive(Arg.Any>()))
+ .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled"));
+ var tokenCacheService = Substitute.For();
+ var sut = CreateSut(app, tokenCacheService);
+
+ _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);
+
+ await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any());
+ }
+
+ [Fact]
+ public async Task when_acquire_token_silent_is_called_then_cache_register_is_called()
+ {
+ var app = Substitute.For();
+ app.GetAccountsAsync().Returns(Task.FromResult>([]));
+ var tokenCacheService = Substitute.For();
+ var sut = CreateSut(app, tokenCacheService);
+
+ _ = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken);
+
+ await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any());
+ }
+
+ [Fact]
+ public async Task when_cache_already_registered_sign_in_called_twice_then_cache_register_called_only_once()
+ {
+ var app = Substitute.For();
+ app.When(a => a.AcquireTokenInteractive(Arg.Any>()))
+ .Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled"));
+ var tokenCacheService = Substitute.For();
+ var sut = CreateSut(app, tokenCacheService);
+
+ _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);
+ _ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);
+
+ await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any());
+ }
+
+ [Fact]
+ public async Task when_acquire_token_silent_throws_msal_ui_required_then_logger_logs_error()
+ {
+ var app = Substitute.For();
+ var account = CreateAccount("target-id");
+ app.GetAccountsAsync().Returns(Task.FromResult>([account]));
+ app.When(a => a.AcquireTokenSilent(Arg.Any>(), Arg.Any()))
+ .Do(_ => throw new MsalUiRequiredException("code", "message"));
+ var logger = Substitute.For>();
+ logger.IsEnabled(MELogLevel.Error).Returns(true);
+ var sut = new AuthService(app, logger, Substitute.For());
+
+ _ = await sut.AcquireTokenSilentAsync("target-id", TestContext.Current.CancellationToken);
+
+ logger.Received(1).Log(
+ MELogLevel.Error,
+ Arg.Any(),
+ Arg.Any