Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
namespace AStar.Dev.CloudSyncFunctional.Auth;

/// <inheritdoc />
public sealed partial class AuthService(IPublicClientApplication app, ILogger<AuthService> logger) : IAuthService
public sealed partial class AuthService(IPublicClientApplication app, ILogger<AuthService> logger, ITokenCacheService tokenCacheService) : IAuthService
{
private static readonly string[] Scopes = ["Files.ReadWrite", "offline_access", "User.Read"];
private bool _cacheRegistered;

/// <inheritdoc />
public async Task<Result<AuthResult, AuthError>> SignInInteractiveAsync(CancellationToken cancellationToken = default)
{
try
{
await EnsureCacheRegisteredAsync(cancellationToken).ConfigureAwait(false);

var msalResult = await app
.AcquireTokenInteractive(Scopes)
.WithPrompt(Prompt.SelectAccount)
Expand All @@ -35,11 +38,13 @@ public async Task<Result<AuthResult, AuthError>> 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);
}
}
Expand All @@ -49,6 +54,8 @@ public async Task<Result<AuthResult, AuthError>> 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)
Expand All @@ -60,11 +67,14 @@ public async Task<Result<AuthResult, AuthError>> 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);
}
}
Expand All @@ -86,6 +96,13 @@ public async Task<IReadOnlyList<string>> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPublicClientApplication>(), Substitute.For<ILogger<AuthService>>());
private static AuthService CreateSut(IPublicClientApplication? app = null, ITokenCacheService? tokenCacheService = null) =>
new(app ?? Substitute.For<IPublicClientApplication>(), Substitute.For<ILogger<AuthService>>(), tokenCacheService ?? Substitute.For<ITokenCacheService>());

private static IAccount CreateAccount(string identifier)
{
var account = Substitute.For<IAccount>();
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<IPublicClientApplication>();
app.When(a => a.AcquireTokenInteractive(Arg.Any<IEnumerable<string>>()))
.Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled"));
var tokenCacheService = Substitute.For<ITokenCacheService>();
var sut = CreateSut(app, tokenCacheService);

_ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);

await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any<CancellationToken>());
}

[Fact]
public async Task when_acquire_token_silent_is_called_then_cache_register_is_called()
{
var app = Substitute.For<IPublicClientApplication>();
app.GetAccountsAsync().Returns(Task.FromResult<IEnumerable<IAccount>>([]));
var tokenCacheService = Substitute.For<ITokenCacheService>();
var sut = CreateSut(app, tokenCacheService);

_ = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken);

await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any<CancellationToken>());
}

[Fact]
public async Task when_cache_already_registered_sign_in_called_twice_then_cache_register_called_only_once()
{
var app = Substitute.For<IPublicClientApplication>();
app.When(a => a.AcquireTokenInteractive(Arg.Any<IEnumerable<string>>()))
.Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled"));
var tokenCacheService = Substitute.For<ITokenCacheService>();
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<CancellationToken>());
}

[Fact]
public async Task when_acquire_token_silent_throws_msal_ui_required_then_logger_logs_error()
{
var app = Substitute.For<IPublicClientApplication>();
var account = CreateAccount("target-id");
app.GetAccountsAsync().Returns(Task.FromResult<IEnumerable<IAccount>>([account]));
app.When(a => a.AcquireTokenSilent(Arg.Any<IEnumerable<string>>(), Arg.Any<IAccount>()))
.Do(_ => throw new MsalUiRequiredException("code", "message"));
var logger = Substitute.For<ILogger<AuthService>>();
logger.IsEnabled(MELogLevel.Error).Returns(true);
var sut = new AuthService(app, logger, Substitute.For<ITokenCacheService>());

_ = await sut.AcquireTokenSilentAsync("target-id", TestContext.Current.CancellationToken);

logger.Received(1).Log(
MELogLevel.Error,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());
}

[Fact]
public async Task when_sign_in_registers_cache_then_silent_acquire_skips_registration()
{
var app = Substitute.For<IPublicClientApplication>();
app.When(a => a.AcquireTokenInteractive(Arg.Any<IEnumerable<string>>()))
.Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled"));
app.GetAccountsAsync().Returns(Task.FromResult<IEnumerable<IAccount>>([]));
var tokenCacheService = Substitute.For<ITokenCacheService>();
var sut = CreateSut(app, tokenCacheService);

_ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);
_ = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken);

await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any<CancellationToken>());
}

[Fact]
public async Task when_silent_acquire_registers_cache_then_sign_in_skips_registration()
{
var app = Substitute.For<IPublicClientApplication>();
app.GetAccountsAsync().Returns(Task.FromResult<IEnumerable<IAccount>>([]));
app.When(a => a.AcquireTokenInteractive(Arg.Any<IEnumerable<string>>()))
.Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled"));
var tokenCacheService = Substitute.For<ITokenCacheService>();
var sut = CreateSut(app, tokenCacheService);

_ = await sut.AcquireTokenSilentAsync("any-id", TestContext.Current.CancellationToken);
_ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);

await tokenCacheService.Received(1).RegisterAsync(app, Arg.Any<CancellationToken>());
}

[Fact]
public async Task when_register_async_throws_during_sign_in_then_returns_failed_error()
{
var app = Substitute.For<IPublicClientApplication>();
var tokenCacheService = Substitute.For<ITokenCacheService>();
tokenCacheService.RegisterAsync(Arg.Any<IPublicClientApplication>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("cache unavailable")));
var sut = CreateSut(app, tokenCacheService);

var result = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);

var fail = result.ShouldBeOfType<Fail<AuthResult, AuthError>>();
fail.Error.ShouldBeOfType<AuthFailedError>();
fail.Error.Message.ShouldContain("cache unavailable");
}

[Fact]
public async Task when_register_async_throws_then_next_sign_in_call_retries_registration()
{
var app = Substitute.For<IPublicClientApplication>();
app.When(a => a.AcquireTokenInteractive(Arg.Any<IEnumerable<string>>()))
.Do(_ => throw new MsalClientException("authentication_canceled", "Cancelled"));
var tokenCacheService = Substitute.For<ITokenCacheService>();
tokenCacheService.RegisterAsync(Arg.Any<IPublicClientApplication>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromException(new InvalidOperationException("cache unavailable")),
Task.CompletedTask);
var sut = CreateSut(app, tokenCacheService);

_ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);
_ = await sut.SignInInteractiveAsync(TestContext.Current.CancellationToken);

await tokenCacheService.Received(2).RegisterAsync(app, Arg.Any<CancellationToken>());
}

[Fact]
public async Task when_sign_in_throws_authentication_canceled_error_code_then_returns_cancelled_error()
{
Expand Down
Loading